package bot import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "lchat/internal/e2ee" ) // Provision turns an admin agent-invite into a bot identity — agent/ // provision.mjs (web/static/join.js run headlessly) in Go. The invite's // #fragment secret T never reaches the server: we derive the invite id from // it, generate our own keypair, claim a device token by presenting the derived // claim secret, and register the public key in the roster. The screen name is // never sent — an agent's label was set server-side when the invite was minted. func Provision(base, fragment, name string) (*Session, error) { base = strings.TrimRight(base, "/") client := &http.Client{Timeout: 30 * time.Second} inv, err := e2ee.DeriveInvite(fragment) if err != nil { return nil, fmt.Errorf("bot: bad invite fragment: %w", err) } var info struct { OK bool `json:"ok"` Wrapped string `json:"wrapped"` } if err := getJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID), &info); err != nil { return nil, err } if !info.OK { return nil, fmt.Errorf("bot: invite is used or invalid — mint a fresh one") } if info.Wrapped != "" { return nil, fmt.Errorf("bot: that's an add-device link; use a fresh-person agent invite") } kp, err := e2ee.GenerateUserKeypair() if err != nil { return nil, err } var claim struct { OK bool `json:"ok"` Token string `json:"token"` } err = postJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID)+"/claim", map[string]string{"claim_secret": inv.ClaimSecret}, &claim) if err != nil { return nil, err } if !claim.OK || claim.Token == "" { return nil, fmt.Errorf("bot: couldn't claim the invite (it may have just been used)") } err = postJSON(client, base+"/api/pubkey", map[string]string{"token": claim.Token, "public_key": kp.PublicKey}, nil) if err != nil { return nil, err } return &Session{Token: claim.Token, Name: name, UserPub: kp.PublicKey, UserPriv: kp.PrivateKey}, nil } // EnsureSession loads the session at path, self-provisioning it first when // the file is absent and an invite is available. This is what makes a freshly // staged sidecar zero-touch (#806): add-bot — and the activator's co-spawn on // a hibernating host — hand the process one-time invites in its environment, // and the first boot redeems them and writes the session, so keys are born on // the machine that runs the bot rather than copied to it. A missing session // with no invite still returns (nil, nil): "not provisioned" stays a normal // state the caller reports. func EnsureSession(base, path, invite, name string, logf func(string, ...any)) (*Session, error) { sess, err := LoadSession(path) if err != nil || sess != nil { return sess, err } invite = strings.TrimSpace(invite) if invite == "" { return nil, nil } if logf != nil { logf("bot: no session at %s — provisioning %q from its invite", path, name) } sess, err = Provision(base, invite, name) if err != nil { return nil, fmt.Errorf("bot: provision %q: %w", name, err) } // Persist before connecting: the invite is one-time, so a session held // only in memory would be unrecoverable after the next restart. if err := sess.Save(path); err != nil { return nil, fmt.Errorf("bot: save session %s: %w", path, err) } return sess, nil } func getJSON(client *http.Client, url string, out any) error { resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(out) } func postJSON(client *http.Client, url string, body, out any) error { raw, err := json.Marshal(body) if err != nil { return err } resp, err := client.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("bot: %s: HTTP %d", url, resp.StatusCode) } return nil } return json.NewDecoder(resp.Body).Decode(out) }