docs: refresh the README — router-first deploy + accuracy pass (#191)
* docs: lead the README deploy section with the self-hosted router Restructure deployment into a `## Deploy` umbrella and make the self-hosted router the default, with Miren as one option among several: - Self-hosted router (recommended) — bootstrap + add-instance + versioned, zero-downtime deploys; links to docs/instance-router.md. - A single instance behind your own reverse proxy (the minimal path). - Miren (reframed as one managed option; content otherwise unchanged). - Other platforms (Fly/Render/Railway/Heroku) — the shared single-binary + persistent-volume recipe; per-platform write-ups left as TODO. Previously the README led with Miren and mentioned a reverse proxy only in passing. Also points the "Build a single binary" section at the new Deploy list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: bring the rest of the README up to date - Tests: `npm test` runs ~10 frontend suites now (not just crypto.test.mjs); the Go tests also cover the router / versioned-deploy control plane. - Layout: reflect the real tree — 13 Go files incl. the router control plane, and shell.js + apps/*.js instead of the retired chat.js. - Other platforms: framed as trivial-but-untested (the recipe, not a proven path). - Encryption: note the per-instance key (profile photos / all-members content). - Fix the XcodeGen link (yonsm → yonaskolb). - Drop "Not in this first slice" — image sharing, typing indicators, message deletion, and key backup are all built now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: reframe the README title/tagline to Eleven Messenger Lead with the product name and drop the stale "tiny, kid-friendly" framing; keep `lchat` as the engine/binary/CLI name, with a note up top clarifying that Eleven Messenger is the product built on it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+121
−29
README.md+121 −29
diff --git a/README.md b/README.md| index 5941fde..294ff2d 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -1,11 +1,15 @@ |
| -# lchat |
| +# Eleven Messenger |
| -A tiny, kid-friendly chat — DMs and group chats, no logins, no passwords, just |
| -secret invite links. Messages are **end-to-end encrypted**: the server only ever |
| -sees ciphertext, so a stolen database or server snapshot exposes no message |
| +A small, self-hostable messenger — DMs and group chats, no logins, no passwords, |
| +just secret invite links. Messages are **end-to-end encrypted**: the server only |
| +ever sees ciphertext, so a stolen database or server snapshot exposes no message |
| content. (It does see metadata — screen names and who shares a thread with whom; |
| see Trade-offs.) |
| +> **`lchat`** is the engine — the Go binary + CLI, and the name used throughout |
| +> this repo. **Eleven Messenger** is the product built on it (the web client plus |
| +> the native iOS/macOS apps). |
| + |
| ## How the encryption works |
| - Each person has their own **keypair** (ECDH P-256), generated in the browser on |
| @@ -13,6 +17,9 @@ see Trade-offs.) |
| device (it ships, encrypted, only via an "add a device" link). |
| - Every **thread** — a DM (2 people) or a group (N) — has its own **AES-GCM key**, |
| wrapped to each member's public key (ECIES). Only members can unwrap it. |
| +- Content shared with everyone (e.g. profile photos) uses one **per-instance key**, |
| + wrapped to each member the same way — so even that reaches the server only as a |
| + blob it can't open. |
| - The browser encrypts every message (including the sender's screen name) before |
| sending, and decrypts on receipt. The server stores and relays only opaque blobs |
| plus key material it can't open. |
| @@ -72,16 +79,16 @@ go build -o lchat . # native |
| GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box |
| ``` |
| -Copy `lchat` to your server and run it behind a TLS-terminating reverse proxy |
| -(Caddy/nginx). **HTTPS is required in production** — the invite token travels to |
| -the server, and the fragment-key model assumes the transport is encrypted. |
| +See **[Deploy](#deploy)** for how to run it in production — a self-hosted router |
| +(the default), a single instance behind your own reverse proxy, Miren, or another |
| +platform. |
| ## Native iOS & macOS app |
| There's a native SwiftUI client in [`ios/`](ios/README.md) that runs on |
| iPhone/iPad and on macOS via **Mac Catalyst** (same target, same code, same |
| end-to-end encryption contract). The `.xcodeproj` is generated by |
| -[`xcodegen`](https://github.com/yonsm/XcodeGen), not committed. |
| +[`xcodegen`](https://github.com/yonaskolb/XcodeGen), not committed. |
| You can build both variants from the command line **without launching Xcode** |
| (handy for CI or a quick compile check) — `CODE_SIGNING_ALLOWED=NO` skips the |
| @@ -123,11 +130,71 @@ instead: `-sdk iphonesimulator` in place of the `-destination` flag. See |
| [`ios/README.md`](ios/README.md) for signing, running on a device, and how the |
| client maps to the server. |
| -## Deploy to Miren |
| +## Deploy |
| + |
| +lchat is a single static binary that keeps all its state in one SQLite file and |
| +speaks WebSockets on `/ws`. Deploying it anywhere is the same three things: run |
| +the binary, give it a **persistent path for the database** (`LCHAT_DB`), and put |
| +it behind **HTTPS** — required in production, since the invite token travels to |
| +the server and the fragment-key model assumes an encrypted transport. SQLite has |
| +a single writer, so run **one instance per database** (scale by adding instances, |
| +not replicas of one). |
| + |
| +### Self-hosted router (recommended) |
| + |
| +How we run it, and the default for hosting **one or many** chats: a single box |
| +runs lchat's own built-in TLS router, and each chat is a fully-isolated instance |
| +on its own subdomain, database, and admin token — automatic Let's Encrypt certs, |
| +no external reverse proxy, no container platform. Because the server is one |
| +static binary, the whole dependency story is "copy the binary." |
| + |
| +```sh |
| +# on a fresh box (Ubuntu; inbound 80 + 443 open), as root: |
| +scp lchat root@box:/opt/lchat/lchat |
| +ssh root@box /opt/lchat/lchat bootstrap # installs the systemd units + starts the router |
| + |
| +# add a chat — point its DNS A record at the box first; the TLS cert then |
| +# issues automatically on the first HTTPS request: |
| +ssh root@box /opt/lchat/lchat add-instance chat.example.com |
| +ssh root@box /opt/lchat/lchat admin-link chat.example.com # prints its admin URL |
| +``` |
| + |
| +Add as many chats as you like; each is isolated. Updates are **versioned and |
| +zero-downtime** — build a binary, stage it, then roll it out to one instance |
| +(canary) or the whole fleet, with a graceful drain and an automatic rollback if |
| +the new build fails its health check: |
| + |
| +```sh |
| +./deploy/build-linux.sh # builds + stamps a version id |
| +scp lchat-linux-amd64 root@box:/opt/lchat/incoming |
| +ssh root@box /opt/lchat/lchat stage --file /opt/lchat/incoming |
| +ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> # one instance |
| +ssh root@box /opt/lchat/lchat deploy --all --version <ver> # the whole fleet |
| +ssh root@box /opt/lchat/lchat rollback chat.example.com # step back |
| +``` |
| + |
| +The full design — routing, supervision (systemd), backups, and hardening — is in |
| +**[docs/instance-router.md](docs/instance-router.md)**. |
| + |
| +### A single instance behind your own reverse proxy |
| -[Miren](https://miren.dev) builds the container, runs it, and handles routing + |
| -TLS, so you don't need a separate reverse proxy. This repo ships a `Dockerfile` |
| -and `.miren/app.toml` ready to go. |
| +The minimal path for one chat, if you'd rather run your own proxy: build, copy up, |
| +and terminate TLS with Caddy or nginx. |
| + |
| +```sh |
| +GOOS=linux GOARCH=amd64 go build -o lchat . |
| +scp lchat you@server:/srv/lchat/ |
| +LCHAT_DB=/srv/lchat/lchat.db ADDR=:1747 ./lchat # via a systemd unit in practice |
| +``` |
| + |
| +Then proxy `https://chat.example.com` → `127.0.0.1:1747` (Caddy gets you TLS in |
| +two lines). |
| + |
| +### Miren |
| + |
| +[Miren](https://miren.dev) is a managed container platform: it builds the |
| +container, runs it, and handles routing + TLS, so you don't run a reverse proxy |
| +yourself. This repo ships a `Dockerfile` and `.miren/app.toml` ready to go. |
| Runtime config is via env vars, so it doesn't matter how Miren launches the |
| binary: |
| @@ -184,6 +251,25 @@ https://group.luan.chat/admin#token=<ADMIN_TOKEN> |
| > router proxies this fine; if the feed ever shows "Reconnecting…" forever, |
| > that's the first thing to check. |
| +### Other platforms (Fly.io, Render, Railway, Heroku, …) |
| + |
| +Each of these should be trivial — it's the same single static binary plus a |
| +persistent volume — but **none is tested yet**, so treat the below as the recipe, |
| +not a proven path. Any host that runs a binary (or the shipped `Dockerfile`) with a |
| +persistent volume works the same way; the constraints are just the three from the |
| +top of this section: |
| + |
| +- Attach a **persistent volume** and set `LCHAT_DB` to a path on it (e.g. |
| + `/data/lchat.db`); the database must survive restarts and redeploys. |
| +- Bind the port the platform gives you: `ADDR=:$PORT` (or the default `:1747`). |
| +- Let the platform terminate **TLS** — and make sure it proxies the `/ws` |
| + WebSocket upgrade (all of these do). |
| +- Set a URL-safe `LCHAT_ADMIN_TOKEN`, and keep the service at **one instance** |
| + (SQLite is single-writer — don't scale to multiple replicas). |
| + |
| +A tested, per-platform write-up (a `fly.toml`, a Render blueprint, an `app.json`) |
| +would be a welcome contribution. |
| + |
| ## Flow |
| 1. **Admin** opens the admin link (roster management only — no decryption key) and |
| @@ -229,15 +315,18 @@ holds only ciphertext it can't open (the message-content guarantee above). |
| ## Tests |
| ```sh |
| -go test ./... # backend: DB logic + HTTP handlers (httptest) |
| -node test/crypto.test.mjs # crypto.js end-to-end (Web Crypto, runs in Node) |
| +go test ./... # backend: DB logic, HTTP handlers (httptest), router control plane |
| +npm test # frontend logic in Node: crypto, ui/format, mini-apps, commands, |
| + # invites, key backup, thread keys, messages |
| ``` |
| The Go tests cover the invite lifecycle, admin auth, messages, push subscriptions, |
| -group name, and the HTTP handlers (auth gating, invite/messages flow, group-name |
| -injection + HTML-escaping). The JS test exercises the E2EE round-trip in `crypto.js`. |
| -Browser/DOM end-to-end (chat UI, onboarding) isn't covered — add Playwright if the |
| -UI grows. |
| +the group name + HTTP handlers (auth gating, invite/messages flow, group-name |
| +injection + HTML-escaping), and the self-hosted router / versioned-deploy control |
| +plane. `npm test` runs the pure-logic frontend modules under Node — the E2EE |
| +round-trip in `crypto.js`, plus `ui.js`/`format.js`, mini-apps, slash-commands, |
| +invites, and key backup. Browser/DOM end-to-end (the chat UI, onboarding) isn't in |
| +CI yet — exercise it against a throwaway instance. |
| ## Layout |
| @@ -245,15 +334,18 @@ UI grows. |
| main.go HTTP routing, handlers, admin bootstrap, //go:embed |
| db.go SQLite: config, invites, messages (ciphertext only) |
| hub.go WebSocket fan-out hub + message ingest |
| -web/pages/*.html chat / join / admin pages |
| -web/static/* crypto.js (Web Crypto), chat.js, join.js, admin.js, style.css |
| -tools/lchat-post.mjs CLI to post an encrypted message from a script (see above) |
| +miniapps.go mini-apps: scheduled messages + per-thread encrypted state |
| +push.go Web Push (self-hosted VAPID); apns.go = central APNs relay client |
| +unfurl.go OpenGraph link previews (SSRF-guarded fetch + cache) |
| +bootstrap.go, control.go, registry.go, router.go, versions.go, bot.go |
| + self-hosted router: provision a box, add-instance / deploy / |
| + rollback, the route registry, TLS front door, versioned deploys |
| +web/pages/*.html chat / join / admin pages |
| +web/static/crypto.js Web Crypto (encrypt/decrypt JSON + bytes, key wrap/unwrap) |
| +web/static/shell.js chat page host: sidebar, thread list, WebSocket, keys, uploads |
| +web/static/apps/*.js mini-app framework + apps, including the chat engine (chat.js) |
| +web/static/*.js also join, admin, and pure ui/format helpers (unit-tested) |
| +web/static/style.css styles |
| +tools/lchat-post.mjs CLI to post an encrypted message from a script (see above) |
| +docs/*.md design notes (router, threads/DMs, push relay, mini-apps, …) |
| ``` |
| - |
| -## Not in this first slice (candidates for next) |
| - |
| -- Rate limiting / spam protection on message posting |
| -- Key backup file export & nicer recovery UX |
| -- Typing indicators, read state, message deletion |
| -- Image/file sharing (encrypted) |
| -- Per-message sender identity that survives a screen-name change |