2054 lines
74 KiB
Markdown
2054 lines
74 KiB
Markdown
# 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<ApiError> {
|
||
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<SessionState | null> {
|
||
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<ApiError | null> {
|
||
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<void> {
|
||
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 (
|
||
<main>
|
||
<header>
|
||
<strong>ecomm</strong>
|
||
<button type="button" onClick={onLogIn}>
|
||
Log in
|
||
</button>
|
||
</header>
|
||
<section>
|
||
<h1>Honest commerce. Your storefront is yours.</h1>
|
||
<p>Sell online on a platform that treats you straight — no dark patterns, no lock-in.</p>
|
||
<button type="button" onClick={onGetStarted}>
|
||
Create your storefront
|
||
</button>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<string | null>(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 (
|
||
<main>
|
||
<header>
|
||
<strong>ecomm</strong>
|
||
<button type="button" onClick={onBack}>
|
||
Back
|
||
</button>
|
||
</header>
|
||
<form onSubmit={submitEmail}>
|
||
<h1>{heading}</h1>
|
||
<label>
|
||
Email
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
autoFocus
|
||
required
|
||
onChange={(ev) => setEmail(ev.target.value)}
|
||
/>
|
||
</label>
|
||
<p>We'll email you a one-time code. That's all we need.</p>
|
||
{error && <p role="alert">{error}</p>}
|
||
<button type="submit" disabled={busy}>
|
||
{busy ? "Sending…" : "Send code"}
|
||
</button>
|
||
</form>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main>
|
||
<header>
|
||
<strong>ecomm</strong>
|
||
<button type="button" onClick={() => setStep("email")}>
|
||
Wrong address?
|
||
</button>
|
||
</header>
|
||
<form onSubmit={submitCode}>
|
||
<h1>Enter your code</h1>
|
||
<p>We sent a code to {email}.</p>
|
||
<label>
|
||
Code
|
||
<input
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
value={code}
|
||
autoFocus
|
||
required
|
||
onChange={(ev) => setCode(ev.target.value)}
|
||
/>
|
||
</label>
|
||
{error && <p role="alert">{error}</p>}
|
||
<button type="submit" disabled={busy}>
|
||
{busy ? "Checking…" : "Continue"}
|
||
</button>
|
||
</form>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<main>
|
||
<header>
|
||
<strong>ecomm</strong>
|
||
<span>{email}</span>
|
||
<button type="button" onClick={signOut}>
|
||
Sign out
|
||
</button>
|
||
</header>
|
||
<section>
|
||
<h1>{created ? "Welcome to ecomm" : "Welcome back"}</h1>
|
||
<p>You're signed in as {email}.</p>
|
||
<p>Creating your storefront arrives in the next slice. Nothing else is needed yet.</p>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<SessionState | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [door, setDoor] = useState<Door | null>(null);
|
||
const [justCreated, setJustCreated] = useState(false);
|
||
|
||
async function reload() {
|
||
setLoading(true);
|
||
setSession(await getMe());
|
||
setLoading(false);
|
||
}
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, []);
|
||
|
||
if (loading) return <main>Loading…</main>;
|
||
|
||
const route = routeFor(session ?? { account: null, storefront: null });
|
||
|
||
if (route === "landing") {
|
||
if (door) {
|
||
return (
|
||
<SignIn
|
||
door={door}
|
||
onBack={() => {
|
||
setDoor(null);
|
||
}}
|
||
onAuthed={(result: VerifyResult) => {
|
||
setJustCreated(result.created);
|
||
setDoor(null);
|
||
void reload();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return <Landing onGetStarted={() => 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 (
|
||
<SignedIn
|
||
email={session!.account!.email}
|
||
created={justCreated}
|
||
onSignedOut={() => {
|
||
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=<that 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 <http://localhost:5173> 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.
|
||
```
|