eleven / messenger Public

Connected services via OAuth: the connect broker + DNSimple (#216)

* connect: OAuth PKCE exchange core (no pasted key, no client secret)

The server-side half of the "Connect with DNSimple" flow, verified end-to-end
headlessly against a PKCE-enforcing fake OAuth server:

- crypto: pkcePair() — one-time verifier + S256 challenge for a public client.
- agent/dnsimple: exchangeCode() — server-side code+verifier → token (public
  client, no client_secret; form-encoded).
- agent/runtime: connect.oauth handler — the browser hands the bot the one-time
  code+verifier; the bot exchanges, seals the token to the MEMBER's key, and
  returns it, keeping no copy.
- test/connect: pkcePair (challenge = base64url(S256(verifier))) + exchangeCode
  against a mock.

Verified: join → PKCE authorize → bot exchange → sealed token → store → /list
returns the account's domains. Browser UI wiring (button/popup/callback) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* connect: browser OAuth UI (Connect with DNSimple, popup + callback)

The clickable half of the flow:
- server: GET /api/oauth/config (public client_id + authorize URL, from env) and
  GET /oauth/{service}/callback (a tiny landing page that postMessages the
  one-time code back to the app and closes — holds no secret, sees only a code).
- web/static/oauth-callback.js: the callback relay.
- shell.js host bridge: startOAuth (open the authorize popup, PKCE), the popup
  message listener → completeOAuth (hand code+verifier to the bot, receive the
  token sealed to us, store it), plus list()/disconnect() and a shared
  findBotThread().
- connect app: a surface with a "Connect with DNSimple" button + status +
  Disconnect; /connect now opens the surface (no pasted token).

Verified headlessly incl. the config endpoint; browser popup UI pending manual test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* connect: OAuth via a shared broker (relay + exchange), off every instance

DNSimple only offers confidential OAuth apps, so there's a client_secret. Rather
than copy it onto every instance's sidecar (the old .p8-on-every-box problem),
consolidate it into one broker — the same move as the push relay.

- broker/ — a push/home-shaped sibling service:
  * GET /oauth/dnsimple/callback — the fleet's single registered redirect_uri; a
    stateless relay that bounces the one-time code back to the originating
    instance (read from an allowlisted `state`). Sees only a code → safe if
    compromised.
  * POST /api/exchange — bearer per-instance token; swaps code→token using the
    client_secret it holds; returns the token, stores nothing.
  * admin mint/list/revoke per-instance tokens (label = instance origin, doubles
    as the relay allowlist). Tests: exchange (fake DNSimple) + relay + auth.
- sidecar: exchangeViaBroker() replaces the direct DNSimple exchange — the sidecar
  no longer holds the secret, just a broker URL + per-instance token.
- instance: /api/oauth/config now also serves the broker redirect_uri; the client
  authorizes against it and encodes its origin in `state` so the broker relays back.

No instance or sidecar holds the client_secret anymore; only the broker does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* broker: gofmt

* connect: broker is the config source — instances need no DNSimple env

Per review: DNSimple config belongs to the broker, not every instance. Instances
now fetch the public OAuth params from the default broker, with env as override
only — a stock instance configures nothing for connecting services.

- broker: add public GET /config → {client_id, authorize_url, redirect_uri}
  (no secret) + an authorize_url it serves (env-overridable, DNSimple default).
- instance: /api/oauth/config fetches the broker's /config (cached 10m) instead
  of reading DNSimple env; broker URL defaults to connect.elevenmessenger.com,
  override with LCHAT_CONNECT_BROKER_URL. No client_id/authorize_url/redirect_uri
  env on the instance anymore.
- sidecar: broker URL defaults to connect.elevenmessenger.com too; only the
  per-instance broker TOKEN remains required (auth, like the push relay token).
- test: broker /config serves the params and never leaks the secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record confidential broker reality + self-host auto-registration plan

Supersede the public/PKCE sections: DNSimple is confidential-only, so the shared
connect broker holds the client_secret (relay + exchange, per-instance token,
compromise table). Note the planned on-the-fly registration (domain-control
proof) so self-hosters onboard to the shared broker without a hand-minted token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* connect: same-tab OAuth redirect + route to a connect-capable bot + loading state

DNSimple rejects popup/programmatic navigations to /oauth/authorize (only a
top-level tab nav works — also what the native app wants), so switch to a
whole-tab redirect: stash the pending {service,state} in sessionStorage, navigate
to consent, and finish on the way back (ws.onopen → completeOAuthReturn). The
callback page relays the code via sessionStorage instead of postMessage.

Also: findBotThread was grabbing the first agent DM, which could be the write-only
Announcer or a foreign bot ("Dev") that never runs the connect handler. Rank
agents (Eleven > Claude > any AI; never the Announcer) so the op reaches a bot
that actually handles it. A proper per-bot "handles connect" capability flag is
the real fix (noted in the design doc).

And a "Connecting…" state on the surface while the returned code is exchanged, so
it no longer flashes "Not connected" then "Connected".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* connect: clear pending state before the repaint (loading state was stuck)

dispatchEvent is synchronous, so repainting inside try then clearing oauthCompleting
in finally left the surface stuck on 'Connecting…'. Clear before the final dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: capture the companion-app direction for connected services

Once connected, a service installs as a client-side companion app (Chat |
Interface | commands | AI-later), delegating API calls to the existing sidecar
via grants (no per-service sidecar; browser can't call DNSimple — no CORS).
Longer term, companion apps should be installable/maintained out of this repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 2757fd0b3a370e1863569a9c4774836409acc2e5 parent a58d33b
13 files changed, +1332 −76
  • agent/dnsimple.mjs +19 −0
  • agent/runtime.mjs +46 −2
  • broker/broker_test.go +144 −0
  • broker/db.go +153 −0
  • broker/main.go +396 −0
  • connected.go +77 −0
  • docs/connected-services.md +74 −3
  • main.go +4 −0
  • test/connect.test.mjs +47 −10
  • web/static/apps/connect.js +110 −28
  • web/static/crypto.js +12 −0
  • web/static/oauth-callback.js +31 −0
  • web/static/shell.js +219 −33
diff --git a/agent/dnsimple.mjs b/agent/dnsimple.mjs
index 97d7be5..d3ff208 100644
--- a/agent/dnsimple.mjs
+++ b/agent/dnsimple.mjs
@@ -21,6 +21,25 @@ async function api(token, method, path) {
return data;
}
+// exchangeViaBroker swaps an authorization code for an access token via the
+// connect broker (docs/connected-services.md). DNSimple only offers CONFIDENTIAL
+// OAuth apps, so the exchange needs the client_secret — which lives ONLY on the
+// broker (like the APNs .p8 on the push relay), never on this sidecar. We
+// authenticate to the broker with a per-instance bearer token and get back only
+// the user's access token, which the caller seals to the member and drops.
+export async function exchangeViaBroker({ code, state, brokerUrl, brokerToken }) {
+ if (!brokerUrl || !brokerToken)
+ throw new Error("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)");
+ const r = await fetch(`${brokerUrl.replace(/\/$/, "")}/api/exchange`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${brokerToken}` },
+ body: JSON.stringify({ code, state }),
+ });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok || !d.access_token) throw new Error(d && d.message ? d.message : `broker exchange ${r.status}`);
+ return d.access_token;
+}
+
// whoami resolves the single account the token acts for. An account-scoped token
// returns `{ data: { account: { id } } }`; a user token that spans several
// accounts returns a user instead — we refuse it, since registrar calls are
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index 5f1b1f0..6928863 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -7,11 +7,11 @@
import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import WebSocket from "ws";
-import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed } from "../web/static/crypto.js";
+import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed, sealForPub } from "../web/static/crypto.js";
import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs";
import { actionsFromToolUse } from "./actions.mjs";
import { runCommand } from "./commands.mjs";
-import { listDomains } from "./dnsimple.mjs";
+import { listDomains, exchangeViaBroker } from "./dnsimple.mjs";
const log = (m) => {
try {
@@ -335,6 +335,46 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
}
}
+ // A connected-service OAuth exchange (docs/connected-services.md). The member
+ // did the PKCE dance in their browser — no key pasted — and hands us the
+ // one-time `code` + `verifier`. We run the server-side token exchange (public
+ // client, no secret), seal the token to the MEMBER's public key so only they
+ // can open it, and hand it straight back. We keep no copy.
+ async function handleOAuthExchange(threadId, entry, p) {
+ if (p.service !== "dnsimple") return;
+ let token;
+ try {
+ // The broker holds the client_secret and does the exchange; this sidecar
+ // just forwards the one-time code + state with its per-instance broker token.
+ token = await exchangeViaBroker({
+ code: p.code,
+ state: p.state,
+ brokerUrl: process.env.LCHAT_CONNECT_BROKER_URL || "https://connect.elevenmessenger.com",
+ brokerToken: process.env.LCHAT_CONNECT_BROKER_TOKEN,
+ });
+ } catch (e) {
+ tag(`connect: dnsimple exchange failed: ${e.message}`);
+ await postReply(threadId, entry, `Couldn't finish connecting DNSimple: ${e.message}`);
+ return;
+ }
+ try {
+ const { roster = [] } = await get("/api/roster");
+ const sender = roster.find((e) => e.user_id === p.uid);
+ if (!sender || !sender.public_key) {
+ await postReply(threadId, entry, "Connected, but I couldn't find your key to hand it back — try again.");
+ return;
+ }
+ const sealed = await sealForPub(sender.public_key, { secret: token });
+ send({
+ type: "msg",
+ thread_id: threadId,
+ blob: await encrypt(entry.key, { type: "connect.token", service: "dnsimple", sealed, name, ts: Date.now() }),
+ });
+ } finally {
+ token = null; // drop the token; the member holds the only copy now
+ }
+ }
+
// Strip a leading "@Name" so a group command ("@Eleven /help") parses; a bare
// command in a DM passes through unchanged.
function stripMention(text) {
@@ -429,6 +469,10 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
await handleConnect(threadId, entry, p);
return;
}
+ if (p.type === "connect.oauth") {
+ await handleOAuthExchange(threadId, entry, p);
+ return;
+ }
const trigger = replyTrigger(entry.kind, p.text);
if (!trigger) return;
// The bare-name gate needs the model; with no AI there's nothing to classify.
diff --git a/broker/broker_test.go b/broker/broker_test.go
new file mode 100644
index 0000000..eca8009
--- /dev/null
+++ b/broker/broker_test.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func newTestBroker(t *testing.T, tokenURL string) *httptest.Server {
+ t.Helper()
+ db, err := openDB(filepath.Join(t.TempDir(), "broker.db"))
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ s := &Server{
+ db: db,
+ adminHash: hashToken("admintok"),
+ clientID: "cid",
+ clientSecret: "sec",
+ redirectURI: "https://broker.example/oauth/dnsimple/callback",
+ authorizeURL: "https://dnsimple.com/oauth/authorize",
+ tokenURL: tokenURL,
+ http: &http.Client{Timeout: 5 * time.Second},
+ perInstance: newRateLimiter(60, 10),
+ global: newRateLimiter(600, 100),
+ }
+ // expose the db to tests via a helper server that shares it
+ ts := httptest.NewServer(s.handler())
+ t.Cleanup(ts.Close)
+ testDB = db
+ return ts
+}
+
+var testDB *DB // set by newTestBroker so tests can mint tokens directly
+
+// A fake DNSimple token endpoint: returns a token for a well-formed confidential
+// exchange (client_secret present), else 400.
+func fakeDNSimple(t *testing.T) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ if r.FormValue("grant_type") == "authorization_code" && r.FormValue("client_secret") != "" && r.FormValue("code") != "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"access_token":"tok-abc","token_type":"Bearer","account_id":1}`))
+ return
+ }
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"message":"invalid grant"}`))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// The broker holds the secret and exchanges a code for a token; unauthenticated
+// callers are refused.
+func TestExchange(t *testing.T) {
+ dns := fakeDNSimple(t)
+ ts := newTestBroker(t, dns.URL)
+ _, tok, err := testDB.mintToken("https://test.luan.chat")
+ if err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+
+ req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"code":"c1","state":"s1"}`))
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("exchange: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("exchange: got %d", resp.StatusCode)
+ }
+ var out struct {
+ AccessToken string `json:"access_token"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ if out.AccessToken != "tok-abc" {
+ t.Fatalf("access_token = %q", out.AccessToken)
+ }
+
+ // No bearer token → 401.
+ resp2, _ := http.Post(ts.URL+"/api/exchange", "application/json", strings.NewReader(`{"code":"c"}`))
+ if resp2.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no-token exchange: got %d want 401", resp2.StatusCode)
+ }
+ resp2.Body.Close()
+}
+
+// The public /config serves the non-secret OAuth params (no secret leaks) so
+// instances need no DNSimple config of their own.
+func TestConfig(t *testing.T) {
+ ts := newTestBroker(t, "http://unused")
+ resp, err := http.Get(ts.URL + "/config")
+ if err != nil {
+ t.Fatalf("config: %v", err)
+ }
+ defer resp.Body.Close()
+ var c map[string]string
+ json.NewDecoder(resp.Body).Decode(&c)
+ if c["client_id"] != "cid" || c["authorize_url"] == "" || c["redirect_uri"] == "" {
+ t.Fatalf("config = %v", c)
+ }
+ if _, leaked := c["client_secret"]; leaked {
+ t.Fatalf("/config leaked the client_secret")
+ }
+}
+
+// The redirect relay bounces a code back only to a registered instance origin,
+// and never to an unregistered one (no open redirect).
+func TestCallbackRelay(t *testing.T) {
+ ts := newTestBroker(t, "http://unused")
+ if _, _, err := testDB.mintToken("https://test.luan.chat"); err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+ noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+
+ good := base64.RawURLEncoding.EncodeToString([]byte("https://test.luan.chat")) + ".nonce123"
+ resp, err := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + good)
+ if err != nil {
+ t.Fatalf("callback: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("callback: got %d want 302", resp.StatusCode)
+ }
+ loc := resp.Header.Get("Location")
+ if !strings.HasPrefix(loc, "https://test.luan.chat/oauth/dnsimple/callback?") || !strings.Contains(loc, "code=XYZ") || !strings.Contains(loc, "state=") {
+ t.Fatalf("relay Location = %q", loc)
+ }
+
+ // Unregistered origin → refused, no redirect.
+ bad := base64.RawURLEncoding.EncodeToString([]byte("https://evil.example")) + ".n"
+ resp2, _ := noRedirect.Get(ts.URL + "/oauth/dnsimple/callback?code=XYZ&state=" + bad)
+ if resp2.StatusCode != http.StatusBadRequest {
+ t.Fatalf("unregistered origin: got %d want 400", resp2.StatusCode)
+ }
+ resp2.Body.Close()
+}
diff --git a/broker/db.go b/broker/db.go
new file mode 100644
index 0000000..9f78883
--- /dev/null
+++ b/broker/db.go
@@ -0,0 +1,153 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// DB holds the broker's only state: the per-instance bearer tokens the admin has
+// minted. Each token's label IS the instance's origin (e.g. "https://test.luan.chat")
+// — used both for logging and as the redirect-relay allowlist, so the relay only
+// ever bounces a code back to an origin an admin has explicitly registered. It
+// stores NO OAuth traffic — no codes, no access tokens ever touch disk. Tokens
+// are stored hashed (cf. the push relay), so a stolen DB yields no live credential.
+type DB struct{ sql *sql.DB }
+
+func openDB(path string) (*DB, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA synchronous=NORMAL",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ db := &DB{sql: d}
+ if err := db.migrate(); err != nil {
+ return nil, err
+ }
+ return db, nil
+}
+
+func (db *DB) migrate() error {
+ _, err := db.sql.Exec(`
+CREATE TABLE IF NOT EXISTS instance_tokens (
+ id TEXT PRIMARY KEY, -- random public handle, used to revoke
+ label TEXT NOT NULL, -- the instance ORIGIN (scheme+host), e.g. https://test.luan.chat
+ token_hash TEXT NOT NULL UNIQUE, -- sha256(bearer token); the secret itself is never stored
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+);`)
+ return err
+}
+
+func hashToken(t string) string {
+ h := sha256.Sum256([]byte(t))
+ return hex.EncodeToString(h[:])
+}
+
+func randToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// InstanceToken is a token row for the admin listing (never carries the secret).
+type InstanceToken struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ CreatedAt int64 `json:"created_at"`
+ LastUsedAt *int64 `json:"last_used_at,omitempty"`
+}
+
+// mintToken creates a per-instance token whose label is the instance origin,
+// stores only its hash, and returns the plaintext ONCE (unrecoverable after).
+func (db *DB) mintToken(label string) (id, plaintext string, err error) {
+ if id, err = randToken(); err != nil {
+ return "", "", err
+ }
+ if plaintext, err = randToken(); err != nil {
+ return "", "", err
+ }
+ _, err = db.sql.Exec(
+ `INSERT INTO instance_tokens(id, label, token_hash, created_at) VALUES(?, ?, ?, ?)`,
+ id, label, hashToken(plaintext), time.Now().Unix(),
+ )
+ if err != nil {
+ return "", "", err
+ }
+ return id, plaintext, nil
+}
+
+// authInstance resolves a bearer token to its instance origin (label), bumping
+// last_used_at. Returns ""/false for an empty or unknown token.
+func (db *DB) authInstance(token string) (origin string, ok bool) {
+ if token == "" {
+ return "", false
+ }
+ var id string
+ err := db.sql.QueryRow(
+ `SELECT id, label FROM instance_tokens WHERE token_hash = ?`, hashToken(token),
+ ).Scan(&id, &origin)
+ if err != nil {
+ return "", false
+ }
+ _, _ = db.sql.Exec(`UPDATE instance_tokens SET last_used_at = ? WHERE id = ?`, time.Now().Unix(), id)
+ return origin, true
+}
+
+// originAllowed reports whether an admin has registered this exact origin (via a
+// minted token). The redirect relay uses it so it can never be an open redirector.
+func (db *DB) originAllowed(origin string) bool {
+ if origin == "" {
+ return false
+ }
+ var x int
+ return db.sql.QueryRow(`SELECT 1 FROM instance_tokens WHERE label = ?`, origin).Scan(&x) == nil
+}
+
+func (db *DB) listTokens() ([]InstanceToken, error) {
+ rows, err := db.sql.Query(
+ `SELECT id, label, created_at, last_used_at FROM instance_tokens ORDER BY created_at`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []InstanceToken{}
+ for rows.Next() {
+ var t InstanceToken
+ var last sql.NullInt64
+ if err := rows.Scan(&t.ID, &t.Label, &t.CreatedAt, &last); err != nil {
+ return nil, err
+ }
+ if last.Valid {
+ t.LastUsedAt = &last.Int64
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func (db *DB) revokeToken(id string) (bool, error) {
+ res, err := db.sql.Exec(`DELETE FROM instance_tokens WHERE id = ?`, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
diff --git a/broker/main.go b/broker/main.go
new file mode 100644
index 0000000..01179cb
--- /dev/null
+++ b/broker/main.go
@@ -0,0 +1,396 @@
+// The Eleven connect broker: the one place the shared DNSimple OAuth client_id +
+// client_secret live, so instances (and their bot sidecars) never hold the secret
+// — the same move as the push relay for the APNs .p8. See docs/connected-services.md.
+//
+// It does two jobs, both for a CONFIDENTIAL OAuth client (DNSimple offers no
+// public/PKCE client):
+//
+// 1. Redirect relay (GET /oauth/dnsimple/callback) — the single registered
+// redirect_uri for the whole fleet. DNSimple redirects here with a one-time
+// ?code; the broker bounces it back to the originating instance (read from an
+// allowlisted `state`). It sees only a single-use code — no secret, no token —
+// so a relay compromise leaks nothing usable.
+//
+// 2. Token exchange (POST /api/exchange) — a bot sidecar authenticates with a
+// per-instance bearer token and sends {code, state}; the broker swaps it for
+// an access token using the client_secret it holds, and returns the token.
+// It stores nothing; the sidecar seals the token to the member. This is the
+// one place that holds the secret and transiently sees a user token.
+package main
+
+import (
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+const maxBodyBytes = 8 << 10
+
+type Server struct {
+ db *DB
+ adminHash string // sha256 of LCHAT_ADMIN_TOKEN; constant-time compared
+
+ clientID string
+ clientSecret string
+ redirectURI string // the broker's own registered callback; sent verbatim at exchange
+ authorizeURL string // where the browser sends the user to consent (public)
+ tokenURL string // DNSimple token endpoint (overridable for tests)
+ http *http.Client
+
+ perInstance *rateLimiter
+ global *rateLimiter
+}
+
+func main() {
+ addr := flag.String("addr", envOr("ADDR", ":1750"), "listen address (env: ADDR)")
+ dbPath := flag.String("db", envOr("LCHAT_BROKER_DB", "broker.db"), "SQLite path (env: LCHAT_BROKER_DB)")
+ flag.Parse()
+
+ // Fail loud: a misconfigured broker breaks connecting services fleet-wide.
+ admin := os.Getenv("LCHAT_ADMIN_TOKEN")
+ clientID := os.Getenv("LCHAT_DNSIMPLE_CLIENT_ID")
+ clientSecret := os.Getenv("LCHAT_DNSIMPLE_CLIENT_SECRET")
+ redirectURI := os.Getenv("LCHAT_DNSIMPLE_REDIRECT_URI") // e.g. https://connect.elevenmessenger.com/oauth/dnsimple/callback
+ if admin == "" || clientID == "" || clientSecret == "" || redirectURI == "" {
+ log.Fatal("broker: LCHAT_ADMIN_TOKEN, LCHAT_DNSIMPLE_CLIENT_ID, LCHAT_DNSIMPLE_CLIENT_SECRET, LCHAT_DNSIMPLE_REDIRECT_URI are required")
+ }
+
+ db, err := openDB(*dbPath)
+ if err != nil {
+ log.Fatalf("open db: %v", err)
+ }
+ s := &Server{
+ db: db,
+ adminHash: hashToken(admin),
+ clientID: clientID,
+ clientSecret: clientSecret,
+ redirectURI: redirectURI,
+ authorizeURL: envOr("LCHAT_DNSIMPLE_AUTHORIZE_URL", "https://dnsimple.com/oauth/authorize"),
+ tokenURL: envOr("LCHAT_DNSIMPLE_TOKEN_URL", "https://api.dnsimple.com/v2/oauth/access_token"),
+ http: &http.Client{Timeout: 15 * time.Second},
+ perInstance: newRateLimiter(60, 10),
+ global: newRateLimiter(600, 100),
+ }
+ log.Printf("connect broker listening on %s (db: %s, redirect: %s)", *addr, *dbPath, redirectURI)
+ if err := serve(*addr, s.handler()); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// serve listens on addr: a "/"-prefixed addr is a unix socket (how the router
+// fronts this service); otherwise a TCP address. Mirrors the push relay.
+func serve(addr string, h http.Handler) error {
+ if !strings.HasPrefix(addr, "/") {
+ return http.ListenAndServe(addr, h)
+ }
+ if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil {
+ c.Close()
+ return fmt.Errorf("socket %s is already in use by another process", addr)
+ }
+ if err := os.Remove(addr); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove stale socket %s: %w", addr, err)
+ }
+ ln, err := net.Listen("unix", addr)
+ if err != nil {
+ return err
+ }
+ defer ln.Close()
+ if err := os.Chmod(addr, 0o600); err != nil {
+ return fmt.Errorf("chmod socket %s: %w", addr, err)
+ }
+ return http.Serve(ln, h)
+}
+
+func (s *Server) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
+ mux.HandleFunc("GET /", s.root)
+
+ // Public config: the non-secret OAuth params an instance needs to start a flow
+ // (client_id, authorize URL, the broker's redirect_uri). No secret here — so
+ // instances need NO DNSimple config of their own; they just ask the broker.
+ mux.HandleFunc("GET /config", s.configHandler)
+
+ // Redirect relay: DNSimple's single registered redirect target for the fleet.
+ mux.HandleFunc("GET /oauth/dnsimple/callback", s.callback)
+ // Token exchange: a sidecar swaps a code for a token. Bearer instance token.
+ mux.HandleFunc("POST /api/exchange", s.exchange)
+
+ // Admin: mint / list / revoke per-instance tokens (rotation needs no redeploy).
+ mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler))
+ mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listHandler))
+ mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeHandler))
+
+ return securityHeaders(mux)
+}
+
+func (s *Server) root(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write([]byte("Eleven connect broker. Holds the OAuth app secret; relays codes; exchanges tokens. See docs/connected-services.md.\n"))
+}
+
+// configHandler serves the public OAuth params (no secret) so an instance can
+// start a flow without holding any DNSimple config of its own.
+func (s *Server) configHandler(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "client_id": s.clientID,
+ "authorize_url": s.authorizeURL,
+ "redirect_uri": s.redirectURI,
+ })
+}
+
+// ---- redirect relay --------------------------------------------------------
+
+// callback is the fleet's single registered redirect_uri. DNSimple sends a
+// one-time ?code (or ?error) + the ?state we minted. `state` is
+// base64url(origin) + "." + nonce; we decode the origin, refuse it unless an
+// admin has registered it (originAllowed — so this can never be an open
+// redirector), and 302 the code+state on to that instance's own callback page,
+// which hands it to the app. We hold nothing and see only a single-use code.
+func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ state := q.Get("state")
+ dot := strings.IndexByte(state, '.')
+ if dot <= 0 {
+ http.Error(w, "bad state", http.StatusBadRequest)
+ return
+ }
+ originBytes, err := base64.RawURLEncoding.DecodeString(state[:dot])
+ if err != nil {
+ http.Error(w, "bad state", http.StatusBadRequest)
+ return
+ }
+ origin := string(originBytes)
+ if !s.db.originAllowed(origin) {
+ // Never redirect to an unregistered origin.
+ http.Error(w, "unknown instance", http.StatusBadRequest)
+ return
+ }
+ dest, err := url.Parse(origin + "/oauth/dnsimple/callback")
+ if err != nil {
+ http.Error(w, "bad origin", http.StatusBadRequest)
+ return
+ }
+ fwd := url.Values{}
+ fwd.Set("state", state)
+ if code := q.Get("code"); code != "" {
+ fwd.Set("code", code)
+ }
+ if e := q.Get("error"); e != "" { // relay a user cancellation/denial too
+ fwd.Set("error", e)
+ }
+ dest.RawQuery = fwd.Encode()
+ http.Redirect(w, r, dest.String(), http.StatusFound)
+}
+
+// ---- token exchange --------------------------------------------------------
+
+func (s *Server) exchange(w http.ResponseWriter, r *http.Request) {
+ origin, ok := s.db.authInstance(bearerToken(r))
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if !s.perInstance.allow(origin) || !s.global.allow("*") {
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+ var body struct {
+ Code string `json:"code"`
+ State string `json:"state"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&body); err != nil || body.Code == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ token, err := s.exchangeCode(body.Code, body.State)
+ if err != nil {
+ // Log the failure for us (never the code or token), return a generic error.
+ log.Printf("exchange[%s]: %v", origin, err)
+ http.Error(w, "exchange failed", http.StatusBadGateway)
+ return
+ }
+ // The access token is returned to the caller and never persisted or logged.
+ writeJSON(w, http.StatusOK, map[string]any{"access_token": token})
+}
+
+// exchangeCode swaps an authorization code for an access token as a confidential
+// client (client_secret held here). Standard OAuth token request, form-encoded.
+func (s *Server) exchangeCode(code, state string) (string, error) {
+ form := url.Values{}
+ form.Set("grant_type", "authorization_code")
+ form.Set("client_id", s.clientID)
+ form.Set("client_secret", s.clientSecret)
+ form.Set("code", code)
+ form.Set("redirect_uri", s.redirectURI)
+ if state != "" {
+ form.Set("state", state)
+ }
+ resp, err := s.http.Post(s.tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
+ var out struct {
+ AccessToken string `json:"access_token"`
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal(raw, &out)
+ if resp.StatusCode != http.StatusOK || out.AccessToken == "" {
+ if out.Message != "" {
+ return "", fmt.Errorf("provider %d: %s", resp.StatusCode, out.Message)
+ }
+ return "", fmt.Errorf("provider %d", resp.StatusCode)
+ }
+ return out.AccessToken, nil
+}
+
+// ---- admin -----------------------------------------------------------------
+
+func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if subtle.ConstantTimeCompare([]byte(hashToken(bearerToken(r))), []byte(s.adminHash)) != 1 {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (s *Server) mintHandler(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Label string `json:"label"` // the instance ORIGIN, e.g. https://test.luan.chat
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) != nil || body.Label == "" {
+ http.Error(w, "label (instance origin) required", http.StatusBadRequest)
+ return
+ }
+ id, token, err := s.db.mintToken(body.Label)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": body.Label, "token": token})
+}
+
+func (s *Server) listHandler(w http.ResponseWriter, r *http.Request) {
+ tokens, err := s.db.listTokens()
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
+}
+
+func (s *Server) revokeHandler(w http.ResponseWriter, r *http.Request) {
+ deleted, err := s.db.revokeToken(r.PathValue("id"))
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if !deleted {
+ http.Error(w, "no such token", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// ---- middleware & helpers --------------------------------------------------
+
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Strict-Transport-Security", "max-age=31536000")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func bearerToken(r *http.Request) string {
+ const p = "Bearer "
+ h := r.Header.Get("Authorization")
+ if len(h) > len(p) && h[:len(p)] == p {
+ return h[len(p):]
+ }
+ return ""
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("write json: %v", err)
+ }
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+// ---- rate limiting (per-key token bucket, copied from push/main.go) --------
+
+type rateLimiter struct {
+ mu sync.Mutex
+ buckets map[string]*bucket
+ burst float64
+ perSec float64
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func newRateLimiter(burst, perSec float64) *rateLimiter {
+ return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec}
+}
+
+func (rl *rateLimiter) allow(key string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ now := time.Now()
+ if len(rl.buckets) > 10000 {
+ for k, b := range rl.buckets {
+ if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour {
+ delete(rl.buckets, k)
+ }
+ }
+ }
+ b, ok := rl.buckets[key]
+ if !ok {
+ b = &bucket{tokens: rl.burst, last: now}
+ rl.buckets[key] = b
+ }
+ b.tokens += now.Sub(b.last).Seconds() * rl.perSec
+ if b.tokens > rl.burst {
+ b.tokens = rl.burst
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
diff --git a/connected.go b/connected.go
index 49d31d1..9b68e9c 100644
--- a/connected.go
+++ b/connected.go
@@ -3,7 +3,11 @@ package main
import (
"encoding/json"
"net/http"
+ "os"
"regexp"
+ "strings"
+ "sync"
+ "time"
)
// Connected third-party services (docs/connected-services.md).
@@ -110,3 +114,76 @@ func (s *Server) deleteConnected(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+
+// brokerURL is the connect broker (docs/connected-services.md) that instances
+// fetch OAuth config from and sidecars exchange codes through. It defaults to the
+// shared Eleven broker, so a stock instance needs NO connect config at all; a
+// self-hoster running their own points LCHAT_CONNECT_BROKER_URL at it. No DNSimple
+// config ever lives on the instance — it all comes from the broker.
+func brokerURL() string {
+ if v := strings.TrimSpace(os.Getenv("LCHAT_CONNECT_BROKER_URL")); v != "" {
+ return strings.TrimRight(v, "/")
+ }
+ return "https://connect.elevenmessenger.com"
+}
+
+// The broker's public OAuth config (client_id, authorize_url, redirect_uri — all
+// non-secret), cached briefly so opening the connect surface doesn't refetch each
+// time. On a fetch failure we serve the last-known value (or nil → the client
+// shows "not configured").
+var (
+ oauthCfgMu sync.Mutex
+ oauthCfgVal map[string]any
+ oauthCfgTime time.Time
+)
+
+func brokerOAuthConfig() map[string]any {
+ oauthCfgMu.Lock()
+ defer oauthCfgMu.Unlock()
+ if oauthCfgVal != nil && time.Since(oauthCfgTime) < 10*time.Minute {
+ return oauthCfgVal
+ }
+ resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(brokerURL() + "/config")
+ if err != nil {
+ return oauthCfgVal
+ }
+ defer resp.Body.Close()
+ var cfg map[string]any
+ if resp.StatusCode == http.StatusOK && json.NewDecoder(resp.Body).Decode(&cfg) == nil {
+ oauthCfgVal, oauthCfgTime = cfg, time.Now()
+ }
+ return oauthCfgVal
+}
+
+// GET /api/oauth/config?service=dnsimple -> {"client_id","authorize_url","redirect_uri"}
+//
+// The browser needs the *public* OAuth params to start a flow. The instance holds
+// none of this itself — it fetches it from the default connect broker (override
+// with LCHAT_CONNECT_BROKER_URL). No secret is ever involved.
+func (s *Server) oauthConfig(w http.ResponseWriter, r *http.Request) {
+ if !s.tokenOK(r.URL.Query().Get("token")) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ if r.URL.Query().Get("service") != "dnsimple" {
+ writeJSON(w, http.StatusOK, map[string]any{})
+ return
+ }
+ cfg := brokerOAuthConfig()
+ if cfg == nil {
+ cfg = map[string]any{}
+ }
+ writeJSON(w, http.StatusOK, cfg)
+}
+
+// GET /oauth/{service}/callback — the registered OAuth redirect target. It runs a
+// tiny page that hands the one-time `code` back to the app window that started
+// the flow (which alone holds the PKCE verifier) via postMessage, then closes.
+// It stores nothing and sees only a single-use code — safe if compromised.
+func (s *Server) oauthCallbackPage(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`<!doctype html><meta charset="utf-8"><title>Connecting…</title>` +
+ `<body style="font:16px system-ui;margin:2rem;color:#333">Connecting… you can close this window.` +
+ `<script src="/static/oauth-callback.js"></script>`))
+}
diff --git a/docs/connected-services.md b/docs/connected-services.md
index ec4dbfa..e0d3ad2 100644
--- a/docs/connected-services.md
+++ b/docs/connected-services.md
@@ -1,8 +1,11 @@
# Connecting third-party services (`/register`, and friends)
-Status: **design note — nothing shipped.** This captures the trust model we
-settled on so it's decided before any code. DNSimple is the worked example; the
-shape is meant to generalise to any bearer-token / OAuth API.
+Status: **prototype shipping.** A connect broker is live at
+`connect.elevenmessenger.com` and `test-bots.luan.chat` is wired to it (branch
+`connect-oauth`). DNSimple is the worked example; the shape generalises to any
+OAuth API. **The plan evolved during implementation** — DNSimple turned out to be
+confidential-only, so the public/PKCE sections further down are **superseded** by
+*What shipped* immediately below.
## The goal
@@ -16,6 +19,74 @@ nothing usable.
*not* building. That path is manual, per-operator, and puts a live secret on the
box forever. We want connect-through-the-app as a first-class flow.
+## What shipped: DNSimple is confidential-only → the connect broker
+
+Implementation reality overrode the PKCE plan below: **DNSimple offers no
+public/PKCE client** — every OAuth app is *confidential*, with a `client_secret`.
+So there IS a secret. Rather than copy it onto every instance (the old
+`.p8`-on-every-box problem), it lives in **one shared broker** — the same move as
+the push relay.
+
+The **connect broker** (`broker/`, deployed as `connect.elevenmessenger.com`)
+holds the `client_id` + `client_secret` and does two jobs:
+
+- **Redirect relay** (`GET /oauth/dnsimple/callback`) — the single registered
+ redirect_uri for the whole fleet. DNSimple redirects here with a one-time
+ `code`; the broker bounces it back to the originating instance, read from an
+ **allowlisted** `state` (`base64url(origin).nonce`). It sees only a single-use
+ code → **safe if compromised**.
+- **Token exchange** (`POST /api/exchange`) — a bot sidecar sends `{code, state}`
+ with a per-instance bearer token; the broker swaps it for an access token using
+ the secret and returns it. It stores nothing; the sidecar seals the token to the
+ member. This is the one place holding the secret + transiently seeing a token.
+
+Instances fetch the public params (`client_id`, `authorize_url`, `redirect_uri`)
+from the broker's `GET /config`, so **a stock instance needs no DNSimple config at
+all**; the broker URL defaults to `connect.elevenmessenger.com`
+(`LCHAT_CONNECT_BROKER_URL` overrides for a self-hosted broker).
+
+| Compromised | Holds | Usable to an attacker? |
+|---|---|---|
+| Redirect relay (public endpoint) | nothing | single-use codes only → **no** |
+| Messenger instance | opaque ciphertext blobs | **no** |
+| Exchange broker | `client_secret` + in-flight tokens | secret is *app-identity* (impersonation, not account access); tokens in-flight only, never stored |
+| Per-instance sidecar | a user token transiently (to seal it) | scoped to that instance |
+
+The `client_secret` is *app-identity auth* — same tier as the admin token / APNs
+`.p8`. A leak enables impersonation in an OAuth prompt, not access to anyone's
+account (DNSimple still requires the user's consent to mint a token).
+
+### Self-hosting: on-the-fly broker registration (planned, not built)
+
+Today the broker admin hand-mints a per-instance token (fine for our fleet, but a
+barrier for a self-hoster pointing at our broker — they'd have to ask us). The
+plan is **dynamic registration with a domain-control proof**: an instance POSTs
+its origin to the broker; the broker verifies the instance actually serves at that
+origin (ACME/HTTP-01-style — fetch a challenge from
+`https://<origin>/.well-known/lchat-connect`); on success it auto-allowlists the
+origin and issues a token. No human in the loop, and safe: **you can only register
+an origin you control**, so no open-redirect and no code hijacking. (A self-hoster
+running their *own* broker needs nothing from us.)
+
+### Next: connected services as companion apps (POC direction)
+
+Once connected, a service should *install as a companion app*: DNSimple appears in
+your app list, opens as its own thread with a **Chat** view (a bot-style
+conversation) and an **Interface** view (your domains), takes `/`-commands, and —
+with AI enabled — answers in natural language. It's a **client-side mini-app**, a
+"dynamic bot" — **no per-service sidecar**.
+
+The catch: DNSimple's API has no browser CORS (confirmed — same as the token
+endpoint), so the app can't call it directly. It **delegates each operation to the
+existing per-instance sidecar via a time-boxed grant** — exactly how `/list` works
+today; the sidecar makes the call and the result folds into the app's Interface +
+Chat. *Not* the broker: proxying per-call there would make the shared broker see
+every user's token on every call (breaking "shared infra leaks nothing"). On
+**native** there's no CORS wall, so the app could call the API directly.
+
+Longer term these companion apps should be **installable and maintained outside
+this repo** rather than baked in; the in-repo `connect` app is the proof-of-concept.
+
## The one split that unlocks it: storage vs. execution
- **Storage** — where the credential rests, and in what form.
diff --git a/main.go b/main.go
index 09b3e50..5bf5c0d 100644
--- a/main.go
+++ b/main.go
@@ -322,6 +322,10 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/connected/{service}", s.getConnected)
mux.HandleFunc("PUT /api/connected/{service}", s.putConnected)
mux.HandleFunc("DELETE /api/connected/{service}", s.deleteConnected)
+ // OAuth connect (docs/connected-services.md): public config for the browser to
+ // start a PKCE flow, and the redirect landing that relays the code back.
+ mux.HandleFunc("GET /api/oauth/config", s.oauthConfig)
+ mux.HandleFunc("GET /oauth/{service}/callback", s.oauthCallbackPage)
// Encrypted attachments: bytes are E2E-encrypted in the browser; the server
// stores/serves opaque blobs only. Both ends are token-gated.
diff --git a/test/connect.test.mjs b/test/connect.test.mjs
index 41bd78d..a8dc6f2 100644
--- a/test/connect.test.mjs
+++ b/test/connect.test.mjs
@@ -1,7 +1,9 @@
// Connected-services core (docs/connected-services.md): the capability-grant
-// crypto (sealForPub/openSealed) and the DNSimple client's read-only checks.
+// crypto (sealForPub/openSealed), the OAuth PKCE helper, and the DNSimple client
+// (token exchange + read-only checks).
import http from "node:http";
-import { generateUserKeypair, sealForPub, openSealed } from "../web/static/crypto.js";
+import crypto from "node:crypto";
+import { generateUserKeypair, sealForPub, openSealed, pkcePair } from "../web/static/crypto.js";
function assert(cond, msg) {
if (!cond) {
@@ -35,12 +37,34 @@ async function testGrant() {
// A fake DNSimple so the bot's read-only availability check is exercised without
// a real account (LCHAT_DNSIMPLE_BASE is read at import time, so import late).
+// A PKCE verifier's challenge is exactly base64url(S256(verifier)), a fresh pair
+// each call — the basis of authorizing with no client secret.
+async function testPkce() {
+ const { verifier, challenge } = await pkcePair();
+ const expected = crypto.createHash("sha256").update(verifier).digest("base64url");
+ assert(challenge === expected, "pkce challenge is base64url(S256(verifier))");
+ assert(verifier.length >= 43 && !/[+/=]/.test(verifier), "verifier is url-safe and long enough (RFC 7636)");
+ assert((await pkcePair()).verifier !== verifier, "each pkcePair is fresh");
+}
+
function startMock() {
const server = http.createServer((req, res) => {
const send = (obj, code = 200) => {
res.writeHead(code, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
+ // Connect broker exchange endpoint: bearer-authed, returns an access token.
+ if (req.method === "POST" && req.url === "/api/exchange") {
+ if (!/^Bearer /.test(req.headers.authorization || "")) return send({ message: "unauthorized" }, 401);
+ let body = "";
+ req.on("data", (ch) => (body += ch));
+ req.on("end", () => {
+ const p = JSON.parse(body || "{}");
+ if (p.code) return send({ access_token: "scoped_from_exchange" });
+ send({ message: "bad exchange request" }, 400);
+ });
+ return;
+ }
if (!/^Bearer scoped_/.test(req.headers.authorization || "")) return send({ message: "Authentication failed" }, 401);
if (req.url === "/v2/whoami") return send({ data: { account: { id: 4242 } } });
if (req.url.startsWith("/v2/4242/domains")) return send({ data: [{ name: "one.example" }, { name: "two.example" }] });
@@ -54,18 +78,30 @@ function startMock() {
return new Promise((resolve) => server.listen(0, () => resolve(server)));
}
-async function testDnsimple() {
+async function testClient() {
const server = await startMock();
process.env.LCHAT_DNSIMPLE_BASE = `http://127.0.0.1:${server.address().port}`;
- const { checkAvailability, listDomains } = await import("../agent/dnsimple.mjs");
+ const base = process.env.LCHAT_DNSIMPLE_BASE;
+ const { exchangeViaBroker, checkAvailability, listDomains } = await import("../agent/dnsimple.mjs");
+
+ // The sidecar swaps a code for a token via the broker (which holds the secret).
+ const token = await exchangeViaBroker({ code: "abc", state: "s", brokerUrl: base, brokerToken: "brk" });
+ assert(token === "scoped_from_exchange", "exchangeViaBroker returns the access token from the broker");
+ let noBroker = false;
+ try {
+ await exchangeViaBroker({ code: "abc", state: "s" });
+ } catch {
+ noBroker = true;
+ }
+ assert(noBroker, "exchangeViaBroker refuses without broker config");
- // /list — the wired, read-only command.
- const domains = await listDomains("scoped_tok");
+ // /list — the wired, read-only command, using the exchanged token.
+ const domains = await listDomains(token);
assert(domains.length === 2 && domains.includes("one.example"), "listDomains returns the account's domain names");
- const free = await checkAvailability("free.example", "scoped_tok");
+ const free = await checkAvailability("free.example", token);
assert(free.available === true, "an unregistered domain reports available");
- const taken = await checkAvailability("taken.example", "scoped_tok");
+ const taken = await checkAvailability("taken.example", token);
assert(taken.available === false, "a registered domain reports taken");
let authThrew = false;
@@ -78,7 +114,7 @@ async function testDnsimple() {
let badDomain = false;
try {
- await checkAvailability("not a domain", "scoped_tok");
+ await checkAvailability("not a domain", token);
} catch {
badDomain = true;
}
@@ -88,5 +124,6 @@ async function testDnsimple() {
}
await testGrant();
-await testDnsimple();
+await testPkce();
+await testClient();
console.log("connect.test.mjs OK");
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index ca54f95..14c0f36 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -1,47 +1,31 @@
// Connected services (prototype) — docs/connected-services.md.
//
-// Two slash-commands that ride the mini-app command framework but delegate the
-// privileged crypto to the host (ctx.host.connected), since an app can't touch
-// the session keypair, roster, or fetch:
-// /connect <service> <token> — store a credential, client-encrypted and sealed
-// to you; the server only ever holds ciphertext.
-// /register <domain> — ask the chat's bot to act with it, via a
-// short-lived grant sealed to that bot. Your
-// long-lived token never reaches the bot or server.
-// Both answer in place (ctx.reply); the bot's result lands in your chat with it.
+// A small surface to link a third-party account so a bot can act for you. You
+// authorize on the provider's own site (OAuth PKCE) — no key is pasted, no
+// client secret exists, and the server only ever holds an opaque, client-
+// encrypted blob. The privileged crypto lives in shell.js and is reached via
+// ctx.host.connected (an app can't touch the session keypair, roster, or fetch).
+//
+// Commands: `/connect` opens this surface; `/list` asks the chat's bot to list
+// your domains (answered in place; the bot replies in your chat with it).
import { defineApp } from "../apps.js";
-// Services we know how to connect. DNSimple is the worked example; each entry is
-// a paste-a-scoped-token flow (no OAuth broker needed — see the design note).
-const SERVICES = ["dnsimple"];
-
const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
export const app = defineApp({
name: "connect",
title: "Connected services",
glyph: "🔌",
- scope: "personal", // gets its own thread; here it just hosts the two commands
+ scope: "personal",
initialState: () => ({}),
events: {},
commands: {
"/connect": {
desc: "Connect a third-party service",
- args: "<service> <token>",
- run: async (args, ctx) => {
+ args: "",
+ run: (_args, ctx) => {
if (!ctx.host || !ctx.host.connected) return unavailable(ctx);
- const sp = args.indexOf(" ");
- const service = (sp === -1 ? args : args.slice(0, sp)).trim().toLowerCase();
- const token = sp === -1 ? "" : args.slice(sp + 1).trim();
- if (!SERVICES.includes(service))
- return ctx.reply({ text: `I can connect: ${SERVICES.join(", ")}. Try /connect dnsimple <token>.` });
- if (!token) return ctx.reply({ text: `Paste your ${service} access token: /connect ${service} <token>` });
- const res = await ctx.host.connected.store(service, token);
- ctx.reply({
- text: res.ok
- ? `Connected ${service}. Your token is encrypted to you — the server can't read it. Try /register <domain>.`
- : `Couldn't connect ${service}: ${res.error}`,
- });
+ ctx.reply({ text: "Open Connected services to link an account — you'll authorize on the provider's site, no key to paste.", actions: [{ label: "Open Connected services", run: ctx.open }] });
},
},
// NOTE: `/list` is a generic verb. Until the context-scoped command resolver
@@ -62,4 +46,102 @@ export const app = defineApp({
},
},
},
+
+ render(_state, el, ctx) {
+ el.textContent = "";
+ const wrap = document.createElement("div");
+ wrap.className = "connect-app";
+
+ const h = document.createElement("h2");
+ h.className = "connect-h";
+ h.textContent = "Connected services";
+ wrap.appendChild(h);
+
+ const sub = document.createElement("p");
+ sub.className = "connect-sub";
+ sub.textContent = "Link an account so a bot can act for you. You authorize on the provider's own site — no key is pasted, and the server never sees it.";
+ wrap.appendChild(sub);
+
+ const row = document.createElement("div");
+ row.className = "connect-row";
+ const name = document.createElement("span");
+ name.className = "connect-name";
+ name.textContent = "DNSimple";
+ const status = document.createElement("span");
+ status.className = "connect-status";
+ const btn = document.createElement("button");
+ btn.className = "connect-btn";
+ btn.type = "button";
+ row.append(name, status, btn);
+ wrap.appendChild(row);
+
+ const note = document.createElement("div");
+ note.className = "connect-note";
+ wrap.appendChild(note);
+ el.appendChild(wrap);
+
+ const host = ctx.host && ctx.host.connected;
+ if (host && host.prepare) host.prepare("dnsimple"); // warm OAuth config so Connect opens directly
+ let busy = false;
+ async function paint() {
+ if (!host) {
+ status.textContent = "unavailable here";
+ btn.hidden = true;
+ return;
+ }
+ // Mid-exchange (just came back from consent): show a loading state, not a
+ // flash of "Not connected".
+ if (host.pending && host.pending()) {
+ status.textContent = "Connecting…";
+ status.classList.remove("on");
+ btn.textContent = "Connecting…";
+ btn.disabled = true;
+ return;
+ }
+ const services = await host.list();
+ const on = services.includes("dnsimple");
+ status.textContent = on ? "Connected" : "Not connected";
+ status.classList.toggle("on", on);
+ btn.textContent = busy ? "Connecting…" : on ? "Disconnect" : "Connect with DNSimple";
+ btn.disabled = busy;
+ btn.onclick = async () => {
+ if (busy) return;
+ if (on) {
+ await host.disconnect("dnsimple");
+ return;
+ }
+ note.textContent = "";
+ busy = true;
+ paint();
+ const res = await host.startOAuth("dnsimple");
+ busy = false;
+ paint();
+ if (!res.ok) note.textContent = res.error || "Couldn't start connecting.";
+ };
+ }
+ paint();
+
+ // shell.js fires this after a connect/disconnect completes (it carries a
+ // human-readable note); repaint and surface the note inline (no toast).
+ const onChange = (e) => {
+ if (e.detail && typeof e.detail.text === "string") note.textContent = e.detail.text;
+ busy = false;
+ paint();
+ };
+ window.addEventListener("lchat:connected-changed", onChange);
+ ctx.onCleanup(() => window.removeEventListener("lchat:connected-changed", onChange));
+ },
+
+ styles: `
+ .connect-app { max-width: 560px; margin: 0 auto; padding: 24px 20px; }
+ .connect-h { margin: 0 0 6px; font-size: 20px; }
+ .connect-sub { margin: 0 0 20px; color: var(--muted, #667); font-size: 14px; line-height: 1.5; }
+ .connect-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid var(--line, #e3e3e8); border-radius: 12px; }
+ .connect-name { font-weight: 600; }
+ .connect-status { margin-left: auto; font-size: 13px; color: var(--muted, #889); }
+ .connect-status.on { color: #1a8f4c; }
+ .connect-btn { padding: 8px 14px; border-radius: 9px; border: 0; background: var(--accent, #2f6fed); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
+ .connect-btn:disabled { opacity: .6; cursor: default; }
+ .connect-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted, #667); }
+ `,
});
diff --git a/web/static/crypto.js b/web/static/crypto.js
index 33da1b0..289e2d5 100644
--- a/web/static/crypto.js
+++ b/web/static/crypto.js
@@ -213,3 +213,15 @@ export async function openSealed(sealedB64, privKeyB64) {
const pt = await decryptBytes(aes, buf.slice(65));
return JSON.parse(new TextDecoder().decode(pt));
}
+
+// ---- OAuth PKCE (see docs/connected-services.md) ------------------------
+// pkcePair mints a one-time PKCE verifier + its S256 challenge for an OAuth
+// authorization-code flow with a *public* client (no client secret). The
+// verifier stays in the browser and is presented only at the token exchange;
+// the challenge rides in the authorize URL. This is what lets a connected
+// service be authorized with no pasted key and no stored secret.
+export async function pkcePair() {
+ const verifier = bytesToB64url(crypto.getRandomValues(new Uint8Array(32))); // 43-char base64url
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
+ return { verifier, challenge: bytesToB64url(digest) };
+}
diff --git a/web/static/oauth-callback.js b/web/static/oauth-callback.js
new file mode 100644
index 0000000..d780eef
--- /dev/null
+++ b/web/static/oauth-callback.js
@@ -0,0 +1,31 @@
+// OAuth redirect landing (docs/connected-services.md). The broker relays the
+// provider's one-time ?code (+ ?state) here — same origin as the app. We hand it
+// back to the app to complete the exchange (the app holds the session/keys, the
+// bot does the exchange via the broker), then return to it.
+//
+// Same-tab flow (the one we use): merge the code into the pending sessionStorage
+// entry (verifying state), then navigate back to the app, which finishes on boot.
+// Popup flow (kept for a possible future/native path): postMessage to the opener.
+(function () {
+ var q = new URLSearchParams(location.search);
+ var code = q.get("code");
+ var state = q.get("state");
+ var error = q.get("error");
+ if (window.opener) {
+ try {
+ window.opener.postMessage({ source: "lchat-oauth", code: code, state: state, error: error }, location.origin);
+ } catch (e) {}
+ window.close();
+ return;
+ }
+ try {
+ var pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
+ if (error || !code) {
+ sessionStorage.setItem("lchat_oauth", JSON.stringify({ service: pend && pend.service, error: error || "no code" }));
+ } else if (pend && pend.state === state) {
+ pend.code = code;
+ sessionStorage.setItem("lchat_oauth", JSON.stringify(pend));
+ }
+ } catch (e) {}
+ location.replace("/");
+})();
diff --git a/web/static/shell.js b/web/static/shell.js
index f74bcf2..45c7d5b 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -18,6 +18,8 @@ import {
unwrapThreadKey,
sealForPub,
openSealed,
+ pkcePair,
+ bytesToB64url,
randomSecret,
deriveInvite,
} from "/static/crypto.js";
@@ -1795,13 +1797,159 @@ setEmitTransport((threadId, event) => {
// bot's public key. The bot never gets your long-lived token — only a copy
// that self-expires, sealed so only it can open it.
// Web-only: the native embed never holds userPriv, so both no-op there.
+// Find a chat that has a bot in it (prefer a DM) — the bot is the actor that does
+// connected-service work (docs/connected-services.md).
+// Find the best thread to hand a connected-service op to. Only the default agents
+// (Eleven/Claude) run the connect handler, so rank agents: the always-present
+// assistant Eleven first, then Claude, then any AI bot; never the write-only
+// Announcer, and prefer a lower-ranked custom bot only if nothing better exists.
+// (A proper per-bot "handles connect" capability flag is the real fix — see
+// docs/connected-services.md — this heuristic covers the default fleet.)
+function agentRank(e) {
+ if (!e || !e.is_agent || !e.public_key) return -1;
+ if (/announcer/i.test(e.label || "")) return -1;
+ if (/eleven/i.test(e.label || "")) return 3;
+ if (/claude/i.test(e.label || "")) return 2;
+ if (e.kind === "ai") return 1;
+ return 0;
+}
+
+function findBotThread() {
+ let best = null;
+ for (const t of threads) {
+ for (const id of t.members || []) {
+ if (id === myUserId) continue;
+ const e = roster.find((x) => x.user_id === id);
+ const r = agentRank(e);
+ if (r < 0) continue;
+ const score = r * 2 + (t.kind === "dm" ? 1 : 0); // prefer higher-ranked bot, then a DM
+ if (!best || score > best.score) best = { thread: t, bot: e, score };
+ }
+ }
+ return best ? { thread: best.thread, bot: best.bot } : null;
+}
+
+// Poll a thread for the bot's sealed connect.token reply (the OAuth result).
+async function awaitConnectToken(threadId, key, service, tries = 24) {
+ for (let i = 0; i < tries; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const r = await fetch(`/api/messages?thread=${encodeURIComponent(threadId)}&token=${encodeURIComponent(session.token)}&limit=20`);
+ const { messages = [] } = r.ok ? await r.json() : {};
+ for (const m of messages) {
+ try {
+ const p = await decrypt(key, m.blob);
+ if (p.type === "connect.token" && p.service === service) return p.sealed;
+ } catch {}
+ }
+ }
+ return null;
+}
+
+let oauthPending = null; // { service, state }
+const oauthCfgCache = {}; // service -> { client_id, authorize_url, redirect_uri }
+let oauthCompleting = false; // true while a returned code is being exchanged (drives the "Connecting…" state)
+
+// Fetch + cache the broker's public OAuth config. The connect surface warms this
+// on render so the Connect click can open the popup DIRECTLY at the authorize URL:
+// a blank popup navigated afterwards (window.open("") then .location=) is rejected
+// by DNSimple (the blob/access-control 400), a direct top-level nav is not.
+async function loadOAuthCfg(service) {
+ if (oauthCfgCache[service]) return oauthCfgCache[service];
+ try {
+ const r = await fetch(`/api/oauth/config?service=${encodeURIComponent(service)}&token=${encodeURIComponent(session.token)}`);
+ const cfg = r.ok ? await r.json() : null;
+ if (cfg && cfg.client_id) oauthCfgCache[service] = cfg;
+ return cfg;
+ } catch {
+ return null;
+ }
+}
+
+// Send the whole tab to the provider's authorize page. DNSimple rejects popup /
+// programmatic-window navigations (the blob/access-control 400); only a top-level
+// tab navigation works — and this is also what the native app wants (consent in
+// the system browser). `state` carries this instance's origin (base64url) so the
+// broker relays the code back here, plus a nonce for CSRF matching; we stash the
+// pending {service,state} in sessionStorage because module state is lost across
+// the full-tab round-trip. The browser never holds a secret.
+function openAuthorize(service, cfg) {
+ if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
+ if (!cfg || !cfg.client_id || !cfg.authorize_url || !cfg.redirect_uri)
+ return { ok: false, error: `${service} isn't set up for OAuth on this server yet` };
+ const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
+ const state = bytesToB64url(new TextEncoder().encode(location.origin)) + "." + nonce;
+ sessionStorage.setItem("lchat_oauth", JSON.stringify({ service, state }));
+ const url =
+ `${cfg.authorize_url}?response_type=code&client_id=${encodeURIComponent(cfg.client_id)}` +
+ `&redirect_uri=${encodeURIComponent(cfg.redirect_uri)}&state=${encodeURIComponent(state)}`;
+ location.assign(url);
+ return { ok: true };
+}
+
+// On the way back (the app booted at "/" after the callback stashed the code),
+// finish the exchange. Runs on ws.onopen when roster/threads are loaded; no-ops
+// unless there's a returned code waiting. Idempotent — clears the pending entry.
+async function completeOAuthReturn() {
+ let pend;
+ try {
+ pend = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
+ } catch {
+ pend = null;
+ }
+ if (!pend) return;
+ if (pend.error) {
+ sessionStorage.removeItem("lchat_oauth");
+ window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text: `${pend.service || "Connection"} cancelled.` } }));
+ return;
+ }
+ if (!pend.code || oauthCompleting) return; // no code yet (still at consent), or already completing
+ oauthCompleting = true;
+ window.dispatchEvent(new CustomEvent("lchat:connected-changed")); // repaint into "Connecting…"
+ let text;
+ try {
+ await completeOAuth(pend, pend.code);
+ text = `${pend.service} connected — the server never saw your token.`;
+ } catch (err) {
+ text = `Couldn't connect ${pend.service}: ${err.message}`;
+ }
+ // Clear pending state BEFORE the repaint (dispatchEvent is synchronous — clearing
+ // in a finally after the dispatch would leave the surface stuck on "Connecting…").
+ oauthCompleting = false;
+ sessionStorage.removeItem("lchat_oauth");
+ window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text } }));
+}
+
+// After the popup relays the one-time code (the verifier stayed here in memory),
+// hand code+verifier to the bot; it exchanges (no client secret) and returns the
+// token sealed to us; we store it client-encrypted. The browser never does the
+// exchange (CORS) and never holds a secret.
+async function completeOAuth(pend, code) {
+ const target = findBotThread();
+ if (!target) throw new Error("start a chat with a bot first — it does the work");
+ const key = threadKeys[target.thread.id];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) throw new Error("not connected right now");
+ const msg = { type: "connect.oauth", service: pend.service, code, state: pend.state, name: session.name, ts: Date.now() };
+ if (myUserId) msg.uid = myUserId;
+ ws.send(JSON.stringify({ type: "msg", thread_id: target.thread.id, blob: await encrypt(key, msg) }));
+ const sealed = await awaitConnectToken(target.thread.id, key, pend.service);
+ if (!sealed) throw new Error("the bot didn't hand back a token — is it running?");
+ const opened = await openSealed(sealed, session.userPriv);
+ const me = roster.find((e) => e.user_id === myUserId);
+ const r = await fetch(`/api/connected/${encodeURIComponent(pend.service)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, blob: await sealForPub(me.public_key, { secret: opened.secret }) }),
+ });
+ if (!r.ok) throw new Error(`couldn't save the connection (${r.status})`);
+}
+
setHostBridge({
connected: {
async store(service, secret) {
if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
const me = roster.find((e) => e.user_id === myUserId);
if (!me || !me.public_key) return { ok: false, error: "your encryption key isn't ready yet" };
- const blob = await sealForPub(me.public_key, { secret }); // sealed to you; only you can reopen
+ const blob = await sealForPub(me.public_key, { secret });
const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -1809,45 +1957,80 @@ setHostBridge({
});
return r.ok ? { ok: true } : { ok: false, error: `couldn't save (${r.status})` };
},
- async request(service, op, arg, ttlMs = 120000) {
- if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
- // Find a chat that has a bot in it (prefer a DM) — that's who does the work.
- let target = null;
- let bot = null;
- for (const t of threads) {
- const agent = (t.members || [])
- .filter((id) => id !== myUserId)
- .map((id) => roster.find((e) => e.user_id === id))
- .find((e) => e && e.is_agent && e.public_key);
- if (!agent) continue;
- if (!target || (t.kind === "dm" && target.kind !== "dm")) {
- target = t;
- bot = agent;
- }
- if (t.kind === "dm") break;
- }
- if (!target || !bot) return { ok: false, error: "start a chat with a bot first — it does the work" };
- const key = threadKeys[target.id];
- if (!key || !ws || ws.readyState !== WebSocket.OPEN) return { ok: false, error: "not connected right now" };
- const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
- const got = r.ok ? await r.json() : {};
- if (!got.connected || !got.blob) return { ok: false, error: `you haven't connected ${service} yet — try /connect ${service} <token>` };
- let opened;
+ // The connected service names, so the surface can show what's linked.
+ async list() {
+ const r = await fetch(`/api/connected?token=${encodeURIComponent(session.token)}`);
+ return r.ok ? (await r.json()).services || [] : [];
+ },
+ // True while a returned OAuth code is being exchanged (or waiting to be) — so
+ // the surface can show "Connecting…" instead of flashing "Not connected".
+ pending() {
+ if (oauthCompleting) return true;
try {
- opened = await openSealed(got.blob, session.userPriv);
+ const p = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
+ return !!(p && p.code);
} catch {
- return { ok: false, error: "couldn't reopen your stored credential" };
+ return false;
}
- const grant = await sealForPub(bot.public_key, { service, token: opened.secret, exp: Date.now() + ttlMs });
- const payload = { type: "connect.op", service, op, domain: arg, grant, name: session.name, ts: Date.now() };
- if (myUserId) payload.uid = myUserId;
- const blob = await encrypt(key, payload);
- ws.send(JSON.stringify({ type: "msg", thread_id: target.id, blob }));
- return { ok: true, bot: bot.label || "the bot" };
+ },
+ async disconnect(service) {
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`, { method: "DELETE" });
+ window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text: r.ok ? `Disconnected ${service}.` : "" } }));
+ return { ok: r.ok };
+ },
+ // Warm the OAuth config so the Connect click can open the popup directly.
+ prepare(service) {
+ loadOAuthCfg(service);
+ },
+ // Open the authorize popup. If config is already warm (the surface prefetched
+ // it), open synchronously so the click's user-gesture is kept and the popup
+ // navigates DIRECTLY to the URL. Uncached (a very fast click) falls back to a
+ // fetch-then-open, which the browser may block — rare, given the prefetch.
+ startOAuth(service) {
+ const cached = oauthCfgCache[service];
+ if (cached) return Promise.resolve(openAuthorize(service, cached));
+ return loadOAuthCfg(service).then((cfg) => openAuthorize(service, cfg));
+ },
+ request(service, op, arg, ttlMs = 120000) {
+ return (async () => {
+ if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
+ const target = findBotThread();
+ if (!target) return { ok: false, error: "start a chat with a bot first — it does the work" };
+ const key = threadKeys[target.thread.id];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return { ok: false, error: "not connected right now" };
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
+ const got = r.ok ? await r.json() : {};
+ if (!got.connected || !got.blob) return { ok: false, error: `you haven't connected ${service} yet` };
+ let opened;
+ try {
+ opened = await openSealed(got.blob, session.userPriv);
+ } catch {
+ return { ok: false, error: "couldn't reopen your stored credential" };
+ }
+ const grant = await sealForPub(target.bot.public_key, { service, token: opened.secret, exp: Date.now() + ttlMs });
+ const payload = { type: "connect.op", service, op, domain: arg, grant, name: session.name, ts: Date.now() };
+ if (myUserId) payload.uid = myUserId;
+ ws.send(JSON.stringify({ type: "msg", thread_id: target.thread.id, blob: await encrypt(key, payload) }));
+ return { ok: true, bot: target.bot.label || "the bot" };
+ })();
},
},
});
+// The OAuth popup lands on /oauth/<service>/callback, which postMessages the
+// one-time code back here; we hold the matching verifier. Complete on receipt.
+window.addEventListener("message", (e) => {
+ if (e.origin !== location.origin || !e.data || e.data.source !== "lchat-oauth") return;
+ const pend = oauthPending;
+ if (!pend || e.data.state !== pend.state) return;
+ oauthPending = null;
+ const done = (text) => window.dispatchEvent(new CustomEvent("lchat:connected-changed", { detail: { text } }));
+ if (e.data.error || !e.data.code) return done(`${pend.service} connection cancelled.`);
+ completeOAuth(pend, e.data.code)
+ .then(() => done(`${pend.service} connected — the server never saw your token.`))
+ .catch((err) => done(`Couldn't connect ${pend.service}: ${err.message}`));
+});
+
// Scheduling transport (ctx.schedule / ctx.unschedule): encrypt the event NOW —
// the server holds only ciphertext + (thread, time) — and let it deliver the
// blob into the thread at the due moment, where it arrives as a normal message.
@@ -3312,6 +3495,9 @@ function connectWS() {
// fresh load, so there's nothing to catch up on then.
if (everConnected) resyncActive();
everConnected = true;
+ // If we just came back from an OAuth consent redirect, finish it now that the
+ // socket's open and roster/threads are loaded.
+ completeOAuthReturn();
};
ws.onmessage = async (ev) => {
let msg;