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>
43 KiB
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-levelapp; configures logging then mounts routersapp/config.py—Settings(BaseSettings)with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinelSECRET_KEYin production;get_settings()islru_cache-dapp/logging_config.py—configure_logging(app_env);ConsoleRendererin dev,JSONRendererotherwiseapp/routes/health.py—APIRoutermountingGET /healthz, typedHealthResponsepydantic modelapp/models/,app/services/,app/templates/,app/static/— placeholder dirs with.gitkeeptests/test_healthz.py— FastAPITestClientsmoke testrequirements.txt— 17 pinned packages,>=X,<next-majorranges.env.example— public env contract;.envstays gitignoredDockerfile— multi-stage (builder+runtime),python:3.12-slim-bookworm,libmagic1runtime; root user and no HEALTHCHECK (Phase 6 hardening)docker-compose.yml—webservice withenv_file: .env,./data:/app/databind mount,GIT_COMMIT_SHAbuild arg.gitignore— adjusteddata/rule todata/*so!data/.gitkeepworks
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_SHAenv var, baked into the image viaARG GIT_COMMIT_SHA=unknown+ENV GIT_COMMIT_SHA. Surfaces as"unknown"when unset (dev). - Config loader: single
Settingsclass covers every ROADMAP env-var row. Fields not yet used at Phase 0 (RESEND_*,HCAPTCHA_*,ADMIN_CONTACT_EMAIL) areOptional[str] = None.SECRET_KEYdefaults to the sentinel"dev-insecure-change-me"; a@model_validator(mode="after")refuses to boot if that sentinel survives intoAPP_ENV=production. - Admin emails: stored as raw comma-separated string;
admin_emails_listproperty returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth). - Logging:
configure_loggingruns insidecreate_app()before anystructlog.get_loggercall;app_startedstructured event fires once at startup withapp_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 -q1 passed ✓ ·curl /healthzreturned both the default"unknown"payload and the real commit SHA whenGIT_COMMIT_SHA=$(git rev-parse HEAD)was set ✓ ·docker compose configexit 0 ✓. - Branch: built on
chore/phase-0-foundationoffdev; merged--no-ffintodevon 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)withslug/title/published_at/excerpt— list-view projection used by the homepage; richerPostarrives 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—APIRouterwithGET /,/about,/contact,/shop; pulls templates offapp.state.templatesviaget_templates()DI helper.app/templates/public/base.html— layout: skip link,<header>/<nav>/<main>/<footer>,aria-currenton active nav item,<picture>logo (WebP + PNG fallback), favicon/apple-touch-icon links, mobile nav toggle via plainaddEventListenerscript.app/templates/public/home.html— blog index; loops_post_card.htmlor 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 inputsdisabled, nomethod="POST",action="", showsmailto:link only ifsettings.admin_contact_emailis 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,:rootpalette 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 CLIStaticAssetBuilderthat regenerates the four image assets fromLogo/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:StaticFilesmount at/static,Jinja2Templatesinstantiated once ontoapp.state.templates,public_routerincluded;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 + optionalmailto:fromADMIN_CONTACT_EMAIL. Phase 5 replaces with a working POST.GET /shop— "Coming soon" card.Mount /static—StaticFilesservingapp/static/{css,img}(andfonts/later if ever needed).
Key details:
- Stable seam for Phase 2:
PostService.list_published()+PostSummarynames 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 reserveapp/templates/admin/andapp/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.txtfrom Phase 0. - Verification run:
python -c "from app.main import app"✓ ·pytest -q8 passed ✓ · uvicorn smoke:/,/about,/contact,/shop,/healthz,/static/css/site.css,/static/img/logo.webpall 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page hasdisabledinputs and nomethodattribute ✓ ·docker compose configexit 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 scansapp/models/migrations/*.sqlin lex order and records applications inschema_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+ mutatelast_login_at,used_at, etc.app/models/mappers.py—row_to_user/post/page/...converters,_parse_datetime/_parse_boolhelpers.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: markerseed_001inschema_migrations+INSERT OR IGNOREbelt-and-braces. Seeds user id=1 (seed@chickenbabies.local, "Head Hen",active=0— not a real admin, cannot log in), post slugwelcome-to-the-farm, page slugabout.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}. Nostyle, noclass, no raw HTML pass-through.app/services/posts.py— rewritten:PostService(engine)runs parameterizedSELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit, converts rows toPostSummary. Excerpt derived frombody_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 ontoapp.state.{engine,post_service,page_service}.app/routes/public.py—/aboutnow pulls the seededPagefromPageService; 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";/aboutasserts 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 checkingschema_migrations. Adding a new phase = addNNN_description.sql. The bootstrap forschema_migrationsitself 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_migrationsmarkerseed_001+INSERT OR IGNOREon every row. Second boot logsseed_skipped; counts stay 1/1/1 (users/pages/posts). - PostSummary excerpt is derived from
body_html_cached(HTML-stripped + truncated), not re-rendered frombody_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_eventstables exist with their indexes;Userdataclass +PostStatusenum + 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 -q36 passed ✓ · fresh-boot smoke:/shows welcome title,/aboutshows seeded Markdown,/healthz200 ✓ ·PRAGMA journal_mode=wal✓ · second boot logsmigrations_up_to_date+seed_skipped, table counts stayusers=1 pages=1 posts=1✓ ·docker compose configexit 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 anauth_eventsrow + mirrors the event to structlog;detailis JSON. Referenced session by last-6 hash chars only.app/services/email.py—EmailService.send_magic_link(...): renders HTML + text templates, posts via Resend whenresend_api_keyset, otherwise logsmagic_link_dev_fallbackwith 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 storessha256(raw). Cookiecb_session,HttpOnly=True,SameSite=lax,SecureON 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) andAuthService.consume(atomicUPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now, then upsertusersrow, thenSessionService.create). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.app/services/rate_limit.py— module-levellimiter = Limiter(key_func=get_remote_address, storage_uri="memory://"). Singleton because@limiter.limitdecorates at import time.app/routes/admin.py— all admin HTTP routes;POST /admin/logindecorated@limiter.limit("5/15 minutes"),GET /admin/auth/consume/{token}decorated@limiter.limit("20/15 minutes").app/dependencies/auth.py—get_current_userandrequire_admin(raisesHTTPException(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— buildsURLSafeTimedSerializer(salt="session"), instantiates audit/email/sessions/auth ontoapp.state, installs SlowAPIRateLimitExceededhandler that rendersadmin/rate_limited.htmlwith 429 and auditsrate_limitedwithscope="ip", includes admin router.app/config.py— addedpublic_base_urlfield (defaulthttp://127.0.0.1:8000); added_require_auth_config_in_productionmodel validator: production boot refuses emptyRESEND_API_KEY/RESEND_FROM/ADMIN_EMAILS..env.example— addedPUBLIC_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 renderslogin_sent.htmlregardless of allowlist (anti-enumeration). Audit rowlink_requestedwith{"allowlisted": bool}every time.GET /admin/auth/consume/{token}— rate-limited (20/15min/IP). Atomic single-use consume; on success setscb_sessioncookie + 303 to/admin; on failure renders genericlogin_failed.html(no reason leakage).GET /admin—require_admin; renders placeholderindex.htmlwith{{ user.display_name }}+ logout form. Phase 4 replaces with real CMS dashboard.POST /admin/logout—require_admin; flipssessions.revoked_at = now, clears cookie withMax-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_requestedaudit 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
usersrow withdisplay_name = local-part.title()(e.g.driver@example.com→ "Driver"),active=1; subsequent logins updatelast_login_at. - Dev Resend fallback: when
resend_api_keyis falsy,EmailServiceemitsmagic_link_dev_fallbackstructured log with the full URL and returns cleanly — never 500s the request path. - Production config guardrails:
app_env == "production"requires non-emptyRESEND_API_KEY,RESEND_FROM,ADMIN_EMAILS. Missing any →ValueErrorat startup. - Single-writer deadlock avoided:
AuthService.consumedoes the atomic token update insideengine.begin(), then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee viarowcount==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_admindependency +get_current_userare 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 viaURLSafeTimedSerializer(secret_key, salt="csrf"). Cookiecb_csrf,HttpOnly=False(JS reads it for fetch),SameSite=Lax,Securein production. Separate from thecb_sessionsession cookie.app/dependencies/csrf.py—require_csrf_form(form-fieldcsrf_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 viapython-magic(acceptimage/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-sideAdminPostsService:list_all/get_by_id/create/update/toggle_publish/delete. Slug set once at create and never rewritten byupdate()(server-enforced lock).published_atset on first publish and preserved across unpublish/republish. Every write invalidates the read-sidePostServicecache.app/services/admin_pages.py— About-only write service; slug immutable. InvalidatesPageServicecache 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 carryrequire_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-debouncedPOST /admin/previewwithX-CSRF-Tokenheader, swaps preview via<template>+cloneNode(neverinnerHTML); drag-drop / file-picker upload to/admin/media/uploadwith FormData, insertsat 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— instantiatesCSRFService,MarkdownService,AdminPostsService,AdminPagesService,MediaService; mounts/mediaStaticFiles onsettings.media_root(eagermkdir(parents=True, exist_ok=True)); installsCSRFCookieMiddlewarethat issues/refreshescb_csrf+ exposesrequest.state.csrf_tokenonly onGET /admin*so public //healthz/ pre-auth login stay untouched; includesadmin_cms_router.app/routes/admin.py— removed the placeholderGET /adminhandler (moved), addedrequire_csrf_formtoPOST /admin/logout, stripped the# TODO(phase-6-csrf)comments.app/config.py— addedmedia_root: str = Field(default="data/media")..env.example— addedMEDIA_ROOT=data/media..gitignore— carved out!data/media/.gitkeepso 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 updatedtest_admin_routes.pyfor 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—StaticFilesservingdata/media/<yyyy>/<mm>/<token>.jpg.
Key details:
- CSRF gating is narrow by design. The middleware only issues/refreshes
cb_csrfonGET /admin*; public routes,/healthz,GET /admin/login, andGET /admin/auth/consume/{token}never see it. Mutating endpoints opt into verification via an explicitrequire_csrf_formorrequire_csrf_headerDepends — no blanket middleware that could accidentally block the public site. - Slug lock is server-enforced.
AdminPostsService.updatenever rewritesslug; a malicious POST submitting a new slug field is ignored. Slug only exists becausecreate()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, notinnerHTML. Server-sidebleachis 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 3auth_eventstable —event_typewas 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.pyis reused. - Phase 5 hook: Contact form POST will get
require_csrf_formfor 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. Asynchttpx.AsyncClientPOST tohttps://hcaptcha.com/siteverifywith a 5s timeout. Dev fallback: whenhcaptcha_secretis falsy, logshcaptcha_dev_fallbackand returnsTrue. Fail-closed on any network error / non-200 / malformed JSON /success=false(returnsFalse). Never raises from the request path.app/services/contact.py—ContactService(engine, email, audit, settings):record_submission(...)inserts acontact_submissionsrow and returns a mappedContactSubmission;send_notification(submission)is best-effort (never raises, no-ops whenadmin_contact_emailis unset — only possible in dev).app/services/email.py— addedsend_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 logscontact_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 honeypotwebsiteinput, hCaptcha widget rendered only whenhcaptcha_site_keyis truthy (dev just shows an HTML comment), inlineerrors.{field}+ top-levelform_errorflash slots.app/templates/public/contact_sent.html— thank-you page;active_nav = "contact"; back-to-home link.app/routes/public.py— addedPOST /contacthandler 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_agentshared with admin module's pattern. Also tightenedGET /contactto pass the new template context (hcaptcha_site_key,errors,form,form_error).app/main.py— instantiatesHCaptchaService(settings)andContactService(engine, email_service, audit_service, settings); attaches toapp.state.hcaptcha_service/app.state.contact_service.app/config.py— extended_require_auth_config_in_productionto also requireADMIN_CONTACT_EMAIL,HCAPTCHA_SECRET, andHCAPTCHA_SITE_KEYin production (missing any →ValueErrorat 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. Acceptsname,email,message, hiddenwebsite(honeypot), andh-captcha-responseas multipart/form-urlencoded. Returns 200contact_sent.htmlon success / spam / honeypot; 400contact.htmlwith inline errors on validation fail; 429admin/rate_limited.htmlwhen 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
Falseboth render the same generic success page so bot operators can't probe which filter caught them. The audit row (contact_spam_rejectedwith{"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_submissioncommits beforesend_notificationis called; if Resend is down, the row is still incontact_submissionsand Head Hen can action it from the table. The request path always ends atcontact_sent.html. - No CSRF on
/contact. Public, pre-auth, no session cookie to hijack. SlowAPI + hCaptcha + honeypot + DB-sidecontact_submissionsaudit are the controls. Documented inline in the route docstring. - Field validation lives in the route, not the service.
name1-80;emailmatches^[^@\s]+@[^@\s]+\.[^@\s]+$AND length ≤254;message10-4000 after.strip(). On error: re-render witherrorsdict + preserved values + HTTP 400. - Audit trail extends cleanly. New event types on the existing
auth_eventstable:contact_submitted(withsubmission_id,message_length, truncated 40-charmessage_preview),contact_spam_rejected(withreason),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 remoteapi.jsonly when the site key is truthy; otherwise an HTML comment stands in. The server-side verify service mirrors this by returningTruewhen the secret is unset. - Reusing Phase 3's 429 handler is acceptable for now. The registered
RateLimitExceededhandler rendersadmin/rate_limited.html; on/contactit 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_KEYinAPP_ENV=productionraises 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'srequirements.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 ofdata/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)
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_cachedandposts.body_html_cachedstore 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 |