From d2eceac272d7891e365edee932cb2d4dbd828c4a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:34:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-2):=20deps=20=E2=80=94=20mailer=20+?= =?UTF-8?q?=20session=20dependencies;=20build=20mailer=20at=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/main.py | 117 +++++++++++++++++++++++++++++++++-- backend/app/platform/deps.py | 24 ++++++- 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 2386454..459466d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,19 +1,62 @@ """ecomm backend — FastAPI app factory + the REST BFF. -SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in -later slices. create_app() opens the connection pool and self-migrates the database -at startup (INV-1, INV-7): the app boots against empty persistence and applies its -own schema — there is no seed step. +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.deps import get_conn +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: @@ -24,12 +67,13 @@ def create_app(database_url: str | None = None) -> 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.1", lifespan=lifespan) + app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan) @app.get("/healthz") def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)): @@ -45,6 +89,67 @@ def create_app(database_url: str | None = None) -> FastAPI: 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 diff --git a/backend/app/platform/deps.py b/backend/app/platform/deps.py index 36cdfca..7c599dc 100644 --- a/backend/app/platform/deps.py +++ b/backend/app/platform/deps.py @@ -1,18 +1,38 @@ """FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps). -Yields a pooled connection per request. The pool lives on app.state, built once in -create_app(). Later slices add the current-session and mailer dependencies here. +Yields a pooled connection per request, the process-wide mailer, and the verified session +payload (from the signed cookie). The pool and mailer live on app.state, built once in +create_app(). The storefronts dependency lands in SLICE-3. """ from __future__ import annotations from collections.abc import Iterator +from typing import Any import psycopg from fastapi import Request +from app.platform import config, session +from app.platform.mailer import Mailer + +SESSION_COOKIE = "ecomm_session" + def get_conn(request: Request) -> Iterator[psycopg.Connection]: """A pooled connection for the duration of one request.""" pool = request.app.state.pool with pool.connection() as conn: yield conn + + +def get_mailer(request: Request) -> Mailer: + """The process-wide mailer adapter (built from config at startup, INV-8).""" + return request.app.state.mailer + + +def get_session(request: Request) -> dict[str, Any] | None: + """The verified session payload from the cookie, or None if absent/invalid.""" + token = request.cookies.get(SESSION_COOKIE) + if not token: + return None + return session.verify(token, config.session_secret())