eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download
.claude/
.github/
agent/
broker/
changelog/
deploy/
disabled-apps/
docs/
eleven/
home/
infra/
integrations/
internal/
ios/
prototypes/
push/
test/
tools/
web/
.dockerignore
.gitignore
CLAUDE.md
Dockerfile
LICENSE
README.md
activator.go
activator_live_test.go
activator_test.go
agents_bridge_test.go
agents_transport_test.go
agentscmd.go
announce.go
announce_test.go
apns.go
apns_relay_test.go
attach_bot_test.go
authz_hardening_test.go
bake.go
bake_test.go
ban_test.go
billing.go
blobmirror.go
blobmirror_test.go
blobstore.go
blobstore_test.go
blocks_test.go
bootstrap.go
bootstrap_test.go
bot.go
bot_announcer_test.go
bot_appbuild_test.go
bot_appfix_test.go
bot_botbuild_test.go
bot_charterfail_test.go
bot_charterrename_test.go
bot_charterthread_test.go
bot_charterupdate_test.go
bot_connect_test.go
bot_flowmute_test.go
bot_memory_test.go
bot_provision_test.go
bot_run_test.go
bot_serviceop_test.go
bot_test.go
botcmd.go
bots_test.go
branding.go
branding_test.go
certcache.go
certcache_test.go
checkpoint.go
checkpoint_test.go
cloud.go
cloud_bands_test.go
cloud_billing_test.go
cloud_budget.go
cloud_budget_test.go
cloud_db.go
cloud_ids_test.go
cloud_perseat_test.go
cloud_test.go
cloud_usage.go
cloud_usage_test.go
cloudreplication.go
cloudreplication_test.go
connected.go
connected_test.go
control.go
control_selfupdate_test.go
control_test.go
controld.go
controld_test.go
customapps.go
db.go
db_test.go
demo.go
demo_test.go
deploy_pin_test.go
descriptions_test.go
dmprivacy_test.go
donate.go
donate_test.go
eleven_client_test.go
eleven_vault_live_test.go
enterprise.go
enterprise_test.go
envcmd.go
envcmd_test.go
feature_gates_test.go
fetch.go
fetch_test.go
fetchpow.go
go.mod
go.sum
handlers_test.go
hooks.go
hooks_test.go
hookscmd.go
hub.go
hub_test.go
install_test.go
integrations_api.go
integrations_api_test.go
invite_claim_test.go
invites_test.go
labels_test.go
links.go
links_test.go
linkscmd.go
linkscmd_test.go
main.go
majorpin_test.go
memberapps.go
memberapps_test.go
memberbots.go
memberbots_test.go
mesh.go
mesh_live_test.go
mesh_test.go
meshreplicate.go
meshreplicate_test.go
meshstats.go
meshstats_test.go
meter.go
meter_test.go
miniapps.go
miniapps_test.go
mute_test.go
package.json
pool.go
pool_test.go
presence.go
presence_test.go
push.go
push_ssrf_test.go
push_token_hash_test.go
ratelimit.go
reaper.go
reaper_test.go
registry.go
relaycmd.go
release.go
release_test.go
releasealert.go
releasealert_test.go
relocate.go
relocate_test.go
reports_test.go
roles_test.go
route53dns.go
route53dns_test.go
router.go
router_test.go
s3blobstore.go
s3blobstore_test.go
security_headers_test.go
setup_invite.go
setup_invite_test.go
spacethread.go
spacethread_test.go
subcommands.go
subcommands_test.go
subscriptions.go
subscriptions_handlers.go
subscriptions_lifetime_test.go
subscriptions_platform.go
subscriptions_platform_test.go
subscriptions_test.go
trycmd.go
unfurl.go
unfurl_test.go
updatecheck.go
updatecheck_test.go
updater.go
updater_test.go
updaterservices.go
updaterservices_test.go
usage.go
usage_test.go
vault_offline_test.go
versions.go
versions_test.go
wildcard.go
wildcard_test.go

πŸ€– Eleven Messenger

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 first join. The public key lives in the roster; the private key never leaves the 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.
  • An invite link carries a one-time secret in its #fragment (e.g. /join/<name>#<secret>) used to bootstrap the joiner's keys β€” never sent to the server. The admin token authorises roster management only; it is not a decryption key. Even a full copy of the database reveals no message content.

Trade-offs (inherent to this model):

  • Membership is the boundary, enforced cryptographically: to read a thread you must be one of its members (hold its wrapped key).
  • Removing a member stops the server delivering new messages to them, but doesn't claw back a key they already hold (no forward secrecy on leave).
  • If a member loses their device with no backup/add-device link, their key material β€” and the threads only they could decrypt β€” is gone.
  • Metadata is not encrypted. The server can't read message content, but the roster stores each member's screen name in clear (separate from the encrypted copy carried inside each message), and the server necessarily knows who shares a thread with whom in order to route messages. So a server compromise reveals the social graph and names β€” just not what anyone said.
  • Unsending leaves a dated marker. Deleting a message really deletes the ciphertext, but the server keeps a small tombstone row β€” which thread, which message id, when β€” so members' devices that were offline at the time still learn to drop their local copy. The tombstone holds no content and no key material, but it is a durable record that a deletion happened (the server transiently knew that anyway β€” it performed the delete).
  • Key distribution trusts the server. Public keys are handed out by the server's roster and aren't yet verified out of band (no safety-number check). A passive compromise β€” a stolen database β€” can't use that (the content guarantee above holds), but an active malicious server could substitute a public key to intercept new conversations. Out-of-band key verification is planned to close this.
  • A bot is a member whose "device" is a machine you choose. Adding a bot (see agent/) to a thread hands that thread's key to the machine the bot runs on β€” so whoever controls that machine holds the key to those conversations, exactly as if they were a member. Threads without a bot are untouched. Crucially, the bot doesn't have to live on the chat server: it's a pure outbound client (it dials the instance and the model API; nothing ever dials it β€” no open ports), so you can run it on any machine you already trust β€” a home server behind your router, or a box on a private network whose only way out is to your instance and the model API. Run it there and a fully compromised chat server still yields nothing: the keys are on a machine an attacker can't even reach.

Run it

go run .                 # listens on :1747, creates lchat.db

On first start it prints an admin link containing your admin token β€” open it once and keep it secret:

Admin link (open once, keep it secret):
http://localhost:1747/admin#token=...

Flags / env:

  • -addr :1747 β€” listen address
  • -db lchat.db β€” database file path
  • -reset-admin β€” generate and print a fresh admin token
  • LCHAT_ADMIN_TOKEN=... β€” set a fixed admin token instead of a generated one

Build a single binary

The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo), so deployment is one file:

go build -o lchat .          # native
GOOS=linux GOARCH=amd64 go build -o lchat .   # cross-compile for a Linux box

See Deploy for how to run it in production β€” a self-hosted router (the default), a single instance behind your own reverse proxy, Docker, or another platform.

Native iOS & macOS app

There's a native SwiftUI client in ios/ 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, 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 need for a signing team:

cd ios
xcodegen generate        # writes LChat.xcodeproj from project.yml

# iOS (compiles against the Simulator SDK β€” no device or signing needed):
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build

# macOS (Mac Catalyst β€” a native build that doesn't touch the iOS Simulator):
xcodebuild -project LChat.xcodeproj -scheme LChat \
  -destination 'platform=macOS,variant=Mac Catalyst' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build

-derivedDataPath build keeps the products inside the repo (under the gitignored ios/build/) instead of a hashed folder buried in ~/Library/Developer/Xcode/DerivedData/. The built apps land at:

  • ios/build/Build/Products/Debug-iphonesimulator/LChat.app
  • ios/build/Build/Products/Debug-maccatalyst/LChat.app ← the runnable Mac app

That Mac path is still a mouthful, so symlink it somewhere handy and launch it (the LChat-*.app symlink names are gitignored):

ln -sfn build/Build/Products/Debug-maccatalyst/LChat.app LChat-mac.app
open LChat-mac.app        # runs the Mac (Catalyst) build

A successful build ends in ** BUILD SUCCEEDED **. If a Simulator destination can't be resolved (e.g. an out-of-date CoreSimulator), target the SDK directly instead: -sdk iphonesimulator in place of the -destination flag. See ios/README.md for signing, running on a device, and how the client maps to the server.

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. One line installs the latest stable release (sha256-verified; amd64 or arm64) and bootstraps the box:

# on a fresh box (Ubuntu/Debian; inbound 80 + 443 open):
curl -fsSL https://github.com/elevenmessenger/releases/releases/download/server/install.sh | sudo bash

# add a chat β€” point its DNS A record at the box first; the TLS cert issues
# automatically, and the printed setup link makes you the admin:
lchat add-instance chat.example.com

Add as many chats as you like; each is isolated. The box then keeps itself up to date from the signed stable release channel β€” hourly checks, per-instance health check, automatic rollback β€” and each chat's admin picks a channel (stable / beta / edge) in their admin panel. The full guide (backups, bots, honest limitations) is docs/self-hosting.md, published as the README of the public releases repo.

Deploying your own builds instead (development, forks): build, stage, then roll out β€” zero-downtime, health-checked, auto-rollback; pin canaries so the auto-updater leaves them alone:

./deploy/build-linux.sh                                       # builds + stamps a version id
scp lchat-linux-amd64 root@box:/opt/lchat/incoming/lchat-upload
ssh root@box /opt/lchat/lchat stage  --file /opt/lchat/incoming/lchat-upload
ssh root@box /opt/lchat/lchat deploy chat.example.com --version <ver> --pin   # canary, held
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), releases + auto-update, backups, and hardening β€” is in docs/instance-router.md and docs/server-releases.md.

A single instance behind your own reverse proxy

The minimal path for one chat, if you'd rather run your own proxy: build, copy up, and terminate TLS with Caddy or nginx.

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).

Docker

The repo ships a Dockerfile: a fully static binary in a distroless image (~2MB of image around the binary, no shell), running as a non-root user. Runtime config is env vars:

  • LCHAT_DB β€” SQLite path; point it at the persistent volume (/data/lchat.db)
  • LCHAT_ADMIN_TOKEN β€” admin token
  • ADDR β€” listen address (defaults to :1747, matching the exposed port)

> Gotcha (learned the hard way): the env var is LCHAT_ADMIN_TOKEN, not > ADMIN_TOKEN. Some container platforms auto-provision their own secret for a > bare ADMIN_TOKEN ("referenced in code") and inject that, silently overriding > whatever you set. The LCHAT_ prefix dodges that.

# 1. Build, and generate a URL-safe admin token (it goes in a URL #fragment):
docker build -t lchat .
TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')

# 2. Run with a named volume for the database:
docker run -d --name lchat --restart unless-stopped \
  -p 127.0.0.1:1747:1747 \
  -v lchat-data:/data \
  -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" \
  lchat
# Your admin URL is then: https://group.example.com/admin#token=$TOKEN

Then terminate TLS in front of it exactly as in the reverse-proxy section above (https://group.example.com β†’ 127.0.0.1:1747; make sure the proxy passes the /ws WebSocket upgrade).

The container runs as uid 65532, and /data in the image is owned by that user β€” a named volume (as above) inherits the ownership on first use. If you'd rather bind-mount a host directory, chown it first (chown 65532:65532 /srv/lchat-data). Keep the service at a single replica β€” SQLite has one writer.

Updating later (no data loss): rebuild and recreate the container; the volume (messages, invites, push subscriptions, VAPID keys, group name) survives. Only deleting the volume wipes data.

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 creates invites. Each invite β†’ a secret link.
  2. A guest opens their link, picks a screen name, and joins β€” their browser generates a keypair and registers its public key on the roster.
  3. Anyone can start a DM with another member or spin up a group; each thread gets its own key, wrapped to its members. The instance is the trust boundary (its roster), Γ  la a private Slack/iMessage.

Posting from a script (bots, cron, CI)

You can post into a thread from outside the browser without weakening the encryption. The trick: the encryption stays on your side, never the server's. Each thread has its own AES key, wrapped to every member's keypair. So a script posts as a real member: give it that user's device token (authorises the API) and private key (unwraps the thread key), plus the thread id. The helper fetches the thread's wrapped key, unwraps it locally, encrypts the message, and hands the server only ciphertext β€” exactly like the browser does.

# text as an argument
tools/lchat-post.mjs --host https://group.example.com \
  --token "$TOKEN" --priv "$PRIV" --thread "$TID" "deploy finished βœ…"

# or pipe it in, with a custom screen name (config via env)
echo "build #42 passed" | tools/lchat-post.mjs --thread "$TID" --name CI

Config can come from flags or LCHAT_* env vars (LCHAT_HOST / LCHAT_TOKEN / LCHAT_PRIV / LCHAT_THREAD, and LCHAT_NAME for the screen name). The token and private key come from a joined session (or an add-device link). The helper reuses web/static/crypto.js, so its blobs are byte-for-byte what the browser produces and render as ordinary chat messages.

Under the hood it POSTs to /api/messages?token=… with {"thread_id":"…","blob":"<ciphertext>"}. The server checks the poster is a member of that thread, then stores, broadcasts, and pushes it just like a WebSocket msg frame β€” it never sees the plaintext, the private key, or the thread key. Keep those on the calling host, never on the server, so the server holds only ciphertext it can't open (the message-content guarantee above).

Accessibility

Eleven Messenger commits to WCAG 2.2 Level AA across the web surfaces, and to the same bar on iOS/Mac via VoiceOver, Dynamic Type, and Reduce Motion support. The full statement β€” what the commitment means for UI changes, and where we honestly are today β€” is docs/accessibility.md.

Tests

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, 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

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
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, …)

Licence

The Mozilla Public License, Version 2.0 β€” an OSI-approved, file-level ("weak") copyleft licence: modifications to this code stay open under the same terms, while a separate work that merely depends on it isn't dragged open with it.

This is an interim choice, not a final one. In the spirit of the Eleven Factors, the project's longer-term goal is a purpose-drafted licence β€” provisionally named GAIEST, the Generative Artificial Intelligence Evolving Software Technology Licence β€” that also closes the copyleft gap for hosted/SaaS use and carries a real disclosure requirement for AI-written material, once that draft has been through proper legal review and an OSI submission. Until then, AI-authorship marking is a project convention, not a licence condition β€” see docs/ai-markers.md.