Files
chicken_babies_site/docs/ROADMAP.md
Phillip Tarrant 59dea99079 feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
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.
2026-04-21 16:20:51 -05:00

28 KiB
Raw Blame History

Chicken Babies R Us — Roadmap

High-level phased plan. Each phase ends in a mergeable dev state and a passing manual test. Claude implements phase-by-phase. This document intentionally avoids application code; the only code here is data model (dataclasses) and SQL schema, which are authoritative.


Phase 0 — Foundation

Completed: 2026-04-21

Summary: Scaffolded the FastAPI skeleton — package layout, pinned deps, multi-stage Dockerfile, compose file, typed config loader, structlog init, and a /healthz liveness endpoint surfacing app version + git commit SHA.

Key files:

  • app/__init__.py — package __version__ = "0.1.0"
  • app/main.pycreate_app() factory + module-level app; configures logging then mounts routers
  • app/config.pySettings(BaseSettings) with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinel SECRET_KEY in production; get_settings() is lru_cache-d
  • app/logging_config.pyconfigure_logging(app_env); ConsoleRenderer in dev, JSONRenderer otherwise
  • app/routes/health.pyAPIRouter mounting GET /healthz, typed HealthResponse pydantic model
  • app/models/, app/services/, app/templates/, app/static/ — placeholder dirs with .gitkeep
  • tests/test_healthz.py — FastAPI TestClient smoke test
  • requirements.txt — 17 pinned packages, >=X,<next-major ranges
  • .env.example — public env contract; .env stays gitignored
  • Dockerfile — multi-stage (builder + runtime), python:3.12-slim-bookworm, libmagic1 runtime; root user and no HEALTHCHECK (Phase 6 hardening)
  • docker-compose.ymlweb service with env_file: .env, ./data:/app/data bind mount, GIT_COMMIT_SHA build arg
  • .gitignore — adjusted data/ rule to data/* so !data/.gitkeep works

Endpoints created:

  • GET /healthz — public, unauthenticated. Returns minimal flat JSON {"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}.

Key details:

  • Healthz shape: minimal flat JSON (not the full code_guidelines envelope). Future phases that add JSON APIs will use the envelope; the healthcheck stays small on purpose.
  • Version source: app.__version__ string constant. Commit SHA source: GIT_COMMIT_SHA env var, baked into the image via ARG GIT_COMMIT_SHA=unknown + ENV GIT_COMMIT_SHA. Surfaces as "unknown" when unset (dev).
  • Config loader: single Settings class covers every ROADMAP env-var row. Fields not yet used at Phase 0 (RESEND_*, HCAPTCHA_*, ADMIN_CONTACT_EMAIL) are Optional[str] = None. SECRET_KEY defaults to the sentinel "dev-insecure-change-me"; a @model_validator(mode="after") refuses to boot if that sentinel survives into APP_ENV=production.
  • Admin emails: stored as raw comma-separated string; admin_emails_list property returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth).
  • Logging: configure_logging runs inside create_app() before any structlog.get_logger call; app_started structured event fires once at startup with app_env, version, commit_sha (no secrets).
  • Docker CMD: uvicorn runs with --proxy-headers --forwarded-allow-ips=127.0.0.1 (Phase 6 will swap the IP for Caddy's LAN address).
  • Verification run: python -c "from app.main import app" ✓ · pytest -q 1 passed ✓ · curl /healthz returned both the default "unknown" payload and the real commit SHA when GIT_COMMIT_SHA=$(git rev-parse HEAD) was set ✓ · docker compose config exit 0 ✓.
  • Branch: built on chore/phase-0-foundation off dev; merged --no-ff into dev on completion. Not pushed.

Phase 1 — Public Site Skeleton

Completed: 2026-04-21

Summary: Shipped the public brochure site: base Jinja layout with logo + nav + footer, mobile-first single-file CSS using the ROADMAP palette, and four public routes (/, /about, /contact, /shop). Blog index renders via a service stub returning []; Phase 2 swaps the body for SQLite without touching the route.

Key files:

  • app/models/posts.pyPostSummary @dataclass(frozen=True) with slug/title/published_at/excerpt — list-view projection used by the homepage; richer Post arrives in Phase 2 alongside.
  • app/services/posts.pyPostService.list_published(limit=20) -> list[PostSummary] stub returning []; get_post_service() DI helper (Phase 2 keeps the signature, swaps the body).
  • app/routes/public.pyAPIRouter with GET /, /about, /contact, /shop; pulls templates off app.state.templates via get_templates() DI helper.
  • app/templates/public/base.html — layout: skip link, <header>/<nav>/<main>/<footer>, aria-current on active nav item, <picture> logo (WebP + PNG fallback), favicon/apple-touch-icon links, mobile nav toggle via plain addEventListener script.
  • app/templates/public/home.html — blog index; loops _post_card.html or renders "No posts yet — check back soon!" on empty list.
  • app/templates/public/about.html — static placeholder copy (Head Hen rewrites via Phase 4 admin).
  • app/templates/public/contact.html — inert form: all inputs disabled, no method="POST", action="", shows mailto: link only if settings.admin_contact_email is truthy.
  • app/templates/public/shop.html — "Coming soon" card teasing eggs / chicks / waterfowl.
  • app/templates/public/partials/_post_card.html — single post-card partial.
  • app/static/css/site.css — single stylesheet: reset, :root palette tokens (--c-sky, --c-sky-deep, --c-cream, --c-wheat, --c-ink, --c-leaf) + spacing/radius scale, system font stacks, components, one 48rem breakpoint.
  • app/static/img/logo.png (573×256 RGBA), logo.webp (q=82, method=6), favicon.ico (16/32/48), apple-touch-icon.png (180×180 on #FAF3E7).
  • scripts/generate_static_assets.py — Pillow CLI StaticAssetBuilder that regenerates the four image assets from Logo/chicken babies r us.png; committed for reproducibility.
  • docs/MANUAL_TESTING.md — per-route + responsive (360/768/1280px) + a11y + static-assets checklist.
  • tests/test_public_routes.py — 7 tests (4 parametrized route smokes + empty-state copy + logo path + aria-current).
  • app/main.py — modified: StaticFiles mount at /static, Jinja2Templates instantiated once onto app.state.templates, public_router included; create_app() stays idempotent.

Endpoints created:

  • GET / — blog index (empty-state message until Phase 2 seeds content).
  • GET /about — static About page.
  • GET /contact — inert form + optional mailto: from ADMIN_CONTACT_EMAIL. Phase 5 replaces with a working POST.
  • GET /shop — "Coming soon" card.
  • Mount /staticStaticFiles serving app/static/{css,img} (and fonts/ later if ever needed).

Key details:

  • Stable seam for Phase 2: PostService.list_published() + PostSummary names are fixed contracts. Phase 2 only changes the method body to hit SQLite.
  • No DB, no CSRF, no CSP, no auth, no contact POST. Scope held strictly to the roadmap's Phase 1 bullets.
  • Templates live under app/templates/public/ to reserve app/templates/admin/ and app/templates/emails/ for Phases 35.
  • Logo delivery: <picture><source type="image/webp"><img alt="Chicken Babies R Us" height="48"></picture> — modern browsers pull the WebP, older ones fall back to PNG.
  • Address still not rendered. Only city + state ("Morrison, Tennessee") appear per CLAUDE.md's "address is intentionally not displayed" wording.
  • No new packages. Pillow / Jinja2 / Starlette StaticFiles were already in requirements.txt from Phase 0.
  • Verification run: python -c "from app.main import app" ✓ · pytest -q 8 passed ✓ · uvicorn smoke: /, /about, /contact, /shop, /healthz, /static/css/site.css, /static/img/logo.webp all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has disabled inputs and no method attribute ✓ · docker compose config exit 0 ✓.

Phase 2 — Content Model + Cache

Completed: 2026-04-21

Summary: Stood up the full SQLite content layer: all 7 tables from the authoritative schema, entity dataclasses + row mappers, hand-rolled versioned migrations with a schema_migrations tracker, idempotent Python seed (system user + welcome post + About page), a Markdown→HTML service with a strict bleach allowlist, a typed in-process TTL cache, and DB-backed PostService / PageService. / and /about now read from the DB.

Key files:

  • app/db.pycreate_engine() factory, per-connection PRAGMA listener (WAL + foreign_keys=ON), run_migrations(engine) runner that scans app/models/migrations/*.sql in lex order and records applications in schema_migrations.
  • app/models/entities.py — all 8 dataclasses (User, MagicLinkToken, Session, Page, Post, Media, ContactSubmission, AuthEvent) + PostStatus(str, Enum) matching roadmap 1:1. NOT frozen — Phase 3+ mutate last_login_at, used_at, etc.
  • app/models/mappers.pyrow_to_user/post/page/... converters, _parse_datetime / _parse_bool helpers.
  • app/models/migrations/001_init.sql — verbatim roadmap schema: 7 tables, idx_magic_email_created, idx_posts_status_pub, idx_auth_events_created, CHECK (status IN ('draft','published')).
  • app/models/seed.py — idempotent: marker seed_001 in schema_migrations + INSERT OR IGNORE belt-and-braces. Seeds user id=1 (seed@chickenbabies.local, "Head Hen", active=0 — not a real admin, cannot log in), post slug welcome-to-the-farm, page slug about.
  • app/services/cache.pyTTLCache[K, V] generic (~50 lines). get/set/invalidate_all(). Monotonic clock. 60s default TTL.
  • app/services/markdown.pyMarkdownService.render(md) -> str: MarkdownIt("commonmark") (tables disabled — allowlist doesn't include <table>) → bleach.clean(..., strip=True) with tags {p br strong em a ul ol li h1..h4 blockquote code pre img hr}, attrs {a:[href,title,rel], img:[src,alt,title,width,height]}, protocols {http https mailto}. No style, no class, no raw HTML pass-through.
  • app/services/posts.py — rewritten: PostService(engine) runs parameterized SELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit, converts rows to PostSummary. Excerpt derived from body_html_cached. TTL-cached. invalidate_all() exposed for Phase 4.
  • app/services/pages.pyPageService(engine).get_by_slug(slug) -> Page | None, TTL-cached.
  • app/main.py — wires engine, runs migrations, runs seed, instantiates services onto app.state.{engine,post_service,page_service}.
  • app/routes/public.py/about now pulls the seeded Page from PageService; renders {{ page.title }} + {{ page.body_html_cached | safe }}. Logs an anomaly and returns 500 with a generic message if the page is unexpectedly missing.
  • app/templates/public/about.html — replaced static body with the dynamic page; layout kept.
  • tests/conftest.pydb_engine (session, seeded) + clean_db_engine (function, migrated-only) fixtures, both on temp SQLite files.
  • tests/test_db_migrations.py, test_markdown.py, test_cache.py, test_post_service.py, test_page_service.py — service + schema coverage.
  • tests/test_public_routes.py — updated: homepage now asserts "Welcome to the Farm"; /about asserts seeded Markdown substring.

Endpoints created: none new; / and /about were rewired to DB-backed services (same URLs, same response shapes).

Key details:

  • Migration pattern: every SQL file under app/models/migrations/ gets its own transaction; already-applied files are skipped by checking schema_migrations. Adding a new phase = add NNN_description.sql. The bootstrap for schema_migrations itself is baked into the runner (creates the table before querying it).
  • PRAGMAs are per-connection via @event.listens_for(Engine, "connect") — every pooled connection gets WAL + FK-on, not just the first. There's an explicit test covering this.
  • Seed idempotency is double-guarded: schema_migrations marker seed_001 + INSERT OR IGNORE on every row. Second boot logs seed_skipped; counts stay 1/1/1 (users/pages/posts).
  • PostSummary excerpt is derived from body_html_cached (HTML-stripped + truncated), not re-rendered from body_md. Phase 4 writers store both; readers never touch Markdown.
  • No Markdown tables yet. MarkdownIt.enable("table") was deliberately NOT called — the bleach allowlist doesn't pass <table>. Future tables require widening both layers together; a test documents this invariant.
  • Address still not rendered. Seeded About Markdown mentions Morrison, TN only (no street address, per CLAUDE.md).
  • Phase 3 hooks ready: users / magic_link_tokens / sessions / auth_events tables exist with their indexes; User dataclass + PostStatus enum + row-mapper helpers available.
  • Phase 4 hooks ready: PostService.invalidate_all() + PageService.invalidate_all() exist (no-op callers today). Admin writes will call these after each mutation.
  • No new packages. All deps were already pinned in Phase 0's requirements.txt.
  • Verification run: python -c "from app.main import app" ✓ · pytest -q 36 passed ✓ · fresh-boot smoke: / shows welcome title, /about shows seeded Markdown, /healthz 200 ✓ · PRAGMA journal_mode=wal ✓ · second boot logs migrations_up_to_date + seed_skipped, table counts stay users=1 pages=1 posts=1 ✓ · docker compose config exit 0 ✓.

Completed: 2026-04-21

Summary: Passwordless admin auth end-to-end: email form → 256-bit magic-link token (SHA-256 at rest, 15-min TTL, atomic single-use consume) → Resend email with dev-log fallback → itsdangerous-signed server-side session cookie (30d) → /admin landing → logout revokes the row without deleting. SlowAPI per-IP + DB per-email rate limits, ADMIN_EMAILS allowlist with anti-enumeration, every event audited to auth_events.

Key files:

  • app/services/audit.pyAuditService.record(event_type, ...) writes an auth_events row + mirrors the event to structlog; detail is JSON. Referenced session by last-6 hash chars only.
  • app/services/email.pyEmailService.send_magic_link(...): renders HTML + text templates, posts via Resend when resend_api_key set, otherwise logs magic_link_dev_fallback with the URL (dev shortcut). Never raises from the request path; production startup refuses to boot without the key.
  • app/services/sessions.pySessionService(engine, signer, settings)create/lookup/revoke. Raw session ID exists only in memory and the signed cookie; DB stores sha256(raw). Cookie cb_session, HttpOnly=True, SameSite=lax, Secure ON in prod / OFF in dev, Path=/, Max-Age=SESSION_MAX_DAYS*86400.
  • app/services/auth.pyAuthService.request_link (allowlist check → DB per-email rate-limit count → insert token row → send email → audit) and AuthService.consume (atomic UPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now, then upsert users row, then SessionService.create). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.
  • app/services/rate_limit.py — module-level limiter = Limiter(key_func=get_remote_address, storage_uri="memory://"). Singleton because @limiter.limit decorates at import time.
  • app/routes/admin.py — all admin HTTP routes; POST /admin/login decorated @limiter.limit("5/15 minutes"), GET /admin/auth/consume/{token} decorated @limiter.limit("20/15 minutes").
  • app/dependencies/auth.pyget_current_user and require_admin (raises HTTPException(status_code=303, headers={"Location": "/admin/login"})).
  • app/templates/admin/base.html, login.html, login_sent.html, login_failed.html, index.html, rate_limited.html.
  • app/templates/emails/magic_link.html + magic_link.txt — magic-link email bodies.
  • tests/test_auth_service.py, test_admin_routes.py, test_rate_limit.py, test_session_service.py, test_email_service.py — 25 new tests covering token lifecycle, single-use, expiry, allowlist anti-enumeration, signed-cookie round-trip, revoke, user upsert, IP + DB email limits, dev email fallback, production config refusal.
  • app/main.py — builds URLSafeTimedSerializer(salt="session"), instantiates audit/email/sessions/auth onto app.state, installs SlowAPI RateLimitExceeded handler that renders admin/rate_limited.html with 429 and audits rate_limited with scope="ip", includes admin router.
  • app/config.py — added public_base_url field (default http://127.0.0.1:8000); added _require_auth_config_in_production model validator: production boot refuses empty RESEND_API_KEY / RESEND_FROM / ADMIN_EMAILS.
  • .env.example — added PUBLIC_BASE_URL=http://127.0.0.1:8000.

Endpoints created:

  • GET /admin/login — email-only login form (public, no CSRF — pre-auth).
  • POST /admin/login — rate-limited (5/15min/IP + 5/15min/email via DB count). Always renders login_sent.html regardless of allowlist (anti-enumeration). Audit row link_requested with {"allowlisted": bool} every time.
  • GET /admin/auth/consume/{token} — rate-limited (20/15min/IP). Atomic single-use consume; on success sets cb_session cookie + 303 to /admin; on failure renders generic login_failed.html (no reason leakage).
  • GET /adminrequire_admin; renders placeholder index.html with {{ user.display_name }} + logout form. Phase 4 replaces with real CMS dashboard.
  • POST /admin/logoutrequire_admin; flips sessions.revoked_at = now, clears cookie with Max-Age=0, 303 to /admin/login. Row preserved for audit. Marked # TODO(phase-6-csrf) — SameSite=Lax blocks cross-site POSTs in current browsers; Phase 6 adds a double-submit token.

Key details:

  • Hash-at-rest for both tokens and session IDs. DB stores sha256(raw).hexdigest(); raw values never persisted, never logged. Audit detail only references last-6 hash chars for correlation.
  • Cookie signed with itsdangerous URLSafeTimedSerializer(secret_key, salt="session"). Forged cookie fails signature check before any DB lookup.
  • Anti-enumeration verified: non-allowlisted POST → same 200 HTML response, zero rows inserted into magic_link_tokens, link_requested audit logs {"allowlisted": false}.
  • Rate limit verified: 5 POSTs succeed + 6th returns 429 from same IP; DB per-email count applied even when SlowAPI would allow.
  • User auto-upsert on consume: first successful consume inserts a users row with display_name = local-part.title() (e.g. driver@example.com → "Driver"), active=1; subsequent logins update last_login_at.
  • Dev Resend fallback: when resend_api_key is falsy, EmailService emits magic_link_dev_fallback structured log with the full URL and returns cleanly — never 500s the request path.
  • Production config guardrails: app_env == "production" requires non-empty RESEND_API_KEY, RESEND_FROM, ADMIN_EMAILS. Missing any → ValueError at startup.
  • Single-writer deadlock avoided: AuthService.consume does the atomic token update inside engine.begin(), then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee via rowcount==1).
  • Audit trail smoke-tested rows: link_requested (per attempt), link_consumed, session_created, session_revoked, rate_limited, consume_failed — all emitted during driver verification.
  • Phase 4 hooks ready: require_admin dependency + get_current_user are callable from any admin route. Admin CMS POSTs will reuse them.
  • Phase 6 TODO markers on the logout handler flag the CSRF pickup point.

Verification run: python -c "from app.main import app" ✓ · pytest -q 61 passed ✓ · end-to-end smoke (fresh DB, driver allowlisted): login form 200, POST login 200, dev-log URL captured, consume 200 + cookie set, /admin 200 with "Welcome, Driver", logout 303, post-logout /admin 303 to login ✓ · IP rate limit fires at 5 (6th is 429) ✓ · non-allowlisted POST 200 + zero tokens in DB ✓ · auth_events shows link_requested/link_consumed/session_created/session_revoked/rate_limited rows as expected ✓ · docker compose config exit 0 ✓.

Phase 4 — Admin CMS

  • /admin dashboard: 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-pybleach allowlist → stored in body_html_cached.

Phase 5 — Contact Form

  • /contact POST flow: field validation → hCaptcha verify → honeypot check → rate limit → Resend send → persist submission row → success page.
  • FROM on 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 of data/media/; cron nightly on the VM.
  • Dockerfile hardening: non-root user, slim base, HEALTHCHECK.
  • Gitea Action: on tag push, build image and publish to the internal registry.

Phase 7 (Future) — Shop

  • Stripe Checkout integration (test-mode first).
  • Product catalog: eggs (fertile/hatchable, eating), live birds (geese, ducks, chickens).
  • Inventory counts, order history, email confirmations.
  • Admin views for orders.

Data Model (dataclasses)

from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional


class PostStatus(str, Enum):
    DRAFT = "draft"
    PUBLISHED = "published"


@dataclass
class User:
    id: int
    email: str
    display_name: str
    created_at: datetime
    last_login_at: Optional[datetime]
    active: bool


@dataclass
class MagicLinkToken:
    id: int
    email: str
    token_hash: str            # sha256 of raw token; raw token never stored
    created_at: datetime
    expires_at: datetime
    used_at: Optional[datetime]
    request_ip: str


@dataclass
class Session:
    id: int
    user_id: int
    token_hash: str
    created_at: datetime
    expires_at: datetime
    ip: str
    user_agent: str
    revoked_at: Optional[datetime]


@dataclass
class Page:
    id: int
    slug: str                  # e.g. "about"
    title: str
    body_md: str
    body_html_cached: str      # sanitized, ready-to-render HTML
    updated_at: datetime
    published: bool


@dataclass
class Post:
    id: int
    slug: str
    title: str
    body_md: str
    body_html_cached: str
    status: PostStatus
    published_at: Optional[datetime]
    updated_at: datetime
    author_user_id: int


@dataclass
class Media:
    id: int
    filename: str              # random storage name
    original_filename: str
    content_type: str
    size_bytes: int
    stored_path: str           # e.g. data/media/2026/04/abc123.jpg
    alt_text: str
    uploaded_by: int
    uploaded_at: datetime


@dataclass
class ContactSubmission:
    id: int
    name: str
    email: str
    message: str
    ip: str
    user_agent: str
    submitted_at: datetime
    handled: bool


@dataclass
class AuthEvent:
    id: int
    event_type: str            # link_requested | link_consumed | session_revoked | rate_limited
    email: Optional[str]
    user_id: Optional[int]
    ip: str
    user_agent: str
    created_at: datetime
    detail: str                # JSON string

SQLite Schema (authoritative)

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL,
  created_at TEXT NOT NULL,
  last_login_at TEXT,
  active INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE magic_link_tokens (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL,
  token_hash TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL,
  expires_at TEXT NOT NULL,
  used_at TEXT,
  request_ip TEXT NOT NULL
);
CREATE INDEX idx_magic_email_created ON magic_link_tokens(email, created_at);

CREATE TABLE sessions (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  token_hash TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL,
  expires_at TEXT NOT NULL,
  ip TEXT NOT NULL,
  user_agent TEXT NOT NULL,
  revoked_at TEXT
);

CREATE TABLE pages (
  id INTEGER PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  body_md TEXT NOT NULL,
  body_html_cached TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  published INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE posts (
  id INTEGER PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  body_md TEXT NOT NULL,
  body_html_cached TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('draft','published')),
  published_at TEXT,
  updated_at TEXT NOT NULL,
  author_user_id INTEGER NOT NULL REFERENCES users(id)
);
CREATE INDEX idx_posts_status_pub ON posts(status, published_at DESC);

CREATE TABLE media (
  id INTEGER PRIMARY KEY,
  filename TEXT NOT NULL UNIQUE,
  original_filename TEXT NOT NULL,
  content_type TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  stored_path TEXT NOT NULL,
  alt_text TEXT NOT NULL DEFAULT '',
  uploaded_by INTEGER NOT NULL REFERENCES users(id),
  uploaded_at TEXT NOT NULL
);

CREATE TABLE contact_submissions (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  message TEXT NOT NULL,
  ip TEXT NOT NULL,
  user_agent TEXT NOT NULL,
  submitted_at TEXT NOT NULL,
  handled INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE auth_events (
  id INTEGER PRIMARY KEY,
  event_type TEXT NOT NULL,
  email TEXT,
  user_id INTEGER REFERENCES users(id),
  ip TEXT NOT NULL,
  user_agent TEXT NOT NULL,
  created_at TEXT NOT NULL,
  detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_auth_events_created ON auth_events(created_at DESC);

Caching Strategy

  • Row-level cache: pages.body_html_cached and posts.body_html_cached store the fully rendered, sanitized HTML. Regenerated on write, never on read. Request path = one indexed SELECT + one template render.
  • Query cache: a small in-process TTL cache (60 s) wraps hot queries (published-posts list, page-by-slug). Keyed by route + slug. Explicitly invalidated by any admin write.
  • No Redis / memcached: traffic scale (single-digit requests/second at most) doesn't justify it.

Visual Design

Palette anchored on light blues (Head Hen's preference), softened with farm neutrals. CSS custom-property tokens:

Token Value Use
--c-sky #A9CCE3 Primary surfaces, header accent
--c-sky-deep #5D8AA8 Links, active states
--c-cream #FAF3E7 Page background
--c-wheat #E4D4A8 Card surfaces, subtle emphasis
--c-ink #2B3A42 Body text
--c-leaf #7FA66B Success / small accent

Typography:

  • One serif display face for headers (system serif fallback).
  • One humanist sans for body (system sans fallback).
  • Self-host any webfont. Do not hit Google Fonts on the hot path.

Logo:

  • Source assets in Logo/.
  • Ship PNG (and ideally a WebP conversion) in app/static/img/.
  • Header uses logo at ~48 px tall; include alt="Chicken Babies R Us".

Environment Variables (contract)

.env.example will expose these. None have defaults baked in for secrets.

Var Purpose
APP_ENV development | production
SECRET_KEY itsdangerous signer for cookies / CSRF
DATABASE_URL sqlite:///data/app.db
RESEND_API_KEY Resend server key
RESEND_FROM e.g. no-reply@chickenbabies.example
ADMIN_EMAILS Comma-separated allowlist
ADMIN_CONTACT_EMAIL Target inbox for contact form
HCAPTCHA_SITE_KEY Public key (rendered in template)
HCAPTCHA_SECRET Server verification secret
FORWARDED_ALLOW_IPS Caddy LAN IP (for Uvicorn)
SESSION_MAX_DAYS Default 30
MAGIC_LINK_TTL_MIN Default 15