diff --git a/specs/SD-0001-mvp-sign-up-and-single-storefront.md b/specs/SD-0001-mvp-sign-up-and-single-storefront.md index 2c59643..00aaa7f 100644 --- a/specs/SD-0001-mvp-sign-up-and-single-storefront.md +++ b/specs/SD-0001-mvp-sign-up-and-single-storefront.md @@ -531,19 +531,155 @@ text-first, honest — no countdowns, no badges, no fake density. ### 6.1 Invariants - +- **INV-1 — Empty is a working state.** The application boots against empty + persistence, applies its own schema migrations, and serves the full + sign-up → storefront → admin flow. No code path assumes a pre-existing row; + there is no seed step. *Enforced mechanically:* the bootstrap test (§6.8) + runs the whole flow from a fresh database; it is the first test of the + suite. +- **INV-2 — Email is the canonical account key.** One account per email + (case-insensitively normalized); any future auth method resolves to the + same account by email (corpus 14.01.0010). *Enforced:* unique index on the + normalized email column. +- **INV-3 — One-time codes are handled like secrets.** Stored only as hashes; + expire after 10 minutes; single-use; at most 5 verification attempts per + code; resend no more than once per 60 seconds per email. Codes never appear + in production logs or API responses. *Enforced:* schema (consumed/attempt + columns) + service-layer checks + tests. +- **INV-4 — One storefront per account is the MVP rule, not the schema.** + Ownership lives in a membership relation that admits many storefronts per + account and many members per storefront; the *service layer* refuses a + second storefront for the same account today (and the UX never offers it, + PUC-7). Lifting the rule later is deleting one check, not a migration. + *Enforced:* service-layer guard + test; no `UNIQUE(account_id)` anywhere in + the storefront path. +- **INV-5 — Every storefront-scoped row carries its storefront.** All future + tenant data references `storefront_id`; nothing tenant-scoped is global. + (Trivially satisfied by this MVP's two tenant tables; stated now because it + binds every later Feature.) *Enforced:* schema review + FK constraints. +- **INV-6 — One source of truth for rules.** Authentication, authorization, + and the one-storefront guard live in the domain layer exactly once; every + API surface (today REST; later GraphQL) delegates to them. *Enforced:* + layering contract (import-linter) + the API layer owning no business logic. +- **INV-7 — Migrations are forward-only and fail-stop.** Numbered `.sql` + files applied in order at startup, recorded in a migrations table; a failed + migration aborts startup (the deploy fails; the prior version keeps + serving). No down-migrations. +- **INV-8 — No deployment shape in framework code.** URLs, mail relay + coordinates, secrets: all arrive via environment/deployment configuration + (resolved from Secret Manager in deployed environments); none are baked + into the app (handbook §5.3). *Enforced:* config surface table (§6.6) + + review. +- **INV-9 — Honest failure.** When the platform cannot do what was asked + (code not sent, storefront not created), the user is told so — no fake + success, no silent drop (OHM: Transparency & Traceability). ### 6.2 High-level architecture - +```mermaid +flowchart LR + subgraph Browser + SPA[Admin SPA
React + Vite] + end + subgraph "ecomm backend (FastAPI, one process)" + BFF[REST BFF
main.py] + subgraph domains + ACC[accounts
identity · OTC · sessions] + SF[storefronts
storefront · membership] + end + subgraph platform + DB[db
SQLite WAL + migrations] + MAIL[mailer
port: log / SMTP] + DEPS[deps
request wiring] + end + end + STORE[(SQLite file)] + RELAY[SMTP relay
deployed envs only] + + SPA -->|JSON /api/*| BFF + BFF --> ACC + BFF --> SF + ACC --> DB + SF --> DB + ACC --> MAIL + DB --> STORE + MAIL -.-> RELAY +``` + +Layering is the prototype's four-layer contract, mechanically enforced with +import-linter: `main` (entrypoint + REST BFF) → `domains` → `platform`; +imports flow only downward; domains are imported via their package surface +(`__all__`), never by reaching into modules. + +- **Admin SPA** — the four surfaces of §5; owns no rules. Renders what the + BFF returns; must never enforce a business rule the backend doesn't (the + UX-level one-storefront affordance is presentation; the rule is INV-4's + service guard). +- **REST BFF (`main.py`)** — screen-shaped JSON endpoints, one round-trip per + screen; translates HTTP ↔ domain calls; owns no business logic (INV-6). +- **`accounts` domain** — owns identity: account records, one-time-code + issue/verify (INV-2, INV-3), session establishment, the signed-in-user + gate. Must never know what a storefront is. +- **`storefronts` domain** — owns the storefront entity and the + account↔storefront membership (INV-4, INV-5), including the entry-routing + answer ("which storefront, if any, does this account have?"). Must never + mint identity. +- **`platform/db`** — SQLite WAL connection + forward-only migration runner + (INV-7); owns the schema lifecycle, no business rules. +- **`platform/mailer`** — a one-method port (`send(to, subject, body)`) with + two adapters: `LogMailer` (localhost/dev and tests — the message lands in + the app log / a test inbox) and `SmtpMailer` (PPE/Prod — relay coordinates + from deployment config, INV-8). Chosen by configuration at startup. +- **`platform/deps`** — FastAPI dependency wiring: per-request DB connection, + current-session resolution, configured mailer. + +Sessions are signed HTTP-only cookies (server secret from configuration; no +session table). MVP-honest: signing out clears the cookie; a leaked signing +secret is rotated by replacing the configured secret, which invalidates all +sessions at once. Revisit (server-side sessions) when staff/multi-device +management arrives. ### 6.3 Data model & ownership - +| Entity | Owned by | Key fields | System of record | +| --- | --- | --- | --- | +| `account` | accounts | `id`, `email` (unique, normalized — INV-2), `created_at` | SQLite | +| `auth_code` | accounts | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `consumed_at`, `created_at` (INV-3) | SQLite | +| `storefront` | storefronts | `id`, `name`, `created_at` | SQLite | +| `storefront_membership` | storefronts | `account_id` (FK), `storefront_id` (FK), `role` (MVP: always `'owner'`), `created_at`; PK `(account_id, storefront_id)` | SQLite | + +Notes: + +- **Data minimization (OHM):** the account stores the email and nothing else — + no name, no phone, no profile (corpus sign-up asks for nothing more; + prototype precedent). A display name arrives only when a Feature needs it. +- `auth_code` rows are keyed by *email*, not account, because the code is + issued before the account may exist (sign-up and log-in converge, PUC-3). + Consumed/expired rows are purged opportunistically on issue. +- `storefront_membership.role` is a single-value enum today; it is the seam + where the corpus's staff & permissions model (13.15.\*) attaches later + without reshaping ownership (INV-4 rationale). +- `storefront.name`: when the merchant leaves it blank, a generated default + (e.g. "⟨email local-part⟩'s storefront") is stored — the column is always + populated; "unnamed" is a naming choice, not a NULL (corpus 14.01.0026). ### 6.4 Interfaces & contracts - +All JSON over `/api/*`; session via signed cookie. Errors share one shape: +`{"error": {"code": "", "message": ""}}`. + +| Endpoint | In | Out | Errors | +| --- | --- | --- | --- | +| `POST /api/auth/request-code` | `{email}` | `204` — code issued & dispatched. Response is identical whether the email is new or known (no account enumeration). | `400 invalid_email` · `429 resend_cooldown` (with `retry_after_s`) · `502 delivery_failed` (INV-9 — the relay refused; dev `LogMailer` cannot fail) | +| `POST /api/auth/verify` | `{email, code}` | `200 {account: {email}, storefront: {id, name} \| null, created: bool}` — session cookie set; `created` drives the honest welcome copy (PUC-3); `storefront` drives entry routing (§6.5). | `400 code_mismatch` (with `attempts_remaining`) · `400 code_expired` · `400 code_exhausted` | +| `GET /api/auth/me` | — | `200 {account: {email}, storefront: {id, name} \| null}` | `401 unauthenticated` | +| `POST /api/auth/logout` | — | `204` — cookie cleared | — (idempotent) | +| `POST /api/storefronts` | `{name?: string}` | `201 {id, name}` — membership row created with `role='owner'` | `401 unauthenticated` · `409 already_owns_storefront` (INV-4, PUC-7) | +| `GET /healthz` | — | `200 {status: "ok"}` — process up, DB reachable, migrations current | `503` otherwise (deploy gate, §6.9) | + +Contract stance: these are the stable seams the SPA and the tests bind to; +adding fields is free, renaming/removing is a spec change (this document, +§3.1-handbook). ### 6.5 Per–Product-Use-Case design