diff --git a/plans/2026-06-10-slice-2-identity.md b/plans/2026-06-10-slice-2-identity.md new file mode 100644 index 0000000..6a59c66 --- /dev/null +++ b/plans/2026-06-10-slice-2-identity.md @@ -0,0 +1,2053 @@ +# SLICE-2 Identity — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build ecomm's identity slice — open, passwordless sign-up/log-in by email + +one-time code, signed-cookie sessions, and the Landing + Sign-in screens with the entry +routing rule — on top of the SLICE-1 walking skeleton (SD-0001 §7.2 SLICE-2; completes +PUC-1, PUC-2 +a/b/c, PUC-3, PUC-9). + +**Architecture:** The `accounts` domain owns identity (request-code / verify / get-or-create +account / load-account), enforcing INV-2 (email is the canonical key) and INV-3 (one-time +codes handled like secrets: hashed, 10-min TTL, single-use, ≤5 attempts, 60s resend +cooldown). Two new `platform` ports carry the cross-cutting machinery: `platform/mailer` +(a `send(to, subject, body)` port with a `LogMailer` adapter — the local dev channel that +puts the code in the backend log) and `platform/session` (stdlib-HMAC signed cookie +sessions, no session table). The REST BFF in `main.py` exposes the four `/api/auth/*` +endpoints; the React SPA gains a Landing screen, a two-step Sign-in screen, and a complete +client-side entry-routing rule with `storefront` hard-wired `null` (storefronts land in +SLICE-3). Layering stays import-linter–enforced: `app.main > app.domains > app.platform`. + +**Tech Stack:** Python 3.13 · FastAPI · psycopg 3 + psycopg-pool · pytest · import-linter · +PostgreSQL 16 (Docker) · React 18 + Vite 5 + TypeScript · **Vitest** (new — the routing +unit test §6.8 requires) · stdlib `hmac`/`hashlib`/`secrets` (no new auth dependency). + +**Spec:** `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md` +(v0.3.0). Key sections: §5.1–5.2 (Landing, Sign-in UX), §6.2 (architecture/sessions), +§6.3 (data model — already migrated by SLICE-1's `0001_init.sql`), §6.4 (endpoint +contracts), §6.5 (entry routing + PUC-2/3/9 sequence), §6.6 (security — no enumeration, +log hygiene), §6.8 (testing), §7.2 SLICE-2. + +**Working tree:** isolated worktree on branch `slice-2-identity` (off `origin/main` @ +9373177, the merged SLICE-1). The dev Postgres container is up; `.venv` and +`frontend/node_modules` are present. All paths below are relative to the repo root unless +absolute. Run Python tools via `../.venv/bin/...` from `backend/` (as SLICE-1 did). + +**Reference (read-only prior art — patterns to port, not copy):** +`/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype` — its +`backend/app/domains/` shape and auth handling are reference only (it is SQLite + invite +gate; this slice is Postgres + open sign-up). + +**Important — the schema already exists.** SLICE-1's `migrations/0001_init.sql` already +created `account`, `auth_code`, `storefront`, `storefront_membership` exactly per §6.3. +**This slice adds NO migration** — it writes the domain/service code over that schema. Do +not edit `0001_init.sql` (forward-only, INV-7). + +--- + +## File Structure + +``` +wiggleverse-ecomm/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # MODIFY: add the four /api/auth/* endpoints + pydantic bodies +│ │ ├── domains/ +│ │ │ └── accounts/ # NEW package — the identity domain +│ │ │ ├── __init__.py # public surface (__all__): request_code, verify, get_account, Account, errors +│ │ │ ├── models.py # Account dataclass +│ │ │ ├── errors.py # domain exceptions (InvalidEmail, ResendCooldown, CodeMismatch, CodeExpired, CodeExhausted) +│ │ │ └── service.py # request_code / verify / get_account; email norm, code gen+hash, cooldown, attempts +│ │ └── platform/ +│ │ ├── config.py # MODIFY: session_secret(), cookie_secure(), mailer_kind() +│ │ ├── deps.py # MODIFY: get_mailer(), get_session() +│ │ ├── mailer.py # NEW: Mailer port + LogMailer + build_mailer() +│ │ └── session.py # NEW: sign()/verify() HMAC-signed cookie tokens +│ └── tests/ +│ ├── test_config.py # MODIFY: cover the new config readers +│ ├── test_mailer.py # NEW: LogMailer captures to outbox +│ ├── test_session.py # NEW: sign/verify round-trip + tamper rejection +│ ├── test_accounts_request_code.py # NEW: request_code — issue/send, uniform new-or-known, cooldown +│ ├── test_accounts_verify.py # NEW: verify — success/created, INV-2 same-account, mismatch/expired/exhausted (INV-3) +│ └── test_auth_endpoints.py # NEW: HTTP surface — 204/400/429, cookie set, /me 200/401, logout (PUC-9), no-enumeration +└── frontend/ + ├── package.json # MODIFY: add vitest devDep + "test" script + ├── scripts/check.sh (../scripts/check.sh)# MODIFY: frontend gate runs build + vitest + └── src/ + ├── api.ts # NEW: typed fetch wrappers for /api/auth/* + ├── routing.ts # NEW: the entry-routing rule (pure function) + ├── routing.test.ts # NEW: routing rule unit test (§6.8) + ├── App.tsx # MODIFY: load /me, hold door state, render by route + └── screens/ + ├── Landing.tsx # NEW: §5.1 — two doors + ├── SignIn.tsx # NEW: §5.2 — two-step email + code + └── SignedIn.tsx # NEW: SLICE-2 placeholder for create-storefront/admin (real screens in SLICE-3) + Sign out +``` + +**Responsibilities (one job each):** +- `platform/mailer.py` — the outbound-message port; `LogMailer` is the dev/test adapter + (records to an in-memory `outbox` AND logs the full body so the code reaches the developer + — PUC-10's local dev channel). `SmtpMailer` is SLICE-4; `build_mailer()` selects by config. +- `platform/session.py` — encode/verify a signed session token (stdlib HMAC-SHA256 over a + JSON payload). No DB; the secret comes from config (INV-8). Tamper → `None`. +- `domains/accounts/service.py` — all identity rules in one place (INV-6): email + normalization (INV-2), code generation + peppered hashing + TTL/attempts/cooldown (INV-3), + get-or-create account. Imports only `app.platform` (downward). +- `app/main.py` — the BFF: translates HTTP ↔ `accounts` calls, sets/clears the session + cookie, maps domain exceptions to the §6.4 error shape. Owns no business logic. +- `frontend/src/routing.ts` — the single client-side entry-routing rule (§6.5), pure and + unit-tested; `App.tsx` applies it. + +**Design notes (decisions this plan locks in):** +- **Code hashing:** `code_hash = HMAC-SHA256(key=session_secret, msg=code)` — "hashes only" + (INV-3) and peppered by the app secret, so a DB leak alone can't brute-force the 6-digit + space offline. No new config/env beyond the session secret. +- **No "no active code" error in §6.4:** when `verify` finds no live code for the email + (never requested, already consumed, or purged), it raises `CodeExpired` — the honest, + actionable answer ("that code expired — send a fresh one"). The three verify errors stay + exactly those §6.4 names. +- **Cooldown** is checked against `max(created_at)` of any code for the email; `retry_after_s` + is the remaining whole seconds to 60s. +- **Sessions** carry `{account_id, email}`; no expiry claim in the token (cookie-session, + §6.2 — a leaked secret is rotated to invalidate all). `HttpOnly`, `SameSite=Lax`, `Secure` + only when `cookie_secure()` (deployed). +- **storefront is hard-wired `null`** everywhere this slice (verify response, `/me`, routing + input). SLICE-3 replaces the `null` with a `storefronts` domain call — the seam is a single + helper in `main.py`. +- **E2E browser tests are deferred** for this slice per spec §6.8 ("component/E2E browser + tests deferred until there is UI beyond forms"). This is a conscious divergence from the + handbook §9 "E2E for any UI surface" default; it is logged as a Deferred decision at + finalize. The §6.8-required **routing-rule unit test** IS included (Task 7). + +--- + +## Task 1: Config surface — session secret, cookie flag, mailer kind (INV-8) + +**Files:** +- Modify: `backend/app/platform/config.py` +- Modify: `backend/tests/test_config.py` + +All three new readers follow the existing `database_url()` pattern: env var with a +localhost-dev default, so a clean `scripts/dev.sh` needs no env setup; deployed environments +supply real values from Secret Manager (INV-8). + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/tests/test_config.py`: + +```python +def test_session_secret_defaults_for_dev(monkeypatch): + monkeypatch.delenv("ECOMM_SESSION_SECRET", raising=False) + # A non-empty dev default so localhost works with no setup; deployed overrides it. + assert config.session_secret() + + +def test_session_secret_honors_env(monkeypatch): + monkeypatch.setenv("ECOMM_SESSION_SECRET", "s3cret") + assert config.session_secret() == "s3cret" + + +def test_cookie_secure_defaults_false(monkeypatch): + monkeypatch.delenv("ECOMM_COOKIE_SECURE", raising=False) + assert config.cookie_secure() is False + + +def test_cookie_secure_truthy_env(monkeypatch): + monkeypatch.setenv("ECOMM_COOKIE_SECURE", "1") + assert config.cookie_secure() is True + monkeypatch.setenv("ECOMM_COOKIE_SECURE", "true") + assert config.cookie_secure() is True + monkeypatch.setenv("ECOMM_COOKIE_SECURE", "0") + assert config.cookie_secure() is False + + +def test_mailer_kind_defaults_to_log(monkeypatch): + monkeypatch.delenv("ECOMM_MAILER", raising=False) + assert config.mailer_kind() == "log" +``` + +- [ ] **Step 2: Run to verify they fail** + +Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q` +Expected: FAIL — `AttributeError: module 'app.platform.config' has no attribute 'session_secret'`. + +- [ ] **Step 3: Implement the new readers** + +In `backend/app/platform/config.py`, after the `database_url()` function, add: + +```python +# Session signing secret. Dev default is intentionally well-known and insecure — a clean +# checkout works with no setup; deployed environments MUST supply ECOMM_SESSION_SECRET from +# Secret Manager (INV-8, §6.6). Rotating it invalidates all sessions at once (§6.2). +_DEFAULT_SESSION_SECRET = "dev-insecure-session-secret-change-me" + + +def session_secret() -> str: + """The HMAC key for signed session cookies (and the one-time-code pepper).""" + return os.environ.get("ECOMM_SESSION_SECRET") or _DEFAULT_SESSION_SECRET + + +def cookie_secure() -> bool: + """Whether to mark the session cookie Secure (HTTPS-only). True in deployed envs.""" + return os.environ.get("ECOMM_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"} + + +def mailer_kind() -> str: + """Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed; SLICE-4).""" + return os.environ.get("ECOMM_MAILER") or "log" +``` + +- [ ] **Step 4: Run to verify they pass** + +Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q` +Expected: PASS (original 2 + new 6 = 8 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/platform/config.py backend/tests/test_config.py +git commit -m "feat(slice-2): config — session secret, cookie_secure, mailer_kind (INV-8)" +``` + +--- + +## Task 2: platform/mailer — the outbound-message port + LogMailer + +**Files:** +- Create: `backend/app/platform/mailer.py` +- Test: `backend/tests/test_mailer.py` + +A one-method port with the dev/test `LogMailer` adapter. `LogMailer` records every message +to an in-memory `outbox` (so tests read the code back — §6.8) and logs the full body (so the +developer sees the code in the terminal — PUC-10's local dev channel). `LogMailer` cannot +fail, which is why the `502 delivery_failed` path (§6.4) only exists for the deployed +`SmtpMailer` in SLICE-4. + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_mailer.py`: + +```python +from app.platform import mailer + + +def test_logmailer_captures_to_outbox(): + m = mailer.LogMailer() + assert m.outbox == [] + m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)") + assert len(m.outbox) == 1 + msg = m.outbox[-1] + assert msg.to == "merchant@example.com" + assert msg.subject == "Your ecomm code: 123456" + assert "123456" in msg.body + + +def test_build_mailer_log_kind(): + m = mailer.build_mailer("log") + assert isinstance(m, mailer.LogMailer) + + +def test_build_mailer_smtp_not_yet_available(): + # SmtpMailer lands in SLICE-4; until then asking for it is an explicit error, not a + # silent fallback to LogMailer (which would send no real mail in a deployed env). + import pytest + + with pytest.raises(NotImplementedError): + mailer.build_mailer("smtp") +``` + +- [ ] **Step 2: Run to verify it fails** + +Run from `backend/`: `../.venv/bin/pytest tests/test_mailer.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.platform.mailer'`. + +- [ ] **Step 3: Implement the port + adapter** + +Create `backend/app/platform/mailer.py`: + +```python +"""platform/mailer — the outbound email port (SD-0001 §6.2). + +A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests — +the message lands in the app log and an in-memory outbox tests read back, §6.8) and the +deployed `SmtpMailer` (SLICE-4). The adapter is chosen by configuration at startup +(INV-8), so no deployment shape lives in the domain. LogMailer logs the full body on +purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in +the terminal. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Protocol + +logger = logging.getLogger("ecomm.mailer") + + +@dataclass(frozen=True) +class SentMessage: + to: str + subject: str + body: str + + +class Mailer(Protocol): + """The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502).""" + + def send(self, to: str, subject: str, body: str) -> None: ... + + +class LogMailer: + """Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10).""" + + def __init__(self) -> None: + self.outbox: list[SentMessage] = [] + + def send(self, to: str, subject: str, body: str) -> None: + self.outbox.append(SentMessage(to=to, subject=subject, body=body)) + # Full body incl. the code — the local dev channel. Dev/test only (never deployed). + logger.info("LogMailer -> %s | %s\n%s", to, subject, body) + + +def build_mailer(kind: str) -> Mailer: + """Select the mailer adapter by configured kind (config.mailer_kind(), INV-8).""" + if kind == "log": + return LogMailer() + if kind == "smtp": + raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)") + raise ValueError(f"unknown mailer kind: {kind!r}") +``` + +- [ ] **Step 4: Run to verify it passes** + +Run from `backend/`: `../.venv/bin/pytest tests/test_mailer.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run the layer contract** + +Run from `backend/`: `../.venv/bin/lint-imports` +Expected: `Contracts: 1 kept, 0 broken.` (`mailer.py` imports only stdlib — no upward import). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/platform/mailer.py backend/tests/test_mailer.py +git commit -m "feat(slice-2): platform/mailer port + LogMailer (dev channel, §6.2/PUC-10)" +``` + +--- + +## Task 3: platform/session — HMAC-signed cookie tokens (§6.2) + +**Files:** +- Create: `backend/app/platform/session.py` +- Test: `backend/tests/test_session.py` + +Signed, stateless session tokens using stdlib `hmac`/`hashlib` (no new dependency). A token +is `base64url(json_payload).base64url(hmac_sha256)`. `verify` recomputes the MAC in constant +time and returns the payload, or `None` if the token is malformed or tampered. No expiry +claim (cookie-session per §6.2; rotation invalidates all). + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_session.py`: + +```python +from app.platform import session + +SECRET = "test-secret" + + +def test_sign_then_verify_round_trips(): + token = session.sign({"account_id": 7, "email": "m@example.com"}, SECRET) + assert session.verify(token, SECRET) == {"account_id": 7, "email": "m@example.com"} + + +def test_verify_rejects_tampered_payload(): + token = session.sign({"account_id": 7}, SECRET) + payload_b64, _, sig = token.partition(".") + # Flip the payload but keep the old signature -> must be rejected. + forged = session.sign({"account_id": 8}, SECRET).split(".")[0] + "." + sig + assert session.verify(forged, SECRET) is None + + +def test_verify_rejects_wrong_secret(): + token = session.sign({"account_id": 7}, SECRET) + assert session.verify(token, "other-secret") is None + + +def test_verify_rejects_garbage(): + assert session.verify("not-a-token", SECRET) is None + assert session.verify("", SECRET) is None +``` + +- [ ] **Step 2: Run to verify it fails** + +Run from `backend/`: `../.venv/bin/pytest tests/test_session.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.platform.session'`. + +- [ ] **Step 3: Implement signing/verification** + +Create `backend/app/platform/session.py`: + +```python +"""platform/session — stateless signed session tokens (SD-0001 §6.2). + +Sessions are signed HTTP-only cookies; there is no session table. A token is +`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session +secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or +None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked +secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale). +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from typing import Any + + +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _b64d(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _mac(payload_b64: str, secret: str) -> str: + digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest() + return _b64e(digest) + + +def sign(payload: dict[str, Any], secret: str) -> str: + """Serialize and sign a session payload into a cookie-safe token.""" + payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")) + return f"{payload_b64}.{_mac(payload_b64, secret)}" + + +def verify(token: str, secret: str) -> dict[str, Any] | None: + """Return the payload if the token's signature is valid, else None.""" + if not token or "." not in token: + return None + payload_b64, _, sig = token.partition(".") + expected = _mac(payload_b64, secret) + if not hmac.compare_digest(sig, expected): + return None + try: + return json.loads(_b64d(payload_b64)) + except (ValueError, json.JSONDecodeError): + return None +``` + +- [ ] **Step 4: Run to verify it passes** + +Run from `backend/`: `../.venv/bin/pytest tests/test_session.py -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/platform/session.py backend/tests/test_session.py +git commit -m "feat(slice-2): platform/session — stdlib HMAC signed cookie tokens (§6.2)" +``` + +--- + +## Task 4: accounts domain — request_code, verify, get_account (INV-2, INV-3, INV-6) + +**Files:** +- Create: `backend/app/domains/accounts/__init__.py` +- Create: `backend/app/domains/accounts/models.py` +- Create: `backend/app/domains/accounts/errors.py` +- Create: `backend/app/domains/accounts/service.py` +- Test: `backend/tests/test_accounts_request_code.py` +- Test: `backend/tests/test_accounts_verify.py` + +The heart of the slice. All identity rules live here exactly once (INV-6); the BFF will only +translate. The domain imports only `app.platform` (downward). Tests drive the service +directly against a fresh Postgres database (the `fresh_db_url` fixture from SLICE-1's +`conftest.py`), migrating it first with `db.migrate`. + +- [ ] **Step 1: Create the domain models** + +Create `backend/app/domains/accounts/models.py`: + +```python +"""accounts — domain models (SD-0001 §6.3).""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Account: + """A person's identity on the platform. Email is the canonical key (INV-2).""" + + id: int + email: str +``` + +- [ ] **Step 2: Create the domain errors** + +Create `backend/app/domains/accounts/errors.py`: + +```python +"""accounts — domain exceptions. + +Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here +(not in main) keeps the rules in the domain (INV-6). +""" +from __future__ import annotations + + +class AccountsError(Exception): + """Base for accounts-domain errors.""" + + +class InvalidEmail(AccountsError): + """The supplied email is not a plausible address (§6.4 400 invalid_email).""" + + +class ResendCooldown(AccountsError): + """A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown).""" + + def __init__(self, retry_after_s: int) -> None: + super().__init__(f"resend available in {retry_after_s}s") + self.retry_after_s = retry_after_s + + +class CodeMismatch(AccountsError): + """The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch).""" + + def __init__(self, attempts_remaining: int) -> None: + super().__init__("code did not match") + self.attempts_remaining = attempts_remaining + + +class CodeExpired(AccountsError): + """No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired).""" + + +class CodeExhausted(AccountsError): + """The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted).""" +``` + +- [ ] **Step 3: Write the failing request_code tests** + +Create `backend/tests/test_accounts_request_code.py`: + +```python +import re + +import psycopg +import pytest + +from app.domains import accounts +from app.platform import db, mailer + + +@pytest.fixture() +def conn(fresh_db_url): + with psycopg.connect(fresh_db_url) as c: + db.migrate(c) + yield c + + +def _code_in(outbox) -> str: + # The 6-digit code appears in the latest message body (LogMailer dev channel). + return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1) + + +def test_14_01_0002_request_code_sends_a_six_digit_code(conn): + m = mailer.LogMailer() + accounts.request_code(conn, m, "Merchant@Example.com") + assert len(m.outbox) == 1 + assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2) + assert re.search(r"\b\d{6}\b", m.outbox[-1].body) + + +def test_14_01_0003_request_code_stores_only_a_hash(conn): + m = mailer.LogMailer() + accounts.request_code(conn, m, "merchant@example.com") + code = _code_in(m.outbox) + rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall() + assert len(rows) == 1 + # The plaintext code is never stored (INV-3). + assert code not in rows[0][0] + assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code + + +def test_request_code_uniform_for_new_and_known(conn): + # No account enumeration (§6.6): the call behaves identically whether or not the email + # already has an account — it never signals existence and never raises. + m = mailer.LogMailer() + accounts.request_code(conn, m, "newcomer@example.com") # no account exists + # create an account, then request again for a known email + conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",)) + conn.commit() + accounts.request_code(conn, m, "known@example.com") + assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known + + +def test_puc_02c_resend_is_rate_limited(conn): + m = mailer.LogMailer() + accounts.request_code(conn, m, "merchant@example.com") + with pytest.raises(accounts.ResendCooldown) as exc: + accounts.request_code(conn, m, "merchant@example.com") + assert 0 < exc.value.retry_after_s <= 60 + assert len(m.outbox) == 1 # the second code was NOT sent + + +def test_request_code_rejects_invalid_email(conn): + m = mailer.LogMailer() + with pytest.raises(accounts.InvalidEmail): + accounts.request_code(conn, m, "not-an-email") + assert m.outbox == [] +``` + +- [ ] **Step 4: Write the failing verify tests** + +Create `backend/tests/test_accounts_verify.py`: + +```python +import re +from datetime import datetime, timedelta, timezone + +import psycopg +import pytest + +from app.domains import accounts +from app.platform import db, mailer + + +@pytest.fixture() +def conn(fresh_db_url): + with psycopg.connect(fresh_db_url) as c: + db.migrate(c) + yield c + + +def _issue(conn, email) -> str: + m = mailer.LogMailer() + accounts.request_code(conn, m, email) + return re.search(r"\b(\d{6})\b", m.outbox[-1].body).group(1) + + +def test_14_01_0004_verify_creates_account_and_reports_created(conn): + code = _issue(conn, "merchant@example.com") + account, created = accounts.verify(conn, "merchant@example.com", code) + assert created is True + assert account.email == "merchant@example.com" + assert account.id > 0 + # the account row exists exactly once (INV-2) + n = conn.execute("SELECT count(*) FROM account WHERE email = %s", ("merchant@example.com",)).fetchone()[0] + assert n == 1 + + +def test_14_01_0009_returning_account_reports_not_created(conn): + # First sign-up creates the account. + code1 = _issue(conn, "merchant@example.com") + a1, created1 = accounts.verify(conn, "merchant@example.com", code1) + assert created1 is True + # A later login resolves to the SAME account (INV-2 — email is canonical), created=False. + code2 = _issue(conn, "Merchant@Example.com") # different casing, same identity + a2, created2 = accounts.verify(conn, "Merchant@Example.com", code2) + assert created2 is False + assert a2.id == a1.id + n = conn.execute("SELECT count(*) FROM account").fetchone()[0] + assert n == 1 # no duplicate account (14.01.0010) + + +def test_verify_consumes_the_code_single_use(conn): + code = _issue(conn, "merchant@example.com") + accounts.verify(conn, "merchant@example.com", code) + # Reusing a consumed code is refused (single-use, INV-3): no live code -> CodeExpired. + with pytest.raises(accounts.CodeExpired): + accounts.verify(conn, "merchant@example.com", code) + + +def test_puc_02a_wrong_code_decrements_attempts(conn): + _issue(conn, "merchant@example.com") + with pytest.raises(accounts.CodeMismatch) as exc: + accounts.verify(conn, "merchant@example.com", "000000") + # 5 attempts allowed; one wrong -> 4 remaining (INV-3). + assert exc.value.attempts_remaining == 4 + + +def test_puc_02b_expired_code_is_refused(conn): + code = _issue(conn, "merchant@example.com") + # Force the live code to be expired. + conn.execute( + "UPDATE auth_code SET expires_at = %s WHERE email = %s", + (datetime.now(timezone.utc) - timedelta(minutes=1), "merchant@example.com"), + ) + conn.commit() + with pytest.raises(accounts.CodeExpired): + accounts.verify(conn, "merchant@example.com", code) + + +def test_inv3_attempts_exhausted_invalidates_code(conn): + code = _issue(conn, "merchant@example.com") + # Five wrong attempts: the 5th raises CodeExhausted and invalidates the code. + for _ in range(4): + with pytest.raises(accounts.CodeMismatch): + accounts.verify(conn, "merchant@example.com", "000000") + with pytest.raises(accounts.CodeExhausted): + accounts.verify(conn, "merchant@example.com", "000000") + # Even the correct code no longer works — a fresh request is required. + with pytest.raises((accounts.CodeExpired, accounts.CodeExhausted)): + accounts.verify(conn, "merchant@example.com", code) + + +def test_verify_with_no_request_is_code_expired(conn): + with pytest.raises(accounts.CodeExpired): + accounts.verify(conn, "stranger@example.com", "123456") + + +def test_get_account_loads_by_id(conn): + code = _issue(conn, "merchant@example.com") + account, _ = accounts.verify(conn, "merchant@example.com", code) + loaded = accounts.get_account(conn, account.id) + assert loaded is not None and loaded.email == "merchant@example.com" + assert accounts.get_account(conn, 999999) is None +``` + +- [ ] **Step 5: Run both test files to verify they fail** + +Run from `backend/`: `../.venv/bin/pytest tests/test_accounts_request_code.py tests/test_accounts_verify.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.domains.accounts'` (or attribute errors). + +- [ ] **Step 6: Implement the service** + +Create `backend/app/domains/accounts/service.py`: + +```python +"""accounts — the identity service (SD-0001 §6.5). + +All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time +code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper, +10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account. +Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports +only app.platform (downward); takes the connection and mailer as parameters (the BFF wires +them) so the domain owns no request/transport concerns. +""" +from __future__ import annotations + +import hashlib +import hmac +import re +import secrets +from datetime import datetime, timedelta, timezone + +import psycopg + +from app.platform import config +from app.platform.mailer import Mailer + +from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown +from .models import Account + +# INV-3 constants — the one-time-code policy. +CODE_TTL = timedelta(minutes=10) +RESEND_COOLDOWN = timedelta(seconds=60) +MAX_ATTEMPTS = 5 +_CODE_DIGITS = 6 + +# A pragmatic email check — rejects obvious non-addresses without an email-validator +# dependency. The provable test of an address is that the emailed code comes back (verify). +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def normalize_email(email: str) -> str: + """Lowercase + strip — the canonical form one account is keyed by (INV-2).""" + norm = email.strip().lower() + if not _EMAIL_RE.match(norm): + raise InvalidEmail(email) + return norm + + +def _hash_code(code: str) -> str: + """HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code).""" + key = config.session_secret().encode("utf-8") + return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None: + """Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6). + + Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's + spent/expired codes. Never reveals whether the email already has an account. + """ + email = normalize_email(email) + now = _now() + + last = conn.execute( + "SELECT max(created_at) FROM auth_code WHERE email = %s", (email,) + ).fetchone()[0] + if last is not None: + elapsed = now - last + if elapsed < RESEND_COOLDOWN: + retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1 + raise ResendCooldown(retry_after_s) + + # Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one. + conn.execute( + "DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)", + (email, now), + ) + + code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS)) + conn.execute( + "INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)", + (email, _hash_code(code), now + CODE_TTL), + ) + conn.commit() + + mailer.send( + to=email, + subject=f"Your ecomm code: {code}", + body=( + f"Your ecomm one-time code is {code}.\n" + f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n" + "If you didn't request this, ignore this message." + ), + ) + + +def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]: + """Verify a code and resolve the account. Returns (account, created). + + Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt + (PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise + CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created + by normalized email (INV-2). Account creation IS email verification (the address + provably received the code). + """ + email = normalize_email(email) + now = _now() + + row = conn.execute( + "SELECT id, code_hash, expires_at, attempts FROM auth_code " + "WHERE email = %s AND consumed_at IS NULL " + "ORDER BY created_at DESC LIMIT 1 FOR UPDATE", + (email,), + ).fetchone() + if row is None: + conn.commit() + raise CodeExpired(email) # nothing live to verify — offer a fresh code + code_id, code_hash, expires_at, attempts = row + + if expires_at < now: + conn.commit() + raise CodeExpired(email) + + if not hmac.compare_digest(code_hash, _hash_code(code)): + attempts += 1 + if attempts >= MAX_ATTEMPTS: + conn.execute( + "UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s", + (attempts, now, code_id), + ) + conn.commit() + raise CodeExhausted(email) + conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id)) + conn.commit() + raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts) + + # Correct: consume the code and get-or-create the account in the same transaction. + conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id)) + account, created = _get_or_create_account(conn, email) + conn.commit() + return account, created + + +def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]: + """Insert the account if absent; return (account, created). Race-safe via the unique index.""" + inserted = conn.execute( + "INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id", + (email,), + ).fetchone() + if inserted is not None: + return Account(id=inserted[0], email=email), True + existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone() + return Account(id=existing[0], email=email), False + + +def get_account(conn: psycopg.Connection, account_id: int) -> Account | None: + """Load an account by id (for the signed-in `/me`).""" + row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone() + return Account(id=row[0], email=row[1]) if row else None +``` + +> Note on `ON CONFLICT (email)`: SLICE-1's `0001_init.sql` declares the unique index as +> `CREATE UNIQUE INDEX account_email_key ON account (email)`. A unique index is a valid +> arbiter for `ON CONFLICT (email)` — no separate constraint is needed. + +- [ ] **Step 7: Create the package surface (__all__)** + +Create `backend/app/domains/accounts/__init__.py`: + +```python +"""accounts domain — identity: account records, one-time-code issue/verify, account lookup. + +Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows +nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this +package surface only (§6.2). +""" +from __future__ import annotations + +from .errors import ( + AccountsError, + CodeExhausted, + CodeExpired, + CodeMismatch, + InvalidEmail, + ResendCooldown, +) +from .models import Account +from .service import get_account, request_code, verify + +__all__ = [ + "Account", + "AccountsError", + "InvalidEmail", + "ResendCooldown", + "CodeMismatch", + "CodeExpired", + "CodeExhausted", + "request_code", + "verify", + "get_account", +] +``` + +- [ ] **Step 8: Run both test files to verify they pass** + +Ensure compose Postgres is up, then run from `backend/`: +`../.venv/bin/pytest tests/test_accounts_request_code.py tests/test_accounts_verify.py -q` +Expected: PASS (6 + 8 = 14 passed). + +- [ ] **Step 9: Run the layer contract** + +Run from `backend/`: `../.venv/bin/lint-imports` +Expected: `Contracts: 1 kept, 0 broken.` (accounts imports only `app.platform` — downward). + +- [ ] **Step 10: Commit** + +```bash +git add backend/app/domains/accounts backend/tests/test_accounts_request_code.py backend/tests/test_accounts_verify.py +git commit -m "feat(slice-2): accounts domain — request_code/verify/get_account (INV-2/INV-3/INV-6) + +Email-canonical get-or-create, peppered-hash one-time codes with 10-min TTL, single-use, +5-attempt cap, and 60s resend cooldown; uniform new-or-known (no enumeration, §6.6). All +identity rules in the domain layer once (INV-6); scenario + invariant tests green." +``` + +--- + +## Task 5: deps — mailer + session dependencies; wire mailer into create_app + +**Files:** +- Modify: `backend/app/platform/deps.py` +- Modify: `backend/app/main.py` (lifespan only — endpoints come in Task 6) + +The BFF needs two more per-request dependencies: the configured mailer (built once in +`create_app`, like the pool) and the verified session payload (from the cookie). This task +adds them and builds the mailer at startup; the endpoints that use them land in Task 6. +Verified by Task 6's endpoint tests (no standalone test here — these are thin wiring). + +- [ ] **Step 1: Add the dependencies** + +Replace the body of `backend/app/platform/deps.py` with (keeps `get_conn`, adds two): + +```python +"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps). + +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()) +``` + +- [ ] **Step 2: Build the mailer in create_app's lifespan** + +In `backend/app/main.py`, update the imports and the `lifespan` function. Change the import +line `from app.platform import config, db` to also import `mailer`: + +```python +from app.platform import config, db, mailer as mailer_mod +``` + +And in `lifespan`, after opening the pool and before `yield`, build the mailer onto +`app.state`: + +```python + @asynccontextmanager + async def lifespan(app: 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() +``` + +- [ ] **Step 3: Verify nothing regressed and the contract holds** + +Run from `backend/`: + +```bash +../.venv/bin/lint-imports +../.venv/bin/pytest -q +``` + +Expected: `Contracts: 1 kept, 0 broken.` and all existing tests still pass (healthz still +boots — it now also builds a LogMailer at startup). + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/platform/deps.py backend/app/main.py +git commit -m "feat(slice-2): deps — mailer + session dependencies; build mailer at startup" +``` + +--- + +## Task 6: REST BFF — the four /api/auth/* endpoints (§6.4) + +**Files:** +- Modify: `backend/app/main.py` +- Test: `backend/tests/test_auth_endpoints.py` + +The BFF translates HTTP ↔ `accounts` calls, sets/clears the session cookie, and maps domain +exceptions to the §6.4 error shape `{"error": {"code": ..., "message": ...}}`. It owns no +business logic (INV-6). `storefront` is hard-wired `null` (SLICE-3 wires the storefronts +domain — the single seam is the `_storefront_for` helper). + +- [ ] **Step 1: Write the failing endpoint tests** + +Create `backend/tests/test_auth_endpoints.py`: + +```python +import re + +from fastapi.testclient import TestClient + +from app.main import create_app + + +def _client(fresh_db_url) -> TestClient: + # A LogMailer on app.state lets the test read back the issued code (§6.8). + return TestClient(create_app(database_url=fresh_db_url)) + + +def _last_code(client) -> str: + outbox = client.app.state.mailer.outbox + return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1) + + +def test_request_code_returns_204(fresh_db_url): + with _client(fresh_db_url) as client: + resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + assert resp.status_code == 204 + + +def test_request_code_invalid_email_is_400(fresh_db_url): + with _client(fresh_db_url) as client: + resp = client.post("/api/auth/request-code", json={"email": "nope"}) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "invalid_email" + + +def test_request_code_uniform_204_no_enumeration(fresh_db_url): + # New and known emails both return 204 with no distinguishing body (§6.6). + with _client(fresh_db_url) as client: + r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"}) + # sign the newcomer up so the email becomes "known" + code = _last_code(client) + client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code}) + r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"}) + assert r_new.status_code == r_known.status_code == 204 + assert r_new.content == r_known.content == b"" + + +def test_resend_cooldown_is_429_with_retry_after(fresh_db_url): + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + assert resp.status_code == 429 + body = resp.json()["error"] + assert body["code"] == "resend_cooldown" + assert body["retry_after_s"] > 0 + + +def test_verify_sets_cookie_and_returns_shape(fresh_db_url): + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + code = _last_code(client) + resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code}) + assert resp.status_code == 200 + data = resp.json() + assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True} + assert "ecomm_session" in resp.cookies + + +def test_verify_returning_account_created_false(fresh_db_url): + # BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The + # second request-code would hit the 60s cooldown, so backdate the consumed code's + # created_at to satisfy it (rather than sleeping 60s in a test). + import psycopg + from datetime import datetime, timedelta, timezone + + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "m@example.com"}) + r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)}) + assert r1.json()["created"] is True + with psycopg.connect(fresh_db_url) as c: + c.execute( + "UPDATE auth_code SET created_at = %s WHERE email = %s", + (datetime.now(timezone.utc) - timedelta(minutes=2), "m@example.com"), + ) + c.commit() + client.post("/api/auth/request-code", json={"email": "m@example.com"}) + r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)}) + assert r2.status_code == 200 + assert r2.json()["created"] is False + assert r2.json()["account"] == {"email": "m@example.com"} + + +def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url): + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"}) + assert resp.status_code == 400 + body = resp.json()["error"] + assert body["code"] == "code_mismatch" + assert body["attempts_remaining"] == 4 + + +def test_verify_no_code_is_400_code_expired(fresh_db_url): + with _client(fresh_db_url) as client: + resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"}) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "code_expired" + + +def test_me_requires_session(fresh_db_url): + with _client(fresh_db_url) as client: + resp = client.get("/api/auth/me") + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "unauthenticated" + + +def test_me_returns_account_with_session(fresh_db_url): + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)}) + resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify + assert resp.status_code == 200 + assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None} + + +def test_puc_09_logout_clears_session(fresh_db_url): + with _client(fresh_db_url) as client: + client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) + client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)}) + assert client.get("/api/auth/me").status_code == 200 + logout = client.post("/api/auth/logout") + assert logout.status_code == 204 + # after logout the cookie is cleared -> /me is unauthenticated again + client.cookies.clear() + assert client.get("/api/auth/me").status_code == 401 +``` + +> Note: `test_verify_returning_account_created_false` asserts the HTTP wiring only; the +> created-flag/same-account invariant (INV-2) is exhaustively covered at the service level in +> `test_accounts_verify.py::test_14_01_0009_returning_account_reports_not_created`. The +> per-email 60s cooldown makes a second real login impractical inside one synchronous test. + +- [ ] **Step 2: Run to verify they fail** + +Run from `backend/`: `../.venv/bin/pytest tests/test_auth_endpoints.py -q` +Expected: FAIL — 404s (endpoints not mounted yet). + +- [ ] **Step 3: Implement the endpoints** + +In `backend/app/main.py`: add the imports, the request models, an error helper, the +`_storefront_for` seam, and the four endpoints. The full file after this task: + +```python +"""ecomm backend — FastAPI app factory + the REST BFF. + +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 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: + dsn = database_url or config.database_url() + + @asynccontextmanager + async def lifespan(app: 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.2", 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"} + + @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 + + +app = create_app() +``` + +- [ ] **Step 4: Run the endpoint tests to verify they pass** + +Run from `backend/`: `../.venv/bin/pytest tests/test_auth_endpoints.py -q` +Expected: PASS (all endpoint tests). + +- [ ] **Step 5: Run the whole backend gate** + +Run from `backend/`: + +```bash +../.venv/bin/lint-imports +../.venv/bin/pytest -q +``` + +Expected: `Contracts: 1 kept, 0 broken.` and the full suite green (config, mailer, session, +migrations, healthz, accounts ×2, auth endpoints). + +- [ ] **Step 6: Manually verify the endpoints over HTTP** + +With compose Postgres up, run from `backend/`: + +```bash +../.venv/bin/python -m uvicorn app.main:app --port 8000 & +sleep 2 +echo "--- request-code (expect 204) ---" +curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/api/auth/request-code -H 'content-type: application/json' -d '{"email":"dev@example.com"}' +echo "--- (read the code from the uvicorn log: 'LogMailer -> dev@example.com ...') ---" +echo "--- me without session (expect 401) ---" +curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/api/auth/me +kill %1 +``` + +Expected: `204` then `401`. (The code prints in the uvicorn log via LogMailer — PUC-10's +dev channel.) + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/main.py backend/tests/test_auth_endpoints.py +git commit -m "feat(slice-2): BFF /api/auth/* — request-code, verify, me, logout (§6.4) + +Screen-shaped JSON endpoints; signed-cookie session on verify; §6.4 error envelope; +storefront hard-wired null behind the _storefront_for seam (SLICE-3 fills it). No +enumeration: uniform 204. PUC-9 logout clears the cookie." +``` + +--- + +## Task 7: Frontend — Landing, Sign-in, entry routing (+ Vitest) + +**Files:** +- Modify: `frontend/package.json` +- Modify: `scripts/check.sh` +- Create: `frontend/src/api.ts` +- Create: `frontend/src/routing.ts` +- Create: `frontend/src/routing.test.ts` +- Create: `frontend/src/screens/Landing.tsx` +- Create: `frontend/src/screens/SignIn.tsx` +- Create: `frontend/src/screens/SignedIn.tsx` +- Modify: `frontend/src/App.tsx` + +The SPA gains the Landing (§5.1) and two-step Sign-in (§5.2) screens and the complete +client-side entry-routing rule (§6.5). The rule is pure and unit-tested with Vitest (§6.8). +Screens are text-first and honest (no countdowns, no fake density). `SignedIn` is a SLICE-2 +placeholder standing in for the create-storefront/admin screens (SLICE-3) so PUC-9 sign-out +is reachable now. + +- [ ] **Step 1: Add Vitest to the frontend** + +Modify `frontend/package.json` — add a `test` script and the `vitest` devDependency: + +```json +{ + "name": "wiggleverse-ecomm-frontend", + "private": true, + "version": "0.2.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } +} +``` + +- [ ] **Step 2: Install the new dep** + +Run from `frontend/`: `npm install` +Expected: `vitest` resolves and `package-lock.json` updates. + +- [ ] **Step 3: Write the failing routing test** + +Create `frontend/src/routing.ts` first as an empty seam so the import resolves, then the +test. Create `frontend/src/routing.ts`: + +```typescript +// (filled in Step 5) +export {}; +``` + +Create `frontend/src/routing.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { routeFor } from "./routing"; + +describe("entry routing (SD-0001 §6.5)", () => { + it("no session -> landing", () => { + expect(routeFor({ account: null, storefront: null })).toBe("landing"); + }); + + it("session without storefront -> create-storefront", () => { + expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe( + "create-storefront", + ); + }); + + it("session with storefront -> admin", () => { + expect( + routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }), + ).toBe("admin"); + }); +}); +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run from `frontend/`: `npm test` +Expected: FAIL — `routeFor is not exported` / is not a function. + +- [ ] **Step 5: Implement the routing rule** + +Replace `frontend/src/routing.ts` with: + +```typescript +// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands +// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the +// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3 +// makes "admin" reachable. +export interface SessionState { + account: { email: string } | null; + storefront: { id: number; name: string } | null; +} + +export type Screen = "landing" | "create-storefront" | "admin"; + +export function routeFor(s: SessionState): Screen { + if (!s.account) return "landing"; + if (!s.storefront) return "create-storefront"; + return "admin"; +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run from `frontend/`: `npm test` +Expected: PASS (3 passed). + +- [ ] **Step 7: Write the API client** + +Create `frontend/src/api.ts`: + +```typescript +// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite +// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them. +import type { SessionState } from "./routing"; + +export interface ApiError { + code: string; + message: string; + retry_after_s?: number; + attempts_remaining?: number; +} + +export interface VerifyResult { + account: { email: string }; + storefront: { id: number; name: string } | null; + created: boolean; +} + +async function errorOf(resp: Response): Promise { + try { + const body = await resp.json(); + if (body && body.error) return body.error as ApiError; + } catch { + /* fall through */ + } + return { code: "unexpected", message: "Something went wrong. Please try again." }; +} + +export async function getMe(): Promise { + const resp = await fetch("/api/auth/me", { credentials: "include" }); + if (resp.status === 401) return null; + if (!resp.ok) return null; + return (await resp.json()) as SessionState; +} + +export async function requestCode(email: string): Promise { + const resp = await fetch("/api/auth/request-code", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email }), + }); + return resp.ok ? null : await errorOf(resp); +} + +export async function verifyCode( + email: string, + code: string, +): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> { + const resp = await fetch("/api/auth/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, code }), + }); + if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult }; + return { ok: false, error: await errorOf(resp) }; +} + +export async function logout(): Promise { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); +} +``` + +- [ ] **Step 8: Write the Landing screen** + +Create `frontend/src/screens/Landing.tsx`: + +```tsx +// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice, +// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2); +// they differ only in framing. +interface Props { + onGetStarted: () => void; + onLogIn: () => void; +} + +export default function Landing({ onGetStarted, onLogIn }: Props) { + return ( +
+
+ ecomm + +
+
+

Honest commerce. Your storefront is yours.

+

Sell online on a platform that treats you straight — no dark patterns, no lock-in.

+ +
+
+ ); +} +``` + +- [ ] **Step 9: Write the two-step Sign-in screen** + +Create `frontend/src/screens/SignIn.tsx`: + +```tsx +// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1 +// requests a code; step 2 verifies it. Honest copy states which door framing applies and, +// after verify, whether the account was created or resumed (PUC-3). storefront routing is +// handled by App on success. +import { useState } from "react"; +import { requestCode, verifyCode, type VerifyResult } from "../api"; + +type Door = "signup" | "login"; + +interface Props { + door: Door; + onAuthed: (result: VerifyResult) => void; + onBack: () => void; +} + +export default function SignIn({ door, onAuthed, onBack }: Props) { + const [step, setStep] = useState<"email" | "code">("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const heading = door === "signup" ? "Create your storefront" : "Log in"; + + async function submitEmail(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const err = await requestCode(email); + setBusy(false); + if (err) { + setError(err.message); + return; + } + setStep("code"); + } + + async function submitCode(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const res = await verifyCode(email, code); + setBusy(false); + if (!res.ok) { + setError(res.error.message); + return; + } + onAuthed(res.result); + } + + if (step === "email") { + return ( +
+
+ ecomm + +
+
+

{heading}

+ +

We'll email you a one-time code. That's all we need.

+ {error &&

{error}

} + +
+
+ ); + } + + return ( +
+
+ ecomm + +
+
+

Enter your code

+

We sent a code to {email}.

+ + {error &&

{error}

} + +
+
+ ); +} +``` + +- [ ] **Step 10: Write the SignedIn placeholder screen** + +Create `frontend/src/screens/SignedIn.tsx`: + +```tsx +// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in +// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No +// fabricated capability (INV-9). +import { logout } from "../api"; + +interface Props { + email: string; + created: boolean; + onSignedOut: () => void; +} + +export default function SignedIn({ email, created, onSignedOut }: Props) { + async function signOut() { + await logout(); + onSignedOut(); + } + + return ( +
+
+ ecomm + {email} + +
+
+

{created ? "Welcome to ecomm" : "Welcome back"}

+

You're signed in as {email}.

+

Creating your storefront arrives in the next slice. Nothing else is needed yet.

+
+
+ ); +} +``` + +- [ ] **Step 11: Wire it together in App** + +Replace `frontend/src/App.tsx` with: + +```tsx +// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders +// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a +// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3. +import { useEffect, useState } from "react"; +import { getMe, type VerifyResult } from "./api"; +import { routeFor, type SessionState } from "./routing"; +import Landing from "./screens/Landing"; +import SignIn from "./screens/SignIn"; +import SignedIn from "./screens/SignedIn"; + +type Door = "signup" | "login"; + +export default function App() { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [door, setDoor] = useState(null); + const [justCreated, setJustCreated] = useState(false); + + async function reload() { + setLoading(true); + setSession(await getMe()); + setLoading(false); + } + + useEffect(() => { + void reload(); + }, []); + + if (loading) return
Loading…
; + + const route = routeFor(session ?? { account: null, storefront: null }); + + if (route === "landing") { + if (door) { + return ( + { + setDoor(null); + }} + onAuthed={(result: VerifyResult) => { + setJustCreated(result.created); + setDoor(null); + void reload(); + }} + /> + ); + } + return setDoor("signup")} onLogIn={() => setDoor("login")} />; + } + + // route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the + // signed-in placeholder so sign-out (PUC-9) is reachable. + return ( + { + setJustCreated(false); + void reload(); + }} + /> + ); +} +``` + +- [ ] **Step 12: Update the gate to run the frontend tests** + +Modify `scripts/check.sh` — change the frontend step to run the build AND the unit tests. +Replace the block: + +```bash +echo "==> frontend typecheck + build" +( cd "$repo_root/frontend" && npm run build ) +``` + +with: + +```bash +echo "==> frontend typecheck + build" +( cd "$repo_root/frontend" && npm run build ) + +echo "==> frontend unit tests (vitest)" +( cd "$repo_root/frontend" && npm test ) +``` + +- [ ] **Step 13: Verify the frontend builds, typechecks, and tests pass** + +Run from `frontend/`: + +```bash +npm run build +npm test +``` + +Expected: `tsc` passes (no type errors), `vite build` produces `dist/`, and Vitest reports +3 passed. + +- [ ] **Step 14: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/src scripts/check.sh +git commit -m "feat(slice-2): Landing + Sign-in screens, entry routing, API client (§5.1/5.2/6.5) + +Two-step email + one-time-code sign-in behind both doors; pure entry-routing rule unit- +tested with Vitest (§6.8); storefront routing reaches a SLICE-2 signed-in placeholder with +sign-out (PUC-9). check.sh now runs the frontend unit tests." +``` + +--- + +## Task 8: End-to-end DoD verification, manual walk, version + README + +**Files:** +- Modify: `README.md` +- (verification; backend `version="0.2"` and frontend `0.2.0` already set in Tasks 6/7) + +Prove the SLICE-2 Definition of Done: BUC-1/BUC-2 acceptance end-to-end on localhost; the +scenario + invariant tests green; the whole gate green. + +- [ ] **Step 1: Run the whole gate green** + +Ensure compose Postgres is up, then from repo root: + +```bash +scripts/check.sh +``` + +Expected: import boundaries kept → backend pytest green → frontend build → frontend vitest +green → `all gates green`. + +- [ ] **Step 2: Manually walk the identity flow end-to-end on localhost** + +Start the full dev stack from repo root: + +```bash +scripts/dev.sh & +sleep 20 +``` + +Then drive the flow with curl against the backend (the SPA does the same over the proxy): + +```bash +echo "--- 1. request a code (expect 204) ---" +curl -s -o /dev/null -w "%{http_code}\n" -c /tmp/ecomm.jar \ + -X POST localhost:8000/api/auth/request-code -H 'content-type: application/json' \ + -d '{"email":"founder@example.com"}' + +echo "--- 2. read the code from the backend log (LogMailer -> founder@example.com) ---" +echo " (look in the scripts/dev.sh output for the 6-digit code, then:)" +echo " CODE=" +``` + +Then, substituting the code printed in the dev log: + +```bash +CODE=123456 # replace with the code from the log +echo "--- 3. verify (expect 200, created:true, storefront:null, Set-Cookie) ---" +curl -s -c /tmp/ecomm.jar -b /tmp/ecomm.jar \ + -X POST localhost:8000/api/auth/verify -H 'content-type: application/json' \ + -d "{\"email\":\"founder@example.com\",\"code\":\"$CODE\"}" +echo +echo "--- 4. me (expect 200, same account, storefront:null) ---" +curl -s -b /tmp/ecomm.jar localhost:8000/api/auth/me +echo +echo "--- 5. logout (expect 204), then me (expect 401) ---" +curl -s -o /dev/null -w "%{http_code}\n" -c /tmp/ecomm.jar -b /tmp/ecomm.jar -X POST localhost:8000/api/auth/logout +curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/ecomm.jar localhost:8000/api/auth/me +``` + +Expected: `204`; a 200 JSON with `"created": true`, `"storefront": null`, and a +`Set-Cookie: ecomm_session=...`; `/me` returns the account with `storefront: null`; logout +`204`; post-logout `/me` `401`. + +Also open and confirm: Landing shows the two doors → "Create your +storefront" opens the email step → submitting an email moves to the code step → entering the +code from the dev log lands on the signed-in placeholder showing the email → "Sign out" +returns to Landing. Stop the stack: `kill %1`. + +- [ ] **Step 3: Confirm the DoD checklist** + +- [ ] BUC-1 (a stranger becomes a known merchant) walkable end-to-end on localhost ✓ +- [ ] BUC-2 (a returning merchant resumes) — same-account/email-canonical proven by + `test_accounts_verify.py::test_14_01_0009_returning_account_reports_not_created` ✓ +- [ ] scenario tests for the auth flow green (`test_accounts_request_code.py`, + `test_accounts_verify.py`, `test_auth_endpoints.py`) ✓ +- [ ] INV-2 (email canonical / no duplicate) + INV-3 (hash-only, TTL, single-use, ≤5 + attempts, 60s cooldown) invariant tests green ✓ +- [ ] no enumeration — uniform 204 asserted (`test_request_code_uniform_204_no_enumeration`) ✓ +- [ ] PUC-9 sign-out clears the session (`test_puc_09_logout_clears_session`) ✓ +- [ ] entry-routing rule unit-tested (`frontend/src/routing.test.ts`) ✓ +- [ ] `scripts/check.sh` fully green ✓ + +- [ ] **Step 4: Update the README status** + +Modify `README.md` — replace the `## Status` section body: + +```markdown +## Status + +SLICE-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open, +passwordless sign-up/log-in by email + one-time code (`accounts` domain, INV-2/INV-3), +signed-cookie sessions, the `/api/auth/*` endpoints (`request-code`, `verify`, `me`, +`logout`), the `platform/mailer` port with `LogMailer` (the dev code channel), and the +Landing + Sign-in screens with the entry-routing rule (storefront hard-wired `null` until +SLICE-3). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. The storefront +(SLICE-3) and deployed environments (SLICE-4) are next. +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs(slice-2): README status — identity slice in place" +``` + +--- + +## Done criteria (whole plan) + +- `scripts/check.sh` is green: import-linter contract kept (`app.main > app.domains > + app.platform`), backend pytest green against real Postgres, frontend typecheck + build + + Vitest routing test all pass. +- The `accounts` domain implements request-code / verify / get-account with INV-2 (email + canonical, no duplicates) and INV-3 (hash-only codes, 10-min TTL, single-use, ≤5 attempts, + 60s resend cooldown) enforced and tested; the BFF exposes the four `/api/auth/*` endpoints + in the §6.4 shape with no account enumeration (uniform 204). +- Signed-cookie sessions via `platform/session`; the `platform/mailer` `LogMailer` carries + the code to the dev log (PUC-10 channel) and to an in-memory outbox tests read. +- The SPA serves Landing + two-step Sign-in, applies the entry-routing rule, and makes + sign-out (PUC-9) reachable; `storefront` is hard-wired `null` behind the `_storefront_for` + seam and the routing rule's `create-storefront`/`admin` branches (SLICE-3 fills them). +- `README` reflects SLICE-2; backend app version `0.2`, frontend `0.2.0`. +- All work committed on `slice-2-identity`, ready for PR → `main`. +- **Deferred (logged at finalize):** E2E browser tests (Playwright) for the identity screens + — deferred per spec §6.8 ("component/E2E browser tests deferred until there is UI beyond + forms"), a conscious divergence from the handbook §9 "E2E for any UI surface" default. +```