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>
This commit is contained in:
27
docs/README.md
Normal file
27
docs/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Documentation Folder
|
||||
|
||||
This folder contains business planning, architecture decisions, and documentation.
|
||||
|
||||
**No application code belongs here.**
|
||||
|
||||
## Contents
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `ROADMAP.md` | Phased build plan, data model (dataclasses), SQL schema, visual design, env-var contract |
|
||||
| `MANUAL_TESTING.md` *(added in Phase 1)* | Manual test checklist for the public site + admin |
|
||||
|
||||
## Related Docs (in repo root)
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `../CLAUDE.md` | Project instructions — stack, topology, security must-haves, git flow |
|
||||
| `../code_guidelines.md` | Generic Python coding standards (FastAPI overrides its Flask default for this project) |
|
||||
| `../security.md` | Python security baseline (OWASP-aligned) |
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep documents focused and concise.
|
||||
- Update docs when architecture decisions change.
|
||||
- Use markdown tables for structured information.
|
||||
- Link to external documentation where relevant.
|
||||
326
docs/ROADMAP.md
Normal file
326
docs/ROADMAP.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# 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/<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` |
|
||||
Reference in New Issue
Block a user