docs: add CLAUDE.md (E2EE invariant, conventions, build/deploy)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+124
−0
CLAUDE.md+124 −0
diff --git a/CLAUDE.md b/CLAUDE.md| new file mode 100644 |
| index 0000000..ef913df |
| --- /dev/null |
| +++ b/CLAUDE.md |
| @@ -0,0 +1,124 @@ |
| +# CLAUDE.md |
| + |
| +Working notes for anyone (human or agent) changing this codebase. User-facing |
| +docs and the encryption rationale live in `README.md`; this file is about how we |
| +build and the decisions we've committed to. |
| + |
| +## The one rule: end-to-end encryption is load-bearing |
| + |
| +The whole point of lchat is **"if the server is compromised, the attacker gets |
| +nothing."** Every change must preserve that: |
| + |
| +- The server only ever stores/relays **opaque ciphertext**. It must never see |
| + plaintext message content, screen names, or file bytes, and must never receive |
| + or store the group key. |
| +- The shared **AES-GCM key lives in the URL `#fragment`** (`/join/<token>#k=…` |
| + and `/admin#token=…&k=…`). Fragments are never sent to the server. All |
| + encryption/decryption happens in the browser (`web/static/crypto.js`). |
| +- The **admin token** authorises invite management only — it is *not* a |
| + decryption key. Store only its hash; never log tokens or keys. |
| +- When you add a feature that handles user content, encrypt it client-side and |
| + store a blob. Example: attachments upload `encryptBytes` output; the filename |
| + and MIME type ride *inside* the encrypted message payload, never as server |
| + metadata. |
| + |
| +The single deliberate exception: **link previews** (`unfurl.go`) — the server |
| +fetches a posted URL to build an OpenGraph card, so it briefly sees URLs (not |
| +message text). This was an explicit, accepted trade-off and is easy to gate |
| +behind a setting later. |
| + |
| +## Architecture |
| + |
| +Single static Go binary; the frontend and a pure-Go SQLite engine are compiled |
| +in, so deploy is one file (no cgo). |
| + |
| +- `main.go` — routes (all wired in `Server.handler()` so tests exercise the real |
| + router via `httptest`), HTTP handlers, bootstrap. Reads `LCHAT_ADMIN_TOKEN`, |
| + `LCHAT_DB`, `ADDR` from env. |
| +- `db.go` — SQLite (WAL, `modernc.org/sqlite`), all tables + queries. Migrations |
| + are additive `CREATE TABLE IF NOT EXISTS`, so deploying over existing data is |
| + safe. |
| +- `hub.go` — WebSocket fan-out (`coder/websocket`). Handles `msg` / `react` / |
| + `unreact`; ignores unknown types (e.g. the client keepalive `ping`). |
| +- `push.go` — Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`). |
| +- `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache). |
| +- `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped). |
| +- `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed` |
| + with `Cache-Control: no-cache`. |
| + |
| +Frontend module layout: |
| +- `crypto.js` — AES-GCM helpers (`encrypt`/`decrypt` for JSON, `encryptBytes`/ |
| + `decryptBytes` for binary). Format is base64url/`iv(12)||ciphertext`. |
| +- `chat.js` — the chat page (render, WebSocket, composer, reactions, attachments, |
| + link previews). |
| +- `format.js`, `ui.js` — **pure, framework-free helpers kept separate so they're |
| + unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`). |
| +- `push.js` / `sw.js` — push subscription + service worker. |
| +- `admin.js` — admin console (invites, group name, encryption key). |
| + |
| +## Conventions |
| + |
| +- **Never `innerHTML` untrusted content.** Build DOM nodes / use `textContent`. |
| + `innerHTML` is only acceptable with static, in-code strings (e.g. `ICON_X`). |
| +- **Token-gate every data endpoint** with `s.tokenOK(token)` (or the equivalent |
| + invite check) — messages, uploads, blob downloads, unfurl, push subscribe. |
| +- **Pull pure logic into a testable module** rather than burying it in DOM code, |
| + then test it (see `format.js`/`ui.js` + `test/*.mjs`). |
| +- **Platform split** via `isCoarse` (`matchMedia("(pointer: coarse)")`): |
| + - Composer: desktop Enter sends / Shift+Enter newlines; touch Enter newlines, |
| + the send button sends (`shouldSend`). |
| + - Reactions: **touch = double-tap, desktop = press-and-hold.** Long-press is |
| + left free on touch so native text selection/copy works. |
| +- **Images** are downscaled in the browser before upload (`processImage`, max |
| + 2048px long edge) — there is no server-side image proxy (it only has |
| + ciphertext). Re-encoding also strips EXIF/GPS. |
| +- **Push notifications** suppress two cases: your *own* messages (server skips the |
| + sender's subscriptions by token) and messages while the app is open (the page |
| + writes a `lastActiveAt` heartbeat to IndexedDB that the SW checks — more |
| + reliable than `clients.matchAll().focused` on iOS). The admin "test" push |
| + always shows. |
| +- Match the surrounding code's style and comment density. Comment the **why**, |
| + not the what. |
| + |
| +## Build & test |
| + |
| +```sh |
| +go build -o lchat . # or: CGO_ENABLED=0 go build ./... |
| +go test ./... # db_test, handlers_test, unfurl_test (httptest) |
| +npm test # node test/{crypto,ui,format}.test.mjs |
| +node --check web/static/chat.js # JS isn't run by tests; syntax-check edits |
| +``` |
| + |
| +Add tests as you go. Go handler tests use `newTestServer(t)` which returns |
| +`(*httptest.Server, *Server)` so you can seed the DB directly. SSRF tests flip |
| +`allowPrivatePreviewHosts` to reach a loopback `httptest` server. |
| + |
| +## Deploy |
| + |
| +Hosted on a Hetzner box via **Miren**, cluster `luanchat`. Five fully-isolated |
| +instances (each its own app, disk, admin token, subdomain): `lchat` |
| +(group.luan.chat), `test`, `paja`, `paulandciara`, `miren`. |
| + |
| +```sh |
| +# Update an existing instance — DATA-SAFE. Reuse its token; never mint a new one. |
| +TOK="$(miren env get LCHAT_ADMIN_TOKEN -u -a <app> -C luanchat)" |
| +./deploy-instance.sh <app> <host> "$TOK" |
| +``` |
| + |
| +- A plain `miren deploy` (what `deploy-instance.sh` runs) **preserves the disk**. |
| + **Never `miren app delete` to update** — that wipes the disk. Use delete only |
| + to intentionally reset an instance. |
| +- `deploy-instance.sh` **mints a new admin token if arg 3 is omitted** — always |
| + pass the recovered token for an existing instance. |
| +- Miren gotchas: use `LCHAT_ADMIN_TOKEN` (a bare `ADMIN_TOKEN` is auto-overridden |
| + by Miren); set env with `-e`, not `-s` (sensitive vars aren't injected). |
| +- `test` is the throwaway for verifying on real devices; keep experiments there. |
| + |
| +## Commits |
| + |
| +- Author is `paul@rslw.com` (set locally for this repo). |
| +- Group into **logical commits**; prefix by area: `web:`, `server:`, `admin:`, |
| + `sw:`, `crypto:`. Frontend files (`chat.js`/`style.css`) are often touched by |
| + several features at once — commit by layer when they can't be cleanly split. |
| +- End commit messages with the `Co-Authored-By:` trailer for the assisting model. |
| +- Commit/push/deploy only when asked. |