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>
51 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 ✅
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.py—SecurityHeadersMiddleware(BaseHTTPMiddleware). Per-request nonce viasecrets.token_urlsafe(16), stashed onrequest.state.csp_nonce. CSP builds on each dispatch so the nonce threads intoscript-src. Conditional HSTS: constructor takesproduction: bool, only emitsStrict-Transport-Security: max-age=31536000; includeSubDomainswhenTrueso localhost-over-http dev still works. Also setsX-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.py—AccessLogMiddleware(BaseHTTPMiddleware)emits onehttp_requestINFO event per request withmethod,path,status_code,duration_ms(int),client_ip(fromrequest.client.host— already resolved throughProxyHeadersMiddleware),user_agent(truncated to 256 chars). Skips/healthzto avoid compose-loop noise. Paths matching^/admin/auth/consume/have the token segment replaced with<redacted>before logging. Unhandled downstream exceptions are logged withstatus_code=500, then re-raised so FastAPI's exception machinery still runs.app/main.py— imports + wires both middlewares. Install order:SecurityHeadersMiddlewareadded first,AccessLogMiddlewareadded 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 carriesnonce="{{ request.state.csp_nonce }}"so it passes strict CSP. External hCaptcha script onpublic/contact.htmlleft alone (allowlisted by origin inscript-src, not nonced). Admin templates were all-external-JS already; nothing to patch there.Dockerfile— runtime stage addsgroupadd -g 10001 app && useradd -m -u 10001 -g app app,chown -R app:app /app /opt/venv, andUSER appbeforeCMD. 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.sh—set -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, retentionls -t | tail -n +15 | xargs -r rmso only the newest 14 of each artifact survive.mkdir -p data/backupsis idempotent. Chmod +x committed..gitea/workflows/build-image.yml— triggers onpushtomaster+workflow_dispatch. Buildx +docker/login-action@v3using${{ gitea.actor }}/${{ secrets.REGISTRY_TOKEN }}.metadata-action@v5tags:latest+sha-<short>.build-push-action@v5passesbuild-args: GIT_COMMIT_SHA=${{ gitea.sha }}so/healthzreports the real commit in deployed images. Registry cache-from/to pinned to the:buildcachetag.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, HEALTHCHECKdocker pshealthy, backup dry run, retention prune).tests/test_security_headers.py(4 tests) — asserts every expected header is present on/, CSP containsnonce-, two back-to-back requests get different nonces, HSTS is absent when the middleware is constructed withproduction=Falseand present withmax-age=31536000; includeSubDomainswhenproduction=True.tests/test_access_log.py(4 tests) — capsys-based capture with ANSI-escape stripping. Verifieshttp_requestevents fire for 200/404,/healthzis skipped,/admin/auth/consume/<token>is redacted before logging, and a downstream-handler exception is logged withstatus_code=500then 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:8000stays reachable. Prod flips on the header via theproduction=(settings.app_env == "production")flag increate_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.
/healthzskipped 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.pynow 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 noteschown -R 10001:10001 data/or mounting into a uid-remapped volume as the host-side prep step. - Gitea workflow publishes on every
masterpush. 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) andtest_layout_includes_logo_image(Phase 4logo→logo-markasset rename) failed ondevbefore 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 -q → 149 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_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 |