Compare commits

..

51 Commits

Author SHA1 Message Date
ben.stull 118e925580 fix(signin): key the resend cooldown to the address it was set for (#20)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
The email-step Send button disabled on any running cooldown, so a merchant
who mistyped their address was locked out of sending to the corrected one
for the rest of the 60s window — a guard the server never had: request_code
enforces the cooldown per address (SD-0001 INV-3 -> PUC-2c).

Track which address the running cooldown belongs to (cooldownFor) and gate
the email-step button through cooldownAppliesTo(), which blocks only while
the countdown runs AND the input normalizes (strip+lowercase, mirroring
normalize_email / INV-2) to that address. Same address still shows the
honest 'Resend in Ns'; any other address sends immediately. The code-step
resend is unchanged. Verified in the live UI (wrong address -> Wrong
address? -> corrected address sends at once).

package-lock.json: sync recorded version with package.json (0.4.0).

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:21:23 -07:00
ben.stull 11acb3a5b1 Merge pull request 'docs: land session 0023 plan doc (stray untracked file)' (#18) from docs-archive-0023-plan into main
ci / check (push) Has been cancelled
2026-06-11 07:56:34 +00:00
ben.stull efc9c8edb9 docs: land session 0023's ui-designs-collection plan in-repo (was untracked; content archive already had it)
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:56:31 -07:00
ben.stull f7b76d5370 Merge pull request 'SLICE-4: record the first PPE bootstrap rehearsal (PUC-11) — green' (#17) from slice-4-rehearsal-record into main
ci / check (push) Has been cancelled
2026-06-11 07:54:50 +00:00
ben.stull 4bb9763633 docs(slice-4): record the first PPE bootstrap rehearsal (PUC-11, 2026-06-11) — green
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:54:47 -07:00
ben.stull a456d51f17 Merge pull request 'SLICE-4: deployment.toml — the ecomm PPE record' (#11) from slice-4-deployment-record into main
ci / check (push) Has been cancelled
2026-06-11 06:45:08 +00:00
ben.stull ac3c4ffe36 feat(slice-4): deployment.toml — the ecomm PPE record (launch-app §5.1; Cloud SQL via secret ref)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:45:05 -07:00
ben.stull 8a2ec24d86 Merge pull request 'SLICE-4 (part 1): deploy contract — versioned health, backend-served SPA, SmtpMailer + honest 502 (SD-0001 §7.2)' (#10) from slice-4-deploy-contract into main
ci / check (push) Has been cancelled
2026-06-11 06:36:26 +00:00
ben.stull acff7ac588 docs(slice-4): BOOTSTRAP.md PPE runbook, version 0.4.0, README status
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:36:24 -07:00
ben.stull 22d738fc74 feat(slice-4): honest delivery failure — send-before-commit + 502 delivery_failed (INV-9; closes #7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:35:01 -07:00
ben.stull 1b8c60fcb5 feat(slice-4): SmtpMailer — real mail over a configured relay, log-hygienic (INV-8, §6.6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:33:40 -07:00
ben.stull 9f4295b77e feat(slice-4): backend serves the built SPA — the deployed nginx-proxies-all topology (launch-app §2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:32:22 -07:00
ben.stull a1c5544694 feat(slice-4): VERSION file + versioned /healthz — the flotilla verify contract (§8.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:20 -07:00
ben.stull 0747181157 Merge pull request 'SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2)' (#9) from slice-3-storefront into main
ci / check (push) Has been cancelled
2026-06-11 05:47:02 +00:00
ben.stull 877be249c7 chore(slice-3): version 0.3, README status, label-input association fix (a11y §6.6)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:46:12 -07:00
ben.stull 25ac540171 feat(slice-3): Admin shell + complete entry routing (§5.4/§6.5, PUC-6/8/9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:41:38 -07:00
ben.stull 10d22938f1 feat(slice-3): Create-storefront screen + API helper (§5.3, PUC-4/5/7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:41:04 -07:00
ben.stull ddc949615e feat(slice-3): Sign-in re-skinned — code cells, cooldown countdown, honest §5.2 states
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:40:33 -07:00
ben.stull 0b302ea1e1 feat(slice-3): Landing re-skinned to the Claude Design export (§5.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:39:57 -07:00
ben.stull ef1498ba08 feat(slice-3): design-system foundation — Wiggleverse tokens, fonts, brand marks, UI kit (per ui/designs export)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:39:30 -07:00
ben.stull cc9d9dda5f test(slice-3): INV-1 whole-flow bootstrap test — empty DB to admin answer (§6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:37:10 -07:00
ben.stull 306b5c1e5d feat(slice-3): POST /api/storefronts + storefronts-fed entry routing (§6.4/§6.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:36:50 -07:00
ben.stull e11fb032b1 test(slice-3): INV-4 invariants — concurrent-create refusal + many-capable membership schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:34:24 -07:00
ben.stull ba8b493a31 feat(slice-3): storefronts domain — create + membership + INV-4 guard (§6.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:33:57 -07:00
ben.stull 1b40bde4a0 Merge pull request 'SLICE-2: identity — email + one-time-code sign-up, sessions, Landing/Sign-in (SD-0001 §7.2)' (#6) from slice-2-identity into main
ci / check (push) Has been cancelled
2026-06-10 17:51:18 +00:00
ben.stull 725877c5c6 docs(slice-2): implementation plan for the identity slice
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:43:09 -07:00
ben.stull bf62f413d7 docs(slice-2): README status — identity slice in place
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:42:49 -07:00
ben.stull cfc28d7002 fix(slice-2): surface ecomm.* INFO logs so the one-time code reaches the dev log
uvicorn configures only its own loggers, so the LogMailer INFO line — PUC-10's local dev
channel (the code in the backend log) — was swallowed. Add an idempotent dedicated handler
for the 'ecomm' logger (propagate=False) in create_app so dev.sh/uvicorn runs show the code.
Caught by the SLICE-2 end-to-end walk; unit tests read the in-memory outbox and didn't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:42:30 -07:00
ben.stull b3ffb2d4b6 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:39:48 -07:00
ben.stull cfd2b4ecc7 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:35:01 -07:00
ben.stull d2eceac272 feat(slice-2): deps — mailer + session dependencies; build mailer at startup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:34:13 -07:00
ben.stull 95680c9960 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:31:32 -07:00
ben.stull 2d15f1a2cb feat(slice-2): platform/session — stdlib HMAC signed cookie tokens (§6.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:27:35 -07:00
ben.stull 446c13211a feat(slice-2): platform/mailer port + LogMailer (dev channel, §6.2/PUC-10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:26:58 -07:00
ben.stull 7a6b396f65 feat(slice-2): config — session secret, cookie_secure, mailer_kind (INV-8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:26:15 -07:00
ben.stull 9373177ed3 Merge pull request 'SLICE-1: walking skeleton (SD-0001 §7.2) — scaffold, dev Postgres, migration runner, /healthz' (#5) from claude/agitated-heyrovsky-1a1f47 into main
ci / check (push) Has been cancelled
2026-06-10 15:50:42 +00:00
ben.stull f184802f1f docs(slice-1): README status — walking skeleton in place
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:47:28 -07:00
ben.stull 4d1c957f42 fix(slice-1): dev.sh portable on bash 3.2 — drop wait -n, reap process subtree
macOS ships bash 3.2 (no wait -n): poll with kill -0 instead. npm spawns vite as a
grandchild, so cleanup now recurses pgrep -P to reap the whole subtree — Ctrl-C frees
both :8000 and :5173 cleanly. Verified clean-checkout -> dev.sh -> empty self-migrated
Postgres -> /healthz 200, and trap teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:46:41 -07:00
ben.stull cd951cca2c docs(slice-1): BOOTSTRAP.md localhost bring-up runbook (BUC-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:42:00 -07:00
ben.stull 886da73eb6 ci(slice-1): Gitea workflow runs scripts/check.sh against a Postgres service
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:41:32 -07:00
ben.stull 6744a6ae0d feat(slice-1): Vite/React shell + /api proxy; scripts/check.sh now fully green
Single tsconfig (no project-references split — avoids composite/noEmit friction for
the minimal shell); tsc typechecks src, vite loads its config via esbuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:41:10 -07:00
ben.stull 36046304da feat(slice-1): scripts/dev.sh — owns dev Postgres lifecycle + backend + Vite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:39:37 -07:00
ben.stull d7b9f81e53 feat(slice-1): scripts/check.sh — the single gate (lint-imports, pytest, frontend build)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:39:14 -07:00
ben.stull ef385340e8 feat(slice-1): FastAPI factory + /healthz; self-migrate at startup (INV-1/§6.4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:38:42 -07:00
ben.stull 9462423642 feat(slice-1): platform/db migration runner + migration 0001 (INV-7, §6.3 schema)
Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays
the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty
and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:37:09 -07:00
ben.stull ab4317e105 feat(slice-1): dev Postgres compose — single pinned service, named volume (D-7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:35:53 -07:00
ben.stull 346e7f2e22 feat(slice-1): platform/config — single env-driven config surface (INV-8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:35:16 -07:00
ben.stull 4acab40653 feat(slice-1): backend skeleton, deps, and import-linter layer contract
Four-layer package (main > domains > platform) per SD-0001 §6.2; GraphQL api layer
deferred. main.py is a stub so the top layer exists from the start (fleshed out in
the /healthz task). lint-imports green on the skeleton.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:34:38 -07:00
ben.stull d125317c00 plan(slice-1): SD-0001 walking-skeleton implementation plan
Just-in-time plan for SLICE-1 (§7.2): backend 4-layer scaffold + import-linter,
dev Postgres compose, psycopg migration runner + migration 0001, /healthz,
scripts/check.sh + dev.sh, Vite/React shell, CI, docs/BOOTSTRAP.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:32:28 -07:00
ben.stull 0078b0c6da chore(specs): SD-0001 lives only in wiggleverse-ecomm-content/specs/ (#4) 2026-06-10 14:53:13 +00:00
ben.stull 23afcf95ce Merge pull request 'SD-0001: Solution Design — MVP sign up and create a single storefront (Feature #1)' (#2) from claude/optimistic-lumiere-ef8b1e into main 2026-06-10 14:12:57 +00:00
80 changed files with 11738 additions and 1061 deletions
+57
View File
@@ -0,0 +1,57 @@
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
# the local and server gates can never drift: import-linter (main > domains >
# platform), then backend pytest against a Postgres service, then the frontend
# typecheck + build. Requires a registered Gitea Actions runner advertising the
# `ubuntu-latest` label.
name: ci
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: ecomm
POSTGRES_PASSWORD: ecomm
POSTGRES_DB: ecomm
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U ecomm"
--health-interval 2s
--health-timeout 3s
--health-retries 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install backend deps
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r backend/requirements.txt
- name: Install frontend deps
run: |
cd frontend && npm ci
- name: Run the pre-merge gate
env:
PYTHON: ${{ github.workspace }}/.venv/bin/python
ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres
run: scripts/check.sh
+3
View File
@@ -6,3 +6,6 @@ node_modules/
dist/
.env
*.local
.pytest_cache/
frontend/dist/
backend/var/
+11 -2
View File
@@ -20,5 +20,14 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
## Status
No application code yet. The architecture and first release are to be designed in
a brainstorming/planning session before any code lands.
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
complete entry-routing rule (landing / create-storefront / admin), and the INV-1
whole-flow bootstrap test — all skinned to the Claude Design export
(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
which also re-skinned the SLICE-2 Landing + Sign-in screens. See
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. SLICE-4's deploy
contract is in place (versioned `/healthz`, backend-served SPA, `SmtpMailer` with
honest 502 on delivery failure); PPE provisioning + the bootstrap rehearsal are the
remaining SLICE-4 steps (BOOTSTRAP.md's PPE section is the runbook).
+1
View File
@@ -0,0 +1 @@
0.4.0
+14
View File
@@ -0,0 +1,14 @@
# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with
# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the
# MVP contract has three layers; `app.api` is added back between main and domains
# when GraphQL arrives. Run from backend/: .venv/bin/lint-imports
[importlinter]
root_package = app
[importlinter:contract:layers]
name = App layering (main > domains > platform)
type = layers
layers =
app.main
app.domains
app.platform
View File
+6
View File
@@ -0,0 +1,6 @@
"""domains layer — bounded contexts (accounts, storefronts).
Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
The package exists now so the layer contract (.importlinter) has a target and later
slices add to a stable seam. Domains import only from app.platform, never upward.
"""
+33
View File
@@ -0,0 +1,33 @@
"""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,
DeliveryFailed,
InvalidEmail,
ResendCooldown,
)
from .models import Account
from .service import get_account, request_code, verify
__all__ = [
"Account",
"AccountsError",
"InvalidEmail",
"ResendCooldown",
"CodeMismatch",
"CodeExpired",
"CodeExhausted",
"DeliveryFailed",
"request_code",
"verify",
"get_account",
]
+42
View File
@@ -0,0 +1,42 @@
"""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)."""
class DeliveryFailed(AccountsError):
"""The relay refused the code email; nothing was committed (INV-9 → §6.4 502 delivery_failed)."""
+12
View File
@@ -0,0 +1,12 @@
"""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
+173
View File
@@ -0,0 +1,173 @@
"""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, MailerError
from .errors import (
CodeExhausted,
CodeExpired,
CodeMismatch,
DeliveryFailed,
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 AND consumed_at IS NULL",
(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),
)
# Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan
# code blocks the 60s cooldown and the caller can honestly retry at once (INV-9).
try:
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."
),
)
except MailerError as exc:
conn.rollback()
raise DeliveryFailed(str(exc)) from exc
conn.commit()
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
@@ -0,0 +1,19 @@
"""storefronts domain — storefront entity, membership, the one-storefront guard.
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
via this package surface only (§6.2).
"""
from __future__ import annotations
from .errors import AlreadyOwnsStorefront, StorefrontsError
from .models import Storefront
from .service import create_storefront, storefront_for
__all__ = [
"Storefront",
"StorefrontsError",
"AlreadyOwnsStorefront",
"create_storefront",
"storefront_for",
]
+10
View File
@@ -0,0 +1,10 @@
"""storefronts domain errors."""
from __future__ import annotations
class StorefrontsError(Exception):
"""Base for storefronts-domain errors."""
class AlreadyOwnsStorefront(StorefrontsError):
"""INV-4: the account already has its one storefront (PUC-7)."""
+10
View File
@@ -0,0 +1,10 @@
"""Storefront record — the §6.3 entity as the domain returns it."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Storefront:
id: int
name: str
@@ -0,0 +1,56 @@
"""storefronts — the storefront + membership service (SD-0001 §6.5).
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
account_id and email down.
"""
from __future__ import annotations
import psycopg
from .errors import AlreadyOwnsStorefront
from .models import Storefront
def _default_name(email: str) -> str:
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
return f"{email.split('@', 1)[0]}'s storefront"
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
row = conn.execute(
"SELECT s.id, s.name FROM storefront s"
" JOIN storefront_membership m ON m.storefront_id = s.id"
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
(account_id,),
).fetchone()
return Storefront(id=row[0], name=row[1]) if row else None
def create_storefront(
conn: psycopg.Connection, account_id: int, email: str, name: str | None
) -> Storefront:
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
account_id serializes concurrent creates for the same account, so the second of two
racing requests sees the first's committed membership and is refused (§6.5).
"""
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
existing = storefront_for(conn, account_id)
if existing is not None:
conn.rollback() # release the advisory lock; nothing was written
raise AlreadyOwnsStorefront()
final_name = (name or "").strip() or _default_name(email)
sf_id = conn.execute(
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
).fetchone()[0]
conn.execute(
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
" VALUES (%s, %s, 'owner')",
(account_id, sf_id),
)
conn.commit()
return Storefront(id=sf_id, name=final_name)
+221
View File
@@ -0,0 +1,221 @@
"""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. SLICE-3 adds POST /api/storefronts and feeds the
_storefront_for seam from the storefronts domain.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.domains import accounts, storefronts
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
# The repo-root VERSION file is the single version source: the deploy pin checks out its
# tag, and /healthz must report it back (flotilla-core's verify gate compares them).
_REPO_ROOT = Path(__file__).resolve().parents[2]
try:
_APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip()
except OSError:
_APP_VERSION = "0.0.0"
class RequestCodeBody(BaseModel):
email: str
class VerifyBody(BaseModel):
email: str
code: str
class CreateStorefrontBody(BaseModel):
name: str | None = None
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)."""
sf = storefronts.storefront_for(conn, account.id)
return {"id": sf.id, "name": sf.name} if sf else None
def _ensure_app_logging() -> None:
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
out of uvicorn's stream and avoids double-logging.
"""
lg = logging.getLogger("ecomm")
if not lg.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
lg.addHandler(handler)
lg.setLevel(logging.INFO)
lg.propagate = False
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, static_dir: str | Path | None = None) -> FastAPI:
_ensure_app_logging()
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=_APP_VERSION, 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", "version": _APP_VERSION}
@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,
)
except accounts.DeliveryFailed:
return _error(
502, "delivery_failed",
"We couldn't send the code — try again in a moment.",
)
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
@app.post("/api/storefronts")
def create_storefront(
body: CreateStorefrontBody,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
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.")
try:
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
except storefronts.AlreadyOwnsStorefront:
return _error(
409, "already_owns_storefront",
"Your account already has its storefront — ecomm is one storefront per account today.",
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
if (spa_dir / "index.html").is_file():
app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
return app
app = create_app()
+5
View File
@@ -0,0 +1,5 @@
"""platform layer — cross-cutting primitives (config, db, deps).
The bottom layer: imports nothing from app.domains or app.main. Owns the schema
lifecycle and request wiring, never business rules (SD-0001 §6.2).
"""
+70
View File
@@ -0,0 +1,70 @@
"""Configuration surface — the single place deployment/environment config is read.
INV-8: no deployment shape is baked into framework code. Every URL, credential, or
relay coordinate arrives through here from the environment (resolved from Secret
Manager in deployed environments). The localhost defaults match compose.yaml so a
clean `scripts/dev.sh` checkout needs no env setup.
"""
from __future__ import annotations
import os
# Dev default points at the single Postgres container compose.yaml brings up. In
# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8).
_DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
def database_url() -> str:
"""The psycopg DSN for the application database."""
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
# 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)."""
return os.environ.get("ECOMM_MAILER") or "log"
# SMTP relay coordinates (deployed envs only; INV-8 — host/port/user/from are non-secret
# overlay values, the password is a Secret Manager reference resolved by the deploy).
def smtp_host() -> str:
return os.environ.get("ECOMM_SMTP_HOST", "")
def smtp_port() -> int:
return int(os.environ.get("ECOMM_SMTP_PORT") or "587")
def smtp_user() -> str:
return os.environ.get("ECOMM_SMTP_USER", "")
def smtp_password() -> str:
return os.environ.get("ECOMM_SMTP_PASSWORD", "")
def smtp_from() -> str:
"""The From header; defaults to the relay user."""
return os.environ.get("ECOMM_SMTP_FROM") or smtp_user()
def smtp_starttls() -> bool:
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
+87
View File
@@ -0,0 +1,87 @@
"""PostgreSQL access (psycopg 3) + a forward-only migration runner.
Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and
numbered forward-only `.sql` migrations applied in filename order, recorded in
`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and
records nothing, so it retries next boot) and guarded by a session advisory lock so
two booting processes never double-apply (INV-7).
"""
from __future__ import annotations
from pathlib import Path
import psycopg
from psycopg_pool import ConnectionPool
from . import config
# app/platform/db.py -> parents[2] is backend/, where migrations/ lives.
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard).
_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1"
def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool:
"""Open a small connection pool against the configured database."""
pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False)
pool.open()
pool.wait()
return pool
def _migration_files(migrations_dir: Path) -> list[Path]:
return sorted(migrations_dir.glob("*.sql"))
def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
"""Apply unapplied migrations in filename order; return the names applied.
Forward-only and fail-stop: each migration runs with its `schema_migrations`
insert in one transaction, so a failure records nothing and is retried next time.
A session advisory lock serializes concurrent migrators (INV-7).
"""
conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,))
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations ("
" filename TEXT PRIMARY KEY,"
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
)
conn.commit()
applied = {
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
}
ran: list[str] = []
for path in _migration_files(migrations_dir):
if path.name in applied:
continue
try:
conn.execute(path.read_text()) # whole file; no params -> multi-statement
conn.execute(
"INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,)
)
conn.commit()
except Exception:
conn.rollback()
raise
ran.append(path.name)
return ran
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,))
conn.commit()
def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
"""Migration filenames present on disk but not yet recorded as applied.
Used by /healthz to report migration currency (§6.4). Returns all on-disk
migrations if the tracking table does not exist yet.
"""
exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0]
if exists is None:
return [p.name for p in _migration_files(migrations_dir)]
applied = {
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
}
return [p.name for p in _migration_files(migrations_dir) if p.name not in applied]
+38
View File
@@ -0,0 +1,38 @@
"""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())
+98
View File
@@ -0,0 +1,98 @@
"""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` (relay coordinates from configuration, INV-8). The adapter is chosen
by configuration at startup, 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. SmtpMailer never logs the body or the recipient (INV-3 / §6.6
log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502).
"""
from __future__ import annotations
import hashlib
import logging
import smtplib
from dataclasses import dataclass
from email.message import EmailMessage
from typing import Protocol
from app.platform import config
logger = logging.getLogger("ecomm.mailer")
class MailerError(Exception):
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
@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)
class SmtpMailer:
"""Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default).
Logs only a hashed recipient — never the address, subject, or body (§6.6).
"""
def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None:
self._host, self._port = host, port
self._user, self._password = user, password
self._sender, self._starttls = sender, starttls
def send(self, to: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["To"] = to
msg["From"] = self._sender
msg["Subject"] = subject
msg.set_content(body)
to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8]
try:
with smtplib.SMTP(self._host, self._port, timeout=10) as smtp:
if self._starttls:
smtp.starttls()
if self._user:
smtp.login(self._user, self._password)
smtp.send_message(msg)
except Exception as exc:
logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__)
raise MailerError(str(exc)) from exc
logger.info("smtp sent to=%s", to_hash)
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":
return SmtpMailer(
host=config.smtp_host(),
port=config.smtp_port(),
user=config.smtp_user(),
password=config.smtp_password(),
sender=config.smtp_from(),
starttls=config.smtp_starttls(),
)
raise ValueError(f"unknown mailer kind: {kind!r}")
+49
View File
@@ -0,0 +1,49 @@
"""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
+48
View File
@@ -0,0 +1,48 @@
-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7):
-- do NOT edit this file once it has merged/applied; add a new numbered migration.
-- account — a person's identity on the platform. Email is the canonical key (INV-2);
-- the address is stored already-normalized (lowercased) by the service layer, so a
-- plain unique index enforces one-account-per-email. Data minimization (OHM): email
-- and nothing else (§6.3).
CREATE TABLE account (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX account_email_key ON account (email); -- INV-2
-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored;
-- keyed by email (the code is issued before the account may exist — sign-up and
-- log-in converge, PUC-3).
CREATE TABLE auth_code (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX auth_code_email_idx ON auth_code (email);
-- storefront — a merchant's business presence. Name is always populated (a blank
-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL.
CREATE TABLE storefront (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a
-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per-
-- account stays open; the one-storefront rule is a deletable service-layer guard, not
-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role`
-- is a single-value enum today; it is the seam the staff/permissions model attaches to.
CREATE TABLE storefront_membership (
account_id BIGINT NOT NULL REFERENCES account (id),
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, storefront_id)
);
+7
View File
@@ -0,0 +1,7 @@
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q
# Put backend/ (this rootdir) on sys.path so tests import the `app` package under
# pytest's default prepend import mode (tests/ has no __init__.py).
pythonpath = .
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.110
uvicorn[standard]>=0.29
httpx>=0.27
psycopg[binary]>=3.1
psycopg-pool>=3.2
pytest>=8.0
import-linter>=2.0
+47
View File
@@ -0,0 +1,47 @@
"""Test fixtures — a fresh, empty PostgreSQL database per test.
Each test gets its own database created on the dev/CI Postgres (every test runs the
real engine, §6.8). The admin DSN points at the `postgres` maintenance database;
ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container).
"""
from __future__ import annotations
import os
import uuid
import psycopg
import pytest
ADMIN_URL = os.environ.get(
"ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres"
)
def _swap_dbname(dsn: str, dbname: str) -> str:
# ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`.
base, _, _old = dsn.rpartition("/")
return f"{base}/{dbname}"
@pytest.fixture()
def fresh_db_url():
"""Create a uniquely-named empty database; drop it after the test."""
dbname = f"ecomm_test_{uuid.uuid4().hex}"
admin = psycopg.connect(ADMIN_URL, autocommit=True)
try:
admin.execute(f'CREATE DATABASE "{dbname}"')
finally:
admin.close()
try:
yield _swap_dbname(ADMIN_URL, dbname)
finally:
admin = psycopg.connect(ADMIN_URL, autocommit=True)
try:
admin.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = %s AND pid <> pg_backend_pid()",
(dbname,),
)
admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"')
finally:
admin.close()
@@ -0,0 +1,91 @@
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 == []
class _FailingMailer:
"""A mailer whose relay always refuses — the INV-9 honest-failure path."""
def send(self, to: str, subject: str, body: str) -> None:
raise mailer.MailerError("relay refused")
def test_delivery_failure_leaves_no_orphan_code(conn):
# ecomm#7: send happens BEFORE commit — a failed delivery must not strand an
# auth_code row (which would also trip the resend cooldown for 60s).
with pytest.raises(accounts.DeliveryFailed):
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
rows = conn.execute("SELECT count(*) FROM auth_code").fetchone()[0]
assert rows == 0
def test_delivery_failure_does_not_trip_cooldown(conn):
with pytest.raises(accounts.DeliveryFailed):
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
# the immediate retry (relay back up) succeeds — no ResendCooldown
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
assert len(m.outbox) == 1
+100
View File
@@ -0,0 +1,100 @@
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
+146
View File
@@ -0,0 +1,146 @@
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
def test_request_code_delivery_failure_is_502(fresh_db_url, monkeypatch):
# INV-9 honest failure over HTTP (§6.4): the relay refused -> 502 delivery_failed,
# never a fake 204. Force the app's mailer to fail after startup.
from app.platform import mailer as mailer_mod
class _FailingMailer:
def send(self, to, subject, body):
raise mailer_mod.MailerError("relay refused")
with _client(fresh_db_url) as client:
client.app.state.mailer = _FailingMailer()
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
assert resp.status_code == 502
assert resp.json()["error"]["code"] == "delivery_failed"
+42
View File
@@ -0,0 +1,42 @@
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py."""
import re
from fastapi.testclient import TestClient
from app.main import create_app
def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
# fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7).
with TestClient(create_app(database_url=fresh_db_url)) as client:
# a fresh deployment serves healthz green before any row exists
assert client.get("/healthz").json()["status"] == "ok"
# PUC-2: first visitor requests a code; it reaches them via the dev channel
assert client.post(
"/api/auth/request-code", json={"email": "first@example.com"}
).status_code == 204
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
# verify creates account #1 — the first row, through the product alone
verified = client.post(
"/api/auth/verify", json={"email": "first@example.com", "code": code}
)
assert verified.status_code == 200
assert verified.json()["created"] is True
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
# PUC-4: create the storefront (blank name -> generated default)
created = client.post("/api/storefronts", json={})
assert created.status_code == 201
assert created.json()["name"] == "first's storefront"
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json() == {
"account": {"email": "first@example.com"},
"storefront": created.json(),
}
+43
View File
@@ -0,0 +1,43 @@
import os
from app.platform import config
def test_database_url_defaults_to_local_compose(monkeypatch):
monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False)
assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm"
def test_database_url_honors_env(monkeypatch):
monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z")
assert config.database_url() == "postgresql://x:y@db:5432/z"
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"
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import create_app
# /healthz reports the VERSION file's value — the flotilla deploy gate compares
# body.version against the pinned target (flotilla-core SPEC §8.1 phase 8).
_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()
def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
app = create_app(database_url=fresh_db_url)
with TestClient(app) as client:
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"status": "ok", "version": _VERSION}
def test_startup_migrates_from_empty(fresh_db_url):
# create_app on an empty DB must self-migrate so /healthz reports current.
app = create_app(database_url=fresh_db_url)
with TestClient(app) as client:
assert client.get("/healthz").status_code == 200
# the schema_migrations row exists -> migrations ran at startup
import psycopg
with psycopg.connect(fresh_db_url) as conn:
n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0]
assert n >= 1
+111
View File
@@ -0,0 +1,111 @@
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)
class _FakeSMTP:
"""Stand-in for smtplib.SMTP capturing the call sequence (no network)."""
instances: list["_FakeSMTP"] = []
fail_on_send = False
def __init__(self, host, port, timeout=None):
self.host, self.port, self.timeout = host, port, timeout
self.calls: list[str] = []
self.message = None
_FakeSMTP.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
self.calls.append("starttls")
def login(self, user, password):
self.calls.append(f"login:{user}")
def send_message(self, msg):
if _FakeSMTP.fail_on_send:
raise RuntimeError("relay refused")
self.calls.append("send_message")
self.message = msg
def _smtp_env(monkeypatch):
monkeypatch.setattr(mailer.smtplib, "SMTP", _FakeSMTP)
_FakeSMTP.instances.clear()
_FakeSMTP.fail_on_send = False
monkeypatch.setenv("ECOMM_SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("ECOMM_SMTP_PORT", "587")
monkeypatch.setenv("ECOMM_SMTP_USER", "sender@example.com")
monkeypatch.setenv("ECOMM_SMTP_PASSWORD", "not-a-real-password")
monkeypatch.setenv("ECOMM_SMTP_FROM", "ecomm <sender@example.com>")
def test_build_mailer_smtp_builds_from_config(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
assert isinstance(m, mailer.SmtpMailer)
def test_smtpmailer_sends_via_starttls_login(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456")
smtp = _FakeSMTP.instances[-1]
assert (smtp.host, smtp.port) == ("smtp.example.com", 587)
assert smtp.calls == ["starttls", "login:sender@example.com", "send_message"]
assert smtp.message["To"] == "merchant@example.com"
assert smtp.message["Subject"] == "Your ecomm code: 123456"
assert smtp.message["From"] == "ecomm <sender@example.com>"
assert "123456" in smtp.message.get_content()
def test_smtpmailer_failure_raises_mailer_error(monkeypatch):
_smtp_env(monkeypatch)
_FakeSMTP.fail_on_send = True
import pytest
m = mailer.build_mailer("smtp")
with pytest.raises(mailer.MailerError):
m.send("merchant@example.com", "subject", "body")
def test_smtpmailer_never_logs_the_body(monkeypatch):
# §6.6 log hygiene: codes never appear in deployed logs. LogMailer logging the body
# is dev-only by configuration; the deployed adapter must not. The handler attaches
# directly to the ecomm.mailer logger (it doesn't propagate to root).
import logging
_smtp_env(monkeypatch)
records: list[str] = []
handler = logging.Handler()
handler.emit = lambda r: records.append(r.getMessage()) # type: ignore[method-assign]
lg = logging.getLogger("ecomm.mailer")
lg.addHandler(handler)
lg.setLevel(logging.DEBUG)
try:
mailer.build_mailer("smtp").send(
"merchant@example.com", "Your ecomm code: 123456", "Code: 123456"
)
finally:
lg.removeHandler(handler)
assert records, "the deployed adapter should log the send event (observability §6.6)"
assert all("123456" not in r and "merchant@example.com" not in r for r in records)
+43
View File
@@ -0,0 +1,43 @@
import psycopg
from app.platform import db
_TABLES = {"account", "auth_code", "storefront", "storefront_membership"}
def _table_names(conn) -> set[str]:
rows = conn.execute(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
).fetchall()
return {r[0] for r in rows}
def test_migrate_from_empty_applies_0001(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
applied = db.migrate(conn)
assert applied == ["0001_init.sql"]
with psycopg.connect(fresh_db_url) as conn:
assert _TABLES.issubset(_table_names(conn))
def test_migrate_is_idempotent(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
with psycopg.connect(fresh_db_url) as conn:
applied_again = db.migrate(conn)
assert applied_again == []
def test_membership_has_no_unique_account_constraint(fresh_db_url):
# INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on
# storefront_membership.account_id anywhere.
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
rows = conn.execute(
"""
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'storefront_membership'
"""
).fetchall()
defs = " ".join(r[0] for r in rows).lower()
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
+26
View File
@@ -0,0 +1,26 @@
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
+24
View File
@@ -0,0 +1,24 @@
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
serves the frontend and the default dist dir simply doesn't exist."""
from fastapi.testclient import TestClient
from app.main import create_app
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
root = client.get("/")
assert root.status_code == 200
assert "ecomm spa" in root.text
# API + health still win over the mount
assert client.get("/healthz").json()["status"] == "ok"
assert client.get("/api/auth/me").status_code == 401
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
assert client.get("/").status_code == 404
assert client.get("/healthz").json()["status"] == "ok"
+154
View File
@@ -0,0 +1,154 @@
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
import re
from contextlib import contextmanager
import psycopg
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.domains import storefronts
from app.platform import db
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
def _account_id(conn, email="merchant@example.com"):
return conn.execute(
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
).fetchone()[0]
def test_create_storefront_with_name(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
assert sf.name == "Ben's Bets"
assert storefronts.storefront_for(migrated_conn, acct) == sf
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
assert sf.name == "merchant's storefront"
def test_whitespace_name_treated_as_blank(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
assert sf.name == "ben's storefront"
def test_second_create_refused_inv_4(migrated_conn):
acct = _account_id(migrated_conn)
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
with pytest.raises(storefronts.AlreadyOwnsStorefront):
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
def test_membership_row_is_owner(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
role = migrated_conn.execute(
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
(acct, sf.id),
).fetchone()[0]
assert role == "owner"
def test_storefront_for_none_when_absent(migrated_conn):
acct = _account_id(migrated_conn)
assert storefronts.storefront_for(migrated_conn, acct) is None
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
@contextmanager
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
with TestClient(create_app(database_url=fresh_db_url)) as client:
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": email, "code": code})
yield client
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
assert resp.status_code == 201
assert resp.json()["name"] == "Ben's Bets"
assert isinstance(resp.json()["id"], int)
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
# No payment method, plan, or commitment: the request body needs nothing at all and
# the response carries only the storefront — no plan/trial/billing fields.
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={})
assert resp.status_code == 201
assert set(resp.json().keys()) == {"id", "name"}
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
with _signed_in_client(fresh_db_url) as client:
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
me = client.get("/api/auth/me").json()
assert me["storefront"] == created
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
me = client.get("/api/auth/me").json()
assert me["storefront"] is None
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
# PUC-6: a returning merchant's verify response already carries the storefront.
from datetime import datetime, timedelta, timezone
email = "returning@example.com"
with _signed_in_client(fresh_db_url, email) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
# fresh login: backdate the consumed code to clear the 60s resend cooldown
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), email),
)
c.commit()
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
assert resp.status_code == 200
assert resp.json()["storefront"]["name"] == "Ben's Bets"
assert resp.json()["created"] is False
def test_puc_07_second_create_refused_409(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "First"})
resp = client.post("/api/storefronts", json={"name": "Second"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "already_owns_storefront"
def test_puc_08_admin_shell_answer(fresh_db_url):
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
me = client.get("/api/auth/me").json()
assert me["account"]["email"] == "merchant@example.com"
assert me["storefront"]["name"] == "Ben's Bets"
def test_create_storefront_requires_session(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
resp = client.post("/api/storefronts", json={"name": "Nope"})
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "unauthenticated"
@@ -0,0 +1,64 @@
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
import threading
import psycopg
import pytest
from app.domains import storefronts
from app.platform import db
@pytest.fixture()
def migrated_url(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
return fresh_db_url
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
with psycopg.connect(migrated_url) as setup:
acct = setup.execute(
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
).fetchone()[0]
setup.commit()
barrier = threading.Barrier(2)
results: list[str] = []
def racer():
with psycopg.connect(migrated_url) as conn:
barrier.wait()
try:
storefronts.create_storefront(conn, acct, "racer@example.com", None)
results.append("created")
except storefronts.AlreadyOwnsStorefront:
results.append("refused")
threads = [threading.Thread(target=racer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(results) == ["created", "refused"]
with psycopg.connect(migrated_url) as check:
count = check.execute(
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
).fetchone()[0]
assert count == 1
def test_inv_4_schema_stays_many_capable(migrated_url):
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
# storefront_membership is its composite PK, so many-per-account stays a deletable
# service rule (R-5 mitigation).
with psycopg.connect(migrated_url) as conn:
uniques = conn.execute(
"SELECT i.indkey::text FROM pg_index i"
" JOIN pg_class c ON c.oid = i.indrelid"
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
).fetchall()
# exactly one unique index (the composite PK over two columns)
assert len(uniques) == 1
assert len(uniques[0][0].split()) == 2
+26
View File
@@ -0,0 +1,26 @@
# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7).
# The app runs natively (fast dev loop); only the database is containerized.
# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the
# bootstrap, BUC-5) with: docker compose down -v
name: ecomm-dev
services:
db:
# Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7).
image: postgres:16
environment:
POSTGRES_USER: ecomm
POSTGRES_PASSWORD: ecomm
POSTGRES_DB: ecomm
ports:
- "5432:5432"
volumes:
- ecomm-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"]
interval: 2s
timeout: 3s
retries: 30
volumes:
ecomm-pgdata:
+42
View File
@@ -0,0 +1,42 @@
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
[app]
name = "ecomm"
repo = "wiggleverse/wiggleverse-ecomm"
gitea_host = "https://git.wiggleverse.org"
version_source = { kind = "file", path = "VERSION" }
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
[vm]
name = "ecomm-ppe"
zone = "us-central1-a"
project = "wiggleverse-ecomm"
machine_type = "e2-micro"
disk_gb = 10
service_user = "ecomm"
install_dir = "/opt/ecomm"
systemd_unit = "ecomm.service"
tunnel_through_iap = true
gcloud_config = "wiggleverse-ecomm"
[edge]
domain = "ecomm-ppe.wiggleverse.org"
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
[overlay] # non-secret env, plaintext (guide §8)
APP_URL = "https://ecomm-ppe.wiggleverse.org"
ECOMM_MAILER = "smtp"
ECOMM_COOKIE_SECURE = "1"
ECOMM_SMTP_HOST = "smtp.gmail.com"
ECOMM_SMTP_PORT = "587"
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
[secrets] # REFERENCES only — never bytes (§8.3)
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
+140
View File
@@ -0,0 +1,140 @@
# Bootstrapping ecomm
Bringing an environment from **empty persistence** to "first merchant, first
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
(INV-1): the app applies its own schema migrations at startup; there is no seed step.
This document grows per environment. SLICE-1 shipped the **localhost** section;
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
stand-up.
## Localhost
### Prerequisites
- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The
dev datastore is a single PostgreSQL container; the app itself runs natively for a
fast dev loop (D-7).
- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend
venv and installs frontend deps on first run.
### Bring-up
From a clean checkout:
```bash
scripts/dev.sh
```
This:
1. starts the dev Postgres container and waits for it to report healthy (the script
owns the container's lifecycle);
2. creates the backend virtualenv and installs dependencies (first run only);
3. installs the frontend's npm dependencies (first run only);
4. runs the FastAPI backend on **:8000** — which connects to the empty database and
applies all pending migrations itself (INV-7) — and the Vite dev server on
**:5173**, proxying `/api` to the backend.
Press **Ctrl-C** to stop the backend and Vite. The database container keeps running.
### Verify
```bash
curl http://localhost:8000/healthz
```
Expected: `{"status":"ok"}` (HTTP 200) — the process is up, the database is reachable,
and migrations are current. Open <http://localhost:5173> to see the app shell.
### Reset to empty (rehearse the bootstrap)
```bash
docker compose down -v
```
This removes the container and its named volume, returning persistence to empty. The
next `scripts/dev.sh` re-migrates from scratch — exactly the day-one state every
environment starts from (BUC-5a).
### Configuration
`scripts/dev.sh` sets `ECOMM_DATABASE_URL` to the local compose DSN
(`postgresql://ecomm:ecomm@localhost:5432/ecomm`). No deployment shape is baked into
the app (INV-8); deployed environments supply this and other config from Secret
Manager (see PPE below).
## Pre-production (PPE)
PPE is stood up and deployed through the **launch-app / flotilla-core** suite only
(handbook §8.5 — provisioning is an operator-run gesture). The app's deployment
record is `deployment.toml` at this repo's root; flotilla-core consumes it.
### One-time provisioning (operator-run, in order)
1. **Cloud foundation**`scaffold-gcp-project`: the GCP project, its dedicated
`--no-activate` gcloud config, billing, core APIs, ADC quota pin.
2. **Managed database**`provision-datastore` (launch-app §6.6a, built for ecomm's
D-7/D-8): Cloud SQL for PostgreSQL 16, private-IP-only, automated backups +
point-in-time recovery; writes the full DSN to Secret Manager as
`<project>/ecomm-ppe-database-url`. Bound on the deployment record as
`ECOMM_DATABASE_URL`.
3. **Secrets** (references only — bytes go in via stdin, never through a session):
`ECOMM_SESSION_SECRET` (fresh random), `ECOMM_SMTP_PASSWORD` (the shared
Wiggleverse relay credential), and flotilla's own Gitea read token for this
private repo.
4. **VM + edge**`provision-vm`: the e2-micro, IAP-only SSH, nginx + Cloudflare
origin TLS, the systemd unit, DNS for `ecomm-ppe.wiggleverse.org`.
5. **Deployment record** — `flotilla-core deployment scaffold ecomm … -o
deployment.toml`, validate, commit it here, `deployment import --reconcile`.
### Deploy
Each release is a git tag `v<VERSION>` matching the repo-root `VERSION` file (the
pin). The one gesture, from the flotilla-core repo's venv:
```bash
CLOUDSDK_ACTIVE_CONFIG_NAME=ecomm .venv/bin/flotilla-core deploy ecomm
```
Nine phases, fail-stop; it ends by polling `https://ecomm-ppe.wiggleverse.org/healthz`
and requiring `status == "ok"` and `version == <target>`. Watch it yourself the same
way:
```bash
curl https://ecomm-ppe.wiggleverse.org/healthz
```
### Configuration surface (PPE values)
| Env var | Kind | Value |
| --- | --- | --- |
| `ECOMM_DATABASE_URL` | secret ref | `<project>/ecomm-ppe-database-url` (from provision-datastore) |
| `ECOMM_SESSION_SECRET` | secret ref | `<project>/ecomm-ppe-session-secret` |
| `ECOMM_SMTP_PASSWORD` | secret ref | the shared Wiggleverse relay credential |
| `ECOMM_MAILER` | overlay | `smtp` |
| `ECOMM_COOKIE_SECURE` | overlay | `1` |
| `ECOMM_SMTP_HOST` / `_PORT` / `_USER` / `_FROM` | overlay | the relay coordinates (non-secret) |
### The rehearsal (PUC-11)
From empty persistence: the deploy migrates the schema at startup (INV-7); then a
real sign-up → one-time code arriving by **real email** → create storefront → admin,
through the public flows alone. Record each rehearsal here when it happens.
**Rehearsals:**
- **2026-06-11 — PPE first bootstrap (v0.4.0, session 0024): ✅** First-ever ecomm
deploy (`flotilla-core deploy ecomm`, 9/9 phases green, deploys.id=40) onto a
freshly provisioned environment — project `wiggleverse-ecomm`, Cloud SQL
`ecomm-ppe-pg` (provisioned by launch-app `provision-datastore`, first run), VM
`ecomm-ppe`. The app self-migrated the empty database at startup; the operator
then walked sign-up → real emailed code (Gmail relay) → create storefront →
honestly-empty admin in a browser, through the public flows alone. Findings
captured: OTC email branding (#16); identity should outgrow ecomm
(engineering#49/#50).
## Production
Lands with the prod stand-up — the **identical gesture** on a prod deployment record
(BUC-5a): same provisioning skills, same deploy, same rehearsal.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
# SLICE-4 (part 1) — Deploy-Contract Code 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:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError``accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
---
### Task 1: VERSION + versioned /healthz
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
- [ ] Write `VERSION` containing `0.4.0`.
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
### Task 2: backend serves the SPA (deploy phase-8 contract)
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
### Task 3: SmtpMailer + config surface (INV-8)
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
- [ ] README status: deploy contract in place; frontend package 0.4.0.
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
## Self-review
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
@@ -0,0 +1,118 @@
# ui/designs Content-Repo Collection 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:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
---
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
**Files:**
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 1014)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
```
- [ ] **Step 2: Create the collection README**
`ui/designs/README.md`:
```markdown
# ui/designs — UI-design artifacts
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
app's UI-design artifacts — primarily Claude Design outputs generated from a
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
A Solution Design with a UX-involving slice references its design artifact here
by path. The spec-linkage gate and the backfill gesture for adding that
reference once a design exists are tracked in
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
Suggested layout: one subfolder per design, named for the spec/slice it serves,
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
```
- [ ] **Step 3: Add the layout-table row**
In the top-level `README.md`, extend the table:
```markdown
| Path | Holds |
| --- | --- |
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
| `plans/` | archived implementation plans |
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
```
- [ ] **Step 4: Commit, push, PR, merge**
```bash
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
```
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
### Task 2: Standardize centrally in engineering schemas docs
**Files:**
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
```
- [ ] **Step 2: Schema description strings + enum**
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]``["1.0", "1.1", "1.2", "1.3"]`
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
- [ ] **Step 3: schemas/README.md**
Changelog entry above 1.2:
```markdown
- **1.3** — docs-only: the content-repo collection convention gains a third
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
outputs generated from a Solution Design), referenced from specs. Like
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
validation change (the spec-linkage gate/backfill tooling is
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
```
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
```bash
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
git -C …/engineering add schemas/app.schema.json schemas/README.md
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
git -C …/engineering push -u origin ui-designs-collection
```
PR + merge (default token for `/pulls`).
### Task 3: Cross-link and close out
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0E1230" />
<style>html { background: #0E1230; }</style>
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
<title>ecomm</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2174
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.4.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"
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse — mono gold</title>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
<g fill="#F4C76B">
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 648 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse</title>
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
<g fill="#EDEAFF">
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
// response carries the same shape as /me, so onAuthed feeds the session directly.
import { useEffect, useState } from "react";
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Admin from "./screens/Admin";
import CreateStorefront from "./screens/CreateStorefront";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";
type Door = "signup" | "login";
type Welcome = "new" | "back" | null;
export default function App() {
const [session, setSession] = useState<SessionState | null>(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState<Door | null>(null);
const [welcome, setWelcome] = useState<Welcome>(null);
async function reload() {
setLoading(true);
setSession(await getMe());
setLoading(false);
}
useEffect(() => {
void reload();
}, []);
if (loading) {
return (
<div className="screen screen--plain">
<main className="screen__main">
<p className="note">Loading</p>
</main>
</div>
);
}
const route = routeFor(session ?? { account: null, storefront: null });
if (route === "landing") {
if (door) {
return (
<SignIn
door={door}
onBack={() => setDoor(null)}
onAuthed={(result: VerifyResult) => {
setWelcome(result.created ? "new" : "back");
setDoor(null);
setSession({ account: result.account, storefront: result.storefront });
}}
/>
);
}
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
const email = session!.account!.email;
function signedOut() {
setWelcome(null);
setDoor(null);
setSession(null);
}
if (route === "create-storefront") {
return (
<CreateStorefront
email={email}
welcome={welcome}
onCreated={(sf: StorefrontResult) => {
setWelcome(null);
setSession({ account: { email }, storefront: sf });
}}
onAlreadyOwns={() => void reload()}
onSignedOut={signedOut}
/>
);
}
return (
<Admin
storefrontName={session!.storefront!.name}
email={email}
welcome={welcome}
onSignedOut={signedOut}
/>
);
}
+79
View File
@@ -0,0 +1,79 @@
// 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" });
}
export interface StorefrontResult {
id: number;
name: string;
}
export async function createStorefront(
name: string,
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
const resp = await fetch("/api/storefronts", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
});
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
return { ok: false, error: await errorOf(resp) };
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { cooldownAppliesTo } from "./cooldown";
describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
it("no cooldown running -> send not blocked", () => {
expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
});
it("cooldown running for the same address -> blocked", () => {
expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
});
it("same address modulo case/whitespace -> still blocked", () => {
expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
});
it("cooldown running but the input is a different address -> not blocked", () => {
expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
});
it("cooldown expired -> not blocked even for the same address", () => {
expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
// per-address, so the client countdown must be too: it blocks sending only while it
// is running AND the current input is the address it was set for. Normalization
// mirrors the backend's normalize_email (lowercase + strip, INV-2).
function normalize(email: string): string {
return email.trim().toLowerCase();
}
export function cooldownAppliesTo(
input: string,
cooldownEmail: string | null,
secondsLeft: number,
): boolean {
if (secondsLeft <= 0 || cooldownEmail === null) return false;
return normalize(input) === normalize(cooldownEmail);
}
+10
View File
@@ -0,0 +1,10 @@
import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
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");
});
});
+16
View File
@@ -0,0 +1,16 @@
// 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";
}
+59
View File
@@ -0,0 +1,59 @@
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
interface Props {
storefrontName: string;
email: string;
welcome: "new" | "back" | null;
onSignedOut: () => void;
}
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<Screen plain>
<TopBar
left={
<span className="storeid">
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
<span className="storeid__col">
<span className="storeid__name">{storefrontName}</span>
<span className="storeid__sub">ecomm storefront</span>
</span>
</span>
}
right={<AccountChip email={email} onSignOut={signOut} />}
/>
<main className="screen__main">
<div className="empty">
{welcome && (
<div style={{ marginBottom: 24, width: "100%" }}>
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
</div>
)}
<div className="empty__seal" aria-hidden="true">
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
</div>
<Eyebrow>Your storefront</Eyebrow>
<h1>{storefrontName}</h1>
<p className="empty__copy">
There's nothing to manage yet — and that's a finished state, not a missing one.
Catalog, orders, and settings will appear here as ecomm grows.
</p>
</div>
</main>
</Screen>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
import { useState } from "react";
import { createStorefront, logout, type StorefrontResult } from "../api";
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
email: string;
welcome: "new" | "back" | null;
onCreated: (storefront: StorefrontResult) => void;
onAlreadyOwns: () => void;
onSignedOut: () => void;
}
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [alreadyOwns, setAlreadyOwns] = useState(false);
const [error, setError] = useState<string | null>(null);
async function signOut() {
await logout();
onSignedOut();
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await createStorefront(name);
setBusy(false);
if (res.ok) {
onCreated(res.storefront);
return;
}
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
else setError(res.error.message);
}
return (
<Screen horizon>
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
<main className="screen__main">
<AuthCard>
<form onSubmit={submit} style={{ display: "contents" }}>
<div>
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
<p className="card__sub">
This is the one thing to do right now. It costs nothing and commits you to nothing.
</p>
</div>
{welcome && (
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
)}
{alreadyOwns && (
<Banner title="Your account already has its storefront">
ecomm is one storefront per account today.{" "}
<button type="button" className="lk" onClick={onAlreadyOwns}>
Go to your admin
</button>
</Banner>
)}
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
<Field
label="Storefront name"
optional
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
inputProps={{
type: "text",
value: name,
placeholder: "e.g. Ben's Bets",
autoFocus: true,
onChange: (ev) => setName(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
</div>
</form>
</AuthCard>
</main>
<Footer />
</Screen>
);
}
+61
View File
@@ -0,0 +1,61 @@
// 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. Visuals per the ui/designs export (hf-landing, Direction A).
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
onGetStarted: () => void;
onLogIn: () => void;
}
const PROMISES: Array<[string, string]> = [
["No trial clock", "Your storefront never expires or locks."],
["No plan wall", "We take only what it takes to run."],
["Your data, yours", "Nothing collected you didn't agree to give."],
];
export default function Landing({ onGetStarted, onLogIn }: Props) {
return (
<Screen horizon>
<TopBar
left={<Wordmark />}
right={
<button type="button" className="lk lk--nav" onClick={onLogIn}>
Log in
</button>
}
/>
<main className="screen__main">
<div className="hero">
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1>
Sell online,
<br />
<em>honestly.</em>
</h1>
<p className="hero__lead">
Claim the one storefront that's yours on a platform that takes only what it takes
to run — no trial countdown, no plan wall, no data you didn't agree to give.
</p>
<div className="hero__actions">
<PrimaryButton type="button" onClick={onGetStarted}>
Create your storefront
</PrimaryButton>
<button type="button" className="lk lk--mute" onClick={onLogIn}>
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
</button>
</div>
<div className="promises">
{PROMISES.map(([title, copy]) => (
<div key={title}>
<div className="promises__title">{title}</div>
<p>{copy}</p>
</div>
))}
</div>
</div>
</main>
<Footer />
</Screen>
);
}
+198
View File
@@ -0,0 +1,198 @@
// 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 for every §5.2 state; storefront routing
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
import { useEffect, useState } from "react";
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import { cooldownAppliesTo } from "../cooldown";
import CodeInput from "../ui/CodeInput";
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
type Door = "signup" | "login";
interface Props {
door: Door;
onAuthed: (result: VerifyResult) => void;
onBack: () => void;
}
function useCooldown(): [number, (s: number) => void] {
const [left, setLeft] = useState(0);
const ticking = left > 0;
useEffect(() => {
if (!ticking) return;
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
return () => clearInterval(t);
}, [ticking]);
return [left, setLeft];
}
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 [fieldError, setFieldError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [bannerError, setBannerError] = useState<ApiError | null>(null);
const [cooldown, setCooldown] = useCooldown();
// The address the running cooldown belongs to — the server's cooldown is per-address
// (INV-3), so a different address must not be blocked by it (bug #20).
const [cooldownFor, setCooldownFor] = useState<string | null>(null);
function applyError(err: ApiError) {
setFieldError(null);
setCodeError(null);
setBannerError(null);
if (err.code === "invalid_email") setFieldError(err.message);
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
}
async function sendCode(): Promise<boolean> {
setBusy(true);
setFieldError(null);
setBannerError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
applyError(err);
if (err.code === "resend_cooldown") setCooldownFor(email);
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
}
setCooldown(60);
setCooldownFor(email);
return true;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code");
}
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
applyError(res.error);
return;
}
onAuthed(res.result);
}
const heading = door === "signup" ? "Create your storefront" : "Log in";
const sub =
door === "signup"
? "Enter your email to begin. There's no password to create."
: "Enter your email and we'll send a one-time code to sign in.";
return (
<Screen horizon>
<TopBar
left={<Wordmark size={22} />}
right={
<button type="button" className="lk lk--mute" onClick={onBack}>
Back
</button>
}
/>
<main className="screen__main">
<AuthCard>
{step === "email" ? (
<form onSubmit={submitEmail} style={{ display: "contents" }}>
<div>
<h1>{heading}</h1>
<p className="card__sub">{sub}</p>
</div>
{bannerError && (
<Banner title="We couldn't send the code">
The email didn't go out. Try again in a moment — nothing was lost.
</Banner>
)}
<Field
label="Email"
error={fieldError}
inputProps={{
type: "email",
value: email,
placeholder: "you@example.com",
autoFocus: true,
required: true,
autoComplete: "email",
onChange: (ev) => setEmail(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
{busy
? "Sending…"
: cooldownAppliesTo(email, cooldownFor, cooldown)
? `Resend in ${cooldown}s`
: "Send code"}
</PrimaryButton>
<p className="note">
We'll email you a one-time code. That's all we need no password, ever.
</p>
</div>
</form>
) : (
<form onSubmit={submitCode} style={{ display: "contents" }}>
<div>
<h1>Check your email</h1>
<p className="card__sub">
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
<button type="button" className="lk" onClick={() => setStep("email")}>
Wrong address?
</button>
</p>
</div>
{bannerError && (
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
{bannerError.code === "code_exhausted"
? "That code is no longer valid. Request a fresh one to keep going."
: bannerError.message}
</Banner>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CodeInput value={code} onChange={setCode} error={!!codeError} />
{codeError && (
<p className="note note--attn" role="alert">
{codeError}
</p>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="note">Good for 10 minutes.</p>
{cooldown > 0 ? (
<span className="note">Resend in {cooldown}s</span>
) : (
<button type="button" className="lk" onClick={resend}>
Resend code
</button>
)}
</div>
</div>
</form>
)}
</AuthCard>
</main>
<Footer />
</Screen>
);
}
+394
View File
@@ -0,0 +1,394 @@
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--wv-midnight);
color: var(--wv-starlight);
font-family: var(--wv-font-body);
-webkit-font-smoothing: antialiased;
}
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
.screen {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
background:
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--horizon {
background:
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--plain { background: var(--wv-midnight); }
.screen__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
.topbar {
flex: 0 0 auto;
height: 68px;
padding: 0 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-soft);
background: var(--glass-sky);
backdrop-filter: blur(var(--glass-blur));
position: relative;
z-index: 5;
}
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
/* ── wordmark ───────────────────────────────────────────────────────────────── */
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
.wordmark img { display: block; border-radius: 6px; }
.wordmark__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 22px;
letter-spacing: var(--tracking-display);
color: var(--wv-starlight);
}
/* ── links & text buttons ───────────────────────────────────────────────────── */
.lk {
color: var(--wv-lilac);
text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.lk:hover { color: var(--wv-gold); }
.lk--mute { color: var(--text-on-dark-mute); }
.lk--nav {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
}
.signout {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--dur-fast) var(--ease);
}
.signout:hover { color: var(--wv-gold); }
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
.chip__avatar {
width: 30px;
height: 30px;
border-radius: 15px;
background: var(--wv-lilac-16);
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 13px;
flex: 0 0 auto;
}
.chip__email {
font-size: 14px;
color: var(--text-on-dark-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
.eyebrow {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: var(--text-eyebrow);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--wv-lilac);
margin: 0;
}
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
.card {
width: 440px;
max-width: 100%;
background: var(--surface-raised);
border: 1px solid var(--border-card);
border-radius: var(--radius-card);
padding: 40px 40px 34px;
display: flex;
flex-direction: column;
gap: 22px;
box-shadow: var(--shadow-soft);
position: relative;
z-index: 2;
}
.card h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
font-size: 27px;
line-height: 1.08;
margin: 0 0 9px;
}
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
/* ── fields ─────────────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 8px; }
.field__label {
font-weight: var(--weight-medium);
font-size: 13.5px;
color: var(--text-on-dark-soft);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
.field__input {
height: 52px;
border-radius: 10px;
padding: 0 16px;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-body);
font-size: 16px;
color: var(--wv-starlight);
width: 100%;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.field__input::placeholder { color: var(--text-on-dark-mute); }
.field__input:hover { border-color: var(--border-strong); }
.field__input:focus {
outline: none;
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
background: rgba(237, 234, 255, .06);
}
.field__input--error { border-color: var(--wv-gold); }
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
.note--attn::before { content: "◆"; font-size: 11px; }
/* ── primary button: gold pill ──────────────────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4em;
width: 100%;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 15.5px;
line-height: 1;
padding: .85rem 1.4rem;
border-radius: var(--radius-pill);
border: var(--btn-border-w) solid transparent;
background: var(--cta);
color: var(--cta-text);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary--auto { width: auto; }
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
.banner {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
align-items: flex-start;
font-size: 14px;
line-height: 1.5;
color: var(--text-on-dark-soft);
text-align: left;
}
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
.banner--attn .banner__icon { color: var(--wv-gold); }
.banner--info .banner__icon { color: var(--wv-lilac); }
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
.code__hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
font-size: 16px;
cursor: pointer;
}
.code__cell {
width: 52px;
height: 64px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 26px;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.code--focus .code__cell--active {
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
}
.code--error .code__cell { border-color: var(--wv-gold); }
/* ── footer ─────────────────────────────────────────────────────────────────── */
.footer {
flex: 0 0 auto;
padding: 20px 36px;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 12.5px;
color: var(--text-on-dark-mute);
background: rgba(9, 12, 34, .4);
}
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
.footer__id img { opacity: .85; }
/* ── landing ────────────────────────────────────────────────────────────────── */
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.hero h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
line-height: 1.02;
font-size: clamp(36px, 6.5vw, 62px);
margin: 20px 0 22px;
}
.hero h1 em { font-style: normal; color: var(--wv-gold); }
.hero__lead {
font-size: clamp(15.5px, 1.8vw, 18.5px);
line-height: 1.6;
color: var(--text-on-dark-soft);
max-width: 520px;
margin: 0 0 32px;
text-wrap: pretty;
}
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
.hero__actions .btn-primary { width: auto; }
.promises {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
margin-top: 44px;
border-top: 1px solid var(--border-soft);
padding-top: 24px;
text-align: left;
}
.promises > div { padding: 0 22px; }
.promises > div + div { border-left: 1px solid var(--border-soft); }
.promises__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14.5px;
}
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
/* ── admin shell ────────────────────────────────────────────────────────────── */
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
.storeid__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 17px;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
.empty__seal {
width: 76px;
height: 76px;
border-radius: 50%;
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 26px;
background: var(--wv-lilac-08);
}
.empty h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
line-height: 1.04;
font-size: clamp(30px, 4.5vw, 42px);
margin: 12px 0 16px;
}
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.topbar { padding: 0 20px; }
.footer { padding: 16px 20px; flex-direction: column; }
.card { padding: 28px 22px 24px; }
.promises { grid-template-columns: 1fr; gap: 14px; }
.promises > div { padding: 0; }
.promises > div + div { border-left: none; }
.code__cell { width: 44px; height: 56px; font-size: 22px; }
}
+37
View File
@@ -0,0 +1,37 @@
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
}
+7
View File
@@ -0,0 +1,7 @@
/* Global style entry point — Wiggleverse design-system tokens (copied from the
ui/designs export) + the ecomm app chrome built on them. */
@import "./fonts.css";
@import "./tokens-colors.css";
@import "./tokens-typography.css";
@import "./tokens-spacing.css";
@import "./app.css";
+60
View File
@@ -0,0 +1,60 @@
/* Wiggleverse — Color tokens
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §89).
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
:root {
/* ---- Brand palette (base values) ---- */
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
--wv-gold-hi: #F7D488; /* gold hover */
--wv-starlight:#EDEAFF; /* nodes / text on dark */
--wv-paper: #F6F4FB; /* light-mode background */
--wv-ink: #3B2F7A; /* text on light */
--wv-night: #090C22; /* footer / deepest ground */
/* CTA text-on-gold (very dark gold-brown, not pure black) */
--wv-gold-ink: #2A2003;
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
--wv-lilac-08: rgba(155, 140, 255, .08);
--wv-lilac-12: rgba(155, 140, 255, .12);
--wv-lilac-16: rgba(155, 140, 255, .16);
--wv-lilac-18: rgba(155, 140, 255, .18);
--wv-lilac-32: rgba(155, 140, 255, .32);
--wv-gold-28: rgba(244, 199, 107, .28);
--wv-gold-40: rgba(244, 199, 107, .40);
--wv-starlight-85: rgba(237, 234, 255, .85);
--wv-starlight-78: rgba(237, 234, 255, .78);
--wv-starlight-60: rgba(237, 234, 255, .60);
--wv-starlight-55: rgba(237, 234, 255, .55);
/* ---- Semantic aliases ---- */
--surface-sky: var(--wv-midnight); /* page ground (dark) */
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
--surface-paper: var(--wv-paper); /* long-reading light sections */
--surface-card-light:#FFFFFF; /* build-cards on paper */
--surface-footer: var(--wv-night);
--text-on-dark: var(--wv-starlight);
--text-on-dark-soft: var(--wv-starlight-78);
--text-on-dark-mute: var(--wv-starlight-60);
--text-on-light: var(--wv-ink);
--text-on-light-soft:#4B4170;
--accent: var(--wv-lilac); /* links + nodes on dark */
--accent-on-light: var(--wv-violet); /* links + strokes on light */
--cta: var(--wv-gold); /* primary action / horizon */
--cta-hover: var(--wv-gold-hi);
--cta-text: var(--wv-gold-ink);
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
--border-card: var(--wv-lilac-18);
--border-strong: var(--wv-lilac-32);
--focus-ring: var(--wv-gold);
}
+55
View File
@@ -0,0 +1,55 @@
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
Derived from the marketing-site CSS. The brand has almost no shadow system —
depth is carried by surface color + hairline borders, not drop shadows. */
:root {
/* ---- Spacing scale (rem) ---- */
--space-0: 0;
--space-1: .25rem;
--space-2: .5rem;
--space-3: .7rem;
--space-4: 1rem;
--space-5: 1.2rem;
--space-6: 1.6rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Section rhythm — fluid vertical padding for page bands */
--section-pad: clamp(3rem, 7vw, 5.5rem);
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
/* ---- Layout ---- */
--wrap-max: 1080px; /* content column */
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
--measure-prose: 68ch; /* reading measure for prose blocks */
/* ---- Radii ---- */
--radius-card: 14px; /* path-cards, build-cards */
--radius-panel: 12px; /* principle tiles */
--radius-sm: 4px; /* focus ring rounding */
--radius-pill: 999px; /* buttons, tags, notices */
--radius-mark: 26px; /* favicon tile rounding */
/* ---- Borders ---- */
--border-hair: 1px; /* default hairline */
--border-card-w: 1px;
--btn-border-w: 1.5px; /* ghost button / focus weight */
/* ---- Elevation ---- *
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
* not a shadow. These tokens exist for the rare card that needs real elevation. */
--shadow-none: none;
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
--lift-3: translateY(-3px); /* @kind other */ /* cards */
/* ---- Glass (sticky header) ---- */
--glass-sky: rgba(14, 18, 48, .82);
--glass-blur: 10px;
/* ---- Motion ---- */
--ease: ease; /* @kind other */
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
}
+53
View File
@@ -0,0 +1,53 @@
/* Wiggleverse — Typography tokens
Two registers, one meaning (BRAND.md):
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
for the lines that carry conscience ("the soul").
@font-face rules live in assets/fonts/fonts.css. */
:root {
/* ---- Families ---- */
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
/* semantic aliases */
--font-display: var(--wv-font-display);
--font-body: var(--wv-font-body);
--font-soul: var(--wv-font-human);
/* ---- Weights ---- */
--weight-regular: 400; /* Inter body */
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
--weight-semibold:600; /* Inter emphasis, ledger totals */
--weight-bold: 700; /* Space Grotesk headings, wordmark */
--weight-soul: 500; /* Fraunces italic pull-quotes */
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
--text-h3: 1.2rem;
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
/* ---- Body / UI scale ---- */
--text-body: 1rem; /* 16px base */
--text-small: .95rem;
--text-fine: .92rem;
--text-eyebrow: .8rem; /* uppercase label */
--text-tag: .72rem; /* pill tags */
/* ---- Line heights ---- */
--leading-tight: 1.12; /* headings */
--leading-body: 1.6; /* paragraphs */
/* ---- Letter spacing ---- */
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
--tracking-tag: 0.08em;
--tracking-notice: 0.06em;
/* ---- Measure ---- */
--measure-lead: 60ch;
--measure-prose:68ch;
}
+46
View File
@@ -0,0 +1,46 @@
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
// paste works because the input is real.
import { useState } from "react";
const LENGTH = 6;
export default function CodeInput({
value,
onChange,
error = false,
}: {
value: string;
onChange: (code: string) => void;
error?: boolean;
}) {
const [focused, setFocused] = useState(false);
const digits = value.slice(0, LENGTH).split("");
const active = Math.min(digits.length, LENGTH - 1);
return (
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
<input
className="code__hidden"
value={value}
inputMode="numeric"
autoComplete="one-time-code"
aria-label="One-time code"
autoFocus
maxLength={LENGTH}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
/>
{Array.from({ length: LENGTH }, (_, i) => (
<span
key={i}
aria-hidden="true"
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
>
{digits[i] ?? ""}
</span>
))}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
import { useId } from "react";
import type { ReactNode } from "react";
export function Screen({
children,
horizon = false,
plain = false,
}: {
children: ReactNode;
horizon?: boolean;
plain?: boolean;
}) {
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
return <div className={cls}>{children}</div>;
}
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
return (
<header className="topbar">
<div className="topbar__side">{left}</div>
<div className="topbar__side">{right}</div>
</header>
);
}
export function Wordmark({ size = 26 }: { size?: number }) {
return (
<span className="wordmark">
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
ecomm
</span>
</span>
);
}
export function Footer() {
return (
<footer className="footer">
<span className="footer__id">
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
ecomm · a Wiggleverse line
</span>
</footer>
);
}
export function AuthCard({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
export function Eyebrow({ children }: { children: ReactNode }) {
return <p className="eyebrow">{children}</p>;
}
export function Banner({
tone = "attn",
title,
children,
}: {
tone?: "attn" | "info";
title?: string;
children: ReactNode;
}) {
return (
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
<span className="banner__icon" aria-hidden="true">
{tone === "attn" ? "◆" : "◇"}
</span>
<div>
{title && <div className="banner__title">{title}</div>}
{children}
</div>
</div>
);
}
export function PrimaryButton({
children,
busy = false,
disabled = false,
type = "submit",
onClick,
}: {
children: ReactNode;
busy?: boolean;
disabled?: boolean;
type?: "submit" | "button";
onClick?: () => void;
}) {
return (
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
{children}
</button>
);
}
export function Field({
label,
optional = false,
error,
helper,
inputProps,
}: {
label: string;
optional?: boolean;
error?: string | null;
helper?: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
}) {
const id = useId();
return (
<div className="field">
<label className="field__label" htmlFor={id}>
<span>{label}</span>
{optional && <span className="field__optional">optional</span>}
</label>
<input
id={id}
className={`field__input${error ? " field__input--error" : ""}`}
aria-invalid={!!error}
{...inputProps}
/>
{error && (
<p className="note note--attn" role="alert">
{error}
</p>
)}
{helper && !error && <p className="note">{helper}</p>}
</div>
);
}
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
return (
<div className="chip">
<span className="chip__avatar" aria-hidden="true">
{email[0]?.toUpperCase()}
</span>
<span className="chip__email">{email}</span>
<span className="chip__divider" aria-hidden="true" />
<button type="button" className="signout" onClick={onSignOut}>
Sign out
</button>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
/// <reference types="vitest/config" />
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
// browser makes no cross-origin calls in dev. Override the target with
// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
// SLICE-2/3 (SD-0001 §6.4).
const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": apiTarget,
},
},
// Tests live alongside source; scoping the include to src/ keeps the runner out of
// node_modules (and any stray local backup dirs) without naming them.
test: {
include: ["src/**/*.test.{ts,tsx}"],
},
});
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
# 1. the import-linter layering contract (main > domains > platform)
# 2. the backend test suite (pytest, against Postgres)
# 3. the frontend typecheck + production build
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
# cannot drift. Exits non-zero on the first failure.
#
# Requires a reachable Postgres for the backend tests: locally the compose service
# (scripts/dev.sh brings it up); in CI a Postgres service container. Override its
# admin DSN with ECOMM_TEST_ADMIN_URL.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Pick an interpreter: explicit $PYTHON (CI), else the repo venv, else PATH.
if [ -n "${PYTHON:-}" ]; then
PY="$PYTHON"
elif [ -x "$repo_root/.venv/bin/python" ]; then
PY="$repo_root/.venv/bin/python"
else
PY="$(command -v python3 || command -v python)"
fi
lint="$(dirname "$PY")/lint-imports"
[ -x "$lint" ] || lint="lint-imports"
echo "==> import boundaries (lint-imports)"
( cd "$repo_root/backend" && "$lint" )
echo "==> backend tests (pytest)"
( cd "$repo_root/backend" && "$PY" -m pytest -q )
echo "==> frontend typecheck + build"
( cd "$repo_root/frontend" && npm run build )
echo "==> frontend unit tests (vitest)"
( cd "$repo_root/frontend" && npm test )
echo "==> all gates green"
Executable
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this:
# 1. starts the dev Postgres container and waits for it to be healthy (owns its
# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md);
# 2. ensures the backend venv + deps and the frontend node_modules;
# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev
# server (:5173, proxying /api -> :8000) together.
# Ctrl-C stops both processes; the container keeps running (stop it with
# `docker compose down`, or `docker compose down -v` to reset to empty).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
export ECOMM_DATABASE_URL="${ECOMM_DATABASE_URL:-postgresql://ecomm:ecomm@localhost:5432/ecomm}"
echo "==> dev Postgres (docker compose up --wait)"
docker compose up -d --wait db
if [ ! -x "$repo_root/.venv/bin/python" ]; then
echo "==> creating backend venv"
python3 -m venv "$repo_root/.venv"
"$repo_root/.venv/bin/python" -m pip install --upgrade pip
"$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt"
fi
if [ ! -d "$repo_root/frontend/node_modules" ]; then
echo "==> installing frontend deps"
( cd "$repo_root/frontend" && npm install )
fi
echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop"
"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload &
backend_pid=$!
( cd "$repo_root/frontend" && npm run dev ) &
frontend_pid=$!
# Kill a process and its whole descendant tree (children first), portably across
# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid
# alone would orphan it on :5173. `pgrep -P` (list children) exists on both.
kill_tree() {
local pid=$1 child
for child in $(pgrep -P "$pid" 2>/dev/null); do
kill_tree "$child"
done
kill "$pid" 2>/dev/null || true
}
cleanup() {
trap - INT TERM
echo
echo "==> stopping backend and Vite"
kill_tree "$backend_pid"
kill_tree "$frontend_pid"
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
}
trap cleanup INT TERM
# Wait until either process exits, then tear the other down. `wait -n` would be
# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably.
while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
sleep 1
done
cleanup
File diff suppressed because it is too large Load Diff