// 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"` // SpaceName/Starred mirror the member's home-vault entry for this space // (its saved display name and star) — CLI display bookkeeping, refreshed // on each signin sync. SpaceName string `json:"spaceName,omitempty"` Starred bool `json:"starred,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) }