Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 118e925580 | |||
| 11acb3a5b1 | |||
| efc9c8edb9 | |||
| f7b76d5370 | |||
| 4bb9763633 | |||
| a456d51f17 | |||
| ac3c4ffe36 | |||
| 8a2ec24d86 | |||
| acff7ac588 | |||
| 22d738fc74 | |||
| 1b8c60fcb5 | |||
| 9f4295b77e | |||
| a1c5544694 | |||
| 0747181157 | |||
| 877be249c7 | |||
| 25ac540171 | |||
| 10d22938f1 | |||
| ddc949615e | |||
| 0b302ea1e1 | |||
| ef1498ba08 | |||
| cc9d9dda5f | |||
| 306b5c1e5d | |||
| e11fb032b1 | |||
| ba8b493a31 | |||
| 1b40bde4a0 | |||
| 725877c5c6 | |||
| bf62f413d7 | |||
| cfc28d7002 | |||
| b3ffb2d4b6 | |||
| cfd2b4ecc7 | |||
| d2eceac272 | |||
| 95680c9960 | |||
| 2d15f1a2cb | |||
| 446c13211a | |||
| 7a6b396f65 | |||
| 9373177ed3 |
@@ -20,8 +20,14 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
||||
|
||||
## Status
|
||||
|
||||
SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
|
||||
self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a
|
||||
Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle
|
||||
(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally.
|
||||
Identity (SLICE-2) and the storefront (SLICE-3) are next.
|
||||
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
|
||||
domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
|
||||
refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
|
||||
complete entry-routing rule (landing / create-storefront / admin), and the INV-1
|
||||
whole-flow bootstrap test — all skinned to the Claude Design export
|
||||
(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
|
||||
which also re-skinned the SLICE-2 Landing + Sign-in screens. See
|
||||
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. SLICE-4's deploy
|
||||
contract is in place (versioned `/healthz`, backend-served SPA, `SmtpMailer` with
|
||||
honest 502 on delivery failure); PPE provisioning + the bootstrap rehearsal are the
|
||||
remaining SLICE-4 steps (BOOTSTRAP.md's PPE section is the runbook).
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
|
||||
|
||||
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
|
||||
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
|
||||
package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import (
|
||||
AccountsError,
|
||||
CodeExhausted,
|
||||
CodeExpired,
|
||||
CodeMismatch,
|
||||
DeliveryFailed,
|
||||
InvalidEmail,
|
||||
ResendCooldown,
|
||||
)
|
||||
from .models import Account
|
||||
from .service import get_account, request_code, verify
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountsError",
|
||||
"InvalidEmail",
|
||||
"ResendCooldown",
|
||||
"CodeMismatch",
|
||||
"CodeExpired",
|
||||
"CodeExhausted",
|
||||
"DeliveryFailed",
|
||||
"request_code",
|
||||
"verify",
|
||||
"get_account",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""accounts — domain exceptions.
|
||||
|
||||
Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here
|
||||
(not in main) keeps the rules in the domain (INV-6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountsError(Exception):
|
||||
"""Base for accounts-domain errors."""
|
||||
|
||||
|
||||
class InvalidEmail(AccountsError):
|
||||
"""The supplied email is not a plausible address (§6.4 400 invalid_email)."""
|
||||
|
||||
|
||||
class ResendCooldown(AccountsError):
|
||||
"""A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown)."""
|
||||
|
||||
def __init__(self, retry_after_s: int) -> None:
|
||||
super().__init__(f"resend available in {retry_after_s}s")
|
||||
self.retry_after_s = retry_after_s
|
||||
|
||||
|
||||
class CodeMismatch(AccountsError):
|
||||
"""The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch)."""
|
||||
|
||||
def __init__(self, attempts_remaining: int) -> None:
|
||||
super().__init__("code did not match")
|
||||
self.attempts_remaining = attempts_remaining
|
||||
|
||||
|
||||
class CodeExpired(AccountsError):
|
||||
"""No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired)."""
|
||||
|
||||
|
||||
class CodeExhausted(AccountsError):
|
||||
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted)."""
|
||||
|
||||
|
||||
class DeliveryFailed(AccountsError):
|
||||
"""The relay refused the code email; nothing was committed (INV-9 → §6.4 502 delivery_failed)."""
|
||||
@@ -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,173 @@
|
||||
"""accounts — the identity service (SD-0001 §6.5).
|
||||
|
||||
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
|
||||
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
|
||||
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
|
||||
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
|
||||
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
|
||||
them) so the domain owns no request/transport concerns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.platform import config
|
||||
from app.platform.mailer import Mailer, MailerError
|
||||
|
||||
from .errors import (
|
||||
CodeExhausted,
|
||||
CodeExpired,
|
||||
CodeMismatch,
|
||||
DeliveryFailed,
|
||||
InvalidEmail,
|
||||
ResendCooldown,
|
||||
)
|
||||
from .models import Account
|
||||
|
||||
# INV-3 constants — the one-time-code policy.
|
||||
CODE_TTL = timedelta(minutes=10)
|
||||
RESEND_COOLDOWN = timedelta(seconds=60)
|
||||
MAX_ATTEMPTS = 5
|
||||
_CODE_DIGITS = 6
|
||||
|
||||
# A pragmatic email check — rejects obvious non-addresses without an email-validator
|
||||
# dependency. The provable test of an address is that the emailed code comes back (verify).
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
|
||||
norm = email.strip().lower()
|
||||
if not _EMAIL_RE.match(norm):
|
||||
raise InvalidEmail(email)
|
||||
return norm
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
|
||||
key = config.session_secret().encode("utf-8")
|
||||
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
|
||||
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
|
||||
|
||||
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
|
||||
spent/expired codes. Never reveals whether the email already has an account.
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
last = conn.execute(
|
||||
"SELECT max(created_at) FROM auth_code WHERE email = %s AND consumed_at IS NULL",
|
||||
(email,),
|
||||
).fetchone()[0]
|
||||
if last is not None:
|
||||
elapsed = now - last
|
||||
if elapsed < RESEND_COOLDOWN:
|
||||
retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1
|
||||
raise ResendCooldown(retry_after_s)
|
||||
|
||||
# Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one.
|
||||
conn.execute(
|
||||
"DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)",
|
||||
(email, now),
|
||||
)
|
||||
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS))
|
||||
conn.execute(
|
||||
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
|
||||
(email, _hash_code(code), now + CODE_TTL),
|
||||
)
|
||||
|
||||
# Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan
|
||||
# code blocks the 60s cooldown and the caller can honestly retry at once (INV-9).
|
||||
try:
|
||||
mailer.send(
|
||||
to=email,
|
||||
subject=f"Your ecomm code: {code}",
|
||||
body=(
|
||||
f"Your ecomm one-time code is {code}.\n"
|
||||
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
|
||||
"If you didn't request this, ignore this message."
|
||||
),
|
||||
)
|
||||
except MailerError as exc:
|
||||
conn.rollback()
|
||||
raise DeliveryFailed(str(exc)) from exc
|
||||
conn.commit()
|
||||
|
||||
|
||||
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
|
||||
"""Verify a code and resolve the account. Returns (account, created).
|
||||
|
||||
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
|
||||
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
|
||||
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
|
||||
by normalized email (INV-2). Account creation IS email verification (the address
|
||||
provably received the code).
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
|
||||
"WHERE email = %s AND consumed_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.commit()
|
||||
raise CodeExpired(email) # nothing live to verify — offer a fresh code
|
||||
code_id, code_hash, expires_at, attempts = row
|
||||
|
||||
if expires_at < now:
|
||||
conn.commit()
|
||||
raise CodeExpired(email)
|
||||
|
||||
if not hmac.compare_digest(code_hash, _hash_code(code)):
|
||||
attempts += 1
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
conn.execute(
|
||||
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
|
||||
(attempts, now, code_id),
|
||||
)
|
||||
conn.commit()
|
||||
raise CodeExhausted(email)
|
||||
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
|
||||
conn.commit()
|
||||
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
|
||||
|
||||
# Correct: consume the code and get-or-create the account in the same transaction.
|
||||
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
|
||||
account, created = _get_or_create_account(conn, email)
|
||||
conn.commit()
|
||||
return account, created
|
||||
|
||||
|
||||
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
|
||||
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
|
||||
inserted = conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
return Account(id=inserted[0], email=email), True
|
||||
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
|
||||
return Account(id=existing[0], email=email), False
|
||||
|
||||
|
||||
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
|
||||
"""Load an account by id (for the signed-in `/me`)."""
|
||||
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
|
||||
return Account(id=row[0], email=row[1]) if row else None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""storefronts domain — storefront entity, membership, the one-storefront guard.
|
||||
|
||||
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
|
||||
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
|
||||
via this package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import AlreadyOwnsStorefront, StorefrontsError
|
||||
from .models import Storefront
|
||||
from .service import create_storefront, storefront_for
|
||||
|
||||
__all__ = [
|
||||
"Storefront",
|
||||
"StorefrontsError",
|
||||
"AlreadyOwnsStorefront",
|
||||
"create_storefront",
|
||||
"storefront_for",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""storefronts domain errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class StorefrontsError(Exception):
|
||||
"""Base for storefronts-domain errors."""
|
||||
|
||||
|
||||
class AlreadyOwnsStorefront(StorefrontsError):
|
||||
"""INV-4: the account already has its one storefront (PUC-7)."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Storefront record — the §6.3 entity as the domain returns it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storefront:
|
||||
id: int
|
||||
name: str
|
||||
@@ -0,0 +1,56 @@
|
||||
"""storefronts — the storefront + membership service (SD-0001 §6.5).
|
||||
|
||||
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
|
||||
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
|
||||
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
|
||||
account_id and email down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
|
||||
from .errors import AlreadyOwnsStorefront
|
||||
from .models import Storefront
|
||||
|
||||
|
||||
def _default_name(email: str) -> str:
|
||||
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
|
||||
return f"{email.split('@', 1)[0]}'s storefront"
|
||||
|
||||
|
||||
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
|
||||
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
|
||||
row = conn.execute(
|
||||
"SELECT s.id, s.name FROM storefront s"
|
||||
" JOIN storefront_membership m ON m.storefront_id = s.id"
|
||||
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return Storefront(id=row[0], name=row[1]) if row else None
|
||||
|
||||
|
||||
def create_storefront(
|
||||
conn: psycopg.Connection, account_id: int, email: str, name: str | None
|
||||
) -> Storefront:
|
||||
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
|
||||
|
||||
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
|
||||
account_id serializes concurrent creates for the same account, so the second of two
|
||||
racing requests sees the first's committed membership and is refused (§6.5).
|
||||
"""
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
|
||||
existing = storefront_for(conn, account_id)
|
||||
if existing is not None:
|
||||
conn.rollback() # release the advisory lock; nothing was written
|
||||
raise AlreadyOwnsStorefront()
|
||||
final_name = (name or "").strip() or _default_name(email)
|
||||
sf_id = conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
|
||||
" VALUES (%s, %s, 'owner')",
|
||||
(account_id, sf_id),
|
||||
)
|
||||
conn.commit()
|
||||
return Storefront(id=sf_id, name=final_name)
|
||||
+178
-8
@@ -1,22 +1,97 @@
|
||||
"""ecomm backend — FastAPI app factory + the REST BFF.
|
||||
|
||||
SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in
|
||||
later slices. create_app() opens the connection pool and self-migrates the database
|
||||
at startup (INV-1, INV-7): the app boots against empty persistence and applies its
|
||||
own schema — there is no seed step.
|
||||
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
|
||||
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
|
||||
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
|
||||
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
|
||||
_storefront_for seam from the storefronts domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domains import accounts, storefronts
|
||||
from app.platform import config, db
|
||||
from app.platform.deps import get_conn
|
||||
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
|
||||
|
||||
|
||||
def create_app(database_url: str | None = None) -> FastAPI:
|
||||
# The repo-root VERSION file is the single version source: the deploy pin checks out its
|
||||
# tag, and /healthz must report it back (flotilla-core's verify gate compares them).
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
_APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip()
|
||||
except OSError:
|
||||
_APP_VERSION = "0.0.0"
|
||||
|
||||
|
||||
class RequestCodeBody(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class VerifyBody(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
|
||||
|
||||
class CreateStorefrontBody(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
||||
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
||||
|
||||
|
||||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||||
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
|
||||
sf = storefronts.storefront_for(conn, account.id)
|
||||
return {"id": sf.id, "name": sf.name} if sf else None
|
||||
|
||||
|
||||
def _ensure_app_logging() -> None:
|
||||
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
|
||||
|
||||
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
|
||||
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
|
||||
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
|
||||
out of uvicorn's stream and avoids double-logging.
|
||||
"""
|
||||
lg = logging.getLogger("ecomm")
|
||||
if not lg.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
|
||||
lg.addHandler(handler)
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.propagate = False
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, account: accounts.Account) -> None:
|
||||
token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret())
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.cookie_secure(),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI:
|
||||
_ensure_app_logging()
|
||||
dsn = database_url or config.database_url()
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,12 +99,13 @@ def create_app(database_url: str | None = None) -> 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.1", lifespan=lifespan)
|
||||
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||
@@ -43,7 +119,101 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
if pending:
|
||||
response.status_code = 503
|
||||
return {"status": "unavailable", "reason": "migrations_pending"}
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "version": _APP_VERSION}
|
||||
|
||||
@app.post("/api/auth/request-code")
|
||||
def request_code(
|
||||
body: RequestCodeBody,
|
||||
conn: psycopg.Connection = Depends(get_conn),
|
||||
mailer: Mailer = Depends(get_mailer),
|
||||
):
|
||||
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
|
||||
try:
|
||||
accounts.request_code(conn, mailer, body.email)
|
||||
except accounts.InvalidEmail:
|
||||
return _error(400, "invalid_email", "That doesn't look like an email address.")
|
||||
except accounts.ResendCooldown as exc:
|
||||
return _error(
|
||||
429, "resend_cooldown",
|
||||
f"Please wait {exc.retry_after_s}s before requesting another code.",
|
||||
retry_after_s=exc.retry_after_s,
|
||||
)
|
||||
except accounts.DeliveryFailed:
|
||||
return _error(
|
||||
502, "delivery_failed",
|
||||
"We couldn't send the code — try again in a moment.",
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/api/auth/verify")
|
||||
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
|
||||
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
|
||||
try:
|
||||
account, created = accounts.verify(conn, body.email, body.code)
|
||||
except accounts.InvalidEmail:
|
||||
return _error(400, "invalid_email", "That doesn't look like an email address.")
|
||||
except accounts.CodeMismatch as exc:
|
||||
return _error(
|
||||
400, "code_mismatch", "That code didn't match.",
|
||||
attempts_remaining=exc.attempts_remaining,
|
||||
)
|
||||
except accounts.CodeExpired:
|
||||
return _error(400, "code_expired", "That code expired — request a fresh one.")
|
||||
except accounts.CodeExhausted:
|
||||
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
|
||||
payload = {
|
||||
"account": {"email": account.email},
|
||||
"storefront": _storefront_for(conn, account),
|
||||
"created": created,
|
||||
}
|
||||
resp = JSONResponse(status_code=200, content=payload)
|
||||
_set_session_cookie(resp, account)
|
||||
return resp
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
|
||||
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
|
||||
if sess is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
account = accounts.get_account(conn, sess["account_id"])
|
||||
if account is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout():
|
||||
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
|
||||
resp = Response(status_code=204)
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
@app.post("/api/storefronts")
|
||||
def create_storefront(
|
||||
body: CreateStorefrontBody,
|
||||
conn: psycopg.Connection = Depends(get_conn),
|
||||
sess: dict | None = Depends(get_session),
|
||||
):
|
||||
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
|
||||
if sess is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
account = accounts.get_account(conn, sess["account_id"])
|
||||
if account is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
try:
|
||||
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
|
||||
except storefronts.AlreadyOwnsStorefront:
|
||||
return _error(
|
||||
409, "already_owns_storefront",
|
||||
"Your account already has its storefront — ecomm is one storefront per account today.",
|
||||
)
|
||||
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
|
||||
|
||||
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
|
||||
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
|
||||
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
|
||||
spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
|
||||
if (spa_dir / "index.html").is_file():
|
||||
app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -17,3 +17,54 @@ _DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||
def database_url() -> str:
|
||||
"""The psycopg DSN for the application database."""
|
||||
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
|
||||
|
||||
|
||||
# Session signing secret. Dev default is intentionally well-known and insecure — a clean
|
||||
# checkout works with no setup; deployed environments MUST supply ECOMM_SESSION_SECRET from
|
||||
# Secret Manager (INV-8, §6.6). Rotating it invalidates all sessions at once (§6.2).
|
||||
_DEFAULT_SESSION_SECRET = "dev-insecure-session-secret-change-me"
|
||||
|
||||
|
||||
def session_secret() -> str:
|
||||
"""The HMAC key for signed session cookies (and the one-time-code pepper)."""
|
||||
return os.environ.get("ECOMM_SESSION_SECRET") or _DEFAULT_SESSION_SECRET
|
||||
|
||||
|
||||
def cookie_secure() -> bool:
|
||||
"""Whether to mark the session cookie Secure (HTTPS-only). True in deployed envs."""
|
||||
return os.environ.get("ECOMM_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def mailer_kind() -> str:
|
||||
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed)."""
|
||||
return os.environ.get("ECOMM_MAILER") or "log"
|
||||
|
||||
|
||||
# SMTP relay coordinates (deployed envs only; INV-8 — host/port/user/from are non-secret
|
||||
# overlay values, the password is a Secret Manager reference resolved by the deploy).
|
||||
|
||||
|
||||
def smtp_host() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_HOST", "")
|
||||
|
||||
|
||||
def smtp_port() -> int:
|
||||
return int(os.environ.get("ECOMM_SMTP_PORT") or "587")
|
||||
|
||||
|
||||
def smtp_user() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_USER", "")
|
||||
|
||||
|
||||
def smtp_password() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_PASSWORD", "")
|
||||
|
||||
|
||||
def smtp_from() -> str:
|
||||
"""The From header; defaults to the relay user."""
|
||||
return os.environ.get("ECOMM_SMTP_FROM") or smtp_user()
|
||||
|
||||
|
||||
def smtp_starttls() -> bool:
|
||||
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
|
||||
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).
|
||||
|
||||
Yields a pooled connection per request. The pool lives on app.state, built once in
|
||||
create_app(). Later slices add the current-session and mailer dependencies here.
|
||||
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,98 @@
|
||||
"""platform/mailer — the outbound email port (SD-0001 §6.2).
|
||||
|
||||
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
|
||||
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
|
||||
deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen
|
||||
by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the
|
||||
full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the
|
||||
developer in the terminal. SmtpMailer never logs the body or the recipient (INV-3 / §6.6
|
||||
log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from typing import Protocol
|
||||
|
||||
from app.platform import config
|
||||
|
||||
logger = logging.getLogger("ecomm.mailer")
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentMessage:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Mailer(Protocol):
|
||||
"""The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502)."""
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None: ...
|
||||
|
||||
|
||||
class LogMailer:
|
||||
"""Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbox: list[SentMessage] = []
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
self.outbox.append(SentMessage(to=to, subject=subject, body=body))
|
||||
# Full body incl. the code — the local dev channel. Dev/test only (never deployed).
|
||||
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
"""Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default).
|
||||
|
||||
Logs only a hashed recipient — never the address, subject, or body (§6.6).
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None:
|
||||
self._host, self._port = host, port
|
||||
self._user, self._password = user, password
|
||||
self._sender, self._starttls = sender, starttls
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["To"] = to
|
||||
msg["From"] = self._sender
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8]
|
||||
try:
|
||||
with smtplib.SMTP(self._host, self._port, timeout=10) as smtp:
|
||||
if self._starttls:
|
||||
smtp.starttls()
|
||||
if self._user:
|
||||
smtp.login(self._user, self._password)
|
||||
smtp.send_message(msg)
|
||||
except Exception as exc:
|
||||
logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__)
|
||||
raise MailerError(str(exc)) from exc
|
||||
logger.info("smtp sent to=%s", to_hash)
|
||||
|
||||
|
||||
def build_mailer(kind: str) -> Mailer:
|
||||
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
|
||||
if kind == "log":
|
||||
return LogMailer()
|
||||
if kind == "smtp":
|
||||
return SmtpMailer(
|
||||
host=config.smtp_host(),
|
||||
port=config.smtp_port(),
|
||||
user=config.smtp_user(),
|
||||
password=config.smtp_password(),
|
||||
sender=config.smtp_from(),
|
||||
starttls=config.smtp_starttls(),
|
||||
)
|
||||
raise ValueError(f"unknown mailer kind: {kind!r}")
|
||||
@@ -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,91 @@
|
||||
import re
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import db, mailer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
db.migrate(c)
|
||||
yield c
|
||||
|
||||
|
||||
def _code_in(outbox) -> str:
|
||||
# The 6-digit code appears in the latest message body (LogMailer dev channel).
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_14_01_0002_request_code_sends_a_six_digit_code(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "Merchant@Example.com")
|
||||
assert len(m.outbox) == 1
|
||||
assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2)
|
||||
assert re.search(r"\b\d{6}\b", m.outbox[-1].body)
|
||||
|
||||
|
||||
def test_14_01_0003_request_code_stores_only_a_hash(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
code = _code_in(m.outbox)
|
||||
rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall()
|
||||
assert len(rows) == 1
|
||||
# The plaintext code is never stored (INV-3).
|
||||
assert code not in rows[0][0]
|
||||
assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code
|
||||
|
||||
|
||||
def test_request_code_uniform_for_new_and_known(conn):
|
||||
# No account enumeration (§6.6): the call behaves identically whether or not the email
|
||||
# already has an account — it never signals existence and never raises.
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "newcomer@example.com") # no account exists
|
||||
# create an account, then request again for a known email
|
||||
conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",))
|
||||
conn.commit()
|
||||
accounts.request_code(conn, m, "known@example.com")
|
||||
assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known
|
||||
|
||||
|
||||
def test_puc_02c_resend_is_rate_limited(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
with pytest.raises(accounts.ResendCooldown) as exc:
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
assert 0 < exc.value.retry_after_s <= 60
|
||||
assert len(m.outbox) == 1 # the second code was NOT sent
|
||||
|
||||
|
||||
def test_request_code_rejects_invalid_email(conn):
|
||||
m = mailer.LogMailer()
|
||||
with pytest.raises(accounts.InvalidEmail):
|
||||
accounts.request_code(conn, m, "not-an-email")
|
||||
assert m.outbox == []
|
||||
|
||||
|
||||
class _FailingMailer:
|
||||
"""A mailer whose relay always refuses — the INV-9 honest-failure path."""
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
raise mailer.MailerError("relay refused")
|
||||
|
||||
|
||||
def test_delivery_failure_leaves_no_orphan_code(conn):
|
||||
# ecomm#7: send happens BEFORE commit — a failed delivery must not strand an
|
||||
# auth_code row (which would also trip the resend cooldown for 60s).
|
||||
with pytest.raises(accounts.DeliveryFailed):
|
||||
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
|
||||
rows = conn.execute("SELECT count(*) FROM auth_code").fetchone()[0]
|
||||
assert rows == 0
|
||||
|
||||
|
||||
def test_delivery_failure_does_not_trip_cooldown(conn):
|
||||
with pytest.raises(accounts.DeliveryFailed):
|
||||
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
|
||||
# the immediate retry (relay back up) succeeds — no ResendCooldown
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
assert len(m.outbox) == 1
|
||||
@@ -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,146 @@
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _client(fresh_db_url) -> TestClient:
|
||||
# A LogMailer on app.state lets the test read back the issued code (§6.8).
|
||||
return TestClient(create_app(database_url=fresh_db_url))
|
||||
|
||||
|
||||
def _last_code(client) -> str:
|
||||
outbox = client.app.state.mailer.outbox
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_request_code_returns_204(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_request_code_invalid_email_is_400(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "nope"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "invalid_email"
|
||||
|
||||
|
||||
def test_request_code_uniform_204_no_enumeration(fresh_db_url):
|
||||
# New and known emails both return 204 with no distinguishing body (§6.6).
|
||||
with _client(fresh_db_url) as client:
|
||||
r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
# sign the newcomer up so the email becomes "known"
|
||||
code = _last_code(client)
|
||||
client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
assert r_new.status_code == r_known.status_code == 204
|
||||
assert r_new.content == r_known.content == b""
|
||||
|
||||
|
||||
def test_resend_cooldown_is_429_with_retry_after(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 429
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "resend_cooldown"
|
||||
assert body["retry_after_s"] > 0
|
||||
|
||||
|
||||
def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
code = _last_code(client)
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
|
||||
assert "ecomm_session" in resp.cookies
|
||||
|
||||
|
||||
def test_verify_returning_account_created_false(fresh_db_url):
|
||||
# BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The
|
||||
# second request-code would hit the 60s cooldown, so backdate the consumed code's
|
||||
# created_at to satisfy it (rather than sleeping 60s in a test).
|
||||
import psycopg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r1.json()["created"] is True
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), "m@example.com"),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["created"] is False
|
||||
assert r2.json()["account"] == {"email": "m@example.com"}
|
||||
|
||||
|
||||
def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "code_mismatch"
|
||||
assert body["attempts_remaining"] == 4
|
||||
|
||||
|
||||
def test_verify_no_code_is_400_code_expired(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "code_expired"
|
||||
|
||||
|
||||
def test_me_requires_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
|
||||
|
||||
def test_me_returns_account_with_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
|
||||
|
||||
|
||||
def test_puc_09_logout_clears_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
logout = client.post("/api/auth/logout")
|
||||
assert logout.status_code == 204
|
||||
# after logout the cookie is cleared -> /me is unauthenticated again
|
||||
client.cookies.clear()
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_request_code_delivery_failure_is_502(fresh_db_url, monkeypatch):
|
||||
# INV-9 honest failure over HTTP (§6.4): the relay refused -> 502 delivery_failed,
|
||||
# never a fake 204. Force the app's mailer to fail after startup.
|
||||
from app.platform import mailer as mailer_mod
|
||||
|
||||
class _FailingMailer:
|
||||
def send(self, to, subject, body):
|
||||
raise mailer_mod.MailerError("relay refused")
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.app.state.mailer = _FailingMailer()
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 502
|
||||
assert resp.json()["error"]["code"] == "delivery_failed"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
|
||||
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed
|
||||
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py."""
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
|
||||
# fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7).
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
# a fresh deployment serves healthz green before any row exists
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
|
||||
# PUC-2: first visitor requests a code; it reaches them via the dev channel
|
||||
assert client.post(
|
||||
"/api/auth/request-code", json={"email": "first@example.com"}
|
||||
).status_code == 204
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
|
||||
# verify creates account #1 — the first row, through the product alone
|
||||
verified = client.post(
|
||||
"/api/auth/verify", json={"email": "first@example.com", "code": code}
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["created"] is True
|
||||
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
|
||||
|
||||
# PUC-4: create the storefront (blank name -> generated default)
|
||||
created = client.post("/api/storefronts", json={})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["name"] == "first's storefront"
|
||||
|
||||
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
|
||||
me = client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json() == {
|
||||
"account": {"email": "first@example.com"},
|
||||
"storefront": created.json(),
|
||||
}
|
||||
@@ -11,3 +11,33 @@ def test_database_url_defaults_to_local_compose(monkeypatch):
|
||||
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"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
# /healthz reports the VERSION file's value — the flotilla deploy gate compares
|
||||
# body.version against the pinned target (flotilla-core SPEC §8.1 phase 8).
|
||||
_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()
|
||||
|
||||
|
||||
def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
|
||||
app = create_app(database_url=fresh_db_url)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
assert resp.json() == {"status": "ok", "version": _VERSION}
|
||||
|
||||
|
||||
def test_startup_migrates_from_empty(fresh_db_url):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from app.platform import mailer
|
||||
|
||||
|
||||
def test_logmailer_captures_to_outbox():
|
||||
m = mailer.LogMailer()
|
||||
assert m.outbox == []
|
||||
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)")
|
||||
assert len(m.outbox) == 1
|
||||
msg = m.outbox[-1]
|
||||
assert msg.to == "merchant@example.com"
|
||||
assert msg.subject == "Your ecomm code: 123456"
|
||||
assert "123456" in msg.body
|
||||
|
||||
|
||||
def test_build_mailer_log_kind():
|
||||
m = mailer.build_mailer("log")
|
||||
assert isinstance(m, mailer.LogMailer)
|
||||
|
||||
|
||||
class _FakeSMTP:
|
||||
"""Stand-in for smtplib.SMTP capturing the call sequence (no network)."""
|
||||
|
||||
instances: list["_FakeSMTP"] = []
|
||||
fail_on_send = False
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host, self.port, self.timeout = host, port, timeout
|
||||
self.calls: list[str] = []
|
||||
self.message = None
|
||||
_FakeSMTP.instances.append(self)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, user, password):
|
||||
self.calls.append(f"login:{user}")
|
||||
|
||||
def send_message(self, msg):
|
||||
if _FakeSMTP.fail_on_send:
|
||||
raise RuntimeError("relay refused")
|
||||
self.calls.append("send_message")
|
||||
self.message = msg
|
||||
|
||||
|
||||
def _smtp_env(monkeypatch):
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP", _FakeSMTP)
|
||||
_FakeSMTP.instances.clear()
|
||||
_FakeSMTP.fail_on_send = False
|
||||
monkeypatch.setenv("ECOMM_SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setenv("ECOMM_SMTP_PORT", "587")
|
||||
monkeypatch.setenv("ECOMM_SMTP_USER", "sender@example.com")
|
||||
monkeypatch.setenv("ECOMM_SMTP_PASSWORD", "not-a-real-password")
|
||||
monkeypatch.setenv("ECOMM_SMTP_FROM", "ecomm <sender@example.com>")
|
||||
|
||||
|
||||
def test_build_mailer_smtp_builds_from_config(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
m = mailer.build_mailer("smtp")
|
||||
assert isinstance(m, mailer.SmtpMailer)
|
||||
|
||||
|
||||
def test_smtpmailer_sends_via_starttls_login(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
m = mailer.build_mailer("smtp")
|
||||
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456")
|
||||
smtp = _FakeSMTP.instances[-1]
|
||||
assert (smtp.host, smtp.port) == ("smtp.example.com", 587)
|
||||
assert smtp.calls == ["starttls", "login:sender@example.com", "send_message"]
|
||||
assert smtp.message["To"] == "merchant@example.com"
|
||||
assert smtp.message["Subject"] == "Your ecomm code: 123456"
|
||||
assert smtp.message["From"] == "ecomm <sender@example.com>"
|
||||
assert "123456" in smtp.message.get_content()
|
||||
|
||||
|
||||
def test_smtpmailer_failure_raises_mailer_error(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
_FakeSMTP.fail_on_send = True
|
||||
import pytest
|
||||
|
||||
m = mailer.build_mailer("smtp")
|
||||
with pytest.raises(mailer.MailerError):
|
||||
m.send("merchant@example.com", "subject", "body")
|
||||
|
||||
|
||||
def test_smtpmailer_never_logs_the_body(monkeypatch):
|
||||
# §6.6 log hygiene: codes never appear in deployed logs. LogMailer logging the body
|
||||
# is dev-only by configuration; the deployed adapter must not. The handler attaches
|
||||
# directly to the ecomm.mailer logger (it doesn't propagate to root).
|
||||
import logging
|
||||
|
||||
_smtp_env(monkeypatch)
|
||||
records: list[str] = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = lambda r: records.append(r.getMessage()) # type: ignore[method-assign]
|
||||
lg = logging.getLogger("ecomm.mailer")
|
||||
lg.addHandler(handler)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
try:
|
||||
mailer.build_mailer("smtp").send(
|
||||
"merchant@example.com", "Your ecomm code: 123456", "Code: 123456"
|
||||
)
|
||||
finally:
|
||||
lg.removeHandler(handler)
|
||||
assert records, "the deployed adapter should log the send event (observability §6.6)"
|
||||
assert all("123456" not in r and "merchant@example.com" not in r for r in records)
|
||||
@@ -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,24 @@
|
||||
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
|
||||
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
|
||||
serves the frontend and the default dist dir simply doesn't exist."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
|
||||
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
|
||||
root = client.get("/")
|
||||
assert root.status_code == 200
|
||||
assert "ecomm spa" in root.text
|
||||
# API + health still win over the mount
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
|
||||
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
@@ -0,0 +1,154 @@
|
||||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _account_id(conn, email="merchant@example.com"):
|
||||
return conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def test_create_storefront_with_name(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
|
||||
assert sf.name == "Ben's Bets"
|
||||
assert storefronts.storefront_for(migrated_conn, acct) == sf
|
||||
|
||||
|
||||
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
assert sf.name == "merchant's storefront"
|
||||
|
||||
|
||||
def test_whitespace_name_treated_as_blank(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
|
||||
assert sf.name == "ben's storefront"
|
||||
|
||||
|
||||
def test_second_create_refused_inv_4(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
|
||||
with pytest.raises(storefronts.AlreadyOwnsStorefront):
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
|
||||
|
||||
|
||||
def test_membership_row_is_owner(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
role = migrated_conn.execute(
|
||||
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
|
||||
(acct, sf.id),
|
||||
).fetchone()[0]
|
||||
assert role == "owner"
|
||||
|
||||
|
||||
def test_storefront_for_none_when_absent(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
assert storefronts.storefront_for(migrated_conn, acct) is None
|
||||
|
||||
|
||||
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
client.post("/api/auth/request-code", json={"email": email})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
yield client
|
||||
|
||||
|
||||
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "Ben's Bets"
|
||||
assert isinstance(resp.json()["id"], int)
|
||||
|
||||
|
||||
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
|
||||
# No payment method, plan, or commitment: the request body needs nothing at all and
|
||||
# the response carries only the storefront — no plan/trial/billing fields.
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={})
|
||||
assert resp.status_code == 201
|
||||
assert set(resp.json().keys()) == {"id", "name"}
|
||||
|
||||
|
||||
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
|
||||
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] == created
|
||||
|
||||
|
||||
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] is None
|
||||
|
||||
|
||||
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
|
||||
# PUC-6: a returning merchant's verify response already carries the storefront.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
email = "returning@example.com"
|
||||
with _signed_in_client(fresh_db_url, email) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
# fresh login: backdate the consumed code to clear the 60s resend cooldown
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": email})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["storefront"]["name"] == "Ben's Bets"
|
||||
assert resp.json()["created"] is False
|
||||
|
||||
|
||||
def test_puc_07_second_create_refused_409(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "First"})
|
||||
resp = client.post("/api/storefronts", json={"name": "Second"})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "already_owns_storefront"
|
||||
|
||||
|
||||
def test_puc_08_admin_shell_answer(fresh_db_url):
|
||||
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["account"]["email"] == "merchant@example.com"
|
||||
assert me["storefront"]["name"] == "Ben's Bets"
|
||||
|
||||
|
||||
def test_create_storefront_requires_session(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Nope"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
|
||||
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
|
||||
import threading
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_url(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
return fresh_db_url
|
||||
|
||||
|
||||
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
|
||||
with psycopg.connect(migrated_url) as setup:
|
||||
acct = setup.execute(
|
||||
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
|
||||
).fetchone()[0]
|
||||
setup.commit()
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[str] = []
|
||||
|
||||
def racer():
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
barrier.wait()
|
||||
try:
|
||||
storefronts.create_storefront(conn, acct, "racer@example.com", None)
|
||||
results.append("created")
|
||||
except storefronts.AlreadyOwnsStorefront:
|
||||
results.append("refused")
|
||||
|
||||
threads = [threading.Thread(target=racer) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert sorted(results) == ["created", "refused"]
|
||||
with psycopg.connect(migrated_url) as check:
|
||||
count = check.execute(
|
||||
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_inv_4_schema_stays_many_capable(migrated_url):
|
||||
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
|
||||
# storefront_membership is its composite PK, so many-per-account stays a deletable
|
||||
# service rule (R-5 mitigation).
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
uniques = conn.execute(
|
||||
"SELECT i.indkey::text FROM pg_index i"
|
||||
" JOIN pg_class c ON c.oid = i.indrelid"
|
||||
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
|
||||
).fetchall()
|
||||
# exactly one unique index (the composite PK over two columns)
|
||||
assert len(uniques) == 1
|
||||
assert len(uniques[0][0].split()) == 2
|
||||
@@ -0,0 +1,42 @@
|
||||
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
|
||||
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
|
||||
|
||||
[app]
|
||||
name = "ecomm"
|
||||
repo = "wiggleverse/wiggleverse-ecomm"
|
||||
gitea_host = "https://git.wiggleverse.org"
|
||||
version_source = { kind = "file", path = "VERSION" }
|
||||
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
|
||||
|
||||
[vm]
|
||||
name = "ecomm-ppe"
|
||||
zone = "us-central1-a"
|
||||
project = "wiggleverse-ecomm"
|
||||
machine_type = "e2-micro"
|
||||
disk_gb = 10
|
||||
service_user = "ecomm"
|
||||
install_dir = "/opt/ecomm"
|
||||
systemd_unit = "ecomm.service"
|
||||
tunnel_through_iap = true
|
||||
gcloud_config = "wiggleverse-ecomm"
|
||||
|
||||
[edge]
|
||||
domain = "ecomm-ppe.wiggleverse.org"
|
||||
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
|
||||
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
|
||||
|
||||
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
|
||||
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
|
||||
[overlay] # non-secret env, plaintext (guide §8)
|
||||
APP_URL = "https://ecomm-ppe.wiggleverse.org"
|
||||
ECOMM_MAILER = "smtp"
|
||||
ECOMM_COOKIE_SECURE = "1"
|
||||
ECOMM_SMTP_HOST = "smtp.gmail.com"
|
||||
ECOMM_SMTP_PORT = "587"
|
||||
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
|
||||
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
|
||||
|
||||
[secrets] # REFERENCES only — never bytes (§8.3)
|
||||
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
|
||||
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
|
||||
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
|
||||
+79
-3
@@ -4,8 +4,9 @@ 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.
|
||||
This document grows per environment. SLICE-1 shipped the **localhost** section;
|
||||
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
|
||||
stand-up.
|
||||
|
||||
## Localhost
|
||||
|
||||
@@ -61,4 +62,79 @@ environment starts from (BUC-5a).
|
||||
`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).
|
||||
Manager (see PPE below).
|
||||
|
||||
## Pre-production (PPE)
|
||||
|
||||
PPE is stood up and deployed through the **launch-app / flotilla-core** suite only
|
||||
(handbook §8.5 — provisioning is an operator-run gesture). The app's deployment
|
||||
record is `deployment.toml` at this repo's root; flotilla-core consumes it.
|
||||
|
||||
### One-time provisioning (operator-run, in order)
|
||||
|
||||
1. **Cloud foundation** — `scaffold-gcp-project`: the GCP project, its dedicated
|
||||
`--no-activate` gcloud config, billing, core APIs, ADC quota pin.
|
||||
2. **Managed database** — `provision-datastore` (launch-app §6.6a, built for ecomm's
|
||||
D-7/D-8): Cloud SQL for PostgreSQL 16, private-IP-only, automated backups +
|
||||
point-in-time recovery; writes the full DSN to Secret Manager as
|
||||
`<project>/ecomm-ppe-database-url`. Bound on the deployment record as
|
||||
`ECOMM_DATABASE_URL`.
|
||||
3. **Secrets** (references only — bytes go in via stdin, never through a session):
|
||||
`ECOMM_SESSION_SECRET` (fresh random), `ECOMM_SMTP_PASSWORD` (the shared
|
||||
Wiggleverse relay credential), and flotilla's own Gitea read token for this
|
||||
private repo.
|
||||
4. **VM + edge** — `provision-vm`: the e2-micro, IAP-only SSH, nginx + Cloudflare
|
||||
origin TLS, the systemd unit, DNS for `ecomm-ppe.wiggleverse.org`.
|
||||
5. **Deployment record** — `flotilla-core deployment scaffold ecomm … -o
|
||||
deployment.toml`, validate, commit it here, `deployment import --reconcile`.
|
||||
|
||||
### Deploy
|
||||
|
||||
Each release is a git tag `v<VERSION>` matching the repo-root `VERSION` file (the
|
||||
pin). The one gesture, from the flotilla-core repo's venv:
|
||||
|
||||
```bash
|
||||
CLOUDSDK_ACTIVE_CONFIG_NAME=ecomm .venv/bin/flotilla-core deploy ecomm
|
||||
```
|
||||
|
||||
Nine phases, fail-stop; it ends by polling `https://ecomm-ppe.wiggleverse.org/healthz`
|
||||
and requiring `status == "ok"` and `version == <target>`. Watch it yourself the same
|
||||
way:
|
||||
|
||||
```bash
|
||||
curl https://ecomm-ppe.wiggleverse.org/healthz
|
||||
```
|
||||
|
||||
### Configuration surface (PPE values)
|
||||
|
||||
| Env var | Kind | Value |
|
||||
| --- | --- | --- |
|
||||
| `ECOMM_DATABASE_URL` | secret ref | `<project>/ecomm-ppe-database-url` (from provision-datastore) |
|
||||
| `ECOMM_SESSION_SECRET` | secret ref | `<project>/ecomm-ppe-session-secret` |
|
||||
| `ECOMM_SMTP_PASSWORD` | secret ref | the shared Wiggleverse relay credential |
|
||||
| `ECOMM_MAILER` | overlay | `smtp` |
|
||||
| `ECOMM_COOKIE_SECURE` | overlay | `1` |
|
||||
| `ECOMM_SMTP_HOST` / `_PORT` / `_USER` / `_FROM` | overlay | the relay coordinates (non-secret) |
|
||||
|
||||
### The rehearsal (PUC-11)
|
||||
|
||||
From empty persistence: the deploy migrates the schema at startup (INV-7); then a
|
||||
real sign-up → one-time code arriving by **real email** → create storefront → admin,
|
||||
through the public flows alone. Record each rehearsal here when it happens.
|
||||
|
||||
**Rehearsals:**
|
||||
|
||||
- **2026-06-11 — PPE first bootstrap (v0.4.0, session 0024): ✅** First-ever ecomm
|
||||
deploy (`flotilla-core deploy ecomm`, 9/9 phases green, deploys.id=40) onto a
|
||||
freshly provisioned environment — project `wiggleverse-ecomm`, Cloud SQL
|
||||
`ecomm-ppe-pg` (provisioned by launch-app `provision-datastore`, first run), VM
|
||||
`ecomm-ppe`. The app self-migrated the empty database at startup; the operator
|
||||
then walked sign-up → real emailed code (Gmail relay) → create storefront →
|
||||
honestly-empty admin in a browser, through the public flows alone. Findings
|
||||
captured: OTC email branding (#16); identity should outgrow ecomm
|
||||
(engineering#49/#50).
|
||||
|
||||
## Production
|
||||
|
||||
Lands with the prod stand-up — the **identical gesture** on a prod deployment record
|
||||
(BUC-5a): same provisioning skills, same deploy, same rehearsal.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
# SLICE-4 (part 1) — Deploy-Contract Code Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
|
||||
|
||||
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
|
||||
|
||||
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError` → `accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
|
||||
|
||||
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: VERSION + versioned /healthz
|
||||
|
||||
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
|
||||
|
||||
- [ ] Write `VERSION` containing `0.4.0`.
|
||||
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
|
||||
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
|
||||
|
||||
### Task 2: backend serves the SPA (deploy phase-8 contract)
|
||||
|
||||
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
|
||||
|
||||
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
|
||||
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
|
||||
|
||||
### Task 3: SmtpMailer + config surface (INV-8)
|
||||
|
||||
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
|
||||
|
||||
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
|
||||
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
|
||||
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
|
||||
|
||||
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
|
||||
|
||||
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
|
||||
|
||||
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
|
||||
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
|
||||
|
||||
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
|
||||
|
||||
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
|
||||
|
||||
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
|
||||
- [ ] README status: deploy contract in place; frontend package 0.4.0.
|
||||
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
|
||||
@@ -0,0 +1,118 @@
|
||||
# ui/designs Content-Repo Collection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
|
||||
|
||||
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
|
||||
|
||||
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 10–14)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the collection README**
|
||||
|
||||
`ui/designs/README.md`:
|
||||
|
||||
```markdown
|
||||
# ui/designs — UI-design artifacts
|
||||
|
||||
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
|
||||
app's UI-design artifacts — primarily Claude Design outputs generated from a
|
||||
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
|
||||
|
||||
A Solution Design with a UX-involving slice references its design artifact here
|
||||
by path. The spec-linkage gate and the backfill gesture for adding that
|
||||
reference once a design exists are tracked in
|
||||
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
Suggested layout: one subfolder per design, named for the spec/slice it serves,
|
||||
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the layout-table row**
|
||||
|
||||
In the top-level `README.md`, extend the table:
|
||||
|
||||
```markdown
|
||||
| Path | Holds |
|
||||
| --- | --- |
|
||||
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
|
||||
| `plans/` | archived implementation plans |
|
||||
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
|
||||
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
|
||||
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
|
||||
|
||||
### Task 2: Standardize centrally in engineering schemas docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Schema description strings + enum**
|
||||
|
||||
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]` → `["1.0", "1.1", "1.2", "1.3"]`
|
||||
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
|
||||
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
|
||||
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
|
||||
|
||||
- [ ] **Step 3: schemas/README.md**
|
||||
|
||||
Changelog entry above 1.2:
|
||||
|
||||
```markdown
|
||||
- **1.3** — docs-only: the content-repo collection convention gains a third
|
||||
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
|
||||
outputs generated from a Solution Design), referenced from specs. Like
|
||||
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
|
||||
validation change (the spec-linkage gate/backfill tooling is
|
||||
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
|
||||
```
|
||||
|
||||
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
|
||||
|
||||
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
|
||||
git -C …/engineering add schemas/app.schema.json schemas/README.md
|
||||
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
|
||||
git -C …/engineering push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR + merge (default token for `/pulls`).
|
||||
|
||||
### Task 3: Cross-link and close out
|
||||
|
||||
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
|
||||
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
|
||||
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0E1230" />
|
||||
<style>html { background: #0E1230; }</style>
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||
<title>ecomm</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+406
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -16,7 +16,8 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1239,6 +1240,129 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
||||
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
|
||||
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
||||
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.9",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
||||
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
||||
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^3.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
||||
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.35",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
|
||||
@@ -1286,6 +1410,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001797",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
||||
@@ -1307,6 +1441,33 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1339,6 +1500,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.371",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
||||
@@ -1346,6 +1517,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1395,6 +1573,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1464,6 +1662,13 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1474,6 +1679,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1510,6 +1725,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1645,6 +1877,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1655,6 +1894,64 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
|
||||
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
||||
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1760,6 +2057,112 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.3.7",
|
||||
"es-module-lexer": "^1.5.4",
|
||||
"pathe": "^1.1.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.8.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
-6
@@ -1,10 +1,91 @@
|
||||
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
|
||||
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
|
||||
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
|
||||
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
|
||||
// response carries the same shape as /me, so onAuthed feeds the session directly.
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
|
||||
import { routeFor, type SessionState } from "./routing";
|
||||
import Admin from "./screens/Admin";
|
||||
import CreateStorefront from "./screens/CreateStorefront";
|
||||
import Landing from "./screens/Landing";
|
||||
import SignIn from "./screens/SignIn";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
type Welcome = "new" | "back" | null;
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="screen screen--plain">
|
||||
<main className="screen__main">
|
||||
<p className="note">Loading…</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => setDoor(null)}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setWelcome(result.created ? "new" : "back");
|
||||
setDoor(null);
|
||||
setSession({ account: result.account, storefront: result.storefront });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
const email = session!.account!.email;
|
||||
|
||||
function signedOut() {
|
||||
setWelcome(null);
|
||||
setDoor(null);
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
if (route === "create-storefront") {
|
||||
return (
|
||||
<CreateStorefront
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onCreated={(sf: StorefrontResult) => {
|
||||
setWelcome(null);
|
||||
setSession({ account: { email }, storefront: sf });
|
||||
}}
|
||||
onAlreadyOwns={() => void reload()}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>ecomm</h1>
|
||||
<p>Honest commerce. Your storefront is yours.</p>
|
||||
</main>
|
||||
<Admin
|
||||
storefrontName={session!.storefront!.name}
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
|
||||
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
|
||||
import type { SessionState } from "./routing";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
retry_after_s?: number;
|
||||
attempts_remaining?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
account: { email: string };
|
||||
storefront: { id: number; name: string } | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return { code: "unexpected", message: "Something went wrong. Please try again." };
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<SessionState | null> {
|
||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (resp.status === 401) return null;
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as SessionState;
|
||||
}
|
||||
|
||||
export async function requestCode(email: string): Promise<ApiError | null> {
|
||||
const resp = await fetch("/api/auth/request-code", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return resp.ok ? null : await errorOf(resp);
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
|
||||
export interface StorefrontResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function createStorefront(
|
||||
name: string,
|
||||
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/storefronts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
|
||||
});
|
||||
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cooldownAppliesTo } from "./cooldown";
|
||||
|
||||
describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
|
||||
it("no cooldown running -> send not blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown running for the same address -> blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("same address modulo case/whitespace -> still blocked", () => {
|
||||
expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("cooldown running but the input is a different address -> not blocked", () => {
|
||||
expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown expired -> not blocked even for the same address", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
|
||||
// per-address, so the client countdown must be too: it blocks sending only while it
|
||||
// is running AND the current input is the address it was set for. Normalization
|
||||
// mirrors the backend's normalize_email (lowercase + strip, INV-2).
|
||||
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function cooldownAppliesTo(
|
||||
input: string,
|
||||
cooldownEmail: string | null,
|
||||
secondsLeft: number,
|
||||
): boolean {
|
||||
if (secondsLeft <= 0 || cooldownEmail === null) return false;
|
||||
return normalize(input) === normalize(cooldownEmail);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./styles/index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
@@ -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,59 @@
|
||||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
|
||||
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
|
||||
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
|
||||
// Visuals per the ui/designs export (hf-admin).
|
||||
import { logout } from "../api";
|
||||
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
storefrontName: string;
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen plain>
|
||||
<TopBar
|
||||
left={
|
||||
<span className="storeid">
|
||||
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
|
||||
<span className="storeid__col">
|
||||
<span className="storeid__name">{storefrontName}</span>
|
||||
<span className="storeid__sub">ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
right={<AccountChip email={email} onSignOut={signOut} />}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
<div className="empty__seal" aria-hidden="true">
|
||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||
</div>
|
||||
<Eyebrow>Your storefront</Eyebrow>
|
||||
<h1>{storefrontName}</h1>
|
||||
<p className="empty__copy">
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
|
||||
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
|
||||
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
|
||||
import { useState } from "react";
|
||||
import { createStorefront, logout, type StorefrontResult } from "../api";
|
||||
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onCreated: (storefront: StorefrontResult) => void;
|
||||
onAlreadyOwns: () => void;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [alreadyOwns, setAlreadyOwns] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await createStorefront(name);
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
onCreated(res.storefront);
|
||||
return;
|
||||
}
|
||||
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
|
||||
else setError(res.error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
<form onSubmit={submit} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
|
||||
<p className="card__sub">
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{welcome && (
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
)}
|
||||
{alreadyOwns && (
|
||||
<Banner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today.{" "}
|
||||
<button type="button" className="lk" onClick={onAlreadyOwns}>
|
||||
Go to your admin →
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
|
||||
<Field
|
||||
label="Storefront name"
|
||||
optional
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
|
||||
inputProps={{
|
||||
type: "text",
|
||||
value: name,
|
||||
placeholder: "e.g. Ben's Bets",
|
||||
autoFocus: true,
|
||||
onChange: (ev) => setName(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
|
||||
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
|
||||
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
|
||||
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
|
||||
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
onGetStarted: () => void;
|
||||
onLogIn: () => void;
|
||||
}
|
||||
|
||||
const PROMISES: Array<[string, string]> = [
|
||||
["No trial clock", "Your storefront never expires or locks."],
|
||||
["No plan wall", "We take only what it takes to run."],
|
||||
["Your data, yours", "Nothing collected you didn't agree to give."],
|
||||
];
|
||||
|
||||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark />}
|
||||
right={
|
||||
<button type="button" className="lk lk--nav" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<div className="hero">
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1>
|
||||
Sell online,
|
||||
<br />
|
||||
<em>honestly.</em>
|
||||
</h1>
|
||||
<p className="hero__lead">
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div className="hero__actions">
|
||||
<PrimaryButton type="button" onClick={onGetStarted}>
|
||||
Create your storefront →
|
||||
</PrimaryButton>
|
||||
<button type="button" className="lk lk--mute" onClick={onLogIn}>
|
||||
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="promises">
|
||||
{PROMISES.map(([title, copy]) => (
|
||||
<div key={title}>
|
||||
<div className="promises__title">{title}</div>
|
||||
<p>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
|
||||
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
|
||||
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
|
||||
import { useEffect, useState } from "react";
|
||||
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
|
||||
import { cooldownAppliesTo } from "../cooldown";
|
||||
import CodeInput from "../ui/CodeInput";
|
||||
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
interface Props {
|
||||
door: Door;
|
||||
onAuthed: (result: VerifyResult) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function useCooldown(): [number, (s: number) => void] {
|
||||
const [left, setLeft] = useState(0);
|
||||
const ticking = left > 0;
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [ticking]);
|
||||
return [left, setLeft];
|
||||
}
|
||||
|
||||
export default function SignIn({ door, onAuthed, onBack }: Props) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
||||
const [cooldown, setCooldown] = useCooldown();
|
||||
// The address the running cooldown belongs to — the server's cooldown is per-address
|
||||
// (INV-3), so a different address must not be blocked by it (bug #20).
|
||||
const [cooldownFor, setCooldownFor] = useState<string | null>(null);
|
||||
|
||||
function applyError(err: ApiError) {
|
||||
setFieldError(null);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
if (err.code === "invalid_email") setFieldError(err.message);
|
||||
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
|
||||
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
|
||||
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
|
||||
}
|
||||
|
||||
async function sendCode(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setFieldError(null);
|
||||
setBannerError(null);
|
||||
const err = await requestCode(email);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
applyError(err);
|
||||
if (err.code === "resend_cooldown") setCooldownFor(email);
|
||||
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
|
||||
}
|
||||
setCooldown(60);
|
||||
setCooldownFor(email);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (await sendCode()) {
|
||||
setCode("");
|
||||
setCodeError(null);
|
||||
setStep("code");
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setCodeError(null);
|
||||
setCode("");
|
||||
await sendCode();
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
const res = await verifyCode(email, code);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
applyError(res.error);
|
||||
return;
|
||||
}
|
||||
onAuthed(res.result);
|
||||
}
|
||||
|
||||
const heading = door === "signup" ? "Create your storefront" : "Log in";
|
||||
const sub =
|
||||
door === "signup"
|
||||
? "Enter your email to begin. There's no password to create."
|
||||
: "Enter your email and we'll send a one-time code to sign in.";
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark size={22} />}
|
||||
right={
|
||||
<button type="button" className="lk lk--mute" onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
{step === "email" ? (
|
||||
<form onSubmit={submitEmail} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>{heading}</h1>
|
||||
<p className="card__sub">{sub}</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title="We couldn't send the code">
|
||||
The email didn't go out. Try again in a moment — nothing was lost.
|
||||
</Banner>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
error={fieldError}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
value: email,
|
||||
placeholder: "you@example.com",
|
||||
autoFocus: true,
|
||||
required: true,
|
||||
autoComplete: "email",
|
||||
onChange: (ev) => setEmail(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
|
||||
{busy
|
||||
? "Sending…"
|
||||
: cooldownAppliesTo(email, cooldownFor, cooldown)
|
||||
? `Resend in ${cooldown}s`
|
||||
: "Send code"}
|
||||
</PrimaryButton>
|
||||
<p className="note">
|
||||
We'll email you a one-time code. That's all we need — no password, ever.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitCode} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>Check your email</h1>
|
||||
<p className="card__sub">
|
||||
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
|
||||
<button type="button" className="lk" onClick={() => setStep("email")}>
|
||||
Wrong address?
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
|
||||
{bannerError.code === "code_exhausted"
|
||||
? "That code is no longer valid. Request a fresh one to keep going."
|
||||
: bannerError.message}
|
||||
</Banner>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<CodeInput value={code} onChange={setCode} error={!!codeError} />
|
||||
{codeError && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{codeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="note">Good for 10 minutes.</p>
|
||||
{cooldown > 0 ? (
|
||||
<span className="note">Resend in {cooldown}s</span>
|
||||
) : (
|
||||
<button type="button" className="lk" onClick={resend}>
|
||||
Resend code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
|
||||
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wv-midnight);
|
||||
color: var(--wv-starlight);
|
||||
font-family: var(--wv-font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
|
||||
.screen {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--horizon {
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--plain { background: var(--wv-midnight); }
|
||||
|
||||
.screen__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
flex: 0 0 auto;
|
||||
height: 68px;
|
||||
padding: 0 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--glass-sky);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
|
||||
/* ── wordmark ───────────────────────────────────────────────────────────────── */
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.wordmark img { display: block; border-radius: 6px; }
|
||||
.wordmark__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 22px;
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
/* ── links & text buttons ───────────────────────────────────────────────────── */
|
||||
.lk {
|
||||
color: var(--wv-lilac);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.lk:hover { color: var(--wv-gold); }
|
||||
.lk--mute { color: var(--text-on-dark-mute); }
|
||||
.lk--nav {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
.signout {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.signout:hover { color: var(--wv-gold); }
|
||||
|
||||
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
|
||||
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.chip__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: var(--wv-lilac-16);
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chip__email {
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-soft);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
|
||||
|
||||
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-eyebrow);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--wv-lilac);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
|
||||
.card {
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 27px;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
|
||||
|
||||
/* ── fields ─────────────────────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field__label {
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 13.5px;
|
||||
color: var(--text-on-dark-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
|
||||
.field__input {
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
padding: 0 16px;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-body);
|
||||
font-size: 16px;
|
||||
color: var(--wv-starlight);
|
||||
width: 100%;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.field__input::placeholder { color: var(--text-on-dark-mute); }
|
||||
.field__input:hover { border-color: var(--border-strong); }
|
||||
.field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
background: rgba(237, 234, 255, .06);
|
||||
}
|
||||
.field__input--error { border-color: var(--wv-gold); }
|
||||
|
||||
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
|
||||
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
|
||||
.note--attn::before { content: "◆"; font-size: 11px; }
|
||||
|
||||
/* ── primary button: gold pill ──────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4em;
|
||||
width: 100%;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 15.5px;
|
||||
line-height: 1;
|
||||
padding: .85rem 1.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--btn-border-w) solid transparent;
|
||||
background: var(--cta);
|
||||
color: var(--cta-text);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary--auto { width: auto; }
|
||||
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
|
||||
.banner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-on-dark-soft);
|
||||
text-align: left;
|
||||
}
|
||||
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
|
||||
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
|
||||
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
|
||||
.banner--attn .banner__icon { color: var(--wv-gold); }
|
||||
.banner--info .banner__icon { color: var(--wv-lilac); }
|
||||
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
|
||||
|
||||
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
|
||||
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
|
||||
.code__hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code__cell {
|
||||
width: 52px;
|
||||
height: 64px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 26px;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.code--focus .code__cell--active {
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
}
|
||||
.code--error .code__cell { border-color: var(--wv-gold); }
|
||||
|
||||
/* ── footer ─────────────────────────────────────────────────────────────────── */
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 20px 36px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-on-dark-mute);
|
||||
background: rgba(9, 12, 34, .4);
|
||||
}
|
||||
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
|
||||
.footer__id img { opacity: .85; }
|
||||
|
||||
/* ── landing ────────────────────────────────────────────────────────────────── */
|
||||
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.hero h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.02;
|
||||
font-size: clamp(36px, 6.5vw, 62px);
|
||||
margin: 20px 0 22px;
|
||||
}
|
||||
.hero h1 em { font-style: normal; color: var(--wv-gold); }
|
||||
.hero__lead {
|
||||
font-size: clamp(15.5px, 1.8vw, 18.5px);
|
||||
line-height: 1.6;
|
||||
color: var(--text-on-dark-soft);
|
||||
max-width: 520px;
|
||||
margin: 0 0 32px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.hero__actions .btn-primary { width: auto; }
|
||||
|
||||
.promises {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: 100%;
|
||||
margin-top: 44px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
.promises > div { padding: 0 22px; }
|
||||
.promises > div + div { border-left: 1px solid var(--border-soft); }
|
||||
.promises__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
|
||||
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
|
||||
|
||||
/* ── admin shell ────────────────────────────────────────────────────────────── */
|
||||
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
|
||||
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
|
||||
.storeid__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
|
||||
.empty__seal {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 26px;
|
||||
background: var(--wv-lilac-08);
|
||||
}
|
||||
.empty h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: 1.04;
|
||||
font-size: clamp(30px, 4.5vw, 42px);
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
|
||||
|
||||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.topbar { padding: 0 20px; }
|
||||
.footer { padding: 16px 20px; flex-direction: column; }
|
||||
.card { padding: 28px 22px 24px; }
|
||||
.promises { grid-template-columns: 1fr; gap: 14px; }
|
||||
.promises > div { padding: 0; }
|
||||
.promises > div + div { border-left: none; }
|
||||
.code__cell { width: 44px; height: 56px; font-size: 22px; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
|
||||
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Global style entry point — Wiggleverse design-system tokens (copied from the
|
||||
ui/designs export) + the ecomm app chrome built on them. */
|
||||
@import "./fonts.css";
|
||||
@import "./tokens-colors.css";
|
||||
@import "./tokens-typography.css";
|
||||
@import "./tokens-spacing.css";
|
||||
@import "./app.css";
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
|
||||
Derived from the marketing-site CSS. The brand has almost no shadow system —
|
||||
depth is carried by surface color + hairline borders, not drop shadows. */
|
||||
|
||||
:root {
|
||||
/* ---- Spacing scale (rem) ---- */
|
||||
--space-0: 0;
|
||||
--space-1: .25rem;
|
||||
--space-2: .5rem;
|
||||
--space-3: .7rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.2rem;
|
||||
--space-6: 1.6rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Section rhythm — fluid vertical padding for page bands */
|
||||
--section-pad: clamp(3rem, 7vw, 5.5rem);
|
||||
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--wrap-max: 1080px; /* content column */
|
||||
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
|
||||
--measure-prose: 68ch; /* reading measure for prose blocks */
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-card: 14px; /* path-cards, build-cards */
|
||||
--radius-panel: 12px; /* principle tiles */
|
||||
--radius-sm: 4px; /* focus ring rounding */
|
||||
--radius-pill: 999px; /* buttons, tags, notices */
|
||||
--radius-mark: 26px; /* favicon tile rounding */
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-hair: 1px; /* default hairline */
|
||||
--border-card-w: 1px;
|
||||
--btn-border-w: 1.5px; /* ghost button / focus weight */
|
||||
|
||||
/* ---- Elevation ---- *
|
||||
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
|
||||
* not a shadow. These tokens exist for the rare card that needs real elevation. */
|
||||
--shadow-none: none;
|
||||
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
|
||||
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
|
||||
--lift-3: translateY(-3px); /* @kind other */ /* cards */
|
||||
|
||||
/* ---- Glass (sticky header) ---- */
|
||||
--glass-sky: rgba(14, 18, 48, .82);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ---- Motion ---- */
|
||||
--ease: ease; /* @kind other */
|
||||
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
|
||||
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Wiggleverse — Typography tokens
|
||||
Two registers, one meaning (BRAND.md):
|
||||
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
|
||||
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
|
||||
for the lines that carry conscience ("the soul").
|
||||
@font-face rules live in assets/fonts/fonts.css. */
|
||||
|
||||
:root {
|
||||
/* ---- Families ---- */
|
||||
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
|
||||
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
|
||||
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
|
||||
|
||||
/* semantic aliases */
|
||||
--font-display: var(--wv-font-display);
|
||||
--font-body: var(--wv-font-body);
|
||||
--font-soul: var(--wv-font-human);
|
||||
|
||||
/* ---- Weights ---- */
|
||||
--weight-regular: 400; /* Inter body */
|
||||
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
|
||||
--weight-semibold:600; /* Inter emphasis, ledger totals */
|
||||
--weight-bold: 700; /* Space Grotesk headings, wordmark */
|
||||
--weight-soul: 500; /* Fraunces italic pull-quotes */
|
||||
|
||||
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
|
||||
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
|
||||
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
--text-h3: 1.2rem;
|
||||
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
|
||||
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
|
||||
|
||||
/* ---- Body / UI scale ---- */
|
||||
--text-body: 1rem; /* 16px base */
|
||||
--text-small: .95rem;
|
||||
--text-fine: .92rem;
|
||||
--text-eyebrow: .8rem; /* uppercase label */
|
||||
--text-tag: .72rem; /* pill tags */
|
||||
|
||||
/* ---- Line heights ---- */
|
||||
--leading-tight: 1.12; /* headings */
|
||||
--leading-body: 1.6; /* paragraphs */
|
||||
|
||||
/* ---- Letter spacing ---- */
|
||||
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
|
||||
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
|
||||
--tracking-tag: 0.08em;
|
||||
--tracking-notice: 0.06em;
|
||||
|
||||
/* ---- Measure ---- */
|
||||
--measure-lead: 60ch;
|
||||
--measure-prose:68ch;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
|
||||
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
|
||||
// paste works because the input is real.
|
||||
import { useState } from "react";
|
||||
|
||||
const LENGTH = 6;
|
||||
|
||||
export default function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
error?: boolean;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const digits = value.slice(0, LENGTH).split("");
|
||||
const active = Math.min(digits.length, LENGTH - 1);
|
||||
|
||||
return (
|
||||
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
|
||||
<input
|
||||
className="code__hidden"
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
aria-label="One-time code"
|
||||
autoFocus
|
||||
maxLength={LENGTH}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
|
||||
/>
|
||||
{Array.from({ length: LENGTH }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
|
||||
>
|
||||
{digits[i] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
|
||||
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
|
||||
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Screen({
|
||||
children,
|
||||
horizon = false,
|
||||
plain = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
horizon?: boolean;
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
|
||||
return <div className={cls}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__side">{left}</div>
|
||||
<div className="topbar__side">{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wordmark({ size = 26 }: { size?: number }) {
|
||||
return (
|
||||
<span className="wordmark">
|
||||
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
|
||||
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
|
||||
ecomm
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<span className="footer__id">
|
||||
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({ children }: { children: ReactNode }) {
|
||||
return <div className="card">{children}</div>;
|
||||
}
|
||||
|
||||
export function Eyebrow({ children }: { children: ReactNode }) {
|
||||
return <p className="eyebrow">{children}</p>;
|
||||
}
|
||||
|
||||
export function Banner({
|
||||
tone = "attn",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone?: "attn" | "info";
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
|
||||
<span className="banner__icon" aria-hidden="true">
|
||||
{tone === "attn" ? "◆" : "◇"}
|
||||
</span>
|
||||
<div>
|
||||
{title && <div className="banner__title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
children,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
type = "submit",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: "submit" | "button";
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
optional = false,
|
||||
error,
|
||||
helper,
|
||||
inputProps,
|
||||
}: {
|
||||
label: string;
|
||||
optional?: boolean;
|
||||
error?: string | null;
|
||||
helper?: string;
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
<span>{label}</span>
|
||||
{optional && <span className="field__optional">optional</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={`field__input${error ? " field__input--error" : ""}`}
|
||||
aria-invalid={!!error}
|
||||
{...inputProps}
|
||||
/>
|
||||
{error && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && <p className="note">{helper}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
|
||||
return (
|
||||
<div className="chip">
|
||||
<span className="chip__avatar" aria-hidden="true">
|
||||
{email[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="chip__email">{email}</span>
|
||||
<span className="chip__divider" aria-hidden="true" />
|
||||
<button type="button" className="signout" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
/// <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
|
||||
@@ -15,4 +16,9 @@ export default defineConfig({
|
||||
"/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}"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,4 +34,7 @@ echo "==> backend tests (pytest)"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user