"""ecomm backend — FastAPI app factory + the network-service seed BFF. This is the pivot to a network-service seed: a minimal FastAPI surface carrying only /healthz and the /api/auth/* identity endpoints (§6.4), backed by the accounts domain. 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 — auth needs the mailer) at startup. The catalog-normalization library (app.domains.products: codec/dialect/models/serialize/ validate/diff) is kept as importable modules but has no HTTP surface yet — the storefront and products import/export/image spine was stripped in the network-seed reset. """ from __future__ import annotations import logging import sys from contextlib import asynccontextmanager from pathlib import Path 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 # The repo-root VERSION file is the single version source: the deploy pin checks out its # tag, and /healthz must report it back (flotilla-core's verify gate compares them). _REPO_ROOT = Path(__file__).resolve().parents[2] try: _APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip() except OSError: _APP_VERSION = "0.0.0" 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 _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, max_size=10) 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=_APP_VERSION, 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", "version": _APP_VERSION} @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, ) except accounts.DeliveryFailed: return _error( 502, "delivery_failed", "We couldn't send the code — try again in a moment.", ) return Response(status_code=204) @app.post("/api/auth/verify") def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)): """Verify a code and start a session (§6.4).""" 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}, "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, or 401 (§6.4).""" 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}} @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()