first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:41:41 +05:30
commit d416fe4cce
69 changed files with 8269 additions and 0 deletions

6
backend/.env.example Normal file
View file

@ -0,0 +1,6 @@
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB_NAME=zero_knowledge_platform
JWT_SECRET=replace-with-a-long-random-secret
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

0
backend/app/__init__.py Normal file
View file

View file

43
backend/app/api/auth.py Normal file
View file

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.core.security import create_access_token
from app.db.mongo import get_database
from app.schemas.auth import (
AuthBootstrapResponse,
AuthLoginBootstrapRequest,
AuthLoginRequest,
AuthSignupRequest,
AuthTokenResponse,
)
from app.services.auth import create_account, get_account_by_identifier, verify_account_login
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/signup", response_model=AuthTokenResponse)
async def signup(payload: AuthSignupRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
owner_id = await create_account(database, payload)
access_token = create_access_token(owner_id)
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)
@router.post("/bootstrap", response_model=AuthBootstrapResponse)
async def bootstrap(
payload: AuthLoginBootstrapRequest,
database: AsyncIOMotorDatabase = Depends(get_database),
) -> AuthBootstrapResponse:
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
return AuthBootstrapResponse(
identifierType=payload.identifierType,
identifier=account["identifier"],
saltAuth=account["saltAuth"],
saltEnc=account["saltEnc"],
)
@router.post("/login", response_model=AuthTokenResponse)
async def login(payload: AuthLoginRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
owner_id = await verify_account_login(database, payload)
access_token = create_access_token(owner_id)
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)

View file

@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.dependencies.auth import get_current_owner_id
from app.db.mongo import get_database
from app.schemas.records import ChildCreateRequest, ChildRecordResponse, ChildUpdateRequest
from app.services.records import create_child, delete_child, get_child, list_children, update_child
router = APIRouter(prefix="/parents/{parent_id}/children", tags=["children"])
@router.post("", response_model=ChildRecordResponse)
async def create_child_record(
parent_id: str,
payload: ChildCreateRequest,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ChildRecordResponse:
if payload.parentId != parent_id:
payload = ChildCreateRequest(parentId=parent_id, iv=payload.iv, ciphertext=payload.ciphertext)
return await create_child(database, owner_id, payload)
@router.get("", response_model=list[ChildRecordResponse])
async def list_child_records(
parent_id: str,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> list[ChildRecordResponse]:
return await list_children(database, owner_id, parent_id)
@router.get("/{child_id}", response_model=ChildRecordResponse)
async def get_child_record(
parent_id: str,
child_id: str,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ChildRecordResponse:
return await get_child(database, owner_id, parent_id, child_id)
@router.put("/{child_id}", response_model=ChildRecordResponse)
async def update_child_record(
parent_id: str,
child_id: str,
payload: ChildUpdateRequest,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ChildRecordResponse:
return await update_child(database, owner_id, parent_id, child_id, payload)
@router.delete("/{child_id}", status_code=204)
async def delete_child_record(
parent_id: str,
child_id: str,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> None:
await delete_child(database, owner_id, parent_id, child_id)

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health_check() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.dependencies.auth import get_current_owner_id
from app.db.mongo import get_database
from app.schemas.records import ParentCreateRequest, ParentRecordResponse, ParentUpdateRequest
from app.services.records import create_parent, delete_parent, get_parent, list_parents, update_parent
router = APIRouter(prefix="/parents", tags=["parents"])
@router.post("", response_model=ParentRecordResponse)
async def create_parent_record(
payload: ParentCreateRequest,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ParentRecordResponse:
return await create_parent(database, owner_id, payload)
@router.get("", response_model=list[ParentRecordResponse])
async def list_parent_records(
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> list[ParentRecordResponse]:
return await list_parents(database, owner_id)
@router.get("/{parent_id}", response_model=ParentRecordResponse)
async def get_parent_record(
parent_id: str,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ParentRecordResponse:
return await get_parent(database, owner_id, parent_id)
@router.put("/{parent_id}", response_model=ParentRecordResponse)
async def update_parent_record(
parent_id: str,
payload: ParentUpdateRequest,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> ParentRecordResponse:
return await update_parent(database, owner_id, parent_id, payload)
@router.delete("/{parent_id}", status_code=204)
async def delete_parent_record(
parent_id: str,
owner_id: str = Depends(get_current_owner_id),
database: AsyncIOMotorDatabase = Depends(get_database),
) -> None:
await delete_parent(database, owner_id, parent_id)

View file

View file

@ -0,0 +1,31 @@
from dataclasses import dataclass, field
import os
from pathlib import Path
from dotenv import load_dotenv
backend_env_path = Path(__file__).resolve().parents[2] / ".env"
load_dotenv(dotenv_path=backend_env_path)
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "Zero Knowledge Platform API")
mongo_uri: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
mongo_db_name: str = os.getenv("MONGODB_DB_NAME", "zero_knowledge_platform")
jwt_secret: str = os.getenv("JWT_SECRET", "replace-with-a-long-random-secret")
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
cors_origins: list[str] = field(
default_factory=lambda: [
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173",
).split(",")
if origin.strip()
]
)
settings = Settings()

View file

@ -0,0 +1,37 @@
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def normalize_identifier(identifier_type: str, identifier: str) -> str:
value = identifier.strip()
if identifier_type == "email":
return value.lower()
if identifier_type == "username":
return value
if identifier_type == "mobile":
return re.sub(r"[\s\-().]", "", value)
return value
def hash_auth_key(auth_key_material: str) -> str:
return pwd_context.hash(auth_key_material)
def verify_auth_key(auth_key_material: str, hashed_auth_key: str) -> bool:
return pwd_context.verify(auth_key_material, hashed_auth_key)
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload: dict[str, Any] = {"sub": subject, "exp": expires_at, "iat": datetime.now(timezone.utc)}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

View file

29
backend/app/db/mongo.py Normal file
View file

@ -0,0 +1,29 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from app.core.config import settings
client: AsyncIOMotorClient | None = None
def get_database() -> AsyncIOMotorDatabase:
if client is None:
raise RuntimeError("MongoDB client has not been initialized")
return client[settings.mongo_db_name]
def get_client() -> AsyncIOMotorClient:
if client is None:
raise RuntimeError("MongoDB client has not been initialized")
return client
async def connect_to_mongo() -> None:
global client
client = AsyncIOMotorClient(settings.mongo_uri)
async def close_mongo_connection() -> None:
global client
if client is not None:
client.close()
client = None

View file

View file

@ -0,0 +1,33 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.core.config import settings
bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_owner_id(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated.",
)
try:
payload = jwt.decode(credentials.credentials, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token.",
) from exc
owner_id = payload.get("sub")
if not isinstance(owner_id, str) or not owner_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload.",
)
return owner_id

51
backend/app/main.py Normal file
View file

@ -0,0 +1,51 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.children import router as children_router
from app.api.parents import router as parents_router
from app.api.health import router as health_router
from app.core.config import settings
from app.db.mongo import close_mongo_connection, connect_to_mongo, get_database
from app.services.records import ensure_record_indexes
from app.services.auth import ensure_auth_indexes
@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_to_mongo()
await ensure_auth_indexes(get_database())
await ensure_record_indexes(get_database())
yield
await close_mongo_connection()
app = FastAPI(title="Zero Knowledge Platform API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(parents_router)
app.include_router(children_router)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": "Internal server error."})
@app.get("/")
async def root() -> dict[str, str]:
return {"message": "Zero Knowledge Platform API"}

View file

View file

@ -0,0 +1,49 @@
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class IdentifierType(str, Enum):
email = "email"
username = "username"
mobile = "mobile"
class AuthIdentifierBase(BaseModel):
identifierType: IdentifierType
identifier: str = Field(min_length=1, max_length=255)
@field_validator("identifier")
@classmethod
def strip_identifier(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("identifier cannot be empty")
return normalized
class AuthSignupRequest(AuthIdentifierBase):
saltAuth: str = Field(min_length=8)
saltEnc: str = Field(min_length=8)
authKeyMaterial: str = Field(min_length=8)
class AuthLoginBootstrapRequest(AuthIdentifierBase):
pass
class AuthLoginRequest(AuthIdentifierBase):
authKeyMaterial: str = Field(min_length=8)
class AuthBootstrapResponse(BaseModel):
identifierType: IdentifierType
identifier: str
saltAuth: str
saltEnc: str
class AuthTokenResponse(BaseModel):
accessToken: str
tokenType: str = "bearer"
ownerId: str

View file

@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class ParentBlobBase(BaseModel):
iv: str = Field(min_length=1)
ciphertext: str = Field(min_length=1)
class ParentCreateRequest(ParentBlobBase):
pass
class ParentUpdateRequest(ParentBlobBase):
pass
class ChildCreateRequest(ParentBlobBase):
parentId: str = Field(min_length=1)
@field_validator("parentId")
@classmethod
def strip_parent_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("parentId cannot be empty")
return normalized
class ChildUpdateRequest(ParentBlobBase):
pass
class ParentRecordResponse(ParentBlobBase):
id: str
ownerId: str
createdAt: datetime
updatedAt: datetime
class ChildRecordResponse(ParentBlobBase):
id: str
parentId: str
ownerId: str
createdAt: datetime
updatedAt: datetime

View file

View file

@ -0,0 +1,68 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from pymongo.errors import DuplicateKeyError
from app.core.security import hash_auth_key, normalize_identifier, verify_auth_key
from app.schemas.auth import AuthLoginRequest, AuthSignupRequest
ACCOUNTS_COLLECTION = "accounts"
async def ensure_auth_indexes(database: AsyncIOMotorDatabase) -> None:
await database[ACCOUNTS_COLLECTION].create_index(
[("identifierType", 1), ("identifier", 1)],
unique=True,
name="uq_accounts_identifier",
)
def _account_filter(identifier_type: str, identifier: str) -> dict[str, str]:
return {
"identifierType": identifier_type,
"identifier": normalize_identifier(identifier_type, identifier),
}
async def create_account(database: AsyncIOMotorDatabase, payload: AuthSignupRequest) -> str:
now = datetime.now(timezone.utc)
account_document = {
"identifierType": payload.identifierType.value,
"identifier": normalize_identifier(payload.identifierType.value, payload.identifier),
"hashedAuthKey": hash_auth_key(payload.authKeyMaterial),
"saltAuth": payload.saltAuth,
"saltEnc": payload.saltEnc,
"createdAt": now,
"updatedAt": now,
}
try:
result = await database[ACCOUNTS_COLLECTION].insert_one(account_document)
except DuplicateKeyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An account with that identifier already exists.",
) from exc
return str(result.inserted_id)
async def get_account_by_identifier(database: AsyncIOMotorDatabase, identifier_type: str, identifier: str) -> dict[str, str]:
account = await database[ACCOUNTS_COLLECTION].find_one(_account_filter(identifier_type, identifier))
if account is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found.",
)
return account
async def verify_account_login(database: AsyncIOMotorDatabase, payload: AuthLoginRequest) -> str:
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
if not verify_auth_key(payload.authKeyMaterial, account["hashedAuthKey"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials.",
)
return str(account["_id"])

View file

@ -0,0 +1,235 @@
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import HTTPException, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from pymongo import ReturnDocument
from pymongo.errors import DuplicateKeyError
from app.schemas.records import (
ChildCreateRequest,
ChildRecordResponse,
ChildUpdateRequest,
ParentCreateRequest,
ParentRecordResponse,
ParentUpdateRequest,
)
PARENTS_COLLECTION = "parents"
CHILDREN_COLLECTION = "children"
class RecordNotFoundError(HTTPException):
def __init__(self, detail: str = "Record not found.") -> None:
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
class BadRequestError(HTTPException):
def __init__(self, detail: str = "Invalid request.") -> None:
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _to_object_id(value: str, field_name: str) -> ObjectId:
if not ObjectId.is_valid(value):
raise BadRequestError(f"Invalid {field_name}.")
return ObjectId(value)
def _serialize_parent(document: dict) -> ParentRecordResponse:
return ParentRecordResponse(
id=str(document["_id"]),
ownerId=document["ownerId"],
iv=document["iv"],
ciphertext=document["ciphertext"],
createdAt=document["createdAt"],
updatedAt=document["updatedAt"],
)
def _serialize_child(document: dict) -> ChildRecordResponse:
return ChildRecordResponse(
id=str(document["_id"]),
parentId=str(document["parentId"]),
ownerId=document["ownerId"],
iv=document["iv"],
ciphertext=document["ciphertext"],
createdAt=document["createdAt"],
updatedAt=document["updatedAt"],
)
async def ensure_record_indexes(database: AsyncIOMotorDatabase) -> None:
await database[PARENTS_COLLECTION].create_index([("ownerId", 1)], name="ix_parents_ownerId")
await database[CHILDREN_COLLECTION].create_index([("ownerId", 1)], name="ix_children_ownerId")
await database[CHILDREN_COLLECTION].create_index([("parentId", 1)], name="ix_children_parentId")
await database[CHILDREN_COLLECTION].create_index(
[("ownerId", 1), ("parentId", 1)],
name="ix_children_ownerId_parentId",
)
async def create_parent(database: AsyncIOMotorDatabase, owner_id: str, payload: ParentCreateRequest) -> ParentRecordResponse:
now = _now()
document = {
"ownerId": owner_id,
"iv": payload.iv,
"ciphertext": payload.ciphertext,
"createdAt": now,
"updatedAt": now,
}
result = await database[PARENTS_COLLECTION].insert_one(document)
document["_id"] = result.inserted_id
return _serialize_parent(document)
async def list_parents(database: AsyncIOMotorDatabase, owner_id: str) -> list[ParentRecordResponse]:
parents: list[ParentRecordResponse] = []
cursor = database[PARENTS_COLLECTION].find({"ownerId": owner_id}).sort("createdAt", -1)
async for document in cursor:
parents.append(_serialize_parent(document))
return parents
async def get_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> ParentRecordResponse:
document = await database[PARENTS_COLLECTION].find_one(
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id}
)
if document is None:
raise RecordNotFoundError("Parent record not found.")
return _serialize_parent(document)
async def update_parent(
database: AsyncIOMotorDatabase,
owner_id: str,
parent_id: str,
payload: ParentUpdateRequest,
) -> ParentRecordResponse:
document = await database[PARENTS_COLLECTION].find_one_and_update(
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id},
{
"$set": {
"iv": payload.iv,
"ciphertext": payload.ciphertext,
"updatedAt": _now(),
}
},
return_document=ReturnDocument.AFTER,
)
if document is None:
raise RecordNotFoundError("Parent record not found.")
return _serialize_parent(document)
async def delete_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> None:
client = database.client
if client is None:
raise RuntimeError("MongoDB client has not been initialized")
parent_object_id = _to_object_id(parent_id, "parentId")
async with await client.start_session() as session:
async with session.start_transaction():
parent = await database[PARENTS_COLLECTION].find_one(
{"_id": parent_object_id, "ownerId": owner_id},
session=session,
)
if parent is None:
raise RecordNotFoundError("Parent record not found.")
await database[CHILDREN_COLLECTION].delete_many(
{"parentId": parent_object_id, "ownerId": owner_id},
session=session,
)
await database[PARENTS_COLLECTION].delete_one(
{"_id": parent_object_id, "ownerId": owner_id},
session=session,
)
async def create_child(
database: AsyncIOMotorDatabase,
owner_id: str,
payload: ChildCreateRequest,
) -> ChildRecordResponse:
parent_object_id = _to_object_id(payload.parentId, "parentId")
parent = await database[PARENTS_COLLECTION].find_one({"_id": parent_object_id, "ownerId": owner_id})
if parent is None:
raise RecordNotFoundError("Parent record not found.")
now = _now()
document = {
"ownerId": owner_id,
"parentId": parent_object_id,
"iv": payload.iv,
"ciphertext": payload.ciphertext,
"createdAt": now,
"updatedAt": now,
}
result = await database[CHILDREN_COLLECTION].insert_one(document)
document["_id"] = result.inserted_id
return _serialize_child(document)
async def list_children(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> list[ChildRecordResponse]:
parent_object_id = _to_object_id(parent_id, "parentId")
children: list[ChildRecordResponse] = []
cursor = database[CHILDREN_COLLECTION].find({"ownerId": owner_id, "parentId": parent_object_id}).sort("createdAt", -1)
async for document in cursor:
children.append(_serialize_child(document))
return children
async def get_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> ChildRecordResponse:
document = await database[CHILDREN_COLLECTION].find_one(
{
"_id": _to_object_id(child_id, "childId"),
"parentId": _to_object_id(parent_id, "parentId"),
"ownerId": owner_id,
}
)
if document is None:
raise RecordNotFoundError("Child record not found.")
return _serialize_child(document)
async def update_child(
database: AsyncIOMotorDatabase,
owner_id: str,
parent_id: str,
child_id: str,
payload: ChildUpdateRequest,
) -> ChildRecordResponse:
document = await database[CHILDREN_COLLECTION].find_one_and_update(
{
"_id": _to_object_id(child_id, "childId"),
"parentId": _to_object_id(parent_id, "parentId"),
"ownerId": owner_id,
},
{
"$set": {
"iv": payload.iv,
"ciphertext": payload.ciphertext,
"updatedAt": _now(),
}
},
return_document=ReturnDocument.AFTER,
)
if document is None:
raise RecordNotFoundError("Child record not found.")
return _serialize_child(document)
async def delete_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> None:
result = await database[CHILDREN_COLLECTION].delete_one(
{
"_id": _to_object_id(child_id, "childId"),
"parentId": _to_object_id(parent_id, "parentId"),
"ownerId": owner_id,
}
)
if result.deleted_count == 0:
raise RecordNotFoundError("Child record not found.")

7
backend/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
motor>=3.6,<4
pydantic>=2.8,<3
python-jose[cryptography]>=3.3,<4
passlib[bcrypt]>=1.7,<2
python-dotenv>=1.0,<2