"""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. SLICE-3 adds POST /api/storefronts and feeds the _storefront_for seam from the storefronts domain. """ from __future__ import annotations import logging import sys 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, storefronts 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 class CreateStorefrontBody(BaseModel): name: str | None = None 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).""" sf = storefronts.storefront_for(conn, account.id) return {"id": sf.id, "name": sf.name} if sf else None def _ensure_app_logging() -> None: """Surface the app's own `ecomm.*` INFO logs on stderr (idempotent). uvicorn configures only its own loggers, leaving the root with no INFO handler — so without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in the backend log) would be swallowed. A dedicated handler with propagate=False keeps it out of uvicorn's stream and avoids double-logging. """ lg = logging.getLogger("ecomm") if not lg.handlers: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s")) lg.addHandler(handler) lg.setLevel(logging.INFO) lg.propagate = False 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: _ensure_app_logging() 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.3", 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 @app.post("/api/storefronts") def create_storefront( body: CreateStorefrontBody, conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session), ): """Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal).""" 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.") try: sf = storefronts.create_storefront(conn, account.id, account.email, body.name) except storefronts.AlreadyOwnsStorefront: return _error( 409, "already_owns_storefront", "Your account already has its storefront — ecomm is one storefront per account today.", ) return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name}) return app app = create_app()