Compare commits

..

26 Commits

Author SHA1 Message Date
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
49 changed files with 7658 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/
+7 -2
View File
@@ -20,5 +20,10 @@ 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-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open,
passwordless sign-up/log-in by email + one-time code (`accounts` domain, INV-2/INV-3),
signed-cookie sessions, the `/api/auth/*` endpoints (`request-code`, `verify`, `me`,
`logout`), the `platform/mailer` port with `LogMailer` (the dev code channel), and the
Landing + Sign-in screens with the entry-routing rule (storefront hard-wired `null` until
SLICE-3). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. The storefront
(SLICE-3) and deployed environments (SLICE-4) are next.
+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.
"""
+31
View File
@@ -0,0 +1,31 @@
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
package surface only (§6.2).
"""
from __future__ import annotations
from .errors import (
AccountsError,
CodeExhausted,
CodeExpired,
CodeMismatch,
InvalidEmail,
ResendCooldown,
)
from .models import Account
from .service import get_account, request_code, verify
__all__ = [
"Account",
"AccountsError",
"InvalidEmail",
"ResendCooldown",
"CodeMismatch",
"CodeExpired",
"CodeExhausted",
"request_code",
"verify",
"get_account",
]
+38
View File
@@ -0,0 +1,38 @@
"""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)."""
+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
+160
View File
@@ -0,0 +1,160 @@
"""accounts — the identity service (SD-0001 §6.5).
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
them) so the domain owns no request/transport concerns.
"""
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
import psycopg
from app.platform import config
from app.platform.mailer import Mailer
from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown
from .models import Account
# INV-3 constants — the one-time-code policy.
CODE_TTL = timedelta(minutes=10)
RESEND_COOLDOWN = timedelta(seconds=60)
MAX_ATTEMPTS = 5
_CODE_DIGITS = 6
# A pragmatic email check — rejects obvious non-addresses without an email-validator
# dependency. The provable test of an address is that the emailed code comes back (verify).
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def normalize_email(email: str) -> str:
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
norm = email.strip().lower()
if not _EMAIL_RE.match(norm):
raise InvalidEmail(email)
return norm
def _hash_code(code: str) -> str:
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
key = config.session_secret().encode("utf-8")
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
def _now() -> datetime:
return datetime.now(timezone.utc)
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
spent/expired codes. Never reveals whether the email already has an account.
"""
email = normalize_email(email)
now = _now()
last = conn.execute(
"SELECT max(created_at) FROM auth_code WHERE email = %s 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),
)
conn.commit()
mailer.send(
to=email,
subject=f"Your ecomm code: {code}",
body=(
f"Your ecomm one-time code is {code}.\n"
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
"If you didn't request this, ignore this message."
),
)
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
"""Verify a code and resolve the account. Returns (account, created).
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
by normalized email (INV-2). Account creation IS email verification (the address
provably received the code).
"""
email = normalize_email(email)
now = _now()
row = conn.execute(
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
"WHERE email = %s AND consumed_at IS NULL "
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
(email,),
).fetchone()
if row is None:
conn.commit()
raise CodeExpired(email) # nothing live to verify — offer a fresh code
code_id, code_hash, expires_at, attempts = row
if expires_at < now:
conn.commit()
raise CodeExpired(email)
if not hmac.compare_digest(code_hash, _hash_code(code)):
attempts += 1
if attempts >= MAX_ATTEMPTS:
conn.execute(
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
(attempts, now, code_id),
)
conn.commit()
raise CodeExhausted(email)
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
conn.commit()
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
# Correct: consume the code and get-or-create the account in the same transaction.
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
account, created = _get_or_create_account(conn, email)
conn.commit()
return account, created
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
inserted = conn.execute(
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
(email,),
).fetchone()
if inserted is not None:
return Account(id=inserted[0], email=email), True
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
return Account(id=existing[0], email=email), False
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
"""Load an account by id (for the signed-in `/me`)."""
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
return Account(id=row[0], email=row[1]) if row else None
+176
View File
@@ -0,0 +1,176 @@
"""ecomm backend — FastAPI app factory + the REST BFF.
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
the configured mailer (INV-8) at startup. storefront is hard-wired null this slice; the
storefronts domain (SLICE-3) replaces the _storefront_for seam.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.domains import accounts
from app.platform import config, db
from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
from app.platform.mailer import Mailer
from app.platform import session as session_mod
class RequestCodeBody(BaseModel):
email: str
class VerifyBody(BaseModel):
email: str
code: str
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
"""The entry-routing answer: which storefront, if any, this account has (§6.5).
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
domain call; the verify/me responses already carry the field.
"""
return None
def _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) -> 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="0.2", lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
"""Liveness + readiness: process up, DB reachable, migrations current (§6.4)."""
try:
conn.execute("SELECT 1")
pending = db.pending_migrations(conn)
except Exception:
response.status_code = 503
return {"status": "unavailable", "reason": "database_unreachable"}
if pending:
response.status_code = 503
return {"status": "unavailable", "reason": "migrations_pending"}
return {"status": "ok"}
@app.post("/api/auth/request-code")
def request_code(
body: RequestCodeBody,
conn: psycopg.Connection = Depends(get_conn),
mailer: Mailer = Depends(get_mailer),
):
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
try:
accounts.request_code(conn, mailer, body.email)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.ResendCooldown as exc:
return _error(
429, "resend_cooldown",
f"Please wait {exc.retry_after_s}s before requesting another code.",
retry_after_s=exc.retry_after_s,
)
return Response(status_code=204)
@app.post("/api/auth/verify")
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
try:
account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.CodeMismatch as exc:
return _error(
400, "code_mismatch", "That code didn't match.",
attempts_remaining=exc.attempts_remaining,
)
except accounts.CodeExpired:
return _error(400, "code_expired", "That code expired — request a fresh one.")
except accounts.CodeExhausted:
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
payload = {
"account": {"email": account.email},
"storefront": _storefront_for(conn, account),
"created": created,
}
resp = JSONResponse(status_code=200, content=payload)
_set_session_cookie(resp, account)
return resp
@app.get("/api/auth/me")
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
@app.post("/api/auth/logout")
def logout():
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
resp = Response(status_code=204)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
return app
app = create_app()
+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).
"""
+40
View File
@@ -0,0 +1,40 @@
"""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; SLICE-4)."""
return os.environ.get("ECOMM_MAILER") or "log"
+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())
+50
View File
@@ -0,0 +1,50 @@
"""platform/mailer — the outbound email port (SD-0001 §6.2).
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
deployed `SmtpMailer` (SLICE-4). The adapter is chosen by configuration at startup
(INV-8), so no deployment shape lives in the domain. LogMailer logs the full body on
purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in
the terminal. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Protocol
logger = logging.getLogger("ecomm.mailer")
@dataclass(frozen=True)
class SentMessage:
to: str
subject: str
body: str
class Mailer(Protocol):
"""The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502)."""
def send(self, to: str, subject: str, body: str) -> None: ...
class LogMailer:
"""Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10)."""
def __init__(self) -> None:
self.outbox: list[SentMessage] = []
def send(self, to: str, subject: str, body: str) -> None:
self.outbox.append(SentMessage(to=to, subject=subject, body=body))
# Full body incl. the code — the local dev channel. Dev/test only (never deployed).
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
def build_mailer(kind: str) -> Mailer:
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
if kind == "log":
return LogMailer()
if kind == "smtp":
raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)")
raise ValueError(f"unknown mailer kind: {kind!r}")
+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,66 @@
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 == []
+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
+130
View File
@@ -0,0 +1,130 @@
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
+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"
+24
View File
@@ -0,0 +1,24 @@
from fastapi.testclient import TestClient
from app.main import create_app
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"}
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
+26
View File
@@ -0,0 +1,26 @@
from app.platform import mailer
def test_logmailer_captures_to_outbox():
m = mailer.LogMailer()
assert m.outbox == []
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)")
assert len(m.outbox) == 1
msg = m.outbox[-1]
assert msg.to == "merchant@example.com"
assert msg.subject == "Your ecomm code: 123456"
assert "123456" in msg.body
def test_build_mailer_log_kind():
m = mailer.build_mailer("log")
assert isinstance(m, mailer.LogMailer)
def test_build_mailer_smtp_not_yet_available():
# SmtpMailer lands in SLICE-4; until then asking for it is an explicit error, not a
# silent fallback to LogMailer (which would send no real mail in a deployed env).
import pytest
with pytest.raises(NotImplementedError):
mailer.build_mailer("smtp")
+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
+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:
+64
View File
@@ -0,0 +1,64 @@
# 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 ships the **localhost** section; the
pre-production and production sections land with SLICE-4.
## 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 (SLICE-4).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}
+64
View File
@@ -0,0 +1,64 @@
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a
// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3.
import { useEffect, useState } from "react";
import { getMe, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";
import SignedIn from "./screens/SignedIn";
type Door = "signup" | "login";
export default function App() {
const [session, setSession] = useState<SessionState | null>(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState<Door | null>(null);
const [justCreated, setJustCreated] = useState(false);
async function reload() {
setLoading(true);
setSession(await getMe());
setLoading(false);
}
useEffect(() => {
void reload();
}, []);
if (loading) return <main>Loading</main>;
const route = routeFor(session ?? { account: null, storefront: null });
if (route === "landing") {
if (door) {
return (
<SignIn
door={door}
onBack={() => {
setDoor(null);
}}
onAuthed={(result: VerifyResult) => {
setJustCreated(result.created);
setDoor(null);
void reload();
}}
/>
);
}
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
// route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the
// signed-in placeholder so sign-out (PUC-9) is reachable.
return (
<SignedIn
email={session!.account!.email}
created={justCreated}
onSignedOut={() => {
setJustCreated(false);
void reload();
}}
/>
);
}
+61
View File
@@ -0,0 +1,61 @@
// 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" });
}
+9
View File
@@ -0,0 +1,9 @@
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";
}
+27
View File
@@ -0,0 +1,27 @@
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
// they differ only in framing.
interface Props {
onGetStarted: () => void;
onLogIn: () => void;
}
export default function Landing({ onGetStarted, onLogIn }: Props) {
return (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={onLogIn}>
Log in
</button>
</header>
<section>
<h1>Honest commerce. Your storefront is yours.</h1>
<p>Sell online on a platform that treats you straight no dark patterns, no lock-in.</p>
<button type="button" onClick={onGetStarted}>
Create your storefront
</button>
</section>
</main>
);
}
+111
View File
@@ -0,0 +1,111 @@
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy states which door framing applies and,
// after verify, whether the account was created or resumed (PUC-3). storefront routing is
// handled by App on success.
import { useState } from "react";
import { requestCode, verifyCode, type VerifyResult } from "../api";
type Door = "signup" | "login";
interface Props {
door: Door;
onAuthed: (result: VerifyResult) => void;
onBack: () => void;
}
export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const heading = door === "signup" ? "Create your storefront" : "Log in";
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
setError(err.message);
return;
}
setStep("code");
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
setError(res.error.message);
return;
}
onAuthed(res.result);
}
if (step === "email") {
return (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={onBack}>
Back
</button>
</header>
<form onSubmit={submitEmail}>
<h1>{heading}</h1>
<label>
Email
<input
type="email"
value={email}
autoFocus
required
onChange={(ev) => setEmail(ev.target.value)}
/>
</label>
<p>We'll email you a one-time code. That's all we need.</p>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Sending…" : "Send code"}
</button>
</form>
</main>
);
}
return (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={() => setStep("email")}>
Wrong address?
</button>
</header>
<form onSubmit={submitCode}>
<h1>Enter your code</h1>
<p>We sent a code to {email}.</p>
<label>
Code
<input
inputMode="numeric"
autoComplete="one-time-code"
value={code}
autoFocus
required
onChange={(ev) => setCode(ev.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Checking…" : "Continue"}
</button>
</form>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in
// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No
// fabricated capability (INV-9).
import { logout } from "../api";
interface Props {
email: string;
created: boolean;
onSignedOut: () => void;
}
export default function SignedIn({ email, created, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<main>
<header>
<strong>ecomm</strong>
<span>{email}</span>
<button type="button" onClick={signOut}>
Sign out
</button>
</header>
<section>
<h1>{created ? "Welcome to ecomm" : "Welcome back"}</h1>
<p>You're signed in as {email}.</p>
<p>Creating your storefront arrives in the next slice. Nothing else is needed yet.</p>
</section>
</main>
);
}
+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