SLICE-4 (part 1): deploy contract — versioned health, backend-served SPA, SmtpMailer + honest 502 (SD-0001 §7.2) #10
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
+67
-3
@@ -4,8 +4,9 @@ Bringing an environment from **empty persistence** to "first merchant, first
|
||||
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
|
||||
(INV-1): the app applies its own schema migrations at startup; there is no seed step.
|
||||
|
||||
This document grows per environment. SLICE-1 ships the **localhost** section; the
|
||||
pre-production and production sections land with SLICE-4.
|
||||
This document grows per environment. SLICE-1 shipped the **localhost** section;
|
||||
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
|
||||
stand-up.
|
||||
|
||||
## Localhost
|
||||
|
||||
@@ -61,4 +62,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
|
||||
`<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.
|
||||
|
||||
## 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.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user