# 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 - Scaffold `app/` package, `requirements.txt`, `.env.example`, `Dockerfile`, `docker-compose.yml`. - Pinned deps (upper bounds): `fastapi`, `uvicorn[standard]`, `jinja2`, `pydantic`, `pydantic-settings`, `sqlalchemy` (Core only), `markdown-it-py`, `bleach`, `Pillow`, `python-magic`, `resend`, `slowapi`, `structlog`, `itsdangerous`, `python-multipart`, `pytest`, `httpx`. - `structlog` init at app startup. - Health endpoint `/healthz` (returns app version + commit SHA from env). - Typed config loader reading env via `pydantic-settings`. ## Phase 1 — Public Site Skeleton - Base Jinja layout: header with logo, nav (Home · About · Contact · Shop (disabled)), footer. - Mobile-first responsive CSS, no JS framework. CSS custom properties from the palette below. - Routes: `/`, `/about`, `/contact`, `/shop` (shop shows "Coming soon" card, no form). - `/` renders the blog index from DB (empty list is acceptable this phase). - Manual test checklist → `docs/MANUAL_TESTING.md`. ## Phase 2 — Content Model + Cache - SQLite schema (below) with `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`. - Dataclasses (below) as the in-app model; SQL → dataclass mapper lives in `app/models/`. - Row-level rendered-HTML cache (`body_html_cached`) regenerated on write. - In-process TTL cache (60 s) over *hot query results* (published posts list, page-by-slug); invalidated on admin writes. - Initial migration seeds one welcome blog post + an About page so the site is not blank before admin exists. ## Phase 3 — Admin Auth (Magic Link) - `/admin/login` — email-only form. - POST creates a magic-link token (256-bit, hashed at rest, 15-min TTL, single-use), sends via Resend. - Click link → create signed session cookie (30-day) → redirect `/admin`. - Logout revokes the session row (does not delete — audit trail). - Rate limits (SlowAPI): 5 / 15 min / IP *and* / email. - Allowlist from `ADMIN_EMAILS` env var. Non-allowlisted addresses: return same success message, send no email (no user enumeration). - Audit log row for every auth event. ## 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///.`. - 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-.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` |