diff --git a/Makefile b/Makefile index 8d66848..79b4abd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install-backend install-frontend install backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck +.PHONY: install-backend install-frontend install backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants -include .env export @@ -54,7 +54,33 @@ test-frontend: test-e2e: db-clean db-ready ./scripts/e2e.sh -test: test-backend test-frontend test-e2e +test: check-constants check-types-fresh test-backend test-frontend test-e2e -typecheck: +typecheck: generate-types-standalone cd backend && uv run mypy . + cd frontend && npx tsc --noEmit + +generate-types: + cd frontend && npm run generate-api-types + +generate-types-standalone: db-seed + @echo "Starting backend for type generation..." + @cd backend && uv run uvicorn main:app --port 8000 --log-level warning & \ + BACKEND_PID=$$!; \ + sleep 3; \ + cd frontend && npm run generate-api-types; \ + EXIT_CODE=$$?; \ + kill $$BACKEND_PID 2>/dev/null || true; \ + exit $$EXIT_CODE + +check-types-fresh: generate-types-standalone + @if git diff --quiet frontend/app/generated/api.ts 2>/dev/null; then \ + echo "✓ Generated types are up to date"; \ + else \ + echo "✗ Generated types are stale. Run 'make generate-types-standalone' and commit."; \ + git diff frontend/app/generated/api.ts; \ + exit 1; \ + fi + +check-constants: + @cd backend && uv run python validate_constants.py diff --git a/backend/main.py b/backend/main.py index 1149f14..3bcc54d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,11 +11,16 @@ from routes import audit as audit_routes from routes import profile as profile_routes from routes import invites as invites_routes from routes import auth as auth_routes +from routes import meta as meta_routes +from validate_constants import validate_shared_constants @asynccontextmanager async def lifespan(app: FastAPI): - """Create database tables on startup.""" + """Create database tables on startup and validate constants.""" + # Validate shared constants match backend definitions + validate_shared_constants() + async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield @@ -39,3 +44,4 @@ app.include_router(audit_routes.router) app.include_router(profile_routes.router) app.include_router(invites_routes.router) app.include_router(invites_routes.admin_router) +app.include_router(meta_routes.router) diff --git a/backend/routes/meta.py b/backend/routes/meta.py new file mode 100644 index 0000000..c984ab3 --- /dev/null +++ b/backend/routes/meta.py @@ -0,0 +1,18 @@ +"""Meta endpoints for shared constants.""" +from fastapi import APIRouter + +from models import Permission, InviteStatus, ROLE_ADMIN, ROLE_REGULAR +from schemas import ConstantsResponse + +router = APIRouter(prefix="/api/meta", tags=["meta"]) + + +@router.get("/constants", response_model=ConstantsResponse) +async def get_constants() -> ConstantsResponse: + """Get shared constants for frontend/backend synchronization.""" + return ConstantsResponse( + permissions=[p.value for p in Permission], + roles=[ROLE_ADMIN, ROLE_REGULAR], + invite_statuses=[s.value for s in InviteStatus], + ) + diff --git a/backend/schemas.py b/backend/schemas.py index a8e7f38..45b4551 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -138,3 +138,14 @@ class AdminUserResponse(BaseModel): """Minimal user info for admin dropdowns.""" id: int email: str + + +# ============================================================================= +# Meta/Constants Schemas +# ============================================================================= + +class ConstantsResponse(BaseModel): + """Response model for shared constants.""" + permissions: list[str] + roles: list[str] + invite_statuses: list[str] diff --git a/backend/validate_constants.py b/backend/validate_constants.py new file mode 100644 index 0000000..b645114 --- /dev/null +++ b/backend/validate_constants.py @@ -0,0 +1,48 @@ +"""Validate shared constants match backend definitions.""" +import json +from pathlib import Path + +from models import ROLE_ADMIN, ROLE_REGULAR, InviteStatus + + +def validate_shared_constants() -> None: + """ + Validate that shared/constants.json matches backend definitions. + Raises ValueError if there's a mismatch. + """ + constants_path = Path(__file__).parent.parent / "shared" / "constants.json" + + if not constants_path.exists(): + raise ValueError(f"Shared constants file not found: {constants_path}") + + with open(constants_path) as f: + constants = json.load(f) + + # Validate roles + expected_roles = {"ADMIN": ROLE_ADMIN, "REGULAR": ROLE_REGULAR} + if constants.get("roles") != expected_roles: + raise ValueError( + f"Role mismatch in shared/constants.json. " + f"Expected: {expected_roles}, Got: {constants.get('roles')}" + ) + + # Validate invite statuses + expected_statuses = {s.name: s.value for s in InviteStatus} + if constants.get("inviteStatuses") != expected_statuses: + raise ValueError( + f"Invite status mismatch in shared/constants.json. " + f"Expected: {expected_statuses}, Got: {constants.get('inviteStatuses')}" + ) + + # Validate validation rules exist (structure check only) + validation = constants.get("validation", {}) + required_fields = ["telegram", "signal", "nostrNpub"] + for field in required_fields: + if field not in validation: + raise ValueError(f"Missing validation rules for '{field}' in shared/constants.json") + + +if __name__ == "__main__": + validate_shared_constants() + print("✓ Shared constants are valid") + diff --git a/backend/validation.py b/backend/validation.py index 3963667..51a4496 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -1,7 +1,19 @@ """Validation utilities for user profile fields.""" +import json +from pathlib import Path + from email_validator import validate_email, EmailNotValidError from bech32 import bech32_decode +# Load validation rules from shared constants +_constants_path = Path(__file__).parent.parent / "shared" / "constants.json" +with open(_constants_path) as f: + _constants = json.load(f) + +TELEGRAM_RULES = _constants["validation"]["telegram"] +SIGNAL_RULES = _constants["validation"]["signal"] +NPUB_RULES = _constants["validation"]["nostrNpub"] + def validate_contact_email(value: str | None) -> str | None: """ @@ -24,22 +36,25 @@ def validate_telegram(value: str | None) -> str | None: """ Validate Telegram handle. - Must start with @ if provided, with 1-32 characters after @. + Must start with @ if provided, with characters after @ within max length. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None - if not value.startswith("@"): - return "Telegram handle must start with @" + prefix = TELEGRAM_RULES["mustStartWith"] + max_len = TELEGRAM_RULES["maxLengthAfterAt"] + + if not value.startswith(prefix): + return f"Telegram handle must start with {prefix}" handle = value[1:] if not handle: - return "Telegram handle must have at least one character after @" + return f"Telegram handle must have at least one character after {prefix}" - if len(handle) > 32: - return "Telegram handle must be at most 32 characters (after @)" + if len(handle) > max_len: + return f"Telegram handle must be at most {max_len} characters (after {prefix})" return None @@ -48,19 +63,21 @@ def validate_signal(value: str | None) -> str | None: """ Validate Signal username. - Any non-empty string is valid. + Any non-empty string within max length is valid. Returns None if valid, error message if invalid. Empty/None values are valid (field is optional). """ if not value: return None + max_len = SIGNAL_RULES["maxLength"] + # Signal usernames are fairly permissive, just check it's not empty if len(value.strip()) == 0: return "Signal username cannot be empty" - if len(value) > 64: - return "Signal username must be at most 64 characters" + if len(value) > max_len: + return f"Signal username must be at most {max_len} characters" return None @@ -76,8 +93,11 @@ def validate_nostr_npub(value: str | None) -> str | None: if not value: return None - if not value.startswith("npub1"): - return "Nostr npub must start with 'npub1'" + prefix = NPUB_RULES["prefix"] + expected_words = NPUB_RULES["bech32Words"] + + if not value.startswith(prefix): + return f"Nostr npub must start with '{prefix}'" # Decode bech32 to validate checksum hrp, data = bech32_decode(value) @@ -90,7 +110,7 @@ def validate_nostr_npub(value: str | None) -> str | None: # npub should decode to 32 bytes (256 bits) for a public key # In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data - if len(data) != 52: + if len(data) != expected_words: return "Invalid Nostr npub: incorrect length" return None diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 2937072..096d2cf 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -6,35 +6,18 @@ import { api } from "../../api"; import { sharedStyles } from "../../styles/shared"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; +import { components } from "../../generated/api"; +import constants from "../../../../shared/constants.json"; -interface InviteRecord { - id: number; - identifier: string; - godfather_id: number; - godfather_email: string; - status: string; - used_by_id: number | null; - used_by_email: string | null; - created_at: string; - spent_at: string | null; - revoked_at: string | null; -} +const { READY, SPENT, REVOKED } = constants.inviteStatuses; -interface PaginatedResponse { - records: T[]; - total: number; - page: number; - per_page: number; - total_pages: number; -} - -interface UserOption { - id: number; - email: string; -} +// Use generated types from OpenAPI schema +type InviteRecord = components["schemas"]["InviteResponse"]; +type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_"]; +type UserOption = components["schemas"]["AdminUserResponse"]; export default function AdminInvitesPage() { - const [data, setData] = useState | null>(null); + const [data, setData] = useState(null); const [error, setError] = useState(null); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState(""); @@ -63,7 +46,7 @@ export default function AdminInvitesPage() { if (status) { url += `&status=${status}`; } - const data = await api.get>(url); + const data = await api.get(url); setData(data); } catch (err) { setData(null); @@ -117,11 +100,11 @@ export default function AdminInvitesPage() { const getStatusBadgeStyle = (status: string) => { switch (status) { - case "ready": + case READY: return styles.statusReady; - case "spent": + case SPENT: return styles.statusSpent; - case "revoked": + case REVOKED: return styles.statusRevoked; default: return {}; @@ -198,9 +181,9 @@ export default function AdminInvitesPage() { style={styles.filterSelect} > - - - + + + {data?.total ?? 0} invites @@ -240,7 +223,7 @@ export default function AdminInvitesPage() { {formatDate(record.created_at)} - {record.status === "ready" && ( + {record.status === READY && (