eleven / messenger Public

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>
Paul Campbell fec97818983796f2cadcdcb7bfe19bf9c4785b5f parent 7692141
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