eleven / messenger Public

docs: add CLAUDE.md (E2EE invariant, conventions, build/deploy)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 7b9d842bd136da3dedb2e8549fde797471314002 parent 979c71c
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.