"""ecomm backend — FastAPI app factory + the REST BFF. SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds the configured mailer (INV-8) at startup. storefront is hard-wired null this slice; the storefronts domain (SLICE-3) replaces the _storefront_for seam. """ from __future__ import annotations from contextlib import asynccontextmanager from typing import Any import psycopg from fastapi import Depends, FastAPI, Response from fastapi.responses import JSONResponse from pydantic import BaseModel from app.domains import accounts from app.platform import config, db from app.platform import mailer as mailer_mod from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session from app.platform.mailer import Mailer from app.platform import session as session_mod class RequestCodeBody(BaseModel): email: str class VerifyBody(BaseModel): email: str code: str def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse: """The shared §6.4 error envelope: {"error": {"code", "message", ...}}.""" return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}}) def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None: """The entry-routing answer: which storefront, if any, this account has (§6.5). Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts domain call; the verify/me responses already carry the field. """ return None def _set_session_cookie(response: Response, account: accounts.Account) -> None: token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret()) response.set_cookie( SESSION_COOKIE, token, httponly=True, samesite="lax", secure=config.cookie_secure(), path="/", ) def create_app(database_url: str | None = None) -> FastAPI: dsn = database_url or config.database_url() @asynccontextmanager async def lifespan(app: FastAPI): app.state.pool = db.open_pool(dsn) with app.state.pool.connection() as conn: db.migrate(conn) # self-migrate at startup (INV-1, INV-7) app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8 try: yield finally: app.state.pool.close() app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan) @app.get("/healthz") def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)): """Liveness + readiness: process up, DB reachable, migrations current (§6.4).""" try: conn.execute("SELECT 1") pending = db.pending_migrations(conn) except Exception: response.status_code = 503 return {"status": "unavailable", "reason": "database_unreachable"} if pending: response.status_code = 503 return {"status": "unavailable", "reason": "migrations_pending"} return {"status": "ok"} @app.post("/api/auth/request-code") def request_code( body: RequestCodeBody, conn: psycopg.Connection = Depends(get_conn), mailer: Mailer = Depends(get_mailer), ): """Issue + dispatch a one-time code. Uniform for new/known emails (§6.6).""" try: accounts.request_code(conn, mailer, body.email) except accounts.InvalidEmail: return _error(400, "invalid_email", "That doesn't look like an email address.") except accounts.ResendCooldown as exc: return _error( 429, "resend_cooldown", f"Please wait {exc.retry_after_s}s before requesting another code.", retry_after_s=exc.retry_after_s, ) return Response(status_code=204) @app.post("/api/auth/verify") def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)): """Verify a code, start a session, and answer entry routing (§6.4/§6.5).""" try: account, created = accounts.verify(conn, body.email, body.code) except accounts.InvalidEmail: return _error(400, "invalid_email", "That doesn't look like an email address.") except accounts.CodeMismatch as exc: return _error( 400, "code_mismatch", "That code didn't match.", attempts_remaining=exc.attempts_remaining, ) except accounts.CodeExpired: return _error(400, "code_expired", "That code expired — request a fresh one.") except accounts.CodeExhausted: return _error(400, "code_exhausted", "Too many attempts — request a fresh code.") payload = { "account": {"email": account.email}, "storefront": _storefront_for(conn, account), "created": created, } resp = JSONResponse(status_code=200, content=payload) _set_session_cookie(resp, account) return resp @app.get("/api/auth/me") def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)): """The signed-in account + entry-routing answer, or 401 (§6.4/§6.5).""" if sess is None: return _error(401, "unauthenticated", "You are not signed in.") account = accounts.get_account(conn, sess["account_id"]) if account is None: return _error(401, "unauthenticated", "You are not signed in.") return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)} @app.post("/api/auth/logout") def logout(): """End the session by clearing the cookie. Idempotent (PUC-9; no server state).""" resp = Response(status_code=204) resp.delete_cookie(SESSION_COOKIE, path="/") return resp return app app = create_app()