Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 725877c5c6 | |||
| bf62f413d7 | |||
| cfc28d7002 | |||
| b3ffb2d4b6 | |||
| cfd2b4ecc7 | |||
| d2eceac272 | |||
| 95680c9960 | |||
| 2d15f1a2cb | |||
| 446c13211a | |||
| 7a6b396f65 | |||
| 9373177ed3 | |||
| f184802f1f | |||
| 4d1c957f42 | |||
| cd951cca2c | |||
| 886da73eb6 | |||
| 6744a6ae0d | |||
| 36046304da | |||
| d7b9f81e53 | |||
| ef385340e8 | |||
| 9462423642 | |||
| ab4317e105 | |||
| 346e7f2e22 | |||
| 4acab40653 | |||
| d125317c00 | |||
| 0078b0c6da | |||
| 23afcf95ce |
@@ -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
|
||||||
@@ -6,3 +6,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
|
.pytest_cache/
|
||||||
|
frontend/dist/
|
||||||
|
backend/var/
|
||||||
|
|||||||
@@ -20,5 +20,10 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
No application code yet. The architecture and first release are to be designed in
|
SLICE-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open,
|
||||||
a brainstorming/planning session before any code lands.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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).
|
||||||
|
"""
|
||||||
@@ -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"
|
||||||
@@ -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]
|
||||||
@@ -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())
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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 = .
|
||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+2174
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+40
@@ -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
@@ -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
Reference in New Issue
Block a user