SD-0001: Solution Design — MVP sign up and create a single storefront (Feature #1) #2
@@ -531,19 +531,155 @@ text-first, honest — no countdowns, no badges, no fake density.
|
||||
|
||||
### 6.1 Invariants
|
||||
|
||||
<!-- §6.1 pending -->
|
||||
- **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
|
||||
|
||||
<!-- §6.2 pending -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser
|
||||
SPA[Admin SPA<br/>React + Vite]
|
||||
end
|
||||
subgraph "ecomm backend (FastAPI, one process)"
|
||||
BFF[REST BFF<br/>main.py]
|
||||
subgraph domains
|
||||
ACC[accounts<br/>identity · OTC · sessions]
|
||||
SF[storefronts<br/>storefront · membership]
|
||||
end
|
||||
subgraph platform
|
||||
DB[db<br/>SQLite WAL + migrations]
|
||||
MAIL[mailer<br/>port: log / SMTP]
|
||||
DEPS[deps<br/>request wiring]
|
||||
end
|
||||
end
|
||||
STORE[(SQLite file)]
|
||||
RELAY[SMTP relay<br/>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
|
||||
|
||||
<!-- §6.3 pending -->
|
||||
| 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
|
||||
|
||||
<!-- §6.4 pending -->
|
||||
All JSON over `/api/*`; session via signed cookie. Errors share one shape:
|
||||
`{"error": {"code": "<machine-code>", "message": "<honest human text>"}}`.
|
||||
|
||||
| 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user