df92e3f94c
ci / check (push) Has been cancelled
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>
186 lines
7.1 KiB
Python
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()
|