eleven / messenger Public

eleven: a command-line client (E2EE member in your terminal) (#480)

* client: extract E2EE member client layer + the eleven CLI

internal/client is the member client layer: headless join for all three
invite-link shapes (fresh person, member DM invite, add-device β€” mirroring
web/static/join.js), and the WebSocket transport with thread-key unwrap,
cursor-gated delivery, catch-up, and reconnect (the transport half of
internal/bot's runtime, without the bot policy). Session moves here as a
type alias so bot call sites and the Node session.json contract are
untouched; the bot keeps its own transport copy until the follow-up that
moves it onto this package.

eleven/ is the new member-facing binary (like push/ and home/): join with
any invite link, list chats (names decrypted, DMs labelled from the
roster), open a live chat loop in the terminal, or one-shot/pipe a send.
A fresh member's name publish retries each run until another member's
device grants the instance key.

E2E-tested against the real router (eleven_client_test.go): all three
join paths, name decryption, live delivery both ways.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* client: distinguish this process's own echo (Echo) from other-device sends (Mine)

A member on two devices must still see their phone-sent messages in the
open terminal chat; only what THIS process sent is already on screen.
Send records the stored id, delivery flags the echo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d66c438ab07e047589f8abf55b7c49fdc31907e0 parent 1f9021c
10 files changed, +1679 −41
  • .gitignore +1 −0
  • docs/eleven-cli.md +82 −0
  • eleven/main.go +377 −0
  • eleven_client_test.go +299 −0
  • internal/bot/session.go +7 −41
  • internal/client/client.go +632 −0
  • internal/client/invite.go +46 −0
  • internal/client/invite_test.go +34 −0
  • internal/client/join.go +133 −0
  • internal/client/session.go +68 −0
diff --git a/.gitignore b/.gitignore
index bebcc1e..9289367 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Compiled binary
/lchat
+/eleven/eleven
# Linux deploy build artifact (deploy/build-linux.sh output)
/lchat-linux-amd64
diff --git a/docs/eleven-cli.md b/docs/eleven-cli.md
new file mode 100644
index 0000000..d328056
--- /dev/null
+++ b/docs/eleven-cli.md
@@ -0,0 +1,82 @@
+# πŸ€– eleven β€” the command-line client
+
+`eleven` is Eleven Messenger in your terminal: a full E2EE member, not a
+gateway or a bot. It joins with the same invite links every device uses, holds
+its private key in a local file, and encrypts/decrypts everything itself β€” the
+server sees exactly what it sees from any other device: ciphertext.
+
+It's a separate small binary (like `push/` and `home/`): members install the
+client, operators install the server.
+
+```sh
+go build -o eleven ./eleven
+```
+
+## Joining
+
+```sh
+eleven join # prompts for an invite link (and your name)
+eleven join --name "Paul" 'https://yourspace.11ch.at/join/…#…'
+```
+
+All three link shapes work:
+
+- **A fresh invite** makes you a new member with a new keypair.
+- **A friend's invite link** (the kind that opens a DM with them) joins you
+ *and* unlocks that chat β€” the DM key is re-wrapped to your new keypair
+ locally during the claim.
+- **An add-device link** (from your own account) signs the terminal in as
+ *you*: the link carries your keypair in its `#fragment`, so the terminal
+ becomes another of your devices and can read everything your others can.
+
+Your identity lands in `~/.eleven/session.json` (0600) β€” override with
+`-session` or `ELEVEN_SESSION`. That file **is** your private key: anyone who
+reads it can read your chats. Deleting it signs the terminal out (and, if it
+was your only device, loses those keys for good β€” same as any device).
+
+Two honest gaps on a *fresh* join, both inherent to how keys travel: your
+roster name waits for the instance key, which another member's device grants
+once it's online (`eleven` retries the publish on every run until it lands β€”
+your messages carry your name regardless), and chats appear only once someone
+DMs you or adds you to a group. Joining a space where Eleven runs, the
+welcome DM shows up on its own.
+
+## Talking
+
+```sh
+eleven chats # your chats: name, kind, last line
+eleven chat # open your most recent chat
+eleven chat kitchen # …or match one by name (case-insensitive)
+eleven send eleven "what's the weather like for Saturday?"
+git log --oneline -5 | eleven send eleven # pipe stdin as the message
+```
+
+`eleven chat` is a live loop: incoming messages print as they arrive over the
+WebSocket, and anything you type sends on Enter (Ctrl-C or Ctrl-D leaves).
+DMing one of the space's bots this way is Claude-in-your-terminal over your
+own E2EE infrastructure β€” the bot can't tell the terminal from the app.
+
+## What v1 deliberately leaves out
+
+Rendered messages are new chat lines (plus `/me`-style action lines and a
+"[n button(s)]" note on messages carrying chips). Not yet rendered: edits,
+deletions, reactions, typing indicators, attachments, link previews β€” and
+there's no thread creation from the CLI (start chats in the app). Incoming
+lines also interleave with whatever you're typing, classic-IRC style; a
+proper composer line is a follow-up.
+
+## How it's built
+
+`internal/client` is the E2EE member client layer β€” join/claim
+(`join.go`, mirroring `web/static/join.js` headlessly), the WebSocket
+transport with thread-key unwrap, cursor-gated delivery and reconnect
+(`client.go`, the transport half of `internal/bot`'s runtime with the bot
+policy left out), and the invite-link parser (`invite.go`, mirroring
+`web/static/invite.js`). The bot sidecar still carries its own copy of the
+transport; moving it onto `internal/client` is the planned follow-up, so the
+two can't drift. Session files are byte-compatible across the CLI, the Go bot,
+and the Node sidecar.
+
+E2E coverage lives in `eleven_client_test.go` (root package, against the real
+router): all three join paths, name decryption, live delivery, and both-ways
+sends.
diff --git a/eleven/main.go b/eleven/main.go
new file mode 100644
index 0000000..3d90b94
--- /dev/null
+++ b/eleven/main.go
@@ -0,0 +1,377 @@
+// eleven is the Eleven Messenger command line: a full E2EE member in your
+// terminal. Join a space with an invite link (or bring your existing identity
+// over with an add-device link), then read and send messages from the shell β€”
+// the private key lives in a local file and the server only ever sees
+// ciphertext, exactly like every other device.
+//
+// A separate small binary (like push/ and home/), not an lchat subcommand:
+// members install the client, operators install the server.
+package main
+
+import (
+ "bufio"
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "lchat/internal/client"
+)
+
+func main() {
+ fs := flag.NewFlagSet("eleven", flag.ExitOnError)
+ sessionPath := fs.String("session", envOr("ELEVEN_SESSION", defaultSessionPath()),
+ "identity file (env: ELEVEN_SESSION) β€” holds your private key; keep it safe")
+ fs.Usage = usage(fs)
+ _ = fs.Parse(os.Args[1:])
+ args := fs.Args()
+ if len(args) == 0 {
+ fs.Usage()
+ os.Exit(2)
+ }
+
+ switch args[0] {
+ case "join":
+ runJoin(*sessionPath, args[1:])
+ case "chats":
+ runChats(mustClient(*sessionPath))
+ case "chat":
+ runChat(mustClient(*sessionPath), strings.Join(args[1:], " "))
+ case "send":
+ runSend(mustClient(*sessionPath), args[1:])
+ case "help", "-h", "--help":
+ fs.Usage()
+ default:
+ fmt.Fprintf(os.Stderr, "eleven: unknown command %q\n\n", args[0])
+ fs.Usage()
+ os.Exit(2)
+ }
+}
+
+func usage(fs *flag.FlagSet) func() {
+ return func() {
+ fmt.Fprint(os.Stderr, `usage: eleven [-session file] <command>
+
+commands:
+ join [--name you] [link] join a space with an invite link (add-device links
+ sign this terminal in as you; prompts if no link given)
+ chats list your chats
+ chat [name] open a chat and talk; newest chat when no name.
+ Type to send, Ctrl-C or Ctrl-D to leave
+ send <name> [text] send one message and exit (reads stdin without text)
+
+`)
+ fs.PrintDefaults()
+ }
+}
+
+func defaultSessionPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "eleven-session.json"
+ }
+ return filepath.Join(home, ".eleven", "session.json")
+}
+
+func envOr(key, def string) string {
+ if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+ return v
+ }
+ return def
+}
+
+func die(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "eleven: "+format+"\n", args...)
+ os.Exit(1)
+}
+
+func mustClient(sessionPath string) *client.Client {
+ sess, err := client.LoadSession(sessionPath)
+ if err != nil {
+ die("%v", err)
+ }
+ if sess == nil {
+ die("no identity at %s β€” run `eleven join` with an invite link first", sessionPath)
+ }
+ if sess.Base == "" {
+ die("%s has no server URL β€” re-join with `eleven join`", sessionPath)
+ }
+ c := &client.Client{Base: sess.Base, Session: sess}
+ // A fresh member's name publish needs the instance key, granted only once
+ // another member's device is online β€” keep retrying until it lands.
+ if !sess.LabelPublished {
+ if err := c.PublishLabel(); err == nil {
+ sess.LabelPublished = true
+ _ = sess.Save(sessionPath)
+ }
+ }
+ return c
+}
+
+func runJoin(sessionPath string, args []string) {
+ fs := flag.NewFlagSet("eleven join", flag.ExitOnError)
+ name := fs.String("name", "", "your display name (prompts if the link doesn't carry one)")
+ _ = fs.Parse(args)
+
+ if existing, err := client.LoadSession(sessionPath); err != nil {
+ die("%v", err)
+ } else if existing != nil {
+ die("already joined as %q (%s) β€” delete %s to start over", existing.Name, existing.Base, sessionPath)
+ }
+
+ in := bufio.NewReader(os.Stdin)
+ link := strings.TrimSpace(strings.Join(fs.Args(), " "))
+ if link == "" {
+ fmt.Fprint(os.Stderr, "Paste your invite link: ")
+ line, err := in.ReadString('\n')
+ if err != nil && strings.TrimSpace(line) == "" {
+ die("no invite link given")
+ }
+ link = strings.TrimSpace(line)
+ }
+ inv, err := client.ParseInviteLink(link)
+ if err != nil {
+ die("%v", err)
+ }
+
+ who := strings.TrimSpace(*name)
+ if who == "" {
+ who = inv.Name // the inviter's suggestion rides in the link fragment
+ }
+ if who == "" {
+ fmt.Fprint(os.Stderr, "Your name: ")
+ line, _ := in.ReadString('\n')
+ who = strings.TrimSpace(line)
+ if who == "" {
+ die("a name is needed β€” it's how your messages are signed")
+ }
+ }
+
+ sess, res, err := client.Join(inv.Base, inv.T, who)
+ if err != nil {
+ die("%v", err)
+ }
+ c := &client.Client{Base: sess.Base, Session: sess}
+ if res.AddDevice {
+ // An existing member's devices already share a roster name; nothing to publish.
+ sess.LabelPublished = true
+ } else if c.PublishLabel() == nil {
+ // Best-effort: a fresh member's name reaches the roster only once another
+ // member's device grants the instance key; retried on later runs.
+ sess.LabelPublished = true
+ }
+ if err := sess.Save(sessionPath); err != nil {
+ die("%v", err)
+ }
+ if res.AddDevice {
+ fmt.Printf("Signed in as %s on %s β€” this terminal is now one of your devices.\n", sess.Name, sess.Base)
+ } else {
+ fmt.Printf("Joined %s as %s.\n", sess.Base, sess.Name)
+ if !sess.LabelPublished {
+ fmt.Println("(Your name will appear in the member list once another member is online; your messages carry it already.)")
+ }
+ }
+ fmt.Printf("Identity saved to %s β€” it holds your private key; keep it safe.\n", sessionPath)
+ fmt.Println("\nTry: eleven chats")
+}
+
+func runChats(c *client.Client) {
+ threads, err := c.Threads()
+ if err != nil {
+ die("%v", err)
+ }
+ if len(threads) == 0 {
+ fmt.Println("No chats yet β€” they appear here once someone messages you or adds you to a group.")
+ return
+ }
+ for _, t := range threads {
+ name := displayName(t)
+ preview := ""
+ if t.LastText != "" {
+ preview = t.LastName + ": " + firstLine(t.LastText)
+ }
+ fmt.Printf("%-24s %-6s %s %s\n", clip(name, 24), t.Kind, relTime(t.LastAt), clip(preview, 60))
+ }
+}
+
+func runChat(c *client.Client, needle string) {
+ t := resolveThread(c, needle)
+ name := displayName(t)
+
+ history, err := c.History(t.ID, 25)
+ if err != nil {
+ die("%v", err)
+ }
+ fmt.Printf("── %s ── (type to send Β· Ctrl-C to leave)\n", name)
+ for _, m := range history {
+ printMessage(m)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ c.OnMessage = func(m client.Message) {
+ if m.ThreadID != t.ID || m.Echo {
+ return // this terminal's own sends are already on screen as the typed line
+ }
+ printMessage(m) // Mine-but-not-Echo = sent from another of your devices β†’ "you"
+ }
+ errc := make(chan error, 1)
+ go func() { errc <- c.Run(ctx) }()
+
+ in := bufio.NewScanner(os.Stdin)
+ in.Buffer(make([]byte, 64*1024), 64*1024)
+ for in.Scan() {
+ text := strings.TrimSpace(in.Text())
+ if text == "" {
+ continue
+ }
+ if err := c.Send(t.ID, text); err != nil {
+ fmt.Fprintf(os.Stderr, "eleven: not sent: %v\n", err)
+ }
+ }
+ cancel()
+ <-errc
+}
+
+func runSend(c *client.Client, args []string) {
+ if len(args) == 0 {
+ die("usage: eleven send <chat> [text] (reads stdin when text is omitted)")
+ }
+ t := resolveThread(c, args[0])
+ text := strings.TrimSpace(strings.Join(args[1:], " "))
+ if text == "" {
+ raw, err := readAllStdin()
+ if err != nil {
+ die("%v", err)
+ }
+ text = strings.TrimSpace(raw)
+ }
+ if text == "" {
+ die("nothing to send")
+ }
+ if err := c.Send(t.ID, text); err != nil {
+ die("%v", err)
+ }
+ fmt.Printf("Sent to %s.\n", displayName(t))
+}
+
+func readAllStdin() (string, error) {
+ raw, err := io.ReadAll(os.Stdin)
+ return string(raw), err
+}
+
+// resolveThread finds a chat by (case-insensitive) name substring; an empty
+// needle picks the newest chat. Exact match beats substring; ambiguity lists
+// the candidates instead of guessing.
+func resolveThread(c *client.Client, needle string) client.Thread {
+ threads, err := c.Threads()
+ if err != nil {
+ die("%v", err)
+ }
+ if len(threads) == 0 {
+ die("no chats yet β€” someone needs to message you or add you to a group first")
+ }
+ needle = strings.ToLower(strings.TrimSpace(needle))
+ if needle == "" {
+ return threads[0] // server order: pinned, then most recent
+ }
+ var matches []client.Thread
+ for _, t := range threads {
+ n := strings.ToLower(displayName(t))
+ if n == needle {
+ return t
+ }
+ if strings.Contains(n, needle) || t.ID == needle {
+ matches = append(matches, t)
+ }
+ }
+ switch len(matches) {
+ case 1:
+ return matches[0]
+ case 0:
+ die("no chat matches %q β€” see `eleven chats`", needle)
+ default:
+ names := make([]string, len(matches))
+ for i, t := range matches {
+ names[i] = displayName(t)
+ }
+ die("%q matches several chats: %s", needle, strings.Join(names, ", "))
+ }
+ panic("unreachable")
+}
+
+func displayName(t client.Thread) string {
+ if t.Name != "" {
+ return t.Name
+ }
+ switch t.Kind {
+ case "self":
+ return "Notes to self"
+ case "dm":
+ return "Direct message" // partner's name not decryptable (yet)
+ default:
+ return "Group"
+ }
+}
+
+func printMessage(m client.Message) {
+ when := ""
+ if m.Ts > 0 {
+ when = time.UnixMilli(m.Ts).Local().Format("15:04") + " "
+ }
+ who := m.Name
+ if m.Mine {
+ who = "you"
+ }
+ if m.Action {
+ fmt.Printf("%sΒ· %s %s\n", when, who, firstLineKeep(m.Text))
+ return
+ }
+ suffix := ""
+ if m.Actions > 0 {
+ suffix = fmt.Sprintf(" [%d button(s) β€” tap them in the app]", m.Actions)
+ }
+ fmt.Printf("%s%s: %s%s\n", when, who, m.Text, suffix)
+}
+
+func firstLine(s string) string {
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i] + " …"
+ }
+ return s
+}
+
+// firstLineKeep collapses newlines without adding an ellipsis (action lines
+// are short status text).
+func firstLineKeep(s string) string {
+ return strings.ReplaceAll(s, "\n", " ")
+}
+
+func clip(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n-1]) + "…"
+}
+
+func relTime(sec int64) string {
+ if sec == 0 {
+ return " β€”"
+ }
+ d := time.Since(time.Unix(sec, 0))
+ switch {
+ case d < time.Minute:
+ return " now"
+ case d < time.Hour:
+ return fmt.Sprintf("%5dm ", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%5dh ", int(d.Hours()))
+ default:
+ return fmt.Sprintf("%5dd ", int(d.Hours()/24))
+ }
+}
diff --git a/eleven_client_test.go b/eleven_client_test.go
new file mode 100644
index 0000000..454dea5
--- /dev/null
+++ b/eleven_client_test.go
@@ -0,0 +1,299 @@
+package main
+
+// End-to-end tests for internal/client β€” the E2EE member client layer under
+// the eleven CLI β€” against the real router (newTestServer), the same way the
+// bot runtime is tested. They cover all three invite-link shapes Join claims
+// (fresh person, member-minted DM invite, add-device) and the live loop:
+// thread names decrypt, messages arrive over the WebSocket, sends round-trip.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ lclient "lchat/internal/client"
+ "lchat/internal/e2ee"
+)
+
+// joinForTest mints a plain member invite and joins as name.
+func joinForTest(t *testing.T, tsURL, name string) *lclient.Client {
+ t.Helper()
+ secret := mintInviteForTest(t, tsURL, "", false)
+ sess, res, err := lclient.Join(tsURL, secret, name)
+ if err != nil {
+ t.Fatalf("Join(%s): %v", name, err)
+ }
+ if res.AddDevice || res.ThreadID != "" {
+ t.Fatalf("plain invite reported %+v", res)
+ }
+ return &lclient.Client{Base: tsURL, Session: sess}
+}
+
+func TestElevenClientGroupChat(t *testing.T) {
+ ts, _ := newTestServer(t)
+ term := joinForTest(t, ts.URL, "Terminal")
+ friend := joinForTest(t, ts.URL, "Friend")
+
+ termUID, err := term.Me()
+ if err != nil {
+ t.Fatalf("Me: %v", err)
+ }
+ friendUID, err := friend.Me()
+ if err != nil {
+ t.Fatalf("Me: %v", err)
+ }
+
+ // Friend creates a named group with both members β€” what the web composer
+ // does: thread key wrapped to each member's roster pubkey, name encrypted
+ // under the thread key.
+ tk, err := e2ee.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ tkB64 := e2ee.B64uEncode(tk)
+ wrapTo := func(pub string) string {
+ w, err := e2ee.WrapThreadKey(tkB64, pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w
+ }
+ nameBlob, err := e2ee.EncryptJSON(tk, map[string]string{"name": "CLI Test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]any{
+ "token": friend.Session.Token, "kind": "group", "name_blob": nameBlob,
+ "members": []map[string]string{
+ {"user_id": friendUID, "wrapped": wrapTo(friend.Session.UserPub)},
+ {"user_id": termUID, "wrapped": wrapTo(term.Session.UserPub)},
+ },
+ })
+ resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var created struct {
+ ThreadID string `json:"thread_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" {
+ t.Fatalf("create thread: %v (id %q)", err, created.ThreadID)
+ }
+ resp.Body.Close()
+
+ // The CLI's sidebar view: the group's name must decrypt.
+ threads, err := term.Threads()
+ if err != nil {
+ t.Fatalf("Threads: %v", err)
+ }
+ var found bool
+ for _, th := range threads {
+ if th.ID == created.ThreadID {
+ found = true
+ if th.Name != "CLI Test" {
+ t.Errorf("thread name %q, want CLI Test", th.Name)
+ }
+ if th.Kind != "group" {
+ t.Errorf("thread kind %q", th.Kind)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("created thread not listed")
+ }
+
+ // Live loop: a message sent after the terminal connects must arrive
+ // decrypted, attributed, and not Mine.
+ got := make(chan lclient.Message, 8)
+ ready := make(chan struct{})
+ term.OnMessage = func(m lclient.Message) { got <- m }
+ term.OnReady = func() { ready <- struct{}{} }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go term.Run(ctx)
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("client never connected")
+ }
+
+ if err := friend.Send(created.ThreadID, "hello from the web"); err != nil {
+ t.Fatalf("friend Send: %v", err)
+ }
+ select {
+ case m := <-got:
+ if m.Text != "hello from the web" || m.Name != "Friend" || m.Mine || m.ThreadID != created.ThreadID {
+ t.Errorf("got %+v", m)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("message never arrived over the socket")
+ }
+
+ // And the other way: a terminal send must decrypt for the friend, and the
+ // terminal's own echo comes back flagged Mine.
+ if err := term.Send(created.ThreadID, "hello from the terminal"); err != nil {
+ t.Fatalf("term Send: %v", err)
+ }
+ select {
+ case m := <-got:
+ if m.Text != "hello from the terminal" || !m.Mine || !m.Echo {
+ t.Errorf("own echo: %+v", m)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("own echo never arrived")
+ }
+ hist, err := friend.History(created.ThreadID, 10)
+ if err != nil {
+ t.Fatalf("History: %v", err)
+ }
+ var sawTerminal bool
+ for _, m := range hist {
+ if m.Name == "Terminal" && m.Text == "hello from the terminal" {
+ sawTerminal = true
+ }
+ }
+ if !sawTerminal {
+ t.Errorf("friend's history is missing the terminal's message: %+v", hist)
+ }
+}
+
+func TestElevenClientDMInviteJoin(t *testing.T) {
+ ts, s := newTestServer(t)
+ if err := s.db.setUsersCanInvite(true); err != nil {
+ t.Fatal(err)
+ }
+ inviter := joinForTest(t, ts.URL, "Inviter")
+ inviterUID, err := inviter.Me()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = inviterUID
+
+ // Mint a user invite the way the web does (POST /api/invites): the pending
+ // DM's key rides in the link, wrapped under the invite secret; the
+ // inviter's own copy is wrapped to their pubkey.
+ secret, err := e2ee.RandomSecret()
+ if err != nil {
+ t.Fatal(err)
+ }
+ inv, err := e2ee.DeriveInvite(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tk, err := e2ee.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ tkB64 := e2ee.B64uEncode(tk)
+ linkWrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{"threadKey": tkB64})
+ if err != nil {
+ t.Fatal(err)
+ }
+ selfWrapped, err := e2ee.WrapThreadKey(tkB64, inviter.Session.UserPub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The invitee's suggested name, encrypted to the instance key by a real
+ // inviter β€” opaque to the server, so any blob satisfies the endpoint here.
+ labelBlob, err := e2ee.EncryptJSON(tk, "Joiner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{
+ "token": inviter.Session.Token, "id": inv.ID, "claim_hash": inv.ClaimHash,
+ "wrapped": linkWrapped, "self_wrapped": selfWrapped, "label_blob": labelBlob,
+ })
+ resp, err := http.Post(ts.URL+"/api/invites", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("mint user invite: HTTP %d", resp.StatusCode)
+ }
+ resp.Body.Close()
+
+ sess, res, err := lclient.Join(ts.URL, secret, "Joiner")
+ if err != nil {
+ t.Fatalf("Join: %v", err)
+ }
+ if res.AddDevice {
+ t.Error("DM invite reported add-device")
+ }
+ if res.ThreadID == "" {
+ t.Fatal("DM invite returned no pending thread")
+ }
+
+ // The joiner re-wrapped the DM key to itself during the claim, so both
+ // sides can talk straight away.
+ joiner := &lclient.Client{Base: ts.URL, Session: sess}
+ if err := joiner.Send(res.ThreadID, "hi, it's me"); err != nil {
+ t.Fatalf("joiner Send: %v", err)
+ }
+ hist, err := inviter.History(res.ThreadID, 5)
+ if err != nil {
+ t.Fatalf("inviter History: %v", err)
+ }
+ if len(hist) != 1 || hist[0].Text != "hi, it's me" || hist[0].Name != "Joiner" {
+ t.Errorf("inviter sees %+v", hist)
+ }
+}
+
+func TestElevenClientAddDeviceJoin(t *testing.T) {
+ ts, s := newTestServer(t)
+ phone := joinForTest(t, ts.URL, "Paul")
+ uid, err := phone.Me()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Mint an add-device link for the same person: the link carries their
+ // keypair, encrypted under the invite secret (join.js's payload.priv path).
+ secret, err := e2ee.RandomSecret()
+ if err != nil {
+ t.Fatal(err)
+ }
+ inv, err := e2ee.DeriveInvite(secret)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{
+ "priv": phone.Session.UserPriv, "pub": phone.Session.UserPub,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]any{
+ "id": inv.ID, "claim_hash": inv.ClaimHash, "user_id": uid, "wrapped": wrapped,
+ })
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admintok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("mint add-device invite: HTTP %d", resp.StatusCode)
+ }
+ resp.Body.Close()
+
+ sess, res, err := lclient.Join(ts.URL, secret, "Paul")
+ if err != nil {
+ t.Fatalf("Join: %v", err)
+ }
+ if !res.AddDevice {
+ t.Error("add-device link not detected")
+ }
+ if sess.UserPub != phone.Session.UserPub || sess.UserPriv != phone.Session.UserPriv {
+ t.Error("add-device session doesn't carry the member's keypair")
+ }
+ if sess.Token == phone.Session.Token {
+ t.Error("add-device minted no fresh device token")
+ }
+ if got, ok := s.db.deviceTokenUser(sess.Token); !ok || got != uid {
+ t.Errorf("new device token maps to %q, want %q", got, uid)
+ }
+}
diff --git a/internal/bot/session.go b/internal/bot/session.go
index aa5fc25..abb7e58 100644
--- a/internal/bot/session.go
+++ b/internal/bot/session.go
@@ -5,48 +5,14 @@
// server must never be able to read it (CLAUDE.md, the one rule).
package bot
-import (
- "encoding/json"
- "fmt"
- "os"
-)
+import "lchat/internal/client"
-// Session is the bot's identity: its device token and ECDH keypair. The JSON
-// shape is exactly the Node sidecar's session.json (agent/config.mjs), so the
-// Go bot can take over an existing bot's session file in place β€” same identity,
-// same keys, no re-provisioning.
-type Session struct {
- Token string `json:"token"`
- Name string `json:"name"`
- UserPub string `json:"userPub"`
- UserPriv string `json:"userPriv"`
-}
+// Session is the bot's identity: its device token and ECDH keypair. It now
+// lives in internal/client (the shared E2EE member client layer β€” the eleven
+// CLI reads and writes the same file shape); the alias keeps every bot call
+// site and the Node-compat contract (agent/config.mjs's session.json) intact.
+type Session = client.Session
// LoadSession reads a session file. A missing file returns (nil, nil) β€” "not
// provisioned yet" is a normal state, not an error.
-func LoadSession(path string) (*Session, error) {
- raw, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- var s Session
- if err := json.Unmarshal(raw, &s); err != nil {
- return nil, fmt.Errorf("bot: bad session file %s: %w", path, err)
- }
- if s.Token == "" || s.UserPriv == "" {
- return nil, fmt.Errorf("bot: session file %s is missing token or key", path)
- }
- return &s, nil
-}
-
-// Save persists the session. 0600: the file is the bot's private key.
-func (s *Session) Save(path string) error {
- raw, err := json.MarshalIndent(s, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(path, append(raw, '\n'), 0o600)
-}
+func LoadSession(path string) (*Session, error) { return client.LoadSession(path) }
diff --git a/internal/client/client.go b/internal/client/client.go
new file mode 100644
index 0000000..80a4a13
--- /dev/null
+++ b/internal/client/client.go
@@ -0,0 +1,632 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+
+ "lchat/internal/e2ee"
+)
+
+// Client is one member identity's live connection to an instance: it dials the
+// WebSocket, unwraps thread keys, decrypts what arrives, and hands every chat
+// message to OnMessage. It is the transport half of internal/bot's Runtime with
+// the bot policy left out β€” a client shows its person everything (including
+// other bots' messages, which a bot deliberately never sees), and decides
+// nothing on their behalf.
+//
+// Unlike the bot runtime, methods here may be called from other goroutines
+// than the read loop (a CLI sends from stdin while frames arrive), so the
+// key/cursor state is mutex-guarded.
+type Client struct {
+ Base string
+ Session *Session
+ // OnMessage receives every decrypted chat message, live or via catch-up β€”
+ // including this member's own (Mine), which arrive echoed back; the caller
+ // decides how to render each.
+ OnMessage func(Message)
+ // OnReady, if set, is called once each time the client finishes connecting.
+ OnReady func()
+ Logf func(format string, args ...any)
+
+ hc *http.Client
+ uid string // this member's user id (lazily fetched from /api/me)
+
+ mu sync.Mutex // guards keys + cursors
+ keys map[string]keyEntry
+ cursors map[string]int64 // threadId -> last message id delivered
+
+ wmu sync.Mutex // coder/websocket allows one concurrent writer
+ conn *websocket.Conn
+
+ sentMu sync.Mutex // guards sentIDs
+ sentIDs map[int64]bool
+}
+
+type keyEntry struct {
+ key []byte
+ kind string
+}
+
+// Message is one decrypted chat message.
+type Message struct {
+ ThreadID string
+ ID int64
+ Name string // sender's payload name
+ Text string
+ Ts int64 // sender-stamped, ms epoch (0 when the payload carries none)
+ Mine bool // authored by this member (by payload name β€” any of their devices)
+ Echo bool // sent by THIS process, echoed back (already on the caller's screen)
+ Action bool // a /me-style action line, not a bubble
+ Actions int // action chips the message carries (tappable only in the app)
+}
+
+// Thread is one chat as a person sees it in the sidebar: display name
+// decrypted, latest line previewed.
+type Thread struct {
+ ID string
+ Kind string // self | dm | group
+ Name string // decrypted display name ("" when no key or no name yet)
+ LastName string // sender of the latest message ("" when none/undecryptable)
+ LastText string
+ LastAt int64 // unix seconds of the latest message (0 when none)
+}
+
+func (c *Client) init() {
+ if c.hc == nil {
+ c.hc = &http.Client{Timeout: 30 * time.Second}
+ }
+ c.mu.Lock()
+ if c.keys == nil {
+ c.keys = map[string]keyEntry{}
+ c.cursors = map[string]int64{}
+ }
+ c.mu.Unlock()
+}
+
+// Run connects and serves until ctx is cancelled, reconnecting with backoff on
+// any drop.
+func (c *Client) Run(ctx context.Context) error {
+ c.init()
+ backoff := time.Second
+ for {
+ start := time.Now()
+ err := c.connectOnce(ctx)
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if time.Since(start) > time.Minute {
+ backoff = time.Second
+ }
+ c.logf("disconnected (%v); reconnecting in %s", err, backoff)
+ select {
+ case <-time.After(backoff):
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ backoff = min(backoff*2, 30*time.Second)
+ }
+}
+
+func (c *Client) connectOnce(ctx context.Context) error {
+ wsBase := strings.Replace(c.Base, "http", "ws", 1)
+ conn, _, err := websocket.Dial(ctx, wsBase+"/ws?token="+url.QueryEscape(c.Session.Token), nil)
+ if err != nil {
+ return err
+ }
+ defer conn.CloseNow()
+ // The server caps blobs at 64K; the library's 32K default would kill the
+ // socket on the first big message.
+ conn.SetReadLimit(256 * 1024)
+ c.wmu.Lock()
+ c.conn = conn
+ c.wmu.Unlock()
+ defer func() {
+ c.wmu.Lock()
+ c.conn = nil
+ c.wmu.Unlock()
+ }()
+
+ if err := c.loadThreads(); err != nil {
+ return err
+ }
+ c.catchUp()
+
+ // Keepalive: the server closes idle sockets and ignores "ping" frames.
+ pingCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ go func() {
+ t := time.NewTicker(30 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-t.C:
+ c.send(map[string]any{"type": "ping"})
+ case <-pingCtx.Done():
+ return
+ }
+ }
+ }()
+
+ if c.OnReady != nil {
+ c.OnReady()
+ }
+ for {
+ _, data, err := conn.Read(ctx)
+ if err != nil {
+ return err
+ }
+ c.onFrame(data)
+ }
+}
+
+type wsFrame struct {
+ Type string `json:"type"`
+ ThreadID string `json:"thread_id"`
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ Ephemeral bool `json:"ephemeral"`
+}
+
+func (c *Client) onFrame(data []byte) {
+ var f wsFrame
+ if json.Unmarshal(data, &f) != nil {
+ return
+ }
+ // v1 renders new messages only: reactions/edits/deletes and ephemeral
+ // presence traffic (typing beats ride as id-less type:"msg") are skipped.
+ if f.Type != "msg" || f.ThreadID == "" || f.Ephemeral {
+ return
+ }
+ c.mu.Lock()
+ _, known := c.keys[f.ThreadID]
+ c.mu.Unlock()
+ if !known {
+ c.loadThreads() // a thread we were just added to
+ // loadThreads baselines a new thread's cursor at its latest message β€”
+ // which is the very frame that made us load it (the server stores before
+ // it broadcasts), so the gate below would swallow it. Rewind so this
+ // first message is delivered; anything older stays history.
+ c.mu.Lock()
+ if f.ID != 0 && c.cursors[f.ThreadID] >= f.ID {
+ c.cursors[f.ThreadID] = f.ID - 1
+ }
+ c.mu.Unlock()
+ }
+ c.deliver(f.ThreadID, f.ID, f.Blob)
+}
+
+// chatPayload is the decrypted message shape a person's client renders. Typed
+// payloads (connect.*, mini-app events) are protocol traffic, not chat lines.
+type chatPayload struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ Ts int64 `json:"ts"`
+ Action bool `json:"action"`
+ Actions []map[string]any `json:"actions"`
+}
+
+// deliver decrypts one message, advances the thread cursor, and hands it to
+// OnMessage. The cursor gate drops duplicates (a reconnect's catch-up
+// overlapping a live frame) so nothing prints twice.
+func (c *Client) deliver(threadID string, id int64, blob string) {
+ c.mu.Lock()
+ entry, ok := c.keys[threadID]
+ if ok && id != 0 {
+ if id <= c.cursors[threadID] {
+ c.mu.Unlock()
+ return
+ }
+ c.cursors[threadID] = id
+ }
+ c.mu.Unlock()
+ if !ok {
+ return
+ }
+ var p chatPayload
+ if e2ee.DecryptJSON(entry.key, blob, &p) != nil {
+ return // e.g. a message from before we held the key
+ }
+ if p.Type != "" {
+ return // a typed payload (mini-app event, connect traffic), not chat text
+ }
+ if c.OnMessage != nil {
+ c.sentMu.Lock()
+ echo := c.sentIDs[id]
+ c.sentMu.Unlock()
+ c.OnMessage(Message{
+ ThreadID: threadID, ID: id, Name: p.Name, Text: p.Text, Ts: p.Ts,
+ Mine: p.Name == c.Session.Name, Echo: echo, Action: p.Action, Actions: len(p.Actions),
+ })
+ }
+}
+
+// serverThread is the /api/threads wire entry (db.go Thread).
+type serverThread struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ NameBlob string `json:"name_blob"`
+ Wrapped string `json:"wrapped"`
+ Members []string `json:"members"`
+ LastBlob string `json:"last_blob"`
+ LastAt int64 `json:"last_at"`
+ Hidden bool `json:"hidden"`
+}
+
+func (c *Client) fetchThreads() ([]serverThread, error) {
+ c.init()
+ var tl struct {
+ Threads []serverThread `json:"threads"`
+ }
+ if err := c.get("/api/threads", &tl); err != nil {
+ return nil, err
+ }
+ // Cache any key we don't hold yet; a newly-seen thread's cursor starts at
+ // its latest message, so live delivery never replays history.
+ for _, t := range tl.Threads {
+ if t.Wrapped == "" {
+ continue
+ }
+ c.mu.Lock()
+ _, held := c.keys[t.ID]
+ c.mu.Unlock()
+ if held {
+ continue
+ }
+ keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, c.Session.UserPriv)
+ if err != nil {
+ c.logf("thread %s: %v", t.ID, err)
+ continue
+ }
+ key, err := e2ee.ImportKey(keyB64)
+ if err != nil {
+ c.logf("thread %s: %v", t.ID, err)
+ continue
+ }
+ var ml struct {
+ Messages []struct {
+ ID int64 `json:"id"`
+ } `json:"messages"`
+ }
+ latest := int64(0)
+ if err := c.get("/api/messages?thread="+url.QueryEscape(t.ID)+"&limit=1", &ml); err == nil {
+ for _, m := range ml.Messages {
+ latest = max(latest, m.ID)
+ }
+ }
+ c.mu.Lock()
+ if _, held := c.keys[t.ID]; !held {
+ c.keys[t.ID] = keyEntry{key: key, kind: t.Kind}
+ c.cursors[t.ID] = max(c.cursors[t.ID], latest)
+ }
+ c.mu.Unlock()
+ }
+ return tl.Threads, nil
+}
+
+func (c *Client) loadThreads() error {
+ _, err := c.fetchThreads()
+ return err
+}
+
+// catchUp delivers anything that arrived while disconnected. Cursors start at
+// each thread's latest on first sight, so the initial connect backfills
+// nothing.
+func (c *Client) catchUp() {
+ c.mu.Lock()
+ after := make(map[string]int64, len(c.cursors))
+ for id := range c.keys {
+ after[id] = c.cursors[id]
+ }
+ c.mu.Unlock()
+ for threadID, cursor := range after {
+ var ml struct {
+ Messages []struct {
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ } `json:"messages"`
+ }
+ path := fmt.Sprintf("/api/messages?thread=%s&after=%d", url.QueryEscape(threadID), cursor)
+ if err := c.get(path, &ml); err != nil {
+ c.logf("catchUp %s: %v", threadID, err)
+ continue
+ }
+ for _, m := range ml.Messages {
+ c.deliver(threadID, m.ID, m.Blob)
+ }
+ }
+}
+
+// Threads lists this member's chats, sidebar-ready: server order (pinned, then
+// most recent), names decrypted β€” a group's from its name_blob, a DM's from
+// the other member's roster label, a self app-thread's from its app title β€”
+// and the latest line previewed. Hidden (closed) chats are skipped.
+func (c *Client) Threads() ([]Thread, error) {
+ sts, err := c.fetchThreads()
+ if err != nil {
+ return nil, err
+ }
+ labels := c.rosterLabels()
+ me, _ := c.Me()
+ out := []Thread{}
+ for _, st := range sts {
+ if st.Hidden {
+ continue
+ }
+ t := Thread{ID: st.ID, Kind: st.Kind, LastAt: st.LastAt}
+ c.mu.Lock()
+ entry, held := c.keys[st.ID]
+ c.mu.Unlock()
+ if held && st.NameBlob != "" {
+ // A group's explicit name, or a self app-thread's app id + title
+ // (shell.js threadDisplayName).
+ var n struct {
+ Name string `json:"name"`
+ Title string `json:"title"`
+ }
+ if e2ee.DecryptJSON(entry.key, st.NameBlob, &n) == nil {
+ t.Name = n.Name
+ if t.Name == "" {
+ t.Name = n.Title
+ }
+ }
+ }
+ if t.Name == "" && st.Kind == "dm" {
+ for _, uid := range st.Members {
+ if uid != me && labels[uid] != "" {
+ t.Name = labels[uid]
+ break
+ }
+ }
+ }
+ if held && st.LastBlob != "" {
+ var p chatPayload
+ if e2ee.DecryptJSON(entry.key, st.LastBlob, &p) == nil && p.Type == "" {
+ t.LastName, t.LastText = p.Name, p.Text
+ }
+ }
+ out = append(out, t)
+ }
+ return out, nil
+}
+
+// rosterLabels maps user id -> display name for everyone this member can see.
+// Bots carry a plaintext label; people publish theirs encrypted under the
+// instance key, so those decrypt only once another member's device has granted
+// us that key β€” best-effort until then.
+func (c *Client) rosterLabels() map[string]string {
+ labels := map[string]string{}
+ var rl struct {
+ Roster []struct {
+ UserID string `json:"user_id"`
+ Label string `json:"label"`
+ LabelBlob string `json:"label_blob"`
+ } `json:"roster"`
+ }
+ if err := c.get("/api/roster", &rl); err != nil {
+ c.logf("roster: %v", err)
+ return labels
+ }
+ instKey, _ := c.InstanceKey()
+ for _, e := range rl.Roster {
+ name := e.Label
+ if name == "" && e.LabelBlob != "" && instKey != nil {
+ var n string
+ if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil {
+ name = n
+ }
+ }
+ if name != "" {
+ labels[e.UserID] = name
+ }
+ }
+ return labels
+}
+
+// Me returns this member's user id (cached after the first call).
+func (c *Client) Me() (string, error) {
+ if c.uid != "" {
+ return c.uid, nil
+ }
+ c.init()
+ var me struct {
+ UserID string `json:"user_id"`
+ }
+ if err := c.get("/api/me", &me); err != nil {
+ return "", err
+ }
+ c.uid = me.UserID
+ return c.uid, nil
+}
+
+// History returns a thread's latest messages, oldest first, decrypted.
+// Undecryptable or non-chat payloads are skipped.
+func (c *Client) History(threadID string, limit int) ([]Message, error) {
+ key, err := c.threadKey(threadID)
+ if err != nil {
+ return nil, err
+ }
+ var ml struct {
+ Messages []struct {
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ } `json:"messages"`
+ }
+ path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(threadID), limit)
+ if err := c.get(path, &ml); err != nil {
+ return nil, err
+ }
+ var out []Message
+ for _, m := range ml.Messages {
+ var p chatPayload
+ if e2ee.DecryptJSON(key, m.Blob, &p) != nil || p.Type != "" {
+ continue
+ }
+ out = append(out, Message{
+ ThreadID: threadID, ID: m.ID, Name: p.Name, Text: p.Text, Ts: p.Ts,
+ Mine: p.Name == c.Session.Name, Action: p.Action, Actions: len(p.Actions),
+ })
+ }
+ return out, nil
+}
+
+// Send encrypts text under the thread's key and posts it β€” the same wire shape
+// the web composer sends, so no client can tell where a message was typed.
+// Usable without Run (a one-shot `eleven send`).
+func (c *Client) Send(threadID, text string) error {
+ key, err := c.threadKey(threadID)
+ if err != nil {
+ return err
+ }
+ blob, err := e2ee.EncryptJSON(key, map[string]any{
+ "name": c.Session.Name, "text": text, "ts": time.Now().UnixMilli(),
+ })
+ if err != nil {
+ return err
+ }
+ body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
+ if err != nil {
+ return err
+ }
+ var stored struct {
+ ID int64 `json:"id"`
+ }
+ if err := c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, &stored); err != nil {
+ return err
+ }
+ // Remember what this process sent, so its own echo is distinguishable from
+ // the same member's sends on another device (Message.Echo vs Mine).
+ if stored.ID != 0 {
+ c.sentMu.Lock()
+ if c.sentIDs == nil {
+ c.sentIDs = map[int64]bool{}
+ }
+ c.sentIDs[stored.ID] = true
+ c.sentMu.Unlock()
+ }
+ return nil
+}
+
+// threadKey returns the unwrapped key for a thread, loading threads once if it
+// isn't cached yet.
+func (c *Client) threadKey(threadID string) ([]byte, error) {
+ c.init()
+ c.mu.Lock()
+ entry, ok := c.keys[threadID]
+ c.mu.Unlock()
+ if !ok {
+ if err := c.loadThreads(); err != nil {
+ return nil, err
+ }
+ c.mu.Lock()
+ entry, ok = c.keys[threadID]
+ c.mu.Unlock()
+ }
+ if !ok {
+ return nil, fmt.Errorf("client: no key for thread %s (not a member, or the key hasn't been granted yet)", threadID)
+ }
+ return entry.key, nil
+}
+
+// InstanceKey unwraps this member's copy of the instance key β€” nil error only
+// once another member's device has granted it (grants are how keys reach a
+// fresh member; there may be nothing to unwrap in the first minutes).
+func (c *Client) InstanceKey() ([]byte, error) {
+ c.init()
+ var ik struct {
+ Wrapped string `json:"wrapped"`
+ }
+ if err := c.get("/api/instance-key", &ik); err != nil {
+ return nil, err
+ }
+ if ik.Wrapped == "" {
+ return nil, fmt.Errorf("client: no instance key granted yet")
+ }
+ keyB64, err := e2ee.UnwrapThreadKey(ik.Wrapped, c.Session.UserPriv)
+ if err != nil {
+ return nil, err
+ }
+ return e2ee.ImportKey(keyB64)
+}
+
+// PublishLabel publishes this member's display name to the roster, encrypted
+// under the instance key (what the web does after joining β€” the server never
+// sees the plaintext name). Fails until the instance key has been granted.
+func (c *Client) PublishLabel() error {
+ instKey, err := c.InstanceKey()
+ if err != nil {
+ return err
+ }
+ blob, err := e2ee.EncryptJSON(instKey, c.Session.Name)
+ if err != nil {
+ return err
+ }
+ body, err := json.Marshal(map[string]string{"token": c.Session.Token, "label_blob": blob})
+ if err != nil {
+ return err
+ }
+ return c.postJSON("/api/label", body, nil)
+}
+
+func (c *Client) send(v any) error {
+ c.wmu.Lock()
+ defer c.wmu.Unlock()
+ conn := c.conn
+ if conn == nil {
+ return fmt.Errorf("not connected")
+ }
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return conn.Write(ctx, websocket.MessageText, data)
+}
+
+func (c *Client) get(path string, out any) error {
+ c.init()
+ sep := "?"
+ if strings.Contains(path, "?") {
+ sep = "&"
+ }
+ resp, err := c.hc.Get(c.Base + path + sep + "token=" + url.QueryEscape(c.Session.Token))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s: server %d", path, resp.StatusCode)
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func (c *Client) postJSON(path string, body []byte, out any) error {
+ c.init()
+ resp, err := c.hc.Post(c.Base+path, "application/json", strings.NewReader(string(body)))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s: server %d", path, resp.StatusCode)
+ }
+ if out == nil {
+ return nil
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func (c *Client) logf(format string, args ...any) {
+ if c.Logf != nil {
+ c.Logf(format, args...)
+ }
+}
diff --git a/internal/client/invite.go b/internal/client/invite.go
new file mode 100644
index 0000000..6d38a48
--- /dev/null
+++ b/internal/client/invite.go
@@ -0,0 +1,46 @@
+package client
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// invitePrefix is the versioned fingerprint every minted link carries
+// (web/static/invite.js buildInviteFragment). Older links are bare T.
+const invitePrefix = "e1."
+
+// InviteLink is a parsed /join/... link: where to join, the secret T (never
+// sent to the server), and the inviter's suggested pre-fill name (rides in the
+// fragment for the same reason β€” the server must not see it).
+type InviteLink struct {
+ Base string // scheme://host origin
+ T string // the invite secret (base64url)
+ Name string // suggested name ("" when the link carries none)
+}
+
+// ParseInviteLink parses a full invite link β€” https://host/join/<label>#<frag>
+// β€” mirroring web/static/invite.js parseInviteFragment: the fragment is
+// "e1.<T>" or "e1.<T>~<urlencoded name>" (bare T on older links).
+func ParseInviteLink(link string) (InviteLink, error) {
+ u, err := url.Parse(strings.TrimSpace(link))
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return InviteLink{}, fmt.Errorf("client: that doesn't look like an invite link (expected https://…/join/…#…)")
+ }
+ frag := u.Fragment
+ if frag == "" {
+ return InviteLink{}, fmt.Errorf("client: the link is missing its #secret β€” copy the whole invite link")
+ }
+ frag = strings.TrimPrefix(frag, invitePrefix)
+ t, name := frag, ""
+ if i := strings.Index(frag, "~"); i >= 0 {
+ t = frag[:i]
+ if n, err := url.QueryUnescape(frag[i+1:]); err == nil {
+ name = n
+ }
+ }
+ if t == "" {
+ return InviteLink{}, fmt.Errorf("client: the link is missing its #secret β€” copy the whole invite link")
+ }
+ return InviteLink{Base: u.Scheme + "://" + u.Host, T: t, Name: name}, nil
+}
diff --git a/internal/client/invite_test.go b/internal/client/invite_test.go
new file mode 100644
index 0000000..2969fbd
--- /dev/null
+++ b/internal/client/invite_test.go
@@ -0,0 +1,34 @@
+package client
+
+import "testing"
+
+func TestParseInviteLink(t *testing.T) {
+ cases := []struct {
+ link string
+ base, tsec, name string
+ }{
+ // The minted shape (invite.js buildInviteFragment): e1.<T>, optional ~name.
+ {"https://x.11ch.at/join/Paul#e1.abc123", "https://x.11ch.at", "abc123", ""},
+ {"https://x.11ch.at/join/Paul#e1.abc123~Ciara%20B", "https://x.11ch.at", "abc123", "Ciara B"},
+ // Older links carry a bare T.
+ {"https://x.11ch.at/join/Paul#abc123", "https://x.11ch.at", "abc123", ""},
+ // Whitespace from a sloppy paste.
+ {" https://x.11ch.at/join/Paul#e1.abc123\n", "https://x.11ch.at", "abc123", ""},
+ }
+ for _, c := range cases {
+ got, err := ParseInviteLink(c.link)
+ if err != nil {
+ t.Errorf("ParseInviteLink(%q): %v", c.link, err)
+ continue
+ }
+ if got.Base != c.base || got.T != c.tsec || got.Name != c.name {
+ t.Errorf("ParseInviteLink(%q) = %+v, want base=%q T=%q name=%q", c.link, got, c.base, c.tsec, c.name)
+ }
+ }
+
+ for _, bad := range []string{"", "not a link", "https://x.11ch.at/join/Paul", "https://x.11ch.at/join/Paul#e1."} {
+ if _, err := ParseInviteLink(bad); err == nil {
+ t.Errorf("ParseInviteLink(%q) should fail", bad)
+ }
+ }
+}
diff --git a/internal/client/join.go b/internal/client/join.go
new file mode 100644
index 0000000..5ade5f5
--- /dev/null
+++ b/internal/client/join.go
@@ -0,0 +1,133 @@
+package client
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "lchat/internal/e2ee"
+)
+
+// JoinResult says what kind of link Join claimed, beyond the identity itself.
+type JoinResult struct {
+ // AddDevice: the link carried an existing member's keypair β€” the session is
+ // that member's identity on a new device, not a new person.
+ AddDevice bool
+ // ThreadID is the pending DM a user invite drops the joiner into ("" for
+ // other link kinds) β€” a client can open it first.
+ ThreadID string
+}
+
+// Join is web/static/join.js run headlessly: turn any invite link into a local
+// identity. It handles all three link shapes β€” a fresh-person invite (generate
+// a keypair, register it), a member-minted user invite (additionally re-wrap
+// the pending DM's key to the new keypair), and an add-device link (adopt the
+// keypair the link carries; the session then IS that member). The secret T
+// never reaches the server: the invite id and claim secret derive from it, and
+// whatever the link's wrapped blob holds decrypts locally.
+func Join(base, fragment, name string) (*Session, JoinResult, error) {
+ base = strings.TrimRight(base, "/")
+ hc := &http.Client{Timeout: 30 * time.Second}
+
+ inv, err := e2ee.DeriveInvite(fragment)
+ if err != nil {
+ return nil, JoinResult{}, fmt.Errorf("client: bad invite secret: %w", err)
+ }
+
+ var info struct {
+ OK bool `json:"ok"`
+ Wrapped string `json:"wrapped"`
+ }
+ if err := getJSON(hc, base+"/api/key-invite/"+url.PathEscape(inv.ID), &info); err != nil {
+ return nil, JoinResult{}, err
+ }
+ if !info.OK {
+ return nil, JoinResult{}, fmt.Errorf("client: this invite was already used or has expired β€” ask for a fresh one")
+ }
+
+ // What the link carries decides who we become (join.js): a keypair means
+ // add-device; a thread key means a user invite with a pending DM; nothing
+ // means a plain fresh-person invite.
+ var payload struct {
+ Priv string `json:"priv"`
+ Pub string `json:"pub"`
+ ThreadKey string `json:"threadKey"`
+ }
+ if info.Wrapped != "" {
+ if err := e2ee.DecryptJSON(inv.WrapKey, info.Wrapped, &payload); err != nil {
+ return nil, JoinResult{}, fmt.Errorf("client: couldn't unlock this link (wrong or corrupt secret)")
+ }
+ }
+
+ pub, priv := payload.Pub, payload.Priv
+ wrappedSelf := ""
+ if priv == "" {
+ kp, err := e2ee.GenerateUserKeypair()
+ if err != nil {
+ return nil, JoinResult{}, err
+ }
+ pub, priv = kp.PublicKey, kp.PrivateKey
+ if payload.ThreadKey != "" {
+ // User invite: re-wrap the pending DM's key to our new keypair so the
+ // chat with the person who invited us opens readable.
+ if wrappedSelf, err = e2ee.WrapThreadKey(payload.ThreadKey, pub); err != nil {
+ return nil, JoinResult{}, err
+ }
+ }
+ }
+
+ var claim struct {
+ OK bool `json:"ok"`
+ Token string `json:"token"`
+ ThreadID string `json:"thread_id"`
+ }
+ err = postJSON(hc, base+"/api/key-invite/"+url.PathEscape(inv.ID)+"/claim",
+ map[string]string{"claim_secret": inv.ClaimSecret, "wrapped": wrappedSelf}, &claim)
+ if err != nil {
+ return nil, JoinResult{}, err
+ }
+ if !claim.OK || claim.Token == "" {
+ return nil, JoinResult{}, fmt.Errorf("client: couldn't claim the invite (it may have just been used)")
+ }
+
+ // Register (or, on add-device, harmlessly re-register) the public key.
+ err = postJSON(hc, base+"/api/pubkey", map[string]string{"token": claim.Token, "public_key": pub}, nil)
+ if err != nil {
+ return nil, JoinResult{}, err
+ }
+
+ sess := &Session{Base: base, Token: claim.Token, Name: name, UserPub: pub, UserPriv: priv}
+ return sess, JoinResult{AddDevice: payload.Priv != "", ThreadID: claim.ThreadID}, nil
+}
+
+func getJSON(hc *http.Client, url string, out any) error {
+ resp, err := hc.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func postJSON(hc *http.Client, url string, body, out any) error {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ resp, err := hc.Post(url, "application/json", bytes.NewReader(raw))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if out == nil {
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("client: %s: HTTP %d", url, resp.StatusCode)
+ }
+ return nil
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
diff --git a/internal/client/session.go b/internal/client/session.go
new file mode 100644
index 0000000..b5734a3
--- /dev/null
+++ b/internal/client/session.go
@@ -0,0 +1,68 @@
+// Package client is the E2EE member client layer: everything a headless
+// member process needs to join a space, hold its keys, decrypt the threads
+// it's in, and send β€” used by the eleven CLI (eleven/) today; the bot sidecar
+// (internal/bot) keeps its own copy of this transport and moves onto this
+// package next. Like every client, it is pure outbound: the private key in
+// Session never leaves the process, and the server only ever sees ciphertext
+// (CLAUDE.md, the one rule).
+package client
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Session is a member identity: the device token and ECDH keypair. The JSON
+// shape is exactly the Node sidecar's session.json (agent/config.mjs), so bot
+// session files and CLI session files are interchangeable β€” same identity,
+// same keys. Base is a CLI-only convenience (bots pass -base) and is omitted
+// when empty, so bot session files round-trip byte-compatibly.
+type Session struct {
+ Base string `json:"base,omitempty"`
+ Token string `json:"token"`
+ Name string `json:"name"`
+ UserPub string `json:"userPub"`
+ UserPriv string `json:"userPriv"`
+ // LabelPublished: this member's name has reached the roster (PublishLabel
+ // succeeded). CLI-only bookkeeping β€” publishing needs the instance key,
+ // which a fresh member is granted asynchronously, so the CLI retries on
+ // later runs until it lands.
+ LabelPublished bool `json:"labelPublished,omitempty"`
+}
+
+// LoadSession reads a session file. A missing file returns (nil, nil) β€” "not
+// provisioned yet" is a normal state, not an error.
+func LoadSession(path string) (*Session, error) {
+ raw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var s Session
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return nil, fmt.Errorf("client: bad session file %s: %w", path, err)
+ }
+ if s.Token == "" || s.UserPriv == "" {
+ return nil, fmt.Errorf("client: session file %s is missing token or key", path)
+ }
+ return &s, nil
+}
+
+// Save persists the session. 0600 (and a 0700 parent, created if missing):
+// the file is the member's private key.
+func (s *Session) Save(path string) error {
+ if dir := filepath.Dir(path); dir != "." && dir != "/" {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ }
+ raw, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(raw, '\n'), 0o600)
+}