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>
10 files changed,
+1679
−41
.gitignore+1 −0docs/eleven-cli.md+82 −0eleven/main.go+377 −0eleven_client_test.go+299 −0internal/bot/session.go+7 −41internal/client/client.go+632 −0internal/client/invite.go+46 −0internal/client/invite_test.go+34 −0internal/client/join.go+133 −0internal/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) |
| +} |