diff --git a/README.md b/README.md index 1d24904..96feb82 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..1d0ba9e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.0 diff --git a/backend/app/domains/accounts/__init__.py b/backend/app/domains/accounts/__init__.py index 986d2a4..5987594 100644 --- a/backend/app/domains/accounts/__init__.py +++ b/backend/app/domains/accounts/__init__.py @@ -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", diff --git a/backend/app/domains/accounts/errors.py b/backend/app/domains/accounts/errors.py index 68ca901..5d68742 100644 --- a/backend/app/domains/accounts/errors.py +++ b/backend/app/domains/accounts/errors.py @@ -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).""" diff --git a/backend/app/domains/accounts/service.py b/backend/app/domains/accounts/service.py index b6e5874..e9bc953 100644 --- a/backend/app/domains/accounts/service.py +++ b/backend/app/domains/accounts/service.py @@ -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,17 +89,23 @@ 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() - 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." - ), - ) + # Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan + # code blocks the 60s cooldown and the caller can honestly retry at once (INV-9). + try: + mailer.send( + to=email, + subject=f"Your ecomm code: {code}", + body=( + f"Your ecomm one-time code is {code}.\n" + f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n" + "If you didn't request this, ignore this message." + ), + ) + except MailerError as exc: + conn.rollback() + raise DeliveryFailed(str(exc)) from exc + conn.commit() def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]: diff --git a/backend/app/main.py b/backend/app/main.py index 84a43aa..c2c0c69 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/platform/config.py b/backend/app/platform/config.py index 70eae74..70251e7 100644 --- a/backend/app/platform/config.py +++ b/backend/app/platform/config.py @@ -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"} diff --git a/backend/app/platform/mailer.py b/backend/app/platform/mailer.py index f38eb66..92dae36 100644 --- a/backend/app/platform/mailer.py +++ b/backend/app/platform/mailer.py @@ -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}") diff --git a/backend/tests/test_accounts_request_code.py b/backend/tests/test_accounts_request_code.py index e7ac722..34a1b9d 100644 --- a/backend/tests/test_accounts_request_code.py +++ b/backend/tests/test_accounts_request_code.py @@ -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 diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index 1766fd5..aaea47c 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -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" diff --git a/backend/tests/test_bootstrap.py b/backend/tests/test_bootstrap.py index 19ffac6..ec1db53 100644 --- a/backend/tests/test_bootstrap.py +++ b/backend/tests/test_bootstrap.py @@ -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( diff --git a/backend/tests/test_healthz.py b/backend/tests/test_healthz.py index 0dc58ba..c0a8871 100644 --- a/backend/tests/test_healthz.py +++ b/backend/tests/test_healthz.py @@ -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): diff --git a/backend/tests/test_mailer.py b/backend/tests/test_mailer.py index 961bd4e..7eee087 100644 --- a/backend/tests/test_mailer.py +++ b/backend/tests/test_mailer.py @@ -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 ") + + +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 " + 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) diff --git a/backend/tests/test_static_spa.py b/backend/tests/test_static_spa.py new file mode 100644 index 0000000..e07a643 --- /dev/null +++ b/backend/tests/test_static_spa.py @@ -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("ecomm spa") + 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" diff --git a/docs/BOOTSTRAP.md b/docs/BOOTSTRAP.md index 0d0c503..cd0d7c7 100644 --- a/docs/BOOTSTRAP.md +++ b/docs/BOOTSTRAP.md @@ -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,67 @@ 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 + `/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` 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 == `. 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 | `/ecomm-ppe-database-url` (from provision-datastore) | +| `ECOMM_SESSION_SECRET` | secret ref | `/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. + +## Production + +Lands with the prod stand-up — the **identical gesture** on a prod deployment record +(BUC-5a): same provisioning skills, same deploy, same rehearsal. diff --git a/docs/superpowers/plans/2026-06-10-slice-4-deploy-contract.md b/docs/superpowers/plans/2026-06-10-slice-4-deploy-contract.md new file mode 100644 index 0000000..1f376ec --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-slice-4-deploy-contract.md @@ -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` 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=` — §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= flotilla-core deploy `), 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. diff --git a/frontend/package.json b/frontend/package.json index 11eda0d..4103db0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "wiggleverse-ecomm-frontend", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite",