Compare commits

..

13 Commits

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

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

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

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:21:23 -07:00
ben.stull 11acb3a5b1 Merge pull request 'docs: land session 0023 plan doc (stray untracked file)' (#18) from docs-archive-0023-plan into main
ci / check (push) Has been cancelled
2026-06-11 07:56:34 +00:00
ben.stull efc9c8edb9 docs: land session 0023's ui-designs-collection plan in-repo (was untracked; content archive already had it)
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:56:31 -07:00
ben.stull f7b76d5370 Merge pull request 'SLICE-4: record the first PPE bootstrap rehearsal (PUC-11) — green' (#17) from slice-4-rehearsal-record into main
ci / check (push) Has been cancelled
2026-06-11 07:54:50 +00:00
ben.stull 4bb9763633 docs(slice-4): record the first PPE bootstrap rehearsal (PUC-11, 2026-06-11) — green
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:54:47 -07:00
ben.stull a456d51f17 Merge pull request 'SLICE-4: deployment.toml — the ecomm PPE record' (#11) from slice-4-deployment-record into main
ci / check (push) Has been cancelled
2026-06-11 06:45:08 +00:00
ben.stull ac3c4ffe36 feat(slice-4): deployment.toml — the ecomm PPE record (launch-app §5.1; Cloud SQL via secret ref)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:45:05 -07:00
ben.stull 8a2ec24d86 Merge pull request 'SLICE-4 (part 1): deploy contract — versioned health, backend-served SPA, SmtpMailer + honest 502 (SD-0001 §7.2)' (#10) from slice-4-deploy-contract into main
ci / check (push) Has been cancelled
2026-06-11 06:36:26 +00:00
ben.stull acff7ac588 docs(slice-4): BOOTSTRAP.md PPE runbook, version 0.4.0, README status
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:36:24 -07:00
ben.stull 22d738fc74 feat(slice-4): honest delivery failure — send-before-commit + 502 delivery_failed (INV-9; closes #7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:35:01 -07:00
ben.stull 1b8c60fcb5 feat(slice-4): SmtpMailer — real mail over a configured relay, log-hygienic (INV-8, §6.6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:33:40 -07:00
ben.stull 9f4295b77e feat(slice-4): backend serves the built SPA — the deployed nginx-proxies-all topology (launch-app §2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:32:22 -07:00
ben.stull a1c5544694 feat(slice-4): VERSION file + versioned /healthz — the flotilla verify contract (§8.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:20 -07:00
23 changed files with 659 additions and 38 deletions
+4 -2
View File
@@ -27,5 +27,7 @@ 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. Deployed environments
(SLICE-4) are next.
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. SLICE-4's deploy
contract is in place (versioned `/healthz`, backend-served SPA, `SmtpMailer` with
honest 502 on delivery failure); PPE provisioning + the bootstrap rehearsal are the
remaining SLICE-4 steps (BOOTSTRAP.md's PPE section is the runbook).
+1
View File
@@ -0,0 +1 @@
0.4.0
+2
View File
@@ -11,6 +11,7 @@ from .errors import (
CodeExhausted,
CodeExpired,
CodeMismatch,
DeliveryFailed,
InvalidEmail,
ResendCooldown,
)
@@ -25,6 +26,7 @@ __all__ = [
"CodeMismatch",
"CodeExpired",
"CodeExhausted",
"DeliveryFailed",
"request_code",
"verify",
"get_account",
+4
View File
@@ -36,3 +36,7 @@ class CodeExpired(AccountsError):
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)."""
+16 -3
View File
@@ -18,9 +18,16 @@ from datetime import datetime, timedelta, timezone
import psycopg
from app.platform import config
from app.platform.mailer import Mailer
from app.platform.mailer import Mailer, MailerError
from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown
from .errors import (
CodeExhausted,
CodeExpired,
CodeMismatch,
DeliveryFailed,
InvalidEmail,
ResendCooldown,
)
from .models import Account
# INV-3 constants — the one-time-code policy.
@@ -82,8 +89,10 @@ def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
(email, _hash_code(code), now + CODE_TTL),
)
conn.commit()
# 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}",
@@ -93,6 +102,10 @@ def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
"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]:
+26 -3
View File
@@ -11,11 +11,13 @@ 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
@@ -26,6 +28,15 @@ from app.platform.mailer import Mailer
from app.platform import session as session_mod
# The repo-root VERSION file is the single version source: the deploy pin checks out its
# tag, and /healthz must report it back (flotilla-core's verify gate compares them).
_REPO_ROOT = Path(__file__).resolve().parents[2]
try:
_APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip()
except OSError:
_APP_VERSION = "0.0.0"
class RequestCodeBody(BaseModel):
email: str
@@ -79,7 +90,7 @@ def _set_session_cookie(response: Response, account: accounts.Account) -> None:
)
def create_app(database_url: str | None = None) -> FastAPI:
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()
@@ -94,7 +105,7 @@ def create_app(database_url: str | None = None) -> FastAPI:
finally:
app.state.pool.close()
app = FastAPI(title="ecomm", version="0.3", lifespan=lifespan)
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
@@ -108,7 +119,7 @@ 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(
@@ -127,6 +138,11 @@ def create_app(database_url: str | None = None) -> FastAPI:
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")
@@ -192,6 +208,13 @@ def create_app(database_url: str | None = None) -> FastAPI:
)
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
+31 -1
View File
@@ -36,5 +36,35 @@ def cookie_secure() -> bool:
def mailer_kind() -> str:
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed; SLICE-4)."""
"""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"}
+53 -5
View File
@@ -2,20 +2,30 @@
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).
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
@@ -41,10 +51,48 @@ class LogMailer:
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":
raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)")
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}")
@@ -64,3 +64,28 @@ def test_request_code_rejects_invalid_email(conn):
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
+16
View File
@@ -128,3 +128,19 @@ def test_puc_09_logout_clears_session(fresh_db_url):
# 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"
+1 -1
View File
@@ -12,7 +12,7 @@ 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"}
assert client.get("/healthz").json()["status"] == "ok"
# PUC-2: first visitor requests a code; it reaches them via the dev channel
assert client.post(
+7 -1
View File
@@ -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):
+90 -5
View File
@@ -17,10 +17,95 @@ def test_build_mailer_log_kind():
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).
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
with pytest.raises(NotImplementedError):
mailer.build_mailer("smtp")
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)
+24
View File
@@ -0,0 +1,24 @@
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
serves the frontend and the default dist dir simply doesn't exist."""
from fastapi.testclient import TestClient
from app.main import create_app
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
root = client.get("/")
assert root.status_code == 200
assert "ecomm spa" in root.text
# API + health still win over the mount
assert client.get("/healthz").json()["status"] == "ok"
assert client.get("/api/auth/me").status_code == 401
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
assert client.get("/").status_code == 404
assert client.get("/healthz").json()["status"] == "ok"
+42
View File
@@ -0,0 +1,42 @@
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
[app]
name = "ecomm"
repo = "wiggleverse/wiggleverse-ecomm"
gitea_host = "https://git.wiggleverse.org"
version_source = { kind = "file", path = "VERSION" }
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
[vm]
name = "ecomm-ppe"
zone = "us-central1-a"
project = "wiggleverse-ecomm"
machine_type = "e2-micro"
disk_gb = 10
service_user = "ecomm"
install_dir = "/opt/ecomm"
systemd_unit = "ecomm.service"
tunnel_through_iap = true
gcloud_config = "wiggleverse-ecomm"
[edge]
domain = "ecomm-ppe.wiggleverse.org"
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
[overlay] # non-secret env, plaintext (guide §8)
APP_URL = "https://ecomm-ppe.wiggleverse.org"
ECOMM_MAILER = "smtp"
ECOMM_COOKIE_SECURE = "1"
ECOMM_SMTP_HOST = "smtp.gmail.com"
ECOMM_SMTP_PORT = "587"
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
[secrets] # REFERENCES only — never bytes (§8.3)
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
+79 -3
View File
@@ -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.
@@ -0,0 +1,55 @@
# SLICE-4 (part 1) — Deploy-Contract Code Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError``accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
---
### Task 1: VERSION + versioned /healthz
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
- [ ] Write `VERSION` containing `0.4.0`.
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
### Task 2: backend serves the SPA (deploy phase-8 contract)
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
### Task 3: SmtpMailer + config surface (INV-8)
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
- [ ] README status: deploy contract in place; frontend package 0.4.0.
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
## Self-review
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
@@ -0,0 +1,118 @@
# ui/designs Content-Repo Collection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
---
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
**Files:**
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 1014)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
```
- [ ] **Step 2: Create the collection README**
`ui/designs/README.md`:
```markdown
# ui/designs — UI-design artifacts
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
app's UI-design artifacts — primarily Claude Design outputs generated from a
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
A Solution Design with a UX-involving slice references its design artifact here
by path. The spec-linkage gate and the backfill gesture for adding that
reference once a design exists are tracked in
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
Suggested layout: one subfolder per design, named for the spec/slice it serves,
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
```
- [ ] **Step 3: Add the layout-table row**
In the top-level `README.md`, extend the table:
```markdown
| Path | Holds |
| --- | --- |
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
| `plans/` | archived implementation plans |
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
```
- [ ] **Step 4: Commit, push, PR, merge**
```bash
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
```
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
### Task 2: Standardize centrally in engineering schemas docs
**Files:**
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
```
- [ ] **Step 2: Schema description strings + enum**
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]``["1.0", "1.1", "1.2", "1.3"]`
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
- [ ] **Step 3: schemas/README.md**
Changelog entry above 1.2:
```markdown
- **1.3** — docs-only: the content-repo collection convention gains a third
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
outputs generated from a Solution Design), referenced from specs. Like
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
validation change (the spec-linkage gate/backfill tooling is
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
```
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
```bash
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
git -C …/engineering add schemas/app.schema.json schemas/README.md
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
git -C …/engineering push -u origin ui-designs-collection
```
PR + merge (default token for `/pulls`).
### Task 3: Cross-link and close out
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wiggleverse-ecomm-frontend",
"version": "0.2.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiggleverse-ecomm-frontend",
"version": "0.2.0",
"version": "0.4.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { cooldownAppliesTo } from "./cooldown";
describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
it("no cooldown running -> send not blocked", () => {
expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
});
it("cooldown running for the same address -> blocked", () => {
expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
});
it("same address modulo case/whitespace -> still blocked", () => {
expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
});
it("cooldown running but the input is a different address -> not blocked", () => {
expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
});
it("cooldown expired -> not blocked even for the same address", () => {
expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
// per-address, so the client countdown must be too: it blocks sending only while it
// is running AND the current input is the address it was set for. Normalization
// mirrors the backend's normalize_email (lowercase + strip, INV-2).
function normalize(email: string): string {
return email.trim().toLowerCase();
}
export function cooldownAppliesTo(
input: string,
cooldownEmail: string | null,
secondsLeft: number,
): boolean {
if (secondsLeft <= 0 || cooldownEmail === null) return false;
return normalize(input) === normalize(cooldownEmail);
}
+12 -2
View File
@@ -3,6 +3,7 @@
// 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";
@@ -34,6 +35,9 @@ export default function SignIn({ door, onAuthed, onBack }: Props) {
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);
@@ -53,9 +57,11 @@ export default function SignIn({ door, onAuthed, onBack }: Props) {
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;
}
@@ -131,8 +137,12 @@ export default function SignIn({ door, onAuthed, onBack }: Props) {
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldown > 0}>
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
<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.