End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.
Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.
Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).
Mark Phase 3 complete in docs/ROADMAP.md.
28 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
/admindashboard: lists pages + posts, links to edit.- Markdown editor: textarea + live preview + drag-and-drop image upload. Prefer minimal hand-rolled (a single
textarea+fetch-based upload) over a heavy library; EasyMDE acceptable only if the minimal path proves clunky in manual testing. - Media upload endpoint: magic-byte validation, 8 MB cap, Pillow re-encode (JPEG/WebP/PNG), random storage name under
data/media/<yyyy>/<mm>/<random>.<ext>. - CRUD: pages (About), posts (blog) with publish toggle and slug auto-gen.
- Save path: markdown →
markdown-it-py→bleachallowlist → stored inbody_html_cached.
Phase 5 — Contact Form
/contactPOST flow: field validation → hCaptcha verify → honeypot check → rate limit →Resendsend → persist submission row → success page.FROMon verified domain;Reply-To= submitter's email.- No internal errors leak to the user; they see a generic "something went wrong, please try again".
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 |