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>
10 KiB
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. structloginit 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_EMAILSenv 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
/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 |