Merge pull request 'SLICE-2: identity — email + one-time-code sign-up, sessions, Landing/Sign-in (SD-0001 §7.2)' (#6) from slice-2-identity into main
ci / check (push) Has been cancelled
ci / check (push) Has been cancelled
This commit was merged in pull request #6.
This commit is contained in:
@@ -20,8 +20,10 @@ 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-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open,
|
||||
passwordless sign-up/log-in by email + one-time code (`accounts` domain, INV-2/INV-3),
|
||||
signed-cookie sessions, the `/api/auth/*` endpoints (`request-code`, `verify`, `me`,
|
||||
`logout`), the `platform/mailer` port with `LogMailer` (the dev code channel), and the
|
||||
Landing + Sign-in screens with the entry-routing rule (storefront hard-wired `null` until
|
||||
SLICE-3). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. The storefront
|
||||
(SLICE-3) and deployed environments (SLICE-4) are next.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
|
||||
|
||||
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
|
||||
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
|
||||
package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import (
|
||||
AccountsError,
|
||||
CodeExhausted,
|
||||
CodeExpired,
|
||||
CodeMismatch,
|
||||
InvalidEmail,
|
||||
ResendCooldown,
|
||||
)
|
||||
from .models import Account
|
||||
from .service import get_account, request_code, verify
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountsError",
|
||||
"InvalidEmail",
|
||||
"ResendCooldown",
|
||||
"CodeMismatch",
|
||||
"CodeExpired",
|
||||
"CodeExhausted",
|
||||
"request_code",
|
||||
"verify",
|
||||
"get_account",
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""accounts — domain exceptions.
|
||||
|
||||
Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here
|
||||
(not in main) keeps the rules in the domain (INV-6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountsError(Exception):
|
||||
"""Base for accounts-domain errors."""
|
||||
|
||||
|
||||
class InvalidEmail(AccountsError):
|
||||
"""The supplied email is not a plausible address (§6.4 400 invalid_email)."""
|
||||
|
||||
|
||||
class ResendCooldown(AccountsError):
|
||||
"""A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown)."""
|
||||
|
||||
def __init__(self, retry_after_s: int) -> None:
|
||||
super().__init__(f"resend available in {retry_after_s}s")
|
||||
self.retry_after_s = retry_after_s
|
||||
|
||||
|
||||
class CodeMismatch(AccountsError):
|
||||
"""The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch)."""
|
||||
|
||||
def __init__(self, attempts_remaining: int) -> None:
|
||||
super().__init__("code did not match")
|
||||
self.attempts_remaining = attempts_remaining
|
||||
|
||||
|
||||
class CodeExpired(AccountsError):
|
||||
"""No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired)."""
|
||||
|
||||
|
||||
class CodeExhausted(AccountsError):
|
||||
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted)."""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""accounts — domain models (SD-0001 §6.3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
"""A person's identity on the platform. Email is the canonical key (INV-2)."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
@@ -0,0 +1,160 @@
|
||||
"""accounts — the identity service (SD-0001 §6.5).
|
||||
|
||||
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
|
||||
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
|
||||
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
|
||||
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
|
||||
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
|
||||
them) so the domain owns no request/transport concerns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.platform import config
|
||||
from app.platform.mailer import Mailer
|
||||
|
||||
from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown
|
||||
from .models import Account
|
||||
|
||||
# INV-3 constants — the one-time-code policy.
|
||||
CODE_TTL = timedelta(minutes=10)
|
||||
RESEND_COOLDOWN = timedelta(seconds=60)
|
||||
MAX_ATTEMPTS = 5
|
||||
_CODE_DIGITS = 6
|
||||
|
||||
# A pragmatic email check — rejects obvious non-addresses without an email-validator
|
||||
# dependency. The provable test of an address is that the emailed code comes back (verify).
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
|
||||
norm = email.strip().lower()
|
||||
if not _EMAIL_RE.match(norm):
|
||||
raise InvalidEmail(email)
|
||||
return norm
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
|
||||
key = config.session_secret().encode("utf-8")
|
||||
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
|
||||
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
|
||||
|
||||
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
|
||||
spent/expired codes. Never reveals whether the email already has an account.
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
last = conn.execute(
|
||||
"SELECT max(created_at) FROM auth_code WHERE email = %s AND consumed_at IS NULL",
|
||||
(email,),
|
||||
).fetchone()[0]
|
||||
if last is not None:
|
||||
elapsed = now - last
|
||||
if elapsed < RESEND_COOLDOWN:
|
||||
retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1
|
||||
raise ResendCooldown(retry_after_s)
|
||||
|
||||
# Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one.
|
||||
conn.execute(
|
||||
"DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)",
|
||||
(email, now),
|
||||
)
|
||||
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS))
|
||||
conn.execute(
|
||||
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
|
||||
(email, _hash_code(code), now + CODE_TTL),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
mailer.send(
|
||||
to=email,
|
||||
subject=f"Your ecomm code: {code}",
|
||||
body=(
|
||||
f"Your ecomm one-time code is {code}.\n"
|
||||
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
|
||||
"If you didn't request this, ignore this message."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
|
||||
"""Verify a code and resolve the account. Returns (account, created).
|
||||
|
||||
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
|
||||
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
|
||||
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
|
||||
by normalized email (INV-2). Account creation IS email verification (the address
|
||||
provably received the code).
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
|
||||
"WHERE email = %s AND consumed_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.commit()
|
||||
raise CodeExpired(email) # nothing live to verify — offer a fresh code
|
||||
code_id, code_hash, expires_at, attempts = row
|
||||
|
||||
if expires_at < now:
|
||||
conn.commit()
|
||||
raise CodeExpired(email)
|
||||
|
||||
if not hmac.compare_digest(code_hash, _hash_code(code)):
|
||||
attempts += 1
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
conn.execute(
|
||||
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
|
||||
(attempts, now, code_id),
|
||||
)
|
||||
conn.commit()
|
||||
raise CodeExhausted(email)
|
||||
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
|
||||
conn.commit()
|
||||
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
|
||||
|
||||
# Correct: consume the code and get-or-create the account in the same transaction.
|
||||
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
|
||||
account, created = _get_or_create_account(conn, email)
|
||||
conn.commit()
|
||||
return account, created
|
||||
|
||||
|
||||
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
|
||||
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
|
||||
inserted = conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
return Account(id=inserted[0], email=email), True
|
||||
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
|
||||
return Account(id=existing[0], email=email), False
|
||||
|
||||
|
||||
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
|
||||
"""Load an account by id (for the signed-in `/me`)."""
|
||||
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
|
||||
return Account(id=row[0], email=row[1]) if row else None
|
||||
+131
-6
@@ -1,22 +1,85 @@
|
||||
"""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. storefront is hard-wired null this slice; the
|
||||
storefronts domain (SLICE-3) replaces the _storefront_for seam.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import config, db
|
||||
from app.platform.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
|
||||
|
||||
|
||||
class RequestCodeBody(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class VerifyBody(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
||||
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
||||
|
||||
|
||||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||||
"""The entry-routing answer: which storefront, if any, this account has (§6.5).
|
||||
|
||||
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
|
||||
domain call; the verify/me responses already carry the field.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_app_logging() -> None:
|
||||
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
|
||||
|
||||
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
|
||||
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
|
||||
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
|
||||
out of uvicorn's stream and avoids double-logging.
|
||||
"""
|
||||
lg = logging.getLogger("ecomm")
|
||||
if not lg.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
|
||||
lg.addHandler(handler)
|
||||
lg.setLevel(logging.INFO)
|
||||
lg.propagate = False
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, account: accounts.Account) -> None:
|
||||
token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret())
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.cookie_secure(),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def create_app(database_url: str | None = None) -> FastAPI:
|
||||
_ensure_app_logging()
|
||||
dsn = database_url or config.database_url()
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,12 +87,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="0.2", lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||
@@ -45,6 +109,67 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
return {"status": "unavailable", "reason": "migrations_pending"}
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/api/auth/request-code")
|
||||
def request_code(
|
||||
body: RequestCodeBody,
|
||||
conn: psycopg.Connection = Depends(get_conn),
|
||||
mailer: Mailer = Depends(get_mailer),
|
||||
):
|
||||
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
|
||||
try:
|
||||
accounts.request_code(conn, mailer, body.email)
|
||||
except accounts.InvalidEmail:
|
||||
return _error(400, "invalid_email", "That doesn't look like an email address.")
|
||||
except accounts.ResendCooldown as exc:
|
||||
return _error(
|
||||
429, "resend_cooldown",
|
||||
f"Please wait {exc.retry_after_s}s before requesting another code.",
|
||||
retry_after_s=exc.retry_after_s,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.post("/api/auth/verify")
|
||||
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
|
||||
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
|
||||
try:
|
||||
account, created = accounts.verify(conn, body.email, body.code)
|
||||
except accounts.InvalidEmail:
|
||||
return _error(400, "invalid_email", "That doesn't look like an email address.")
|
||||
except accounts.CodeMismatch as exc:
|
||||
return _error(
|
||||
400, "code_mismatch", "That code didn't match.",
|
||||
attempts_remaining=exc.attempts_remaining,
|
||||
)
|
||||
except accounts.CodeExpired:
|
||||
return _error(400, "code_expired", "That code expired — request a fresh one.")
|
||||
except accounts.CodeExhausted:
|
||||
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
|
||||
payload = {
|
||||
"account": {"email": account.email},
|
||||
"storefront": _storefront_for(conn, account),
|
||||
"created": created,
|
||||
}
|
||||
resp = JSONResponse(status_code=200, content=payload)
|
||||
_set_session_cookie(resp, account)
|
||||
return resp
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
|
||||
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
|
||||
if sess is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
account = accounts.get_account(conn, sess["account_id"])
|
||||
if account is None:
|
||||
return _error(401, "unauthenticated", "You are not signed in.")
|
||||
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout():
|
||||
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
|
||||
resp = Response(status_code=204)
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -17,3 +17,24 @@ _DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||
def database_url() -> str:
|
||||
"""The psycopg DSN for the application database."""
|
||||
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
|
||||
|
||||
|
||||
# Session signing secret. Dev default is intentionally well-known and insecure — a clean
|
||||
# checkout works with no setup; deployed environments MUST supply ECOMM_SESSION_SECRET from
|
||||
# Secret Manager (INV-8, §6.6). Rotating it invalidates all sessions at once (§6.2).
|
||||
_DEFAULT_SESSION_SECRET = "dev-insecure-session-secret-change-me"
|
||||
|
||||
|
||||
def session_secret() -> str:
|
||||
"""The HMAC key for signed session cookies (and the one-time-code pepper)."""
|
||||
return os.environ.get("ECOMM_SESSION_SECRET") or _DEFAULT_SESSION_SECRET
|
||||
|
||||
|
||||
def cookie_secure() -> bool:
|
||||
"""Whether to mark the session cookie Secure (HTTPS-only). True in deployed envs."""
|
||||
return os.environ.get("ECOMM_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def mailer_kind() -> str:
|
||||
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed; SLICE-4)."""
|
||||
return os.environ.get("ECOMM_MAILER") or "log"
|
||||
|
||||
@@ -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,50 @@
|
||||
"""platform/mailer — the outbound email port (SD-0001 §6.2).
|
||||
|
||||
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
|
||||
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
|
||||
deployed `SmtpMailer` (SLICE-4). The adapter is chosen by configuration at startup
|
||||
(INV-8), so no deployment shape lives in the domain. LogMailer logs the full body on
|
||||
purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in
|
||||
the terminal. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
logger = logging.getLogger("ecomm.mailer")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentMessage:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Mailer(Protocol):
|
||||
"""The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502)."""
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None: ...
|
||||
|
||||
|
||||
class LogMailer:
|
||||
"""Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbox: list[SentMessage] = []
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
self.outbox.append(SentMessage(to=to, subject=subject, body=body))
|
||||
# Full body incl. the code — the local dev channel. Dev/test only (never deployed).
|
||||
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
|
||||
|
||||
|
||||
def build_mailer(kind: str) -> Mailer:
|
||||
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
|
||||
if kind == "log":
|
||||
return LogMailer()
|
||||
if kind == "smtp":
|
||||
raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)")
|
||||
raise ValueError(f"unknown mailer kind: {kind!r}")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""platform/session — stateless signed session tokens (SD-0001 §6.2).
|
||||
|
||||
Sessions are signed HTTP-only cookies; there is no session table. A token is
|
||||
`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session
|
||||
secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or
|
||||
None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked
|
||||
secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64d(s: str) -> bytes:
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _mac(payload_b64: str, secret: str) -> str:
|
||||
digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return _b64e(digest)
|
||||
|
||||
|
||||
def sign(payload: dict[str, Any], secret: str) -> str:
|
||||
"""Serialize and sign a session payload into a cookie-safe token."""
|
||||
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
return f"{payload_b64}.{_mac(payload_b64, secret)}"
|
||||
|
||||
|
||||
def verify(token: str, secret: str) -> dict[str, Any] | None:
|
||||
"""Return the payload if the token's signature is valid, else None."""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload_b64, _, sig = token.partition(".")
|
||||
expected = _mac(payload_b64, secret)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
return json.loads(_b64d(payload_b64))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
@@ -0,0 +1,66 @@
|
||||
import re
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import db, mailer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
db.migrate(c)
|
||||
yield c
|
||||
|
||||
|
||||
def _code_in(outbox) -> str:
|
||||
# The 6-digit code appears in the latest message body (LogMailer dev channel).
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_14_01_0002_request_code_sends_a_six_digit_code(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "Merchant@Example.com")
|
||||
assert len(m.outbox) == 1
|
||||
assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2)
|
||||
assert re.search(r"\b\d{6}\b", m.outbox[-1].body)
|
||||
|
||||
|
||||
def test_14_01_0003_request_code_stores_only_a_hash(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
code = _code_in(m.outbox)
|
||||
rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall()
|
||||
assert len(rows) == 1
|
||||
# The plaintext code is never stored (INV-3).
|
||||
assert code not in rows[0][0]
|
||||
assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code
|
||||
|
||||
|
||||
def test_request_code_uniform_for_new_and_known(conn):
|
||||
# No account enumeration (§6.6): the call behaves identically whether or not the email
|
||||
# already has an account — it never signals existence and never raises.
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "newcomer@example.com") # no account exists
|
||||
# create an account, then request again for a known email
|
||||
conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",))
|
||||
conn.commit()
|
||||
accounts.request_code(conn, m, "known@example.com")
|
||||
assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known
|
||||
|
||||
|
||||
def test_puc_02c_resend_is_rate_limited(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
with pytest.raises(accounts.ResendCooldown) as exc:
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
assert 0 < exc.value.retry_after_s <= 60
|
||||
assert len(m.outbox) == 1 # the second code was NOT sent
|
||||
|
||||
|
||||
def test_request_code_rejects_invalid_email(conn):
|
||||
m = mailer.LogMailer()
|
||||
with pytest.raises(accounts.InvalidEmail):
|
||||
accounts.request_code(conn, m, "not-an-email")
|
||||
assert m.outbox == []
|
||||
@@ -0,0 +1,100 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import db, mailer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
db.migrate(c)
|
||||
yield c
|
||||
|
||||
|
||||
def _issue(conn, email) -> str:
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, email)
|
||||
return re.search(r"\b(\d{6})\b", m.outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_14_01_0004_verify_creates_account_and_reports_created(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
account, created = accounts.verify(conn, "merchant@example.com", code)
|
||||
assert created is True
|
||||
assert account.email == "merchant@example.com"
|
||||
assert account.id > 0
|
||||
# the account row exists exactly once (INV-2)
|
||||
n = conn.execute("SELECT count(*) FROM account WHERE email = %s", ("merchant@example.com",)).fetchone()[0]
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_14_01_0009_returning_account_reports_not_created(conn):
|
||||
# First sign-up creates the account.
|
||||
code1 = _issue(conn, "merchant@example.com")
|
||||
a1, created1 = accounts.verify(conn, "merchant@example.com", code1)
|
||||
assert created1 is True
|
||||
# A later login resolves to the SAME account (INV-2 — email is canonical), created=False.
|
||||
code2 = _issue(conn, "Merchant@Example.com") # different casing, same identity
|
||||
a2, created2 = accounts.verify(conn, "Merchant@Example.com", code2)
|
||||
assert created2 is False
|
||||
assert a2.id == a1.id
|
||||
n = conn.execute("SELECT count(*) FROM account").fetchone()[0]
|
||||
assert n == 1 # no duplicate account (14.01.0010)
|
||||
|
||||
|
||||
def test_verify_consumes_the_code_single_use(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
# Reusing a consumed code is refused (single-use, INV-3): no live code -> CodeExpired.
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_puc_02a_wrong_code_decrements_attempts(conn):
|
||||
_issue(conn, "merchant@example.com")
|
||||
with pytest.raises(accounts.CodeMismatch) as exc:
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
# 5 attempts allowed; one wrong -> 4 remaining (INV-3).
|
||||
assert exc.value.attempts_remaining == 4
|
||||
|
||||
|
||||
def test_puc_02b_expired_code_is_refused(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
# Force the live code to be expired.
|
||||
conn.execute(
|
||||
"UPDATE auth_code SET expires_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=1), "merchant@example.com"),
|
||||
)
|
||||
conn.commit()
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_inv3_attempts_exhausted_invalidates_code(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
# Five wrong attempts: the 5th raises CodeExhausted and invalidates the code.
|
||||
for _ in range(4):
|
||||
with pytest.raises(accounts.CodeMismatch):
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
with pytest.raises(accounts.CodeExhausted):
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
# Even the correct code no longer works — a fresh request is required.
|
||||
with pytest.raises((accounts.CodeExpired, accounts.CodeExhausted)):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_verify_with_no_request_is_code_expired(conn):
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "stranger@example.com", "123456")
|
||||
|
||||
|
||||
def test_get_account_loads_by_id(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
account, _ = accounts.verify(conn, "merchant@example.com", code)
|
||||
loaded = accounts.get_account(conn, account.id)
|
||||
assert loaded is not None and loaded.email == "merchant@example.com"
|
||||
assert accounts.get_account(conn, 999999) is None
|
||||
@@ -0,0 +1,130 @@
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _client(fresh_db_url) -> TestClient:
|
||||
# A LogMailer on app.state lets the test read back the issued code (§6.8).
|
||||
return TestClient(create_app(database_url=fresh_db_url))
|
||||
|
||||
|
||||
def _last_code(client) -> str:
|
||||
outbox = client.app.state.mailer.outbox
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_request_code_returns_204(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_request_code_invalid_email_is_400(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "nope"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "invalid_email"
|
||||
|
||||
|
||||
def test_request_code_uniform_204_no_enumeration(fresh_db_url):
|
||||
# New and known emails both return 204 with no distinguishing body (§6.6).
|
||||
with _client(fresh_db_url) as client:
|
||||
r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
# sign the newcomer up so the email becomes "known"
|
||||
code = _last_code(client)
|
||||
client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
assert r_new.status_code == r_known.status_code == 204
|
||||
assert r_new.content == r_known.content == b""
|
||||
|
||||
|
||||
def test_resend_cooldown_is_429_with_retry_after(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 429
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "resend_cooldown"
|
||||
assert body["retry_after_s"] > 0
|
||||
|
||||
|
||||
def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
code = _last_code(client)
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
|
||||
assert "ecomm_session" in resp.cookies
|
||||
|
||||
|
||||
def test_verify_returning_account_created_false(fresh_db_url):
|
||||
# BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The
|
||||
# second request-code would hit the 60s cooldown, so backdate the consumed code's
|
||||
# created_at to satisfy it (rather than sleeping 60s in a test).
|
||||
import psycopg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r1.json()["created"] is True
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), "m@example.com"),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["created"] is False
|
||||
assert r2.json()["account"] == {"email": "m@example.com"}
|
||||
|
||||
|
||||
def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "code_mismatch"
|
||||
assert body["attempts_remaining"] == 4
|
||||
|
||||
|
||||
def test_verify_no_code_is_400_code_expired(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "code_expired"
|
||||
|
||||
|
||||
def test_me_requires_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
|
||||
|
||||
def test_me_returns_account_with_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
|
||||
|
||||
|
||||
def test_puc_09_logout_clears_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
logout = client.post("/api/auth/logout")
|
||||
assert logout.status_code == 204
|
||||
# after logout the cookie is cleared -> /me is unauthenticated again
|
||||
client.cookies.clear()
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.platform import mailer
|
||||
|
||||
|
||||
def test_logmailer_captures_to_outbox():
|
||||
m = mailer.LogMailer()
|
||||
assert m.outbox == []
|
||||
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)")
|
||||
assert len(m.outbox) == 1
|
||||
msg = m.outbox[-1]
|
||||
assert msg.to == "merchant@example.com"
|
||||
assert msg.subject == "Your ecomm code: 123456"
|
||||
assert "123456" in msg.body
|
||||
|
||||
|
||||
def test_build_mailer_log_kind():
|
||||
m = mailer.build_mailer("log")
|
||||
assert isinstance(m, mailer.LogMailer)
|
||||
|
||||
|
||||
def test_build_mailer_smtp_not_yet_available():
|
||||
# SmtpMailer lands in SLICE-4; until then asking for it is an explicit error, not a
|
||||
# silent fallback to LogMailer (which would send no real mail in a deployed env).
|
||||
import pytest
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
mailer.build_mailer("smtp")
|
||||
@@ -0,0 +1,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
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+406
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.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.2.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"
|
||||
}
|
||||
}
|
||||
|
||||
+60
-6
@@ -1,10 +1,64 @@
|
||||
// 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. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a
|
||||
// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3.
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe, type VerifyResult } from "./api";
|
||||
import { routeFor, type SessionState } from "./routing";
|
||||
import Landing from "./screens/Landing";
|
||||
import SignIn from "./screens/SignIn";
|
||||
import SignedIn from "./screens/SignedIn";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [justCreated, setJustCreated] = useState(false);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) return <main>Loading…</main>;
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => {
|
||||
setDoor(null);
|
||||
}}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setJustCreated(result.created);
|
||||
setDoor(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
// route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the
|
||||
// signed-in placeholder so sign-out (PUC-9) is reachable.
|
||||
return (
|
||||
<main>
|
||||
<h1>ecomm</h1>
|
||||
<p>Honest commerce. Your storefront is yours.</p>
|
||||
</main>
|
||||
<SignedIn
|
||||
email={session!.account!.email}
|
||||
created={justCreated}
|
||||
onSignedOut={() => {
|
||||
setJustCreated(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
|
||||
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
|
||||
import type { SessionState } from "./routing";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
retry_after_s?: number;
|
||||
attempts_remaining?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
account: { email: string };
|
||||
storefront: { id: number; name: string } | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return { code: "unexpected", message: "Something went wrong. Please try again." };
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<SessionState | null> {
|
||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (resp.status === 401) return null;
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as SessionState;
|
||||
}
|
||||
|
||||
export async function requestCode(email: string): Promise<ApiError | null> {
|
||||
const resp = await fetch("/api/auth/request-code", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return resp.ok ? null : await errorOf(resp);
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeFor } from "./routing";
|
||||
|
||||
describe("entry routing (SD-0001 §6.5)", () => {
|
||||
it("no session -> landing", () => {
|
||||
expect(routeFor({ account: null, storefront: null })).toBe("landing");
|
||||
});
|
||||
|
||||
it("session without storefront -> create-storefront", () => {
|
||||
expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
|
||||
"create-storefront",
|
||||
);
|
||||
});
|
||||
|
||||
it("session with storefront -> admin", () => {
|
||||
expect(
|
||||
routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
|
||||
).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
|
||||
// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
|
||||
// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
|
||||
// makes "admin" reachable.
|
||||
export interface SessionState {
|
||||
account: { email: string } | null;
|
||||
storefront: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export type Screen = "landing" | "create-storefront" | "admin";
|
||||
|
||||
export function routeFor(s: SessionState): Screen {
|
||||
if (!s.account) return "landing";
|
||||
if (!s.storefront) return "create-storefront";
|
||||
return "admin";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
|
||||
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
|
||||
// they differ only in framing.
|
||||
interface Props {
|
||||
onGetStarted: () => void;
|
||||
onLogIn: () => void;
|
||||
}
|
||||
|
||||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<button type="button" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
</header>
|
||||
<section>
|
||||
<h1>Honest commerce. Your storefront is yours.</h1>
|
||||
<p>Sell online on a platform that treats you straight — no dark patterns, no lock-in.</p>
|
||||
<button type="button" onClick={onGetStarted}>
|
||||
Create your storefront
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
|
||||
// requests a code; step 2 verifies it. Honest copy states which door framing applies and,
|
||||
// after verify, whether the account was created or resumed (PUC-3). storefront routing is
|
||||
// handled by App on success.
|
||||
import { useState } from "react";
|
||||
import { requestCode, verifyCode, type VerifyResult } from "../api";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
interface Props {
|
||||
door: Door;
|
||||
onAuthed: (result: VerifyResult) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function SignIn({ door, onAuthed, onBack }: Props) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const heading = door === "signup" ? "Create your storefront" : "Log in";
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await requestCode(email);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
setStep("code");
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await verifyCode(email, code);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
onAuthed(res.result);
|
||||
}
|
||||
|
||||
if (step === "email") {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<button type="button" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</header>
|
||||
<form onSubmit={submitEmail}>
|
||||
<h1>{heading}</h1>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
autoFocus
|
||||
required
|
||||
onChange={(ev) => setEmail(ev.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p>We'll email you a one-time code. That's all we need.</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "Sending…" : "Send code"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<button type="button" onClick={() => setStep("email")}>
|
||||
Wrong address?
|
||||
</button>
|
||||
</header>
|
||||
<form onSubmit={submitCode}>
|
||||
<h1>Enter your code</h1>
|
||||
<p>We sent a code to {email}.</p>
|
||||
<label>
|
||||
Code
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
autoFocus
|
||||
required
|
||||
onChange={(ev) => setCode(ev.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "Checking…" : "Continue"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in
|
||||
// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No
|
||||
// fabricated capability (INV-9).
|
||||
import { logout } from "../api";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
created: boolean;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function SignedIn({ email, created, onSignedOut }: Props) {
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<span>{email}</span>
|
||||
<button type="button" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
<section>
|
||||
<h1>{created ? "Welcome to ecomm" : "Welcome back"}</h1>
|
||||
<p>You're signed in as {email}.</p>
|
||||
<p>Creating your storefront arrives in the next slice. Nothing else is needed yet.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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