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.
445 lines
28 KiB
Markdown
445 lines
28 KiB
Markdown
# 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-level `app`; configures logging then mounts routers
|
||
- `app/config.py` — `Settings(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.py` — `configure_logging(app_env)`; `ConsoleRenderer` in dev, `JSONRenderer` otherwise
|
||
- `app/routes/health.py` — `APIRouter` 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.yml` — `web` 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.py` — `PostSummary` `@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.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` — `APIRouter` 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 /static` — `StaticFiles` 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 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.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.py` — `create_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.py` — `row_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.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}`. 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.py` — `PageService(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.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"; `/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 ✓.
|
||
|
||
## 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 an `auth_events` row + mirrors the event to structlog; `detail` is JSON. Referenced session by last-6 hash chars only.
|
||
- `app/services/email.py` — `EmailService.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.py` — `SessionService(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.py` — `AuthService.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.py` — `get_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 /admin` — `require_admin`; renders placeholder `index.html` with `{{ user.display_name }}` + logout form. Phase 4 replaces with real CMS dashboard.
|
||
- `POST /admin/logout` — `require_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-py` → `bleach` 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)
|
||
|
||
```python
|
||
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)
|
||
|
||
```sql
|
||
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` |
|