Files
chicken_babies_site/docs/ROADMAP.md
Phillip Tarrant bf4352231a docs: bootstrap project instructions and roadmap
Add CLAUDE.md with authoritative stack (FastAPI + Jinja2 + SQLite),
deployment topology (CF-proxied -> OPNsense -> Caddy -> VM),
security must-haves, magic-link auth, and project git flow.

Add docs/ROADMAP.md with phased build plan, dataclasses, SQL schema,
caching strategy, visual design tokens (light-blue farm palette),
and .env contract.

Commit generic code_guidelines.md and security.md, note FastAPI
per-project override. Add docs/README.md. Commit logo assets and
.gitignore (venv, .env, data/, SQLite, caches).

No app code in this pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:22:34 -05:00

10 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

  • 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.
  • /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/<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