diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7bfbec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Companion docs (read these first for non-trivial work) + +- `PROJECT.md` — architecture, auth flow, non-negotiable security properties, what is/isn't safe to change +- `INSTALL.md` — one-time VPS setup +- `OPERATIONS.md` — deploy, invite management, backups, troubleshooting +- `CHECKLIST.md` — manual test matrix keyed by the area you touched (A through I). After any change, mentally walk the relevant section and call out which items the change plausibly affects. + +## Common commands + +```bash +npm install # install deps +npm run dev # node --watch server.js, binds 127.0.0.1:3000 +npm start # production start +node bin/invite.js add # invite (also: remove, list) +``` + +There is no test suite, linter, or build step. Verification is the checklist in `CHECKLIST.md`, primarily by walking the entrance → code → timeline flow in a browser. + +For local dev, `.env` must set `CODE_PEPPER` (≥32 chars; `openssl rand -hex 32`) and SMTP credentials — the server hard-exits on boot without a valid pepper. `NODE_ENV=production` toggles the `Secure` cookie flag, so leave it unset locally (HTTP). + +## Architecture + +Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public ingress. ESM only (`"type": "module"`). + +**Request routing lives in `server.js`** and is deliberately ordered. Understanding this order is the key to the security model: + +1. Security headers + CSP (strict — `script-src 'self'`, no inline scripts) +2. `/auth/*` router (public) +3. `GET /` dispatches to `protected/index.html` (timeline) if `currentSession(req)` is set, else `public/entrance.html` — the same URL serves different pages depending on cookie state +4. `express.static(public)` — ungated assets +5. `requireAuth` **then** `express.static(protected)` — gating runs *before* the file is read off disk. Adding a file to `protected/` gates it automatically; adding to `public/` exposes it automatically. + +**Auth flow** (see `src/auth.js`): email → 6-digit code (HMAC-SHA256 with `CODE_PEPPER`, 10-min TTL) → `/auth/verify-code` (constant-time compare, 5 wrong guesses nukes the code) → opaque 256-bit session ID stored in SQLite, set as `HttpOnly; Secure; SameSite=Lax` cookie. No JWTs; revocation is a DELETE. `/auth/request-code` always returns 200 regardless of invite status (email-enumeration defense). + +**Storage** (`src/db.js`): `better-sqlite3` at `data/fenja.sqlite`, WAL mode, tables `invites` / `codes` / `sessions` / `rate_limits`. Prepared statements exported as `q.*`. A `setInterval().unref()` sweeps expired rows every 5 minutes. + +**Rate limiting** (`src/middleware.js`) is a SQLite-backed sliding window keyed per-IP — 5 code requests/hour, 20 verify attempts/hour. Nginx adds another layer via a `limit_req_zone` declared in `/etc/nginx/nginx.conf`. + +## Security invariants — do not violate without explicit approval + +These are from `PROJECT.md`. A change that breaks any of them is a security regression even if nothing visibly breaks: + +- Protected HTML must never be readable without a valid session cookie — `requireAuth` runs before `express.static(protected)`, don't reorder. +- Session cookie: `HttpOnly`, `Secure` (prod), `SameSite=Lax`, opaque random 256-bit ID. +- Codes hashed with HMAC-SHA256 using `CODE_PEPPER`; comparisons via `crypto.timingSafeEqual`. Never change the pepper on a live server (invalidates pending codes). +- `/auth/request-code` always 200 past the format check (no enumeration). +- No inline `