Files
ben.stull df92e3f94c
ci / check (push) Has been cancelled
chore(reset)!: strip storefront → network-service seed (v0.9.0)
Pivot ecomm from a generic multi-tenant Shopify-alternative storefront to the
commitment-commerce + cross-maker verified referral NETWORK direction
(ecomm-content/rfcs/network_strategy.md; OHM identity/relationality super-drafts).

Phase A of the reset — clear the decks:
- Strip the storefront surfaces with no carry-forward: the storefronts domain,
  the products import/export/image HTTP spine (service/imagefetch/repo + endpoints),
  the SPA frontend, and the Playwright e2e suite.
- Keep the reusable core: /healthz + /api/auth/* (accounts); the
  catalog-normalization library (codec/dialect/models/serialize/validate/diff/hosted —
  importable, no HTTP yet); platform/* infra. Migrations untouched (append-only).
- Reduce check.sh to backend-only (import-linter + pytest); trim dev.sh and the unused
  GCS overlay env. Repoint app.json/README/CLAUDE.md; bump VERSION 0.8.0 -> 0.9.0.

check.sh green: import-linter (main > domains > platform) KEPT, pytest passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:30:01 -07:00

186 lines
7.1 KiB
Python

"""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()