Working /contact POST flow: honeypot → hCaptcha server-verify → field validation → SlowAPI 3/hr IP rate limit → contact_submissions row → best-effort Resend notification (Reply-To = submitter) → generic success page. Spam paths don't persist and render the same success page (anti-enumeration). Send failures don't break the request path — the row is already durable. New services: HCaptchaService (async httpx + dev fallback), ContactService. EmailService gains send_contact_notification. Production config validator now requires ADMIN_CONTACT_EMAIL, HCAPTCHA_SECRET, HCAPTCHA_SITE_KEY. 23 new tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
526 lines
43 KiB
Markdown
526 lines
43 KiB
Markdown
# Chicken Babies R Us — Roadmap
|
||
|
||
High-level phased plan. Each phase ends in a mergeable `dev` state and a passing manual test. Claude implements phase-by-phase. This document intentionally avoids application code; the only code here is **data model (dataclasses)** and **SQL schema**, which are authoritative.
|
||
|
||
---
|
||
|
||
## Phase 0 — Foundation ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Scaffolded the FastAPI skeleton — package layout, pinned deps, multi-stage Dockerfile, compose file, typed config loader, structlog init, and a `/healthz` liveness endpoint surfacing app version + git commit SHA.
|
||
|
||
**Key files:**
|
||
- `app/__init__.py` — package `__version__ = "0.1.0"`
|
||
- `app/main.py` — `create_app()` factory + module-level `app`; configures logging then mounts routers
|
||
- `app/config.py` — `Settings(BaseSettings)` with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinel `SECRET_KEY` in production; `get_settings()` is `lru_cache`-d
|
||
- `app/logging_config.py` — `configure_logging(app_env)`; `ConsoleRenderer` in dev, `JSONRenderer` otherwise
|
||
- `app/routes/health.py` — `APIRouter` mounting `GET /healthz`, typed `HealthResponse` pydantic model
|
||
- `app/models/`, `app/services/`, `app/templates/`, `app/static/` — placeholder dirs with `.gitkeep`
|
||
- `tests/test_healthz.py` — FastAPI `TestClient` smoke test
|
||
- `requirements.txt` — 17 pinned packages, `>=X,<next-major` ranges
|
||
- `.env.example` — public env contract; `.env` stays gitignored
|
||
- `Dockerfile` — multi-stage (`builder` + `runtime`), `python:3.12-slim-bookworm`, `libmagic1` runtime; root user and no HEALTHCHECK (Phase 6 hardening)
|
||
- `docker-compose.yml` — `web` service with `env_file: .env`, `./data:/app/data` bind mount, `GIT_COMMIT_SHA` build arg
|
||
- `.gitignore` — adjusted `data/` rule to `data/*` so `!data/.gitkeep` works
|
||
|
||
**Endpoints created:**
|
||
- `GET /healthz` — public, unauthenticated. Returns minimal flat JSON `{"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}`.
|
||
|
||
**Key details:**
|
||
- **Healthz shape:** minimal flat JSON (not the full code_guidelines envelope). Future phases that add JSON APIs will use the envelope; the healthcheck stays small on purpose.
|
||
- **Version source:** `app.__version__` string constant. **Commit SHA source:** `GIT_COMMIT_SHA` env var, baked into the image via `ARG GIT_COMMIT_SHA=unknown` + `ENV GIT_COMMIT_SHA`. Surfaces as `"unknown"` when unset (dev).
|
||
- **Config loader:** single `Settings` class covers every ROADMAP env-var row. Fields not yet used at Phase 0 (`RESEND_*`, `HCAPTCHA_*`, `ADMIN_CONTACT_EMAIL`) are `Optional[str] = None`. `SECRET_KEY` defaults to the sentinel `"dev-insecure-change-me"`; a `@model_validator(mode="after")` refuses to boot if that sentinel survives into `APP_ENV=production`.
|
||
- **Admin emails:** stored as raw comma-separated string; `admin_emails_list` property returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth).
|
||
- **Logging:** `configure_logging` runs inside `create_app()` before any `structlog.get_logger` call; `app_started` structured event fires once at startup with `app_env`, `version`, `commit_sha` (no secrets).
|
||
- **Docker CMD:** uvicorn runs with `--proxy-headers --forwarded-allow-ips=127.0.0.1` (Phase 6 will swap the IP for Caddy's LAN address).
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓.
|
||
- **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed.
|
||
|
||
## Phase 1 — Public Site Skeleton ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Shipped the public brochure site: base Jinja layout with logo + nav + footer, mobile-first single-file CSS using the ROADMAP palette, and four public routes (`/`, `/about`, `/contact`, `/shop`). Blog index renders via a service stub returning `[]`; Phase 2 swaps the body for SQLite without touching the route.
|
||
|
||
**Key files:**
|
||
- `app/models/posts.py` — `PostSummary` `@dataclass(frozen=True)` with `slug/title/published_at/excerpt` — list-view projection used by the homepage; richer `Post` arrives in Phase 2 alongside.
|
||
- `app/services/posts.py` — `PostService.list_published(limit=20) -> list[PostSummary]` stub returning `[]`; `get_post_service()` DI helper (Phase 2 keeps the signature, swaps the body).
|
||
- `app/routes/public.py` — `APIRouter` with `GET /`, `/about`, `/contact`, `/shop`; pulls templates off `app.state.templates` via `get_templates()` DI helper.
|
||
- `app/templates/public/base.html` — layout: skip link, `<header>`/`<nav>`/`<main>`/`<footer>`, `aria-current` on active nav item, `<picture>` logo (WebP + PNG fallback), favicon/apple-touch-icon links, mobile nav toggle via plain `addEventListener` script.
|
||
- `app/templates/public/home.html` — blog index; loops `_post_card.html` or renders "No posts yet — check back soon!" on empty list.
|
||
- `app/templates/public/about.html` — static placeholder copy (Head Hen rewrites via Phase 4 admin).
|
||
- `app/templates/public/contact.html` — inert form: all inputs `disabled`, no `method="POST"`, `action=""`, shows `mailto:` link only if `settings.admin_contact_email` is truthy.
|
||
- `app/templates/public/shop.html` — "Coming soon" card teasing eggs / chicks / waterfowl.
|
||
- `app/templates/public/partials/_post_card.html` — single post-card partial.
|
||
- `app/static/css/site.css` — single stylesheet: reset, `:root` palette tokens (`--c-sky`, `--c-sky-deep`, `--c-cream`, `--c-wheat`, `--c-ink`, `--c-leaf`) + spacing/radius scale, system font stacks, components, one 48rem breakpoint.
|
||
- `app/static/img/logo.png` (573×256 RGBA), `logo.webp` (q=82, method=6), `favicon.ico` (16/32/48), `apple-touch-icon.png` (180×180 on `#FAF3E7`).
|
||
- `scripts/generate_static_assets.py` — Pillow CLI `StaticAssetBuilder` that regenerates the four image assets from `Logo/chicken babies r us.png`; committed for reproducibility.
|
||
- `docs/MANUAL_TESTING.md` — per-route + responsive (360/768/1280px) + a11y + static-assets checklist.
|
||
- `tests/test_public_routes.py` — 7 tests (4 parametrized route smokes + empty-state copy + logo path + `aria-current`).
|
||
- `app/main.py` — modified: `StaticFiles` mount at `/static`, `Jinja2Templates` instantiated once onto `app.state.templates`, `public_router` included; `create_app()` stays idempotent.
|
||
|
||
**Endpoints created:**
|
||
- `GET /` — blog index (empty-state message until Phase 2 seeds content).
|
||
- `GET /about` — static About page.
|
||
- `GET /contact` — inert form + optional `mailto:` from `ADMIN_CONTACT_EMAIL`. Phase 5 replaces with a working POST.
|
||
- `GET /shop` — "Coming soon" card.
|
||
- `Mount /static` — `StaticFiles` serving `app/static/{css,img}` (and `fonts/` later if ever needed).
|
||
|
||
**Key details:**
|
||
- **Stable seam for Phase 2:** `PostService.list_published()` + `PostSummary` names are fixed contracts. Phase 2 only changes the method body to hit SQLite.
|
||
- **No DB, no CSRF, no CSP, no auth, no contact POST.** Scope held strictly to the roadmap's Phase 1 bullets.
|
||
- **Templates live under `app/templates/public/`** to reserve `app/templates/admin/` and `app/templates/emails/` for Phases 3–5.
|
||
- **Logo delivery:** `<picture><source type="image/webp"><img alt="Chicken Babies R Us" height="48"></picture>` — modern browsers pull the WebP, older ones fall back to PNG.
|
||
- **Address still not rendered.** Only city + state ("Morrison, Tennessee") appear per CLAUDE.md's "address is intentionally not displayed" wording.
|
||
- **No new packages.** Pillow / Jinja2 / Starlette StaticFiles were already in `requirements.txt` from Phase 0.
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 8 passed ✓ · uvicorn smoke: `/`, `/about`, `/contact`, `/shop`, `/healthz`, `/static/css/site.css`, `/static/img/logo.webp` all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has `disabled` inputs and no `method` attribute ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 2 — Content Model + Cache ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Stood up the full SQLite content layer: all 7 tables from the authoritative schema, entity dataclasses + row mappers, hand-rolled versioned migrations with a `schema_migrations` tracker, idempotent Python seed (system user + welcome post + About page), a Markdown→HTML service with a strict bleach allowlist, a typed in-process TTL cache, and DB-backed `PostService` / `PageService`. `/` and `/about` now read from the DB.
|
||
|
||
**Key files:**
|
||
- `app/db.py` — `create_engine()` factory, per-connection PRAGMA listener (WAL + `foreign_keys=ON`), `run_migrations(engine)` runner that scans `app/models/migrations/*.sql` in lex order and records applications in `schema_migrations`.
|
||
- `app/models/entities.py` — all 8 dataclasses (User, MagicLinkToken, Session, Page, Post, Media, ContactSubmission, AuthEvent) + `PostStatus(str, Enum)` matching roadmap 1:1. NOT frozen — Phase 3+ mutate `last_login_at`, `used_at`, etc.
|
||
- `app/models/mappers.py` — `row_to_user/post/page/...` converters, `_parse_datetime` / `_parse_bool` helpers.
|
||
- `app/models/migrations/001_init.sql` — verbatim roadmap schema: 7 tables, `idx_magic_email_created`, `idx_posts_status_pub`, `idx_auth_events_created`, `CHECK (status IN ('draft','published'))`.
|
||
- `app/models/seed.py` — idempotent: marker `seed_001` in `schema_migrations` + `INSERT OR IGNORE` belt-and-braces. Seeds user id=1 (`seed@chickenbabies.local`, "Head Hen", `active=0` — not a real admin, cannot log in), post slug `welcome-to-the-farm`, page slug `about`.
|
||
- `app/services/cache.py` — `TTLCache[K, V]` generic (~50 lines). `get/set/invalidate_all()`. Monotonic clock. 60s default TTL.
|
||
- `app/services/markdown.py` — `MarkdownService.render(md) -> str`: `MarkdownIt("commonmark")` (tables disabled — allowlist doesn't include `<table>`) → `bleach.clean(..., strip=True)` with tags `{p br strong em a ul ol li h1..h4 blockquote code pre img hr}`, attrs `{a:[href,title,rel], img:[src,alt,title,width,height]}`, protocols `{http https mailto}`. No `style`, no `class`, no raw HTML pass-through.
|
||
- `app/services/posts.py` — rewritten: `PostService(engine)` runs parameterized `SELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit`, converts rows to `PostSummary`. Excerpt derived from `body_html_cached`. TTL-cached. `invalidate_all()` exposed for Phase 4.
|
||
- `app/services/pages.py` — `PageService(engine).get_by_slug(slug) -> Page | None`, TTL-cached.
|
||
- `app/main.py` — wires engine, runs migrations, runs seed, instantiates services onto `app.state.{engine,post_service,page_service}`.
|
||
- `app/routes/public.py` — `/about` now pulls the seeded `Page` from `PageService`; renders `{{ page.title }}` + `{{ page.body_html_cached | safe }}`. Logs an anomaly and returns 500 with a generic message if the page is unexpectedly missing.
|
||
- `app/templates/public/about.html` — replaced static body with the dynamic page; layout kept.
|
||
- `tests/conftest.py` — `db_engine` (session, seeded) + `clean_db_engine` (function, migrated-only) fixtures, both on temp SQLite files.
|
||
- `tests/test_db_migrations.py`, `test_markdown.py`, `test_cache.py`, `test_post_service.py`, `test_page_service.py` — service + schema coverage.
|
||
- `tests/test_public_routes.py` — updated: homepage now asserts "Welcome to the Farm"; `/about` asserts seeded Markdown substring.
|
||
|
||
**Endpoints created:** none new; `/` and `/about` were rewired to DB-backed services (same URLs, same response shapes).
|
||
|
||
**Key details:**
|
||
- **Migration pattern:** every SQL file under `app/models/migrations/` gets its own transaction; already-applied files are skipped by checking `schema_migrations`. Adding a new phase = add `NNN_description.sql`. The bootstrap for `schema_migrations` itself is baked into the runner (creates the table before querying it).
|
||
- **PRAGMAs are per-connection** via `@event.listens_for(Engine, "connect")` — every pooled connection gets WAL + FK-on, not just the first. There's an explicit test covering this.
|
||
- **Seed idempotency is double-guarded:** `schema_migrations` marker `seed_001` + `INSERT OR IGNORE` on every row. Second boot logs `seed_skipped`; counts stay 1/1/1 (users/pages/posts).
|
||
- **PostSummary excerpt is derived from `body_html_cached`** (HTML-stripped + truncated), not re-rendered from `body_md`. Phase 4 writers store both; readers never touch Markdown.
|
||
- **No Markdown tables yet.** `MarkdownIt.enable("table")` was deliberately NOT called — the bleach allowlist doesn't pass `<table>`. Future tables require widening both layers together; a test documents this invariant.
|
||
- **Address still not rendered.** Seeded About Markdown mentions Morrison, TN only (no street address, per CLAUDE.md).
|
||
- **Phase 3 hooks ready:** `users` / `magic_link_tokens` / `sessions` / `auth_events` tables exist with their indexes; `User` dataclass + `PostStatus` enum + row-mapper helpers available.
|
||
- **Phase 4 hooks ready:** `PostService.invalidate_all()` + `PageService.invalidate_all()` exist (no-op callers today). Admin writes will call these after each mutation.
|
||
- **No new packages.** All deps were already pinned in Phase 0's `requirements.txt`.
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 36 passed ✓ · fresh-boot smoke: `/` shows welcome title, `/about` shows seeded Markdown, `/healthz` 200 ✓ · `PRAGMA journal_mode=wal` ✓ · second boot logs `migrations_up_to_date` + `seed_skipped`, table counts stay `users=1 pages=1 posts=1` ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 3 — Admin Auth (Magic Link) ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Passwordless admin auth end-to-end: email form → 256-bit magic-link token (SHA-256 at rest, 15-min TTL, atomic single-use consume) → Resend email with dev-log fallback → itsdangerous-signed server-side session cookie (30d) → `/admin` landing → logout revokes the row without deleting. SlowAPI per-IP + DB per-email rate limits, `ADMIN_EMAILS` allowlist with anti-enumeration, every event audited to `auth_events`.
|
||
|
||
**Key files:**
|
||
- `app/services/audit.py` — `AuditService.record(event_type, ...)` writes an `auth_events` row + mirrors the event to structlog; `detail` is JSON. Referenced session by last-6 hash chars only.
|
||
- `app/services/email.py` — `EmailService.send_magic_link(...)`: renders HTML + text templates, posts via Resend when `resend_api_key` set, otherwise logs `magic_link_dev_fallback` with the URL (dev shortcut). Never raises from the request path; production startup refuses to boot without the key.
|
||
- `app/services/sessions.py` — `SessionService(engine, signer, settings)` — `create/lookup/revoke`. Raw session ID exists only in memory and the signed cookie; DB stores `sha256(raw)`. Cookie `cb_session`, `HttpOnly=True`, `SameSite=lax`, `Secure` ON in prod / OFF in dev, `Path=/`, `Max-Age=SESSION_MAX_DAYS*86400`.
|
||
- `app/services/auth.py` — `AuthService.request_link` (allowlist check → DB per-email rate-limit count → insert token row → send email → audit) and `AuthService.consume` (atomic `UPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now`, then upsert `users` row, then `SessionService.create`). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.
|
||
- `app/services/rate_limit.py` — module-level `limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")`. Singleton because `@limiter.limit` decorates at import time.
|
||
- `app/routes/admin.py` — all admin HTTP routes; `POST /admin/login` decorated `@limiter.limit("5/15 minutes")`, `GET /admin/auth/consume/{token}` decorated `@limiter.limit("20/15 minutes")`.
|
||
- `app/dependencies/auth.py` — `get_current_user` and `require_admin` (raises `HTTPException(status_code=303, headers={"Location": "/admin/login"})`).
|
||
- `app/templates/admin/` — `base.html`, `login.html`, `login_sent.html`, `login_failed.html`, `index.html`, `rate_limited.html`.
|
||
- `app/templates/emails/magic_link.html` + `magic_link.txt` — magic-link email bodies.
|
||
- `tests/test_auth_service.py`, `test_admin_routes.py`, `test_rate_limit.py`, `test_session_service.py`, `test_email_service.py` — 25 new tests covering token lifecycle, single-use, expiry, allowlist anti-enumeration, signed-cookie round-trip, revoke, user upsert, IP + DB email limits, dev email fallback, production config refusal.
|
||
- `app/main.py` — builds `URLSafeTimedSerializer(salt="session")`, instantiates audit/email/sessions/auth onto `app.state`, installs SlowAPI `RateLimitExceeded` handler that renders `admin/rate_limited.html` with 429 and audits `rate_limited` with `scope="ip"`, includes admin router.
|
||
- `app/config.py` — added `public_base_url` field (default `http://127.0.0.1:8000`); added `_require_auth_config_in_production` model validator: production boot refuses empty `RESEND_API_KEY` / `RESEND_FROM` / `ADMIN_EMAILS`.
|
||
- `.env.example` — added `PUBLIC_BASE_URL=http://127.0.0.1:8000`.
|
||
|
||
**Endpoints created:**
|
||
- `GET /admin/login` — email-only login form (public, no CSRF — pre-auth).
|
||
- `POST /admin/login` — rate-limited (5/15min/IP + 5/15min/email via DB count). Always renders `login_sent.html` regardless of allowlist (anti-enumeration). Audit row `link_requested` with `{"allowlisted": bool}` every time.
|
||
- `GET /admin/auth/consume/{token}` — rate-limited (20/15min/IP). Atomic single-use consume; on success sets `cb_session` cookie + 303 to `/admin`; on failure renders generic `login_failed.html` (no reason leakage).
|
||
- `GET /admin` — `require_admin`; renders placeholder `index.html` with `{{ user.display_name }}` + logout form. Phase 4 replaces with real CMS dashboard.
|
||
- `POST /admin/logout` — `require_admin`; flips `sessions.revoked_at = now`, clears cookie with `Max-Age=0`, 303 to `/admin/login`. Row preserved for audit. Marked `# TODO(phase-6-csrf)` — SameSite=Lax blocks cross-site POSTs in current browsers; Phase 6 adds a double-submit token.
|
||
|
||
**Key details:**
|
||
- **Hash-at-rest for both tokens and session IDs.** DB stores `sha256(raw).hexdigest()`; raw values never persisted, never logged. Audit detail only references last-6 hash chars for correlation.
|
||
- **Cookie signed with itsdangerous `URLSafeTimedSerializer(secret_key, salt="session")`.** Forged cookie fails signature check before any DB lookup.
|
||
- **Anti-enumeration verified:** non-allowlisted POST → same 200 HTML response, zero rows inserted into `magic_link_tokens`, `link_requested` audit logs `{"allowlisted": false}`.
|
||
- **Rate limit verified:** 5 POSTs succeed + 6th returns 429 from same IP; DB per-email count applied even when SlowAPI would allow.
|
||
- **User auto-upsert on consume:** first successful consume inserts a `users` row with `display_name = local-part.title()` (e.g. `driver@example.com` → "Driver"), `active=1`; subsequent logins update `last_login_at`.
|
||
- **Dev Resend fallback:** when `resend_api_key` is falsy, `EmailService` emits `magic_link_dev_fallback` structured log with the full URL and returns cleanly — never 500s the request path.
|
||
- **Production config guardrails:** `app_env == "production"` requires non-empty `RESEND_API_KEY`, `RESEND_FROM`, `ADMIN_EMAILS`. Missing any → `ValueError` at startup.
|
||
- **Single-writer deadlock avoided:** `AuthService.consume` does the atomic token update inside `engine.begin()`, then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee via `rowcount==1`).
|
||
- **Audit trail smoke-tested rows:** `link_requested` (per attempt), `link_consumed`, `session_created`, `session_revoked`, `rate_limited`, `consume_failed` — all emitted during driver verification.
|
||
- **Phase 4 hooks ready:** `require_admin` dependency + `get_current_user` are callable from any admin route. Admin CMS POSTs will reuse them.
|
||
- **Phase 6 TODO markers** on the logout handler flag the CSRF pickup point.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app"` ✓ · `pytest -q` 61 passed ✓ · end-to-end smoke (fresh DB, driver allowlisted): login form 200, POST login 200, dev-log URL captured, consume 200 + cookie set, `/admin` 200 with "Welcome, Driver", logout 303, post-logout `/admin` 303 to login ✓ · IP rate limit fires at 5 (6th is 429) ✓ · non-allowlisted POST 200 + zero tokens in DB ✓ · `auth_events` shows `link_requested`/`link_consumed`/`session_created`/`session_revoked`/`rate_limited` rows as expected ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 4 — Admin CMS ✅
|
||
|
||
**Completed:** 2026-04-22
|
||
|
||
**Summary:** Shipped the full Head Hen CMS: dashboard listing every post (drafts + published, newest `updated_at` first) plus an About-edit entry point, a hand-rolled Markdown editor (textarea + 300 ms-debounced server-side live preview + drag-drop image upload), a hardened media pipeline (magic-byte sniff → 8 MB cap → Pillow re-encode to JPEG with alpha flattened on white → random `data/media/<yyyy>/<mm>/<token>.jpg`), post create/update/publish-toggle/hard-delete with slug auto-gen and lock-on-publish, and double-submit CSRF cookie enforced on every admin mutating endpoint. Phase 3's `# TODO(phase-6-csrf)` markers are resolved — CSRF is live.
|
||
|
||
**Key files:**
|
||
- `app/services/slugs.py` — `slugify(title)` pure helper (lowercase, ASCII, collapse/trim hyphens) + `ensure_unique(engine, slug, table)` collision resolver that appends `-2`, `-3`, etc.
|
||
- `app/services/csrf.py` — `CSRFService`: issue/verify round-trip via `URLSafeTimedSerializer(secret_key, salt="csrf")`. Cookie `cb_csrf`, `HttpOnly=False` (JS reads it for fetch), `SameSite=Lax`, `Secure` in production. Separate from the `cb_session` session cookie.
|
||
- `app/dependencies/csrf.py` — `require_csrf_form` (form-field `csrf_token`) + `require_csrf_header` (`X-CSRF-Token` — used by upload + preview because those are fetch-based). Both raise 403 on mismatch.
|
||
- `app/services/media.py` — `MediaService(engine, media_root)`; `save_upload(file, uploaded_by) -> Media`. Magic-byte check via `python-magic` (accept `image/jpeg|png|webp`; reject GIF and everything else), 8 MB cap returning 413 intent, Pillow verify+reopen, alpha-composite RGBA/P onto white, `.save(path, "JPEG", quality=85, optimize=True)`. `secrets.token_urlsafe(16)` filename; client extension discarded. Monthly partition dir auto-created.
|
||
- `app/services/admin_posts.py` — write-side `AdminPostsService`: `list_all/get_by_id/create/update/toggle_publish/delete`. Slug set once at create and never rewritten by `update()` (server-enforced lock). `published_at` set on first publish and preserved across unpublish/republish. Every write invalidates the read-side `PostService` cache.
|
||
- `app/services/admin_pages.py` — About-only write service; slug immutable. Invalidates `PageService` cache on write.
|
||
- `app/routes/admin_cms.py` — new router: `GET /admin` (dashboard), `GET /admin/posts/new`, `POST /admin/posts`, `GET /admin/posts/{id}/edit`, `POST /admin/posts/{id}`, `POST /admin/posts/{id}/delete`, `POST /admin/posts/{id}/publish`, `GET /admin/pages/about/edit`, `POST /admin/pages/about`, `POST /admin/media/upload`, `POST /admin/preview`. All mutating routes carry `require_admin` + a CSRF dep.
|
||
- `app/templates/admin/dashboard.html` + `post_form.html` + `page_form.html` + `_post_row.html` — CMS UI. Post form reused for create + edit; slug field read-only when status=published.
|
||
- `app/static/js/admin_editor.js` — hooks `[data-editor]`: 300 ms-debounced `POST /admin/preview` with `X-CSRF-Token` header, swaps preview via `<template>` + `cloneNode` (never `innerHTML`); drag-drop / file-picker upload to `/admin/media/upload` with FormData, inserts `` at the textarea cursor.
|
||
- `app/templates/admin/base.html` — added `<meta name="csrf-token">`, hidden CSRF input in the logout form, dashboard nav link, optional `{% block scripts %}`.
|
||
- `app/main.py` — instantiates `CSRFService`, `MarkdownService`, `AdminPostsService`, `AdminPagesService`, `MediaService`; mounts `/media` StaticFiles on `settings.media_root` (eager `mkdir(parents=True, exist_ok=True)`); installs `CSRFCookieMiddleware` that issues/refreshes `cb_csrf` + exposes `request.state.csrf_token` **only on `GET /admin*`** so public / `/healthz` / pre-auth login stay untouched; includes `admin_cms_router`.
|
||
- `app/routes/admin.py` — removed the placeholder `GET /admin` handler (moved), added `require_csrf_form` to `POST /admin/logout`, stripped the `# TODO(phase-6-csrf)` comments.
|
||
- `app/config.py` — added `media_root: str = Field(default="data/media")`.
|
||
- `.env.example` — added `MEDIA_ROOT=data/media`.
|
||
- `.gitignore` — carved out `!data/media/.gitkeep` so the mount dir survives checkouts.
|
||
- `app/static/css/site.css` — extended with `.admin-dashboard`, `.post-table`, `.editor` (2-column layout at 48rem+), `.drop-zone`, `.status-badge`, `.btn--danger/.btn--secondary/.btn--link`, `.admin-flash`.
|
||
- `docs/MANUAL_TESTING.md` — appended Phase 4 checklist.
|
||
- Tests: `test_slugs.py`, `test_csrf_service.py`, `test_media_service.py`, `test_admin_posts_service.py`, `test_admin_pages_service.py`, `test_admin_cms_routes.py` — 64 new tests; also updated `test_admin_routes.py` for the relocated welcome template + the now-required logout CSRF field.
|
||
|
||
**Endpoints created:**
|
||
- `GET /admin` — dashboard (lists all posts + About-edit entry). Replaces Phase 3 placeholder.
|
||
- `GET /admin/posts/new` — create form.
|
||
- `POST /admin/posts` — create handler (CSRF form).
|
||
- `GET /admin/posts/{id}/edit` — edit form.
|
||
- `POST /admin/posts/{id}` — update handler (CSRF form). Slug field is server-ignored when post is published.
|
||
- `POST /admin/posts/{id}/delete` — hard delete with confirmation (CSRF form).
|
||
- `POST /admin/posts/{id}/publish` — publish/unpublish toggle (CSRF form).
|
||
- `GET /admin/pages/about/edit` — About edit form.
|
||
- `POST /admin/pages/about` — About update (CSRF form). Slug immutable.
|
||
- `POST /admin/media/upload` — multipart upload; returns `{"url": "/media/...", "alt": ""}` JSON (CSRF header).
|
||
- `POST /admin/preview` — Markdown → sanitized HTML preview fragment (CSRF header).
|
||
- `Mount /media` — `StaticFiles` serving `data/media/<yyyy>/<mm>/<token>.jpg`.
|
||
|
||
**Key details:**
|
||
- **CSRF gating is narrow by design.** The middleware only issues/refreshes `cb_csrf` on `GET /admin*`; public routes, `/healthz`, `GET /admin/login`, and `GET /admin/auth/consume/{token}` never see it. Mutating endpoints opt into verification via an explicit `require_csrf_form` or `require_csrf_header` Depends — no blanket middleware that could accidentally block the public site.
|
||
- **Slug lock is server-enforced.** `AdminPostsService.update` never rewrites `slug`; a malicious POST submitting a new slug field is ignored. Slug only exists because `create()` set it.
|
||
- **Unpublish preserves `published_at`.** Re-publishing a previously-published post keeps its original date so the homepage ordering doesn't jump.
|
||
- **Media pipeline: single output format (JPEG).** Simpler write-side, smaller files, predictable downstream behaviour. RGBA / paletted inputs get alpha-composited onto white before encode.
|
||
- **Preview endpoint swap uses `<template>` + `cloneNode`, not `innerHTML`.** Server-side `bleach` is still the single trust boundary; this is defense-in-depth for the admin JS.
|
||
- **Audit trail extends cleanly.** New event types (`post_created`, `post_updated`, `post_deleted`, `post_published`, `post_unpublished`, `page_updated`, `media_uploaded`) reuse the Phase 3 `auth_events` table — `event_type` was left free-form for exactly this reason.
|
||
- **No DB mocking.** Every new test uses a real temp SQLite file per the CLAUDE.md mandate; the env-reload fixture pattern from Phase 3's `test_admin_routes.py` is reused.
|
||
- **Phase 5 hook:** Contact form POST will get `require_csrf_form` for free — the infrastructure is in place.
|
||
- **Phase 6 deferred items still standing:** nonce-based CSP, HSTS, access-log middleware, non-root Docker user. None blocked Phase 4.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app"` ✓ (26 routes registered) · `pytest -q tests/test_slugs.py tests/test_csrf_service.py tests/test_media_service.py tests/test_admin_posts_service.py tests/test_admin_pages_service.py tests/test_admin_cms_routes.py tests/test_admin_routes.py` → 64 passed ✓ · full `pytest -q` → 118 passed, 2 failed; both failures are pre-existing on `dev` (the `logo.` → `logo-mark.` asset rename in commit `f5098c0`; and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into the Settings-validator test) and unrelated to Phase 4.
|
||
|
||
## Phase 5 — Contact Form ✅
|
||
|
||
**Completed:** 2026-04-22
|
||
|
||
**Summary:** Shipped the public contact form: live POST handler with honeypot → hCaptcha (server-verify) → field validation → SlowAPI rate limit → `contact_submissions` row insert → best-effort Resend notification to `ADMIN_CONTACT_EMAIL` (Reply-To = submitter) → generic `contact_sent.html` success page. Spam / honeypot / hCaptcha-fail paths don't persist, still render the success page (anti-enumeration). Send failures don't break the request path — the row is already durable.
|
||
|
||
**Key files:**
|
||
- `app/services/hcaptcha.py` — `HCaptchaService(settings).verify(token, remote_ip) -> bool`. Async `httpx.AsyncClient` POST to `https://hcaptcha.com/siteverify` with a 5s timeout. Dev fallback: when `hcaptcha_secret` is falsy, logs `hcaptcha_dev_fallback` and returns `True`. Fail-closed on any network error / non-200 / malformed JSON / `success=false` (returns `False`). Never raises from the request path.
|
||
- `app/services/contact.py` — `ContactService(engine, email, audit, settings)`: `record_submission(...)` inserts a `contact_submissions` row and returns a mapped `ContactSubmission`; `send_notification(submission)` is best-effort (never raises, no-ops when `admin_contact_email` is unset — only possible in dev).
|
||
- `app/services/email.py` — added `send_contact_notification(*, to, submission_name, submission_email, message, submitted_at, ip)`. Subject `"New contact submission from {submission_name}"`. `from_=settings.resend_from`, `reply_to=submission.email`. Dev fallback logs `contact_notification_dev_fallback`. Resend errors are caught + logged (`contact_email_failed`); request path never sees them.
|
||
- `app/templates/emails/contact_notification.html` + `.txt` — admin notification bodies (name, email, message, submitted_at ISO, ip). Jinja2 autoescape handles all user-supplied fields.
|
||
- `app/templates/public/contact.html` — replaces the Phase 1 inert form: `method="POST"`, name / email / message inputs with HTML5 length bounds + server-side re-validation, visually-hidden honeypot `website` input, hCaptcha widget rendered only when `hcaptcha_site_key` is truthy (dev just shows an HTML comment), inline `errors.{field}` + top-level `form_error` flash slots.
|
||
- `app/templates/public/contact_sent.html` — thank-you page; `active_nav = "contact"`; back-to-home link.
|
||
- `app/routes/public.py` — added `POST /contact` handler decorated `@limiter.limit("3/hour")`. Strict 6-stage flow (honeypot → hCaptcha → validate → persist → notify → success). DI helpers `_get_hcaptcha_service` / `_get_contact_service`; `_client_ip` / `_user_agent` shared with admin module's pattern. Also tightened `GET /contact` to pass the new template context (`hcaptcha_site_key`, `errors`, `form`, `form_error`).
|
||
- `app/main.py` — instantiates `HCaptchaService(settings)` and `ContactService(engine, email_service, audit_service, settings)`; attaches to `app.state.hcaptcha_service` / `app.state.contact_service`.
|
||
- `app/config.py` — extended `_require_auth_config_in_production` to also require `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, and `HCAPTCHA_SITE_KEY` in production (missing any → `ValueError` at startup).
|
||
- `app/static/css/site.css` — added `.contact-form__field-error`, `.contact-form__error`, `.contact-form__captcha`, and the visually-hidden honeypot rule.
|
||
- `docs/MANUAL_TESTING.md` — appended Phase 5 checklist (happy path, honeypot, hCaptcha fail, rate-limit 429, inline validation, dev fallback log, admin inbox Reply-To, production-config refusal).
|
||
- Tests: `tests/test_hcaptcha_service.py` (9), `tests/test_contact_service.py` (5), `tests/test_contact_routes.py` (9) — 23 new tests; temp-SQLite fixtures per CLAUDE.md, httpx boundary mocked at `_post_siteverify`.
|
||
|
||
**Endpoints created:**
|
||
- `POST /contact` — the public submission endpoint. Accepts `name`, `email`, `message`, hidden `website` (honeypot), and `h-captcha-response` as multipart/form-urlencoded. Returns 200 `contact_sent.html` on success / spam / honeypot; 400 `contact.html` with inline errors on validation fail; 429 `admin/rate_limited.html` when the SlowAPI 3/hour IP limit trips.
|
||
- `GET /contact` — unchanged URL, now returns the live (non-disabled) form with hCaptcha widget when configured. Pre-Phase-5 this was the inert placeholder.
|
||
|
||
**Key details:**
|
||
- **Spam short-circuit is anti-enumeration.** Honeypot trip or hCaptcha `False` both render the same generic success page so bot operators can't probe which filter caught them. The audit row (`contact_spam_rejected` with `{"reason":"honeypot"|"hcaptcha"}`) is the only signal — in the DB, not the HTTP response.
|
||
- **Send failure is idempotent from the user's perspective.** `ContactService.record_submission` commits before `send_notification` is called; if Resend is down, the row is still in `contact_submissions` and Head Hen can action it from the table. The request path always ends at `contact_sent.html`.
|
||
- **No CSRF on `/contact`.** Public, pre-auth, no session cookie to hijack. SlowAPI + hCaptcha + honeypot + DB-side `contact_submissions` audit are the controls. Documented inline in the route docstring.
|
||
- **Field validation lives in the route, not the service.** `name` 1-80; `email` matches `^[^@\s]+@[^@\s]+\.[^@\s]+$` AND length ≤254; `message` 10-4000 after `.strip()`. On error: re-render with `errors` dict + preserved values + HTTP 400.
|
||
- **Audit trail extends cleanly.** New event types on the existing `auth_events` table: `contact_submitted` (with `submission_id`, `message_length`, truncated 40-char `message_preview`), `contact_spam_rejected` (with `reason`), `contact_send_failed` (from email service's internal catch). No full message bodies ever flow into audit detail.
|
||
- **hCaptcha widget is optional in dev.** Template renders `<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>` + the remote `api.js` only when the site key is truthy; otherwise an HTML comment stands in. The server-side verify service mirrors this by returning `True` when the secret is unset.
|
||
- **Reusing Phase 3's 429 handler is acceptable for now.** The registered `RateLimitExceeded` handler renders `admin/rate_limited.html`; on `/contact` it works but carries admin styling. Flagged in the Phase 6 polish list (not blocking).
|
||
- **Production config now requires three more fields.** Missing any of `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, `HCAPTCHA_SITE_KEY` in `APP_ENV=production` raises at startup via `_require_auth_config_in_production`. Mirrors the Phase 3 guardrail pattern.
|
||
- **Phase 6 hooks ready:** nonce-based CSP, HSTS, access-log middleware, a public-styled 429 template, and a Dockerfile hardening pass are all still standing as originally planned — none blocked Phase 5.
|
||
- **No new packages.** All deps (`httpx`, `slowapi`, `resend`, `jinja2`, `structlog`, `itsdangerous`) were already pinned in Phase 0's `requirements.txt`.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app; print(len(app.routes))"` → **27** (Phase 4 registered 26; one new `POST /contact`) ✓ · `pytest -q` → **141 passed, 2 failed**; both failures confirmed pre-existing on `dev` (the Phase 4 `logo.` → `logo-mark.` asset rename and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into `test_production_missing_key_refuses_startup`) — verified by running the same two targeted tests on a clean `dev` checkout, both failed there too ✓ · `docker compose config` exit 0 ✓ · route registry confirmed: `('/contact', ['GET'])` + `('/contact', ['POST'])` ✓.
|
||
|
||
**Branch:** `feat/phase-5-contact-form` off `dev`. Not committed, not merged, not pushed — changes staged for human review before `--no-ff` merge into `dev` per CLAUDE.md git strategy.
|
||
|
||
## Phase 6 — Hardening + Deploy
|
||
|
||
- Security headers middleware (strict nonce-based CSP, HSTS, etc.).
|
||
- CSRF middleware on admin routes (double-submit cookie).
|
||
- Structured access log + error log (structlog JSON in prod, pretty in dev).
|
||
- Backup script: `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` plus a tar of `data/media/`; cron nightly on the VM.
|
||
- Dockerfile hardening: non-root user, slim base, `HEALTHCHECK`.
|
||
- Gitea Action: on tag push, build image and publish to the internal registry.
|
||
|
||
## Phase 7 (Future) — Shop
|
||
|
||
- Stripe Checkout integration (test-mode first).
|
||
- Product catalog: eggs (fertile/hatchable, eating), live birds (geese, ducks, chickens).
|
||
- Inventory counts, order history, email confirmations.
|
||
- Admin views for orders.
|
||
|
||
---
|
||
|
||
## Data Model (dataclasses)
|
||
|
||
```python
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Optional
|
||
|
||
|
||
class PostStatus(str, Enum):
|
||
DRAFT = "draft"
|
||
PUBLISHED = "published"
|
||
|
||
|
||
@dataclass
|
||
class User:
|
||
id: int
|
||
email: str
|
||
display_name: str
|
||
created_at: datetime
|
||
last_login_at: Optional[datetime]
|
||
active: bool
|
||
|
||
|
||
@dataclass
|
||
class MagicLinkToken:
|
||
id: int
|
||
email: str
|
||
token_hash: str # sha256 of raw token; raw token never stored
|
||
created_at: datetime
|
||
expires_at: datetime
|
||
used_at: Optional[datetime]
|
||
request_ip: str
|
||
|
||
|
||
@dataclass
|
||
class Session:
|
||
id: int
|
||
user_id: int
|
||
token_hash: str
|
||
created_at: datetime
|
||
expires_at: datetime
|
||
ip: str
|
||
user_agent: str
|
||
revoked_at: Optional[datetime]
|
||
|
||
|
||
@dataclass
|
||
class Page:
|
||
id: int
|
||
slug: str # e.g. "about"
|
||
title: str
|
||
body_md: str
|
||
body_html_cached: str # sanitized, ready-to-render HTML
|
||
updated_at: datetime
|
||
published: bool
|
||
|
||
|
||
@dataclass
|
||
class Post:
|
||
id: int
|
||
slug: str
|
||
title: str
|
||
body_md: str
|
||
body_html_cached: str
|
||
status: PostStatus
|
||
published_at: Optional[datetime]
|
||
updated_at: datetime
|
||
author_user_id: int
|
||
|
||
|
||
@dataclass
|
||
class Media:
|
||
id: int
|
||
filename: str # random storage name
|
||
original_filename: str
|
||
content_type: str
|
||
size_bytes: int
|
||
stored_path: str # e.g. data/media/2026/04/abc123.jpg
|
||
alt_text: str
|
||
uploaded_by: int
|
||
uploaded_at: datetime
|
||
|
||
|
||
@dataclass
|
||
class ContactSubmission:
|
||
id: int
|
||
name: str
|
||
email: str
|
||
message: str
|
||
ip: str
|
||
user_agent: str
|
||
submitted_at: datetime
|
||
handled: bool
|
||
|
||
|
||
@dataclass
|
||
class AuthEvent:
|
||
id: int
|
||
event_type: str # link_requested | link_consumed | session_revoked | rate_limited
|
||
email: Optional[str]
|
||
user_id: Optional[int]
|
||
ip: str
|
||
user_agent: str
|
||
created_at: datetime
|
||
detail: str # JSON string
|
||
```
|
||
|
||
## SQLite Schema (authoritative)
|
||
|
||
```sql
|
||
PRAGMA journal_mode = WAL;
|
||
PRAGMA foreign_keys = ON;
|
||
|
||
CREATE TABLE users (
|
||
id INTEGER PRIMARY KEY,
|
||
email TEXT NOT NULL UNIQUE,
|
||
display_name TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
last_login_at TEXT,
|
||
active INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE magic_link_tokens (
|
||
id INTEGER PRIMARY KEY,
|
||
email TEXT NOT NULL,
|
||
token_hash TEXT NOT NULL UNIQUE,
|
||
created_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
used_at TEXT,
|
||
request_ip TEXT NOT NULL
|
||
);
|
||
CREATE INDEX idx_magic_email_created ON magic_link_tokens(email, created_at);
|
||
|
||
CREATE TABLE sessions (
|
||
id INTEGER PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||
token_hash TEXT NOT NULL UNIQUE,
|
||
created_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
revoked_at TEXT
|
||
);
|
||
|
||
CREATE TABLE pages (
|
||
id INTEGER PRIMARY KEY,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
body_md TEXT NOT NULL,
|
||
body_html_cached TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
published INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE posts (
|
||
id INTEGER PRIMARY KEY,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
body_md TEXT NOT NULL,
|
||
body_html_cached TEXT NOT NULL,
|
||
status TEXT NOT NULL CHECK (status IN ('draft','published')),
|
||
published_at TEXT,
|
||
updated_at TEXT NOT NULL,
|
||
author_user_id INTEGER NOT NULL REFERENCES users(id)
|
||
);
|
||
CREATE INDEX idx_posts_status_pub ON posts(status, published_at DESC);
|
||
|
||
CREATE TABLE media (
|
||
id INTEGER PRIMARY KEY,
|
||
filename TEXT NOT NULL UNIQUE,
|
||
original_filename TEXT NOT NULL,
|
||
content_type TEXT NOT NULL,
|
||
size_bytes INTEGER NOT NULL,
|
||
stored_path TEXT NOT NULL,
|
||
alt_text TEXT NOT NULL DEFAULT '',
|
||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||
uploaded_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE contact_submissions (
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
email TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
submitted_at TEXT NOT NULL,
|
||
handled INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE auth_events (
|
||
id INTEGER PRIMARY KEY,
|
||
event_type TEXT NOT NULL,
|
||
email TEXT,
|
||
user_id INTEGER REFERENCES users(id),
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
detail TEXT NOT NULL DEFAULT '{}'
|
||
);
|
||
CREATE INDEX idx_auth_events_created ON auth_events(created_at DESC);
|
||
```
|
||
|
||
## Caching Strategy
|
||
|
||
- **Row-level cache**: `pages.body_html_cached` and `posts.body_html_cached` store the fully rendered, sanitized HTML. Regenerated on write, never on read. Request path = one indexed SELECT + one template render.
|
||
- **Query cache**: a small in-process TTL cache (60 s) wraps hot queries (published-posts list, page-by-slug). Keyed by route + slug. Explicitly invalidated by any admin write.
|
||
- **No Redis / memcached**: traffic scale (single-digit requests/second at most) doesn't justify it.
|
||
|
||
## Visual Design
|
||
|
||
Palette anchored on light blues (Head Hen's preference), softened with farm neutrals. CSS custom-property tokens:
|
||
|
||
| Token | Value | Use |
|
||
|---|---|---|
|
||
| `--c-sky` | `#A9CCE3` | Primary surfaces, header accent |
|
||
| `--c-sky-deep` | `#5D8AA8` | Links, active states |
|
||
| `--c-cream` | `#FAF3E7` | Page background |
|
||
| `--c-wheat` | `#E4D4A8` | Card surfaces, subtle emphasis |
|
||
| `--c-ink` | `#2B3A42` | Body text |
|
||
| `--c-leaf` | `#7FA66B` | Success / small accent |
|
||
|
||
Typography:
|
||
- One serif display face for headers (system serif fallback).
|
||
- One humanist sans for body (system sans fallback).
|
||
- Self-host any webfont. Do **not** hit Google Fonts on the hot path.
|
||
|
||
Logo:
|
||
- Source assets in `Logo/`.
|
||
- Ship PNG (and ideally a WebP conversion) in `app/static/img/`.
|
||
- Header uses logo at ~48 px tall; include `alt="Chicken Babies R Us"`.
|
||
|
||
---
|
||
|
||
## Environment Variables (contract)
|
||
|
||
`.env.example` will expose these. None have defaults baked in for secrets.
|
||
|
||
| Var | Purpose |
|
||
|---|---|
|
||
| `APP_ENV` | `development` \| `production` |
|
||
| `SECRET_KEY` | itsdangerous signer for cookies / CSRF |
|
||
| `DATABASE_URL` | `sqlite:///data/app.db` |
|
||
| `RESEND_API_KEY` | Resend server key |
|
||
| `RESEND_FROM` | e.g. `no-reply@chickenbabies.example` |
|
||
| `ADMIN_EMAILS` | Comma-separated allowlist |
|
||
| `ADMIN_CONTACT_EMAIL` | Target inbox for contact form |
|
||
| `HCAPTCHA_SITE_KEY` | Public key (rendered in template) |
|
||
| `HCAPTCHA_SECRET` | Server verification secret |
|
||
| `FORWARDED_ALLOW_IPS` | Caddy LAN IP (for Uvicorn) |
|
||
| `SESSION_MAX_DAYS` | Default `30` |
|
||
| `MAGIC_LINK_TTL_MIN` | Default `15` |
|