Files
chicken_babies_site/docs/ROADMAP.md
Phillip Tarrant f9f90d408e chore: phase 6 hardening — CSP/HSTS, access log, docker, backup, CI
Ships the cross-cutting hardening set:

- SecurityHeadersMiddleware: per-request nonce-based CSP, HSTS
  (production only), Referrer-Policy, Permissions-Policy,
  X-Content-Type-Options, frame-ancestors 'none', form-action 'self'.
- AccessLogMiddleware: one http_request INFO event per request
  (method/path/status/duration_ms/ip/ua). Skips /healthz, redacts
  /admin/auth/consume/<token> paths, logs 500 + re-raises on
  downstream exceptions.
- Public base.html inline nav-toggle script gets a nonce so it
  passes strict CSP without relaxing to 'unsafe-inline'.
- Dockerfile: non-root app user (uid/gid 10001) + stdlib-only
  HEALTHCHECK against /healthz.
- scripts/backup.sh: sqlite3 .backup + tar data/media with
  14-entry retention; host-side cron install documented.
- .gitea/workflows/build-image.yml: on push to master /
  workflow_dispatch, builds and publishes
  git.sneakygeek.net/ptarrant/chicken_babies_site:latest +
  sha-<short>, with GIT_COMMIT_SHA threaded as a build-arg so
  /healthz keeps reporting the right commit in deployed images.
- 8 new tests (security headers + access log).

Pre-existing dev failures (logo asset rename + RESEND env
pollution) remain unchanged; verified not Phase 6 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:38:23 -05:00

51 KiB
Raw Blame History

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.pycreate_app() factory + module-level app; configures logging then mounts routers
  • app/config.pySettings(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.pyconfigure_logging(app_env); ConsoleRenderer in dev, JSONRenderer otherwise
  • app/routes/health.pyAPIRouter 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.ymlweb 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.pyPostSummary @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.pyPostService.list_published(limit=20) -> list[PostSummary] stub returning []; get_post_service() DI helper (Phase 2 keeps the signature, swaps the body).
  • app/routes/public.pyAPIRouter 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 /staticStaticFiles 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 35.
  • 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.pycreate_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.pyrow_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.pyTTLCache[K, V] generic (~50 lines). get/set/invalidate_all(). Monotonic clock. 60s default TTL.
  • app/services/markdown.pyMarkdownService.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.pyPageService(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.pydb_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 ✓.

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.pyAuditService.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.pyEmailService.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.pySessionService(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.pyAuthService.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.pyget_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 /adminrequire_admin; renders placeholder index.html with {{ user.display_name }} + logout form. Phase 4 replaces with real CMS dashboard.
  • POST /admin/logoutrequire_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.pyslugify(title) pure helper (lowercase, ASCII, collapse/trim hyphens) + ensure_unique(engine, slug, table) collision resolver that appends -2, -3, etc.
  • app/services/csrf.pyCSRFService: 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.pyrequire_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.pyMediaService(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 ![alt](url) 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 /mediaStaticFiles 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.pyHCaptchaService(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.pyContactService(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 -q141 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

Completed: 2026-04-22

Summary: Locked down the HTTP surface with a strict nonce-based CSP (plus HSTS in prod, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, frame-ancestors, form-action), added a structured per-request access-log middleware with magic-link path redaction and /healthz suppression, hardened the Docker image (non-root uid/gid 10001 + HEALTHCHECK against /healthz), shipped a backup script with 14-day retention, and wired a Gitea Actions workflow that publishes git.sneakygeek.net/ptarrant/chicken_babies_site:latest + :sha-<short> on every push to master (plus workflow_dispatch). CSRF middleware was already live from Phase 4 — that roadmap bullet was a no-op here.

Key files:

  • app/middleware/__init__.py — package marker.
  • app/middleware/security_headers.pySecurityHeadersMiddleware(BaseHTTPMiddleware). Per-request nonce via secrets.token_urlsafe(16), stashed on request.state.csp_nonce. CSP builds on each dispatch so the nonce threads into script-src. Conditional HSTS: constructor takes production: bool, only emits Strict-Transport-Security: max-age=31536000; includeSubDomains when True so localhost-over-http dev still works. Also sets X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), Cross-Origin-Opener-Policy: same-origin.
  • app/middleware/access_log.pyAccessLogMiddleware(BaseHTTPMiddleware) emits one http_request INFO event per request with method, path, status_code, duration_ms (int), client_ip (from request.client.host — already resolved through ProxyHeadersMiddleware), user_agent (truncated to 256 chars). Skips /healthz to avoid compose-loop noise. Paths matching ^/admin/auth/consume/ have the token segment replaced with <redacted> before logging. Unhandled downstream exceptions are logged with status_code=500, then re-raised so FastAPI's exception machinery still runs.
  • app/main.py — imports + wires both middlewares. Install order: SecurityHeadersMiddleware added first, AccessLogMiddleware added last. FastAPI/Starlette wraps in reverse registration order, so the access log runs outermost (timing covers the whole stack) and the security headers run innermost (stamped onto the response before the access log times it). Inline comment documents the ordering so future edits don't flip it.
  • app/templates/public/base.html — line 110 inline <script> now carries nonce="{{ request.state.csp_nonce }}" so it passes strict CSP. External hCaptcha script on public/contact.html left alone (allowlisted by origin in script-src, not nonced). Admin templates were all-external-JS already; nothing to patch there.
  • Dockerfile — runtime stage adds groupadd -g 10001 app && useradd -m -u 10001 -g app app, chown -R app:app /app /opt/venv, and USER app before CMD. Stable UID/GID keeps host bind-mount ownership predictable. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)" — stdlib only, no curl/wget runtime dep.
  • scripts/backup.shset -euo pipefail, UTC timestamp (%Y%m%dT%H%M%SZ), sqlite3 data/app.db ".backup data/backups/app-<ts>.db" + tar -czf data/backups/media-<ts>.tar.gz data/media, retention ls -t | tail -n +15 | xargs -r rm so only the newest 14 of each artifact survive. mkdir -p data/backups is idempotent. Chmod +x committed.
  • .gitea/workflows/build-image.yml — triggers on push to master + workflow_dispatch. Buildx + docker/login-action@v3 using ${{ gitea.actor }} / ${{ secrets.REGISTRY_TOKEN }}. metadata-action@v5 tags: latest + sha-<short>. build-push-action@v5 passes build-args: GIT_COMMIT_SHA=${{ gitea.sha }} so /healthz reports the real commit in deployed images. Registry cache-from/to pinned to the :buildcache tag.
  • docs/MANUAL_TESTING.md — appended Phase 6 checklist (security headers via curl, nonce uniqueness, HSTS dev/prod diff, hCaptcha still loads, admin editor still works, HEALTHCHECK docker ps healthy, backup dry run, retention prune).
  • tests/test_security_headers.py (4 tests) — asserts every expected header is present on /, CSP contains nonce-, two back-to-back requests get different nonces, HSTS is absent when the middleware is constructed with production=False and present with max-age=31536000; includeSubDomains when production=True.
  • tests/test_access_log.py (4 tests) — capsys-based capture with ANSI-escape stripping. Verifies http_request events fire for 200/404, /healthz is skipped, /admin/auth/consume/<token> is redacted before logging, and a downstream-handler exception is logged with status_code=500 then re-raised.

Endpoints created: none. Phase 6 is cross-cutting — route count stayed at 28.

Key details:

  • CSP policy (effective): default-src 'self'; script-src 'self' 'nonce-<N>' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com https://newassets.hcaptcha.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'. style-src 'unsafe-inline' is kept for now because hCaptcha injects inline style into its widget; tightening it is a future task.
  • Single inline script remaining: public/base.html's mobile-nav toggle. The admin editor and everything else are external files already, so no admin template needed a nonce. contact.html's external hCaptcha script is allowed by origin; external scripts don't take nonces.
  • HSTS is production-gated. Local dev over http://127.0.0.1:8000 stays reachable. Prod flips on the header via the production=(settings.app_env == "production") flag in create_app.
  • Access log redaction is defence-in-depth. The magic-link consume route already signs / hashes / single-uses the token, so even if a log line leaked it'd be unreplayable. Redacting anyway keeps the log grep-friendly and avoids any accidental downstream ingestion storing the raw value.
  • /healthz skipped from access logs to keep compose's 30s HEALTHCHECK from drowning signal in noise. Still fully served — just not logged.
  • Middleware install order pitfall documented. FastAPI/Starlette wrap in reverse registration order; the outermost middleware is the LAST one added. app/main.py now has an inline comment so a future refactor doesn't silently flip timing vs. security behaviour.
  • Docker image runs as non-root (uid 10001). Host bind-mount (./data:/app/data) must be writable by this uid. Docs/MANUAL_TESTING notes chown -R 10001:10001 data/ or mounting into a uid-remapped volume as the host-side prep step.
  • Gitea workflow publishes on every master push. No tag trigger this phase; release tags can be layered on later without touching the workflow.
  • Build-arg discipline preserved. Workflow passes GIT_COMMIT_SHA=${{ gitea.sha }} so the published image keeps reporting the correct SHA through /healthz — the Phase 0 contract holds.
  • Backup script is host-side only. Cron installation is a sysadmin step documented in MANUAL_TESTING; the repo deliberately does not ship a systemd unit to keep it host-agnostic.
  • Pre-existing test failures are still pre-existing. test_production_missing_key_refuses_startup (env pollution) and test_layout_includes_logo_image (Phase 4 logologo-mark asset rename) failed on dev before this phase; they still fail, and they are NOT Phase 6 regressions.

Verification run: python -c "from app.main import app; print(len(app.routes))"28 (unchanged) ✓ · pytest -q149 passed, 2 failed — both failures confirmed pre-existing on dev ✓ · dev-mode TestClient /: CSP present with unique nonce across two requests, HSTS absent, X-Content-Type-Options=nosniff, Referrer-Policy=strict-origin-when-cross-origin, Permissions-Policy present, frame-ancestors 'none' in CSP, nonce value matches the nonce="..." attribute emitted into the HTML ✓ · prod-mode (APP_ENV=production + full config env) /: Strict-Transport-Security: max-age=31536000; includeSubDomains present, JSON access-log line emitted ✓ · /healthz returns 200 without producing an http_request log entry ✓ · bash -n scripts/backup.sh clean ✓ · python -c "import yaml; yaml.safe_load(open('.gitea/workflows/build-image.yml'))" clean ✓ · scripts/backup.sh is -rwxrwxr-x (executable) ✓.

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)

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)

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