# 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. ```