eleven / messenger Public

Integrations: the registry, one-click GitHub, and self-rotating tokens (#495)

* integrations: the registry + pinned executor, with reviewed response projection

Port of the integrations prototype's stage 1 onto main (docs/integrations.md
design): an integration is DATA — one reviewed JSON manifest per service
(integrations/registry/) declaring its auth, the ONE api host its ops may
touch, and a closed op list, each read or write, writes each carrying the
confirmation line a person taps. The generic executor (integrations/exec.go)
can only do what a manifest spells: bounded args, slot-aware escaping, host
pinning on the BUILT url, on-host-only redirects, response caps.

New over the prototype: `pick` response projection in schema v1. Provider
responses are as reviewable as requests now — the manifest declares which
fields may flow back (root envelope descent + per-object field projection,
dot-paths flattened), and the executor drops everything else before a result
leaves the sidecar. This replaces the prototype's raw 8KB byte-truncation
(which handed apps broken JSON) with an honest "too large" refusal at a 32KB
cap sized to the 64KB encrypted-message blob limit, and it is what makes
GitHub usable at all (a raw repo object is ~100 fields, several KB).

- connect.op v2 in the sidecar: grants gain access read|write (empty = legacy
  = read-only), payloads gain args; any manifest op runs through the executor
  with grant-service match + write-needs-write-grant enforcement. The legacy
  DNSimple "list" path is byte-for-byte untouched.
- GET /api/integrations serves the embedded registry (public, like /api/apps).
- Manifests: GitHub (viewer, repos.list, issues.list, issue.create), DNSimple,
  Stripe, HubSpot — list ops all carry pick projections.

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

* web+bot: the integrations catalog + service_op consent chips

The member-facing half of the registry (port of the prototype's stages 2+3,
adapted to main's chip framework):

- The Integrations app becomes a registry-driven catalog: one card per service
  (name, category, connected state), each with a paste-a-scoped-token flow
  (sealed to the member's own key — the exact OAuth storage path), the
  manifest's create-a-scoped-token link, and a plain-language summary of what
  connecting allows ("Reads: … · Writes (always confirmed): …"). DNSimple's
  OAuth flow is preserved exactly; a registry fetch failure falls back to the
  old DNSimple-only row. New: pasting a token verifies it immediately through
  the manifest's "viewer" op when one exists ("connected as paulca ✓" / "didn't
  verify: Bad credentials"), best-effort and fail-soft when no bot is online.

- The service_op chip: whitelisted + sanitized (slug/op/access/args all
  bounded), locks on tap, seals an ACCESS-SCOPED one-shot grant
  ({service, token, exp, access}) and round-trips connect.op with args; the
  outcome lands as a private notice in the thread the chip was tapped in —
  never a data dump. sanitizeActions gains the spec.sanitize hook, and
  service_op chips allow a 160-char label so a write's consent line ("Register
  blip.com? This buys the domain and costs real money.") renders whole, never
  cut mid-warning. Legacy requestData keeps its wire bytes; requestOp is the
  scoped sibling apps use.

- The brain proposes ops: a service_op tool whose description is GENERATED
  from the embedded registry (one line per op, with access + args), and every
  proposal is re-checked against the same registry before a chip renders —
  fail at proposal, not at tap. Kept out of chipTools() so the Node-reference
  drift pin stays intact (the Node agent doesn't propose service ops).

- Broker: GET /registry serves the embedded catalog CORS-open — the future
  external-repo serving path (docs/integrations.md).

TestServiceOpEndToEnd drives the whole headline on main's own harness: Eleven
proposes the chip (label = the rendered confirm), the simulated tap seals a
write grant, the fake registrar receives exactly one authorized POST, the
connect.result lands — and the same op under a READ grant is refused with the
registrar untouched.

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

* web: the GitHub companion app — repos, issues, and open-an-issue

The second connected-service app (after DNSimple), and the first built on the
registry path: once GitHub is linked in Integrations (a fine-grained PAT), the
app appears in New chat and opens as its own thread — your repositories
(filterable, keyboard-navigable, private/archived/fork badges, open-issue and
star counts, last-push age), a drill-in to any repo's open issues and PRs, and
a New-issue form whose submission runs the manifest's write op under a
write-scoped one-shot grant.

No GitHub sidecar and no new bot: every fetch is ctx.host.connected.requestOp
through the instance's existing bot, and what comes back is bounded by the
github manifest's pick projection — only the reviewed fields ever ride into
the thread. Takeover app (no chatMessage), so repo and issue data never echoes
into chat bubbles, previews, or notifications. Fold state (repos + a bounded
per-repo issues map) snapshots and syncs across devices like any app.

Smoke-tested against the real GitHub API, which caught a real bug: a raw
/user/repos page (~400KB) blew the executor's 256KB read cap and surfaced as
"response isn't JSON". Projected ops now read up to 4MB — the pick throws
almost all of it away immediately — and an over-cap body refuses honestly
instead of truncating. The per-op result cap rises to 40KB (a 100-repo page
projects to ~26KB; 40KB base64s to ~54KB, inside the 64KB blob limit).

Native: glyph/title mirrored in ThreadStore (the app itself is web-only for
now, like DNSimple — the native embed can't seal grants yet).

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

* docs: integrations design doc + changelog for the catalog and GitHub app

docs/integrations.md describes what shipped (manifests as the palette, pick
response projection, per-bot read/write grants, the service_op chip flow, the
companion-app rail) and the external elevenmessenger/integrations repo plan —
manifest PRs validated by the same exported Parse the binary uses, secrets
staying on the broker, catalog served from the broker while execution trusts
the embedded snapshot. connected-services.md points at it and stays the
trust-model reference.

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

* broker+server+web: the OAuth relay goes service-generic — GitHub one click away

The golden path for connecting a service is now ordered for members: authorize
on the provider's own site whenever the broker has an OAuth app; paste a
scoped token only as the fallback. Nothing here was GitHub-specific to build —
the DNSimple relay generalises:

- Broker: one confidential client per service (oauthApp map). DNSimple keeps
  its legacy LCHAT_DNSIMPLE_* envs; any other OAuth-capable registry service
  is enabled by LCHAT_OAUTH_<ID>_CLIENT_ID/_CLIENT_SECRET, with provider
  endpoints defaulting from the manifest's auth.oauth block (schema gains
  token_url, validated https) and the redirect defaulting to the broker's own
  /oauth/<id>/callback. /config gains ?service=, the relay route becomes
  /oauth/{service}/callback, /api/exchange takes {service, code, state} — all
  three default to dnsimple so pre-multi-service instances and sidecars keep
  working byte-for-byte. The exchange now sends Accept: application/json
  (GitHub answers form-encoded without it) and reads GitHub's
  error/error_description shape alongside DNSimple's message.

- Instance: /api/oauth/config passes ?service= through to the broker with a
  per-service cache; a 404 ("no app for this service") caches as a definitive
  absent so the catalog can decide.

- Web: the catalog probes oauthReady(service) and renders "Connect with X"
  only when the flow can actually start — a service without a registered app
  offers paste-a-token alone, never a dead button. pending() now names the
  service mid-exchange so only that row shows "Connecting…". The sidecar
  exchange (handleOAuthExchange) accepts any registry service and hands the
  sealed token back under the right service name.

github.json flips auth.kind to "both": the moment the broker holds a GitHub
App's client pair, every instance's catalog offers one-click Connect — no
instance or client deploy. Tests: multi-service broker exchange (Accept
header, per-service client pair, unknown-service refusals), github callback
relay, per-service instance config pass-through.

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

* broker+bot+web: expiring OAuth tokens rotate themselves (GitHub Apps)

GitHub App user tokens live 8 hours with a single-use rotating refresh token —
without support, a connection silently dies the same day. Now the pair is
first-class, and the temporal-custody model from connected-services.md is
native: a stolen credential blob ages out in hours.

- Broker: POST /api/refresh (grant_type=refresh_token, same per-instance auth
  and nothing-persisted shape as the exchange); the exchange itself now
  returns the whole pair {access_token, refresh_token, expires_in} when the
  provider issues one. Non-expiring providers (DNSimple) are unchanged.
- Sidecar: the stored credential and the op grant carry
  {secret, refresh, expires_at}; the SIDECAR is the only refresher. A stale
  pair rotates before the provider call (60s slack); a surprise 401 on a
  supposedly-live token rotates once and retries once — never loops. Rotation
  is serialized per credential (refreshGroup singleflight) so two
  near-simultaneous ops share one rotation instead of the loser burning the
  single-use token, and it is REFUSED when the fresh pair couldn't be handed
  back (no resolvable member key) — a failed op beats a silently severed
  connection. integrations.Execute grows a typed StatusError so the 401
  detection isn't string-matching (message bytes unchanged).
- The fresh pair rides back ON the op result ("renewed", sealed to the
  member, never kept by the bot); the requesting device re-stores it. Web:
  completeOAuth stores the whole pair via the shared storeCredential;
  requestOp forwards refresh/expiry in the grant and re-stores a renewed
  pair best-effort.

Tests: broker refresh (rotated pair round-trip, auth refusal), sidecar
refresh-before-expired (stale token never reaches the provider; renewed pair
opens only with the member key), 401-then-retry-once (exactly one rotation,
auth sequence pinned), and refusal-without-member-key (broker and provider
both untouched).

With this, the GitHub App's "Expire user authorization tokens" setting can
stay ON — expiring tokens are now the preferred, self-healing mode.

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

* web: a connected service's primary action becomes "Open <service>"

Once DNSimple or GitHub is linked, the catalog row's first button opens the
service's companion app directly (created on first use, via openAppThread) —
connect once, then go straight to your domains or repos. Services without a
companion app (Stripe, HubSpot) keep Replace token / Disconnect only.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ca9c618d5ab907acc43b35fb139db82d4e398406 parent 4effd1c
32 files changed, +4660 −258
  • bot_serviceop_test.go +168 −0
  • broker/broker_test.go +215 −10
  • broker/main.go +251 −77
  • changelog/2026-07-09-integrations-catalog-github.md +10 −0
  • connected.go +42 −26
  • connected_test.go +56 −0
  • docs/connected-services.md +8 −0
  • docs/integrations.md +172 −0
  • integrations/exec.go +372 −0
  • integrations/exec_test.go +306 −0
  • integrations/registry.go +369 −0
  • integrations/registry/README.md +83 −0
  • integrations/registry/manifests/dnsimple.json +96 −0
  • integrations/registry/manifests/github.json +116 −0
  • integrations/registry/manifests/hubspot.json +53 −0
  • integrations/registry/manifests/stripe.json +59 −0
  • integrations/registry_test.go +135 −0
  • integrations_api.go +18 −0
  • integrations_api_test.go +62 −0
  • internal/bot/brain.go +91 −2
  • internal/bot/connect.go +173 −21
  • internal/bot/connect_v2_test.go +496 −0
  • internal/bot/dnsimple.go +52 −18
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +2 −2
  • main.go +3 −0
  • package.json +1 −1
  • test/github.test.mjs +93 −0
  • test/serviceop.test.mjs +101 −0
  • web/static/apps/connect.js +270 −58
  • web/static/apps/github.js +599 −0
  • web/static/shell.js +154 −41
  • web/static/ui.js +34 −2
diff --git a/bot_serviceop_test.go b/bot_serviceop_test.go
new file mode 100644
index 0000000..e272fb3
--- /dev/null
+++ b/bot_serviceop_test.go
@@ -0,0 +1,168 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+// TestServiceOpEndToEnd drives the interactive write flow of
+// docs/integrations.md against the real router: Owner asks Eleven to register
+// a domain → the (scripted) brain proposes a service_op chip whose label is
+// the manifest's rendered confirm line → the member's "tap" seals a one-shot
+// WRITE grant to Eleven's key and posts the connect.op → the sidecar's pinned
+// executor runs the op against the (fake) registrar → the connect.result
+// lands back in the DM. Then the negative: the same op under a READ grant is
+// refused without the registrar ever seeing a second request.
+func TestServiceOpEndToEnd(t *testing.T) {
+ // The fake registrar records every request the executor makes.
+ type regReq struct {
+ method, path, auth, body string
+ }
+ var regMu sync.Mutex
+ var regReqs []regReq
+ fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ regMu.Lock()
+ regReqs = append(regReqs, regReq{r.Method, r.URL.Path, r.Header.Get("Authorization"), string(raw)})
+ regMu.Unlock()
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"data":{"id":1,"name":"blip.com"}}`))
+ }))
+ defer fake.Close()
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_DNSIMPLE", fake.URL)
+
+ claude := newFakeAnthropic(t, "Here's the button — it costs real money, so it's your tap.",
+ map[string]any{"name": "service_op", "input": map[string]any{
+ "service": "dnsimple", "op": "domain.register",
+ "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"},
+ }})
+ t.Setenv("ANTHROPIC_BASE_URL", claude.srv.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipe(), AIKey: "test-key",
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ postAs(t, ts.URL, owner, dmID, dmKey, "@Eleven register blip.com on dnsimple, account 123, registrant 77")
+
+ // The reply carries the write chip, its label the manifest's confirm line.
+ waitFor(t, "service_op chip", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 })
+ replies := botReplies(t, s, dmID, dmKey, "Eleven")
+ if len(replies[0].Actions) != 1 {
+ t.Fatalf("want the service_op chip, got %+v", replies[0].Actions)
+ }
+ chip := replies[0].Actions[0]
+ if chip["type"] != "service_op" || chip["access"] != "write" || chip["service"] != "dnsimple" || chip["op"] != "domain.register" {
+ t.Fatalf("bad chip: %+v", chip)
+ }
+ if chip["label"] != "Register blip.com? This buys the domain and costs real money." {
+ t.Fatalf("chip label isn't the rendered confirm line: %q", chip["label"])
+ }
+ args, _ := chip["args"].(map[string]any)
+ if args["domain"] != "blip.com" || args["account"] != "123" || args["registrant_id"] != "77" {
+ t.Fatalf("chip args: %+v", args)
+ }
+ regMu.Lock()
+ if len(regReqs) != 0 {
+ t.Fatalf("proposal alone reached the registrar: %+v", regReqs)
+ }
+ regMu.Unlock()
+
+ // The tap: shell.js seals a one-shot WRITE grant to the bot's key and posts
+ // connect.op with the chip's args (requestOp).
+ tap := func(rid, access string) {
+ grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{
+ "service": "dnsimple", "token": "scoped-registrar-tok",
+ "exp": time.Now().Add(2 * time.Minute).UnixMilli(), "access": access,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ownerUID, _ := s.db.deviceTokenUser(owner.Token)
+ postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{
+ "type": "connect.op", "service": "dnsimple", "op": "domain.register",
+ "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"},
+ "grant": grant, "rid": rid, "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(),
+ })
+ }
+ tap("rid-write", "write")
+
+ waitFor(t, "connect.result", func() bool {
+ p := typedReply(t, s, dmID, dmKey, "connect.result")
+ return p != nil && p["rid"] == "rid-write"
+ })
+ res := typedReply(t, s, dmID, dmKey, "connect.result")
+ if res["error"] != nil {
+ t.Fatalf("write op errored: %v", res["error"])
+ }
+ var data struct {
+ Data struct {
+ Name string `json:"name"`
+ } `json:"data"`
+ }
+ if body, _ := res["data"].(string); json.Unmarshal([]byte(body), &data) != nil || data.Data.Name != "blip.com" {
+ t.Fatalf("result data: %v", res["data"])
+ }
+ regMu.Lock()
+ if len(regReqs) != 1 {
+ t.Fatalf("registrar saw %d requests, want 1: %+v", len(regReqs), regReqs)
+ }
+ got := regReqs[0]
+ regMu.Unlock()
+ if got.method != "POST" || got.path != "/v2/123/registrar/domains/blip.com/registrations" {
+ t.Fatalf("registrar request: %s %s", got.method, got.path)
+ }
+ if got.auth != "Bearer scoped-registrar-tok" {
+ t.Fatalf("registrar auth: %q", got.auth)
+ }
+ if !strings.Contains(got.body, `"registrant_id":"77"`) {
+ t.Fatalf("registrar body: %q", got.body)
+ }
+
+ // The negative: the same op under a READ grant must be refused, and the
+ // registrar must never see a second request.
+ tap("rid-read", "read")
+ waitFor(t, "read-grant refusal", func() bool {
+ msgs, err := s.db.messagesAfter(dmID, 0, 200)
+ if err != nil {
+ return false
+ }
+ for _, m := range msgs {
+ var p map[string]any
+ if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-read" {
+ e, _ := p["error"].(string)
+ return strings.Contains(e, "read-only")
+ }
+ }
+ return false
+ })
+ regMu.Lock()
+ defer regMu.Unlock()
+ if len(regReqs) != 1 {
+ t.Fatalf("read grant reached the registrar: %+v", regReqs)
+ }
+}
diff --git a/broker/broker_test.go b/broker/broker_test.go
index 5384e3f..45064e6 100644
--- a/broker/broker_test.go
+++ b/broker/broker_test.go
@@ -11,6 +11,9 @@ import (
"time"
)
+// newTestBroker stands up a broker with two configured OAuth apps: dnsimple
+// (the legacy default) and github (the multi-service path). Both point their
+// token endpoint at tokenURL.
func newTestBroker(t *testing.T, tokenURL string) *httptest.Server {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "broker.db"))
@@ -18,16 +21,29 @@ func newTestBroker(t *testing.T, tokenURL string) *httptest.Server {
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),
+ db: db,
+ adminHash: hashToken("admintok"),
+ apps: map[string]*oauthApp{
+ "dnsimple": {
+ service: "dnsimple",
+ clientID: "cid",
+ clientSecret: "sec",
+ redirectURI: "https://broker.example/oauth/dnsimple/callback",
+ authorizeURL: "https://dnsimple.com/oauth/authorize",
+ tokenURL: tokenURL,
+ },
+ "github": {
+ service: "github",
+ clientID: "gh-cid",
+ clientSecret: "gh-sec",
+ redirectURI: "https://broker.example/oauth/github/callback",
+ authorizeURL: "https://github.com/login/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())
@@ -111,6 +127,166 @@ func TestConfig(t *testing.T) {
}
}
+// The multi-service path: /api/exchange with service=github uses GitHub's own
+// client pair and sends Accept: application/json (GitHub answers form-encoded
+// without it); /config?service= serves per-service params; unknown services
+// are refused everywhere.
+func TestMultiServiceExchangeAndConfig(t *testing.T) {
+ var gotAccept, gotClientID string
+ gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ gotAccept = r.Header.Get("Accept")
+ gotClientID = r.FormValue("client_id")
+ if r.FormValue("client_secret") != "gh-sec" {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"access_token":"gho_tok","token_type":"bearer","scope":""}`))
+ }))
+ t.Cleanup(gh.Close)
+ ts := newTestBroker(t, gh.URL)
+ _, tok, err := testDB.mintToken("https://test.example.com")
+ if err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+
+ req, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"github","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("github exchange: got %d", resp.StatusCode)
+ }
+ var out struct {
+ AccessToken string `json:"access_token"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ if out.AccessToken != "gho_tok" {
+ t.Fatalf("access_token = %q", out.AccessToken)
+ }
+ if gotAccept != "application/json" {
+ t.Fatalf("Accept = %q, want application/json (GitHub answers form-encoded without it)", gotAccept)
+ }
+ if gotClientID != "gh-cid" {
+ t.Fatalf("client_id = %q — the exchange used the wrong service's app", gotClientID)
+ }
+
+ // Per-service public config.
+ respC, err := http.Get(ts.URL + "/config?service=github")
+ if err != nil {
+ t.Fatalf("config: %v", err)
+ }
+ defer respC.Body.Close()
+ var c map[string]string
+ json.NewDecoder(respC.Body).Decode(&c)
+ if c["client_id"] != "gh-cid" || !strings.Contains(c["authorize_url"], "github.com") || !strings.HasSuffix(c["redirect_uri"], "/oauth/github/callback") {
+ t.Fatalf("github config = %v", c)
+ }
+ if _, leaked := c["client_secret"]; leaked {
+ t.Fatalf("/config leaked the client_secret")
+ }
+
+ // Unknown service: config 404s, exchange refuses.
+ respU, _ := http.Get(ts.URL + "/config?service=nope")
+ respU.Body.Close()
+ if respU.StatusCode != http.StatusNotFound {
+ t.Fatalf("unknown-service config: got %d want 404", respU.StatusCode)
+ }
+ reqU, _ := http.NewRequest("POST", ts.URL+"/api/exchange", strings.NewReader(`{"service":"nope","code":"c1"}`))
+ reqU.Header.Set("Authorization", "Bearer "+tok)
+ respX, _ := http.DefaultClient.Do(reqU)
+ respX.Body.Close()
+ if respX.StatusCode != http.StatusBadRequest {
+ t.Fatalf("unknown-service exchange: got %d want 400", respX.StatusCode)
+ }
+}
+
+// /api/refresh rotates an expiring pair: grant_type=refresh_token with the
+// service's client pair, and the WHOLE new pair (access + rotated refresh +
+// expiry) comes back for the sidecar to reseal. Unauthenticated → 401.
+func TestRefresh(t *testing.T) {
+ var gotGrant, gotRefresh string
+ gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ gotGrant = r.FormValue("grant_type")
+ gotRefresh = r.FormValue("refresh_token")
+ if r.FormValue("client_secret") != "gh-sec" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800,"token_type":"bearer"}`))
+ }))
+ t.Cleanup(gh.Close)
+ ts := newTestBroker(t, gh.URL)
+ _, tok, err := testDB.mintToken("https://test.example.com")
+ if err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+
+ req, _ := http.NewRequest("POST", ts.URL+"/api/refresh", strings.NewReader(`{"service":"github","refresh_token":"ghr_old"}`))
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("refresh: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("refresh: got %d", resp.StatusCode)
+ }
+ var out struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ if out.AccessToken != "ghu_new" || out.RefreshToken != "ghr_new" || out.ExpiresIn != 28800 {
+ t.Fatalf("refresh pair = %+v", out)
+ }
+ if gotGrant != "refresh_token" || gotRefresh != "ghr_old" {
+ t.Fatalf("provider saw grant_type=%q refresh_token=%q", gotGrant, gotRefresh)
+ }
+
+ // No bearer token → 401 before the provider is ever touched.
+ resp2, _ := http.Post(ts.URL+"/api/refresh", "application/json", strings.NewReader(`{"service":"github","refresh_token":"x"}`))
+ resp2.Body.Close()
+ if resp2.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("tokenless refresh: got %d want 401", resp2.StatusCode)
+ }
+}
+
+// The relay bounces a github code to the instance's github callback path, and
+// refuses a service the broker has no app for.
+func TestCallbackRelayGitHub(t *testing.T) {
+ ts := newTestBroker(t, "http://unused")
+ if _, _, err := testDB.mintToken("https://test.example.com"); 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.example.com")) + ".nonce123"
+ resp, err := noRedirect.Get(ts.URL + "/oauth/github/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)
+ }
+ if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "https://test.example.com/oauth/github/callback?") || !strings.Contains(loc, "code=XYZ") {
+ t.Fatalf("relay Location = %q", loc)
+ }
+ respU, _ := noRedirect.Get(ts.URL + "/oauth/nope/callback?code=XYZ&state=" + good)
+ respU.Body.Close()
+ if respU.StatusCode != http.StatusNotFound {
+ t.Fatalf("unknown-service callback: got %d want 404", respU.StatusCode)
+ }
+}
+
// 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) {
@@ -142,3 +318,32 @@ func TestCallbackRelay(t *testing.T) {
}
resp2.Body.Close()
}
+
+// The public /registry serves the embedded integrations catalog, CORS-open —
+// public reviewed data that instances' web clients may fetch cross-origin.
+func TestRegistry(t *testing.T) {
+ ts := newTestBroker(t, "http://unused")
+ resp, err := http.Get(ts.URL + "/registry")
+ if err != nil {
+ t.Fatalf("registry: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("registry: got %d", resp.StatusCode)
+ }
+ if cors := resp.Header.Get("Access-Control-Allow-Origin"); cors != "*" {
+ t.Fatalf("Access-Control-Allow-Origin = %q, want *", cors)
+ }
+ var out struct {
+ Schema int `json:"schema"`
+ Services []struct {
+ ID string `json:"id"`
+ } `json:"services"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Schema != 1 || len(out.Services) != 4 {
+ t.Fatalf("registry = schema %d, %d services (want 1, 4)", out.Schema, len(out.Services))
+ }
+}
diff --git a/broker/main.go b/broker/main.go
index 746ba5e..4c48799 100644
--- a/broker/main.go
+++ b/broker/main.go
@@ -1,21 +1,23 @@
-// 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.
+// The Eleven connect broker: the one place OAuth client_id + client_secret
+// pairs live (one per integration service), so instances (and their bot
+// sidecars) never hold a secret — the same move as the push relay for the APNs
+// .p8. See docs/connected-services.md + docs/integrations.md.
//
-// It does two jobs, both for a CONFIDENTIAL OAuth client (DNSimple offers no
-// public/PKCE client):
+// It does two jobs, both for CONFIDENTIAL OAuth clients (DNSimple offers no
+// public/PKCE client; GitHub's exchange requires the secret even with PKCE):
//
-// 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.
+// 1. Redirect relay (GET /oauth/{service}/callback) — each service's single
+// registered redirect_uri for the whole fleet. The provider 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.
+// per-instance bearer token and sends {service, 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 secrets and transiently sees a
+// user token.
package main
import (
@@ -33,20 +35,31 @@ import (
"strings"
"sync"
"time"
+
+ "lchat/integrations"
)
const maxBodyBytes = 8 << 10
-type Server struct {
- db *DB
- adminHash string // sha256 of LCHAT_ADMIN_TOKEN; constant-time compared
-
+// oauthApp is one provider's confidential OAuth client — the secret this
+// broker exists to guard, plus the public endpoints of its flow. One per
+// service; DNSimple keeps its legacy env names, every other service is
+// configured generically (loadOAuthApps).
+type oauthApp struct {
+ service string
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
+ tokenURL string // the provider's code→token endpoint (overridable for tests)
+}
+
+type Server struct {
+ db *DB
+ adminHash string // sha256 of LCHAT_ADMIN_TOKEN; constant-time compared
+
+ apps map[string]*oauthApp // service id → its confidential client
+ http *http.Client
perInstance *rateLimiter
global *rateLimiter
@@ -58,11 +71,11 @@ func main() {
flag.Parse()
// Fail loud: a misconfigured broker breaks connecting services fleet-wide.
+ // DNSimple stays required (the legacy contract of the deployed unit); other
+ // services are additive — configured when their env pair is present.
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 == "" {
+ apps := loadOAuthApps()
+ if admin == "" || apps["dnsimple"] == nil {
log.Fatal("broker: LCHAT_ADMIN_TOKEN, LCHAT_DNSIMPLE_CLIENT_ID, LCHAT_DNSIMPLE_CLIENT_SECRET, LCHAT_DNSIMPLE_REDIRECT_URI are required")
}
@@ -71,23 +84,77 @@ func main() {
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)
+ db: db,
+ adminHash: hashToken(admin),
+ apps: apps,
+ http: &http.Client{Timeout: 15 * time.Second},
+ perInstance: newRateLimiter(60, 10),
+ global: newRateLimiter(600, 100),
+ }
+ services := make([]string, 0, len(apps))
+ for id := range apps {
+ services = append(services, id)
+ }
+ log.Printf("connect broker listening on %s (db: %s, oauth apps: %s)", *addr, *dbPath, strings.Join(services, ", "))
if err := serve(*addr, s.handler()); err != nil {
log.Fatal(err)
}
}
+// loadOAuthApps builds the per-service confidential clients from env.
+// DNSimple reads its legacy LCHAT_DNSIMPLE_* names. Every other OAuth-capable
+// registry service reads LCHAT_OAUTH_<ID>_CLIENT_ID / _CLIENT_SECRET (unset →
+// that service simply isn't offered), with the provider endpoints defaulting
+// to the manifest's auth.oauth block and the redirect defaulting to this
+// broker's own /oauth/<id>/callback (derived from the DNSimple redirect's
+// origin, or set explicitly with LCHAT_OAUTH_<ID>_REDIRECT_URI).
+func loadOAuthApps() map[string]*oauthApp {
+ apps := map[string]*oauthApp{}
+ dnsRedirect := os.Getenv("LCHAT_DNSIMPLE_REDIRECT_URI") // e.g. https://connect.elevenmessenger.com/oauth/dnsimple/callback
+ if id, secret := os.Getenv("LCHAT_DNSIMPLE_CLIENT_ID"), os.Getenv("LCHAT_DNSIMPLE_CLIENT_SECRET"); id != "" && secret != "" && dnsRedirect != "" {
+ apps["dnsimple"] = &oauthApp{
+ service: "dnsimple",
+ clientID: id,
+ clientSecret: secret,
+ redirectURI: dnsRedirect,
+ 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"),
+ }
+ }
+ // The broker's public origin, for deriving other services' redirect URIs.
+ selfOrigin := ""
+ if u, err := url.Parse(dnsRedirect); err == nil && u.Host != "" {
+ selfOrigin = u.Scheme + "://" + u.Host
+ }
+ for _, m := range integrations.MustRegistry().List() {
+ if m.ID == "dnsimple" || m.Auth.OAuth == nil || (m.Auth.Kind != "oauth" && m.Auth.Kind != "both") {
+ continue
+ }
+ prefix := "LCHAT_OAUTH_" + strings.ToUpper(strings.ReplaceAll(m.ID, "-", "_"))
+ id, secret := os.Getenv(prefix+"_CLIENT_ID"), os.Getenv(prefix+"_CLIENT_SECRET")
+ if id == "" || secret == "" {
+ continue
+ }
+ redirect := os.Getenv(prefix + "_REDIRECT_URI")
+ if redirect == "" && selfOrigin != "" {
+ redirect = selfOrigin + "/oauth/" + m.ID + "/callback"
+ }
+ if redirect == "" {
+ log.Printf("broker: %s has a client id but no derivable redirect uri — skipped", m.ID)
+ continue
+ }
+ apps[m.ID] = &oauthApp{
+ service: m.ID,
+ clientID: id,
+ clientSecret: secret,
+ redirectURI: redirect,
+ authorizeURL: envOr(prefix+"_AUTHORIZE_URL", m.Auth.OAuth.AuthorizeURL),
+ tokenURL: envOr(prefix+"_TOKEN_URL", m.Auth.OAuth.TokenURL),
+ }
+ }
+ return apps
+}
+
// 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 {
@@ -117,15 +184,24 @@ func (s *Server) handler() http.Handler {
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.
+ // Public config: the non-secret OAuth params an instance needs to start a
+ // flow (client_id, authorize URL, the broker's redirect_uri), per service —
+ // so instances need NO provider config of their own; they just ask the
+ // broker. ?service defaults to dnsimple (the pre-multi-service clients).
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)
+ // The public integrations registry (docs/integrations.md): the reviewed
+ // manifests, so every instance's web catalog can grow between deploys.
+ mux.HandleFunc("GET /registry", s.registryHandler)
+
+ // Redirect relay: each service's single registered redirect target for the
+ // whole fleet.
+ mux.HandleFunc("GET /oauth/{service}/callback", s.callback)
// Token exchange: a sidecar swaps a code for a token. Bearer instance token.
mux.HandleFunc("POST /api/exchange", s.exchange)
+ // Token refresh: a sidecar rotates an expiring pair (GitHub Apps). Same
+ // auth, same trust shape — nothing persisted.
+ mux.HandleFunc("POST /api/refresh", s.refresh)
// Admin: mint / list / revoke per-instance tokens (rotation needs no redeploy).
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler))
@@ -144,25 +220,50 @@ func (s *Server) root(w http.ResponseWriter, r *http.Request) {
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.
+// configHandler serves one service's public OAuth params (no secret) so an
+// instance can start a flow without holding any provider config of its own. A
+// service the broker has no app for is a 404 — the instance renders that as
+// "OAuth not offered" and falls back to paste-a-token.
func (s *Server) configHandler(w http.ResponseWriter, r *http.Request) {
+ service := r.URL.Query().Get("service")
+ if service == "" {
+ service = "dnsimple" // pre-multi-service clients never send the param
+ }
+ app := s.apps[service]
+ if app == nil {
+ http.Error(w, "unknown service", http.StatusNotFound)
+ return
+ }
writeJSON(w, http.StatusOK, map[string]any{
- "client_id": s.clientID,
- "authorize_url": s.authorizeURL,
- "redirect_uri": s.redirectURI,
+ "service": app.service,
+ "client_id": app.clientID,
+ "authorize_url": app.authorizeURL,
+ "redirect_uri": app.redirectURI,
})
}
+// registryHandler serves the embedded integrations registry. It is public,
+// reviewed data (no secrets live in a manifest) and deliberately CORS-open —
+// instances' web clients may fetch the catalog cross-origin.
+func (s *Server) registryHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ writeJSON(w, http.StatusOK, map[string]any{"schema": 1, "services": integrations.MustRegistry().List()})
+}
+
// ---- 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
+// callback is the fleet's single registered redirect_uri per service. The
+// provider 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) {
+ service := r.PathValue("service")
+ if s.apps[service] == nil {
+ http.Error(w, "unknown service", http.StatusNotFound)
+ return
+ }
q := r.URL.Query()
state := q.Get("state")
dot := strings.IndexByte(state, '.')
@@ -181,7 +282,7 @@ func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown instance", http.StatusBadRequest)
return
}
- dest, err := url.Parse(origin + "/oauth/dnsimple/callback")
+ dest, err := url.Parse(origin + "/oauth/" + service + "/callback")
if err != nil {
http.Error(w, "bad origin", http.StatusBadRequest)
return
@@ -211,55 +312,128 @@ func (s *Server) exchange(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Code string `json:"code"`
- State string `json:"state"`
+ Service string `json:"service"`
+ 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
}
+ if body.Service == "" {
+ body.Service = "dnsimple" // pre-multi-service sidecars never send it
+ }
+ app := s.apps[body.Service]
+ if app == nil {
+ http.Error(w, "unknown service", http.StatusBadRequest)
+ return
+ }
- token, err := s.exchangeCode(body.Code, body.State)
+ pair, err := s.tokenRequest(app, url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {body.Code},
+ "redirect_uri": {app.redirectURI},
+ "state": {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)
+ log.Printf("exchange[%s %s]: %v", origin, body.Service, 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})
+ // The tokens are returned to the caller and never persisted or logged.
+ writeJSON(w, http.StatusOK, pair)
+}
+
+// refresh rotates an expiring access token (providers with expiring
+// user tokens — GitHub Apps). Same trust shape as exchange: the caller is an
+// authenticated sidecar, the secret stays here, nothing is persisted. The
+// provider rotates the refresh token too (GitHub's are single-use), so the
+// whole new pair goes back for the sidecar to reseal to the member.
+func (s *Server) refresh(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 {
+ Service string `json:"service"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&body); err != nil || body.RefreshToken == "" {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ app := s.apps[body.Service]
+ if app == nil {
+ http.Error(w, "unknown service", http.StatusBadRequest)
+ return
+ }
+ pair, err := s.tokenRequest(app, url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {body.RefreshToken},
+ })
+ if err != nil {
+ log.Printf("refresh[%s %s]: %v", origin, body.Service, err)
+ http.Error(w, "refresh failed", http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, pair)
}
-// 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()))
+// tokenRequest runs one confidential token-endpoint request (exchange or
+// refresh — the grant fields differ, the client pair and parsing don't) and
+// returns the token pair. Accept: application/json matters: GitHub answers
+// form-encoded without it; DNSimple answers JSON either way.
+func (s *Server) tokenRequest(app *oauthApp, form url.Values) (map[string]any, error) {
+ form.Set("client_id", app.clientID)
+ form.Set("client_secret", app.clientSecret)
+ if v := form.Get("state"); v == "" {
+ form.Del("state")
+ }
+ req, err := http.NewRequest("POST", app.tokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ resp, err := s.http.Do(req)
if err != nil {
- return "", err
+ return nil, 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"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ Message string `json:"message"` // DNSimple's error shape
+ Error string `json:"error"` // GitHub's error shape
+ ErrorDesc string `json:"error_description"` // (a 200 can still carry an error)
}
_ = json.Unmarshal(raw, &out)
if resp.StatusCode != http.StatusOK || out.AccessToken == "" {
- if out.Message != "" {
- return "", fmt.Errorf("provider %d: %s", resp.StatusCode, out.Message)
+ for _, msg := range []string{out.Message, out.ErrorDesc, out.Error} {
+ if msg != "" {
+ return nil, fmt.Errorf("provider %d: %s", resp.StatusCode, msg)
+ }
}
- return "", fmt.Errorf("provider %d", resp.StatusCode)
+ return nil, fmt.Errorf("provider %d", resp.StatusCode)
+ }
+ // Only the fields the sidecar reseals — a provider answering with extras
+ // (scope, token_type) doesn't ride along.
+ pair := map[string]any{"access_token": out.AccessToken}
+ if out.RefreshToken != "" {
+ pair["refresh_token"] = out.RefreshToken
+ }
+ if out.ExpiresIn > 0 {
+ pair["expires_in"] = out.ExpiresIn
}
- return out.AccessToken, nil
+ return pair, nil
}
// ---- admin -----------------------------------------------------------------
diff --git a/changelog/2026-07-09-integrations-catalog-github.md b/changelog/2026-07-09-integrations-catalog-github.md
new file mode 100644
index 0000000..ee787a8
--- /dev/null
+++ b/changelog/2026-07-09-integrations-catalog-github.md
@@ -0,0 +1,10 @@
+🤖 Integrations grew into a real catalog — and GitHub is here. Open
+Integrations (+ New chat) to see every service you can connect: link one by
+authorizing on its own site or by pasting a scoped token, which is checked on
+the spot ("connected as you ✓") and sealed in your browser so the server can
+never read it. Connect GitHub and a GitHub app appears in New chat: your
+repositories at a glance, any repo's open issues and pull requests, and a
+form to open an issue right from the chat. You can also just ask a bot —
+"@eleven check blip.com" — and it answers with a button; anything that
+changes the real world (registering a domain, opening an issue) always spells
+out exactly what it will do and waits for your tap.
diff --git a/connected.go b/connected.go
index 9b68e9c..fc89928 100644
--- a/connected.go
+++ b/connected.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"net/http"
+ "net/url"
"os"
"regexp"
"strings"
@@ -127,49 +128,64 @@ func brokerURL() string {
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").
+// The broker's public OAuth config per service (client_id, authorize_url,
+// redirect_uri — all non-secret), cached briefly so opening the connect
+// surface doesn't refetch each time. A service the broker has no app for
+// caches as an explicit "absent" (the catalog then offers paste-a-token only);
+// on a fetch failure we serve the last-known value.
var (
oauthCfgMu sync.Mutex
- oauthCfgVal map[string]any
- oauthCfgTime time.Time
+ oauthCfgVals = map[string]*oauthCfgEntry{}
)
-func brokerOAuthConfig() map[string]any {
+type oauthCfgEntry struct {
+ val map[string]any // nil = the broker answered "no app for this service"
+ when time.Time
+}
+
+func brokerOAuthConfig(service string) 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
+ if e := oauthCfgVals[service]; e != nil && time.Since(e.when) < 10*time.Minute {
+ return e.val
+ }
+ resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(brokerURL() + "/config?service=" + url.QueryEscape(service))
+ if err == nil {
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ var cfg map[string]any
+ if json.NewDecoder(resp.Body).Decode(&cfg) == nil {
+ oauthCfgVals[service] = &oauthCfgEntry{val: cfg, when: time.Now()}
+ }
+ case http.StatusNotFound: // a definitive "not offered" — cache that too
+ oauthCfgVals[service] = &oauthCfgEntry{val: nil, when: time.Now()}
+ }
+ }
+ if e := oauthCfgVals[service]; e != nil {
+ return e.val
+ }
+ return nil
}
-// GET /api/oauth/config?service=dnsimple -> {"client_id","authorize_url","redirect_uri"}
+// GET /api/oauth/config?service=github -> {"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.
+// The browser needs the *public* OAuth params to start a flow. The instance
+// holds none of this itself — it asks the connect broker (override with
+// LCHAT_CONNECT_BROKER_URL) for whichever service the catalog is offering; an
+// empty {} means "no OAuth app for this service" and the catalog falls back to
+// paste-a-token. 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" {
+ service := r.URL.Query().Get("service")
+ if !serviceRE.MatchString(service) {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
- cfg := brokerOAuthConfig()
+ cfg := brokerOAuthConfig(service)
if cfg == nil {
cfg = map[string]any{}
}
diff --git a/connected_test.go b/connected_test.go
index 474746c..75ab6ac 100644
--- a/connected_test.go
+++ b/connected_test.go
@@ -2,6 +2,7 @@ package main
import (
"net/http"
+ "net/http/httptest"
"testing"
)
@@ -81,3 +82,58 @@ func TestConnectedServicesAuth(t *testing.T) {
}
resp.Body.Close()
}
+
+// The instance's /api/oauth/config is a per-service pass-through to the connect
+// broker: a service the broker has an app for comes back with its public
+// params; one it doesn't (or a malformed name) is an empty {} — the catalog
+// then offers paste-a-token alone.
+func TestOAuthConfigPerService(t *testing.T) {
+ broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/config" {
+ http.NotFound(w, r)
+ return
+ }
+ switch r.URL.Query().Get("service") {
+ case "github":
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"service":"github","client_id":"gh-cid","authorize_url":"https://github.com/login/oauth/authorize","redirect_uri":"https://broker.example/oauth/github/callback"}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer broker.Close()
+ t.Setenv("LCHAT_CONNECT_BROKER_URL", broker.URL)
+
+ ts, srv := newTestServer(t)
+ tok, _, _ := joinedMember(t, srv, "Ann")
+
+ var cfg map[string]string
+ resp := doReq(t, "GET", ts.URL+"/api/oauth/config?service=github&token="+tok, "")
+ decodeJSON(t, resp, &cfg)
+ if cfg["client_id"] != "gh-cid" || cfg["redirect_uri"] == "" {
+ t.Fatalf("github config = %v", cfg)
+ }
+
+ // A service the broker has no app for → empty config (never an error).
+ var none map[string]string
+ resp = doReq(t, "GET", ts.URL+"/api/oauth/config?service=stripe&token="+tok, "")
+ decodeJSON(t, resp, &none)
+ if len(none) != 0 {
+ t.Fatalf("unoffered service config = %v", none)
+ }
+
+ // A malformed service name never reaches the broker.
+ var bad map[string]string
+ resp = doReq(t, "GET", ts.URL+"/api/oauth/config?service=Not%20A%20Slug&token="+tok, "")
+ decodeJSON(t, resp, &bad)
+ if len(bad) != 0 {
+ t.Fatalf("malformed service config = %v", bad)
+ }
+
+ // No token → refused.
+ resp = doReq(t, "GET", ts.URL+"/api/oauth/config?service=github", "")
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("tokenless: got %d want 403", resp.StatusCode)
+ }
+ resp.Body.Close()
+}
diff --git a/docs/connected-services.md b/docs/connected-services.md
index a09d585..29ebabc 100644
--- a/docs/connected-services.md
+++ b/docs/connected-services.md
@@ -7,6 +7,14 @@ OAuth API. **The plan evolved during implementation** — DNSimple turned out to
confidential-only, so the public/PKCE sections further down are **superseded** by
*What shipped* immediately below.
+> **Generalised since:** the single-service proof below grew into the
+> **integrations registry** — services as reviewed JSON manifests (auth + a
+> pinned host + a closed, read/write-scoped op list with response
+> projections), a generic executor, a catalog UI, and `service_op` consent
+> chips. New services (GitHub is the first) are added as manifests, not Go
+> code. See **`docs/integrations.md`**; this document remains the trust-model
+> reference for credential custody, grants, and the broker.
+
## The goal
A **seamless, out-of-the-box** "Connect a service" experience on a *fresh*
diff --git a/docs/integrations.md b/docs/integrations.md
new file mode 100644
index 0000000..7c21d35
--- /dev/null
+++ b/docs/integrations.md
@@ -0,0 +1,172 @@
+# 🤖 Integrations — a suite of connected services, from one worked example
+
+Status: **built on main** (branch `integrations-github`). Generalises the
+DNSimple proof (`docs/connected-services.md`) into a catalog anyone can
+extend: GitHub, Stripe, HubSpot and friends as installable integrations, with
+read/write permissions scoped per grant — and therefore per bot — and every
+operation available to bots as a tool behind the same consent chips the rest
+of the platform uses. GitHub is the first registry-native service, with its
+own companion app (`web/static/apps/github.js`).
+
+## The shape: manifests are the palette
+
+Everything hangs off one idea, borrowed from bot recipes: **an integration is
+data, not code**. A service ships as a JSON manifest
+(`integrations/registry/`) declaring its auth, the ONE API host its
+operations may touch, and a closed list of operations — each `read` or
+`write`, writes each carrying the confirmation sentence a person taps before
+it runs. The generic executor (`integrations/exec.go`) can only do what a
+manifest spells; per-service Go code (the old `dnsimple.go`) stops being the
+growth path.
+
+**Responses are as reviewable as requests.** Each op may declare a `pick`
+projection — a root envelope to descend plus the exact fields that may flow
+back. The executor drops everything else before a result ever leaves the
+sidecar, which is both a privacy bound (the manifest review covers what data
+can enter a thread) and a practical one: an op result rides inside ONE
+end-to-end-encrypted message (64 KB blob cap), while a raw GitHub
+`/user/repos` page is ~400 KB of mostly-unwanted fields. Oversized results
+refuse honestly; nothing is ever silently truncated.
+
+What each party holds is unchanged from the DNSimple design:
+
+| Party | Holds |
+|---|---|
+| Messenger server | opaque sealed credential blobs, as ever — nothing new |
+| Connect broker | OAuth client secrets (one per OAuth-app service) + the public registry (`GET /registry`) |
+| Member's client | the credential in the clear only with the member present; seals scoped grants to bots |
+| Bot sidecar | a grant in the clear during its window; the manifest-pinned executor |
+
+## Read/write, per bot
+
+A grant sealed to a bot carries `access: "read" | "write"` alongside the
+token and expiry (an access-less grant is legacy = read-only). Because a
+grant is sealed to exactly one bot's public key, **access is per-bot by
+construction**: seal Eleven a write grant for DNSimple and your reporting bot
+a read-only one, and the executor enforces the difference — a read grant
+cannot run a write op, whatever the model asks for. Least privilege stacks in
+three layers:
+
+1. **The token itself** — mint scoped PATs (GitHub fine-grained, Stripe
+ restricted keys, DNSimple domain-scoped); the manifest's `pat_help` points
+ at each service's scoping page, and the catalog verifies a pasted token
+ immediately through the manifest's `viewer` op when one exists.
+2. **The grant** — read/write + expiry, sealed per bot, minted per operation
+ (the apps and chips use one-shot ~2-minute grants).
+3. **The tap** — every write op renders its `confirm` line as a consent chip
+ ("Register blip.com? This buys the domain and costs real money.");
+ nothing irreversible runs from model output alone.
+
+## Bots use connections as tools
+
+Interactive: *"@eleven go and register blip.com"* → the brain's `service_op`
+tool (its description is GENERATED from the registry — the closed list the
+manifests declare is the closed list the model can propose) → a whitelisted
+chip whose label, for writes, is the manifest's rendered confirm line → the
+tap seals a one-shot access-scoped grant and posts the `connect.op` → the
+sidecar executor runs it → the outcome lands as a private notice, never a
+data dump. Reads work the same minus the drama. `TestServiceOpEndToEnd`
+drives the whole flow, including the negative (a read grant refused before
+the registrar is touched).
+
+Standing (designed, deliberately not built): a member-bot charter gains
+`connections: [{service, access}]` — shown on the creation consent card like
+sources are — and at approval the client seals a standing grant to the new
+bot, renewed by presence like the DNSimple autonomous-window design
+(`docs/connected-services.md § token custody`). Task runs then expose the
+granted services' READ ops as model tools; writes stay interactive-only until
+there's a reviewed answer for unattended spending. This is the natural meet
+point with member bots (issue #432).
+
+## Connected-service apps ride the same rail
+
+A service's companion app (DNSimple's domain table, GitHub's repos/issues) is
+a normal mini-app gated by `requiresService` and fed by
+`ctx.host.connected.requestOp(service, op, args, access)` — the app renders,
+the bot executes under the grant, the `pick`-bounded result folds into the
+app's state as an ordinary typed event. That last step is what plugs
+integrations into the app **sharing model**: folded events are the thread's
+log, so the data syncs across the member's devices via snapshots exactly like
+a to-do list, and a future `groupCapable` service app would share one fetched
+view with a whole group through the same log — no new machinery.
+
+## The external registry repo
+
+The registry directory is destined to be its own public repository
+(`elevenmessenger/integrations`) — the audit surface for what integrations
+can do, growable by PR without touching this codebase. The pipeline:
+
+1. **Author** a manifest (PR to the registry repo; review = reading the ops —
+ including `pick`, which is the review of what data can flow back).
+ `integrations.Parse` is exported precisely so the registry repo's CI can
+ validate a PR'd manifest with the same fail-closed rules the binary uses.
+2. **OAuth services**: register the OAuth app once, secret into the connect
+ broker's config — the same one-place-holds-the-secret move as the push
+ relay's `.p8`. PAT-only services (GitHub, Stripe, HubSpot) need nothing
+ beyond the manifest.
+3. **Serve**: the broker embeds the registry and serves `GET /registry`
+ (CORS-open) to every instance's web client; each instance also serves its
+ own embedded copy at `GET /api/integrations` (same-origin, what the
+ catalog uses today); sidecars embed a snapshot at build time
+ (single-binary ethos) — the broker copy lets the *catalog UI* grow between
+ deploys, while *execution* trusts only the reviewed, embedded snapshot it
+ shipped with. Repo split + a broker sync-from-repo job are the ship-time
+ steps (creating the GitHub repo is deliberately a human act).
+
+## What's built (this branch)
+
+1. `integrations/registry/` — schema + README + four manifests (DNSimple,
+ GitHub, Stripe, HubSpot; reads with `pick` projections, confirmed writes).
+2. `integrations` Go package — fail-closed loader + the pinned executor
+ (arg validation, path/query escaping, JSON/form bodies, host pinning,
+ response caps, `pick` projection), embedded into the binary.
+3. Sidecar `connect.op` v2 — any manifest op, grant-scope enforcement
+ (read/write, expiry, service match); the legacy DNSimple `list` path is
+ untouched.
+4. The interactive flow — the brain's `service_op` tool → whitelisted consent
+ chip (labels sized for a whole consent sentence) → the client seals the
+ scoped one-shot grant and round-trips the op → outcome as a private notice.
+5. The catalog — `GET /api/integrations` + broker `GET /registry`; the
+ Integrations app lists every service with paste-a-scoped-PAT (verified on
+ save via `viewer`) and a "Connect with X" button for any service the
+ broker has an OAuth app for.
+6. The GitHub app — repos, per-repo open issues/PRs, open-an-issue (the first
+ write a member performs from inside an app, consented by the form itself).
+7. **The OAuth relay is service-generic.** The golden path is: OAuth on the
+ provider's own site whenever the broker has an app (the catalog probes
+ `/api/oauth/config?service=` and only shows the button when the flow can
+ actually start); paste-a-scoped-token is the fallback. The broker holds one
+ confidential client per service — DNSimple on its legacy `LCHAT_DNSIMPLE_*`
+ envs, everything else on `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET`
+ (provider endpoints come from the manifest's `auth.oauth`; the redirect
+ defaults to the broker's own `/oauth/<id>/callback`). Enabling GitHub OAuth
+ is therefore: create the GitHub App (callback = the broker's
+ `/oauth/github/callback`), set the two envs, restart the broker — no
+ instance or client deploy. Prefer a **GitHub App** over a classic OAuth
+ app: members pick which repositories at consent time (least privilege as a
+ consent screen), and its exchange rides the same confidential broker flow.
+
+8. **Expiring tokens rotate themselves.** Providers with short-lived user
+ tokens (GitHub Apps: 8-hour access + single-use rotating refresh tokens)
+ work first-class: the stored credential is the whole pair
+ (`{secret, refresh, expires_at}`, still sealed to the member's own key),
+ the pair rides inside the op grant, and the SIDECAR is the only refresher —
+ it rotates a stale pair before the call (or on a surprise 401, retrying
+ once), serialized per credential so two near-simultaneous ops share one
+ rotation instead of burning the single-use token. The fresh pair rides
+ back **on the op result**, sealed to the member, and the requesting device
+ re-stores it. Rotation is refused outright when the fresh pair couldn't be
+ handed back (no resolvable member key) — a failed op beats a silently
+ severed connection. Broker side: `POST /api/refresh`, same auth and
+ nothing-persisted shape as the exchange. This is the temporal-custody
+ model from `connected-services.md` made native: a stolen blob ages out in
+ hours. Failure mode is honest: a dead refresh token (revoked, >6 months
+ idle) errors with "reconnect it in Integrations".
+
+Out of scope, called out honestly: standing charter grants (above),
+registering the OAuth apps themselves (a human act per provider; Stripe and
+HubSpot manifests stay `pat` until theirs exist),
+Disconnect-revokes-provider-side, native catalog UI (connected services
+remain web-only), and the Node reference sidecar (`agent/`) neither executes
+registry ops nor proposes `service_op` — the Go sidecar is the production
+path.
diff --git a/integrations/exec.go b/integrations/exec.go
new file mode 100644
index 0000000..037e29a
--- /dev/null
+++ b/integrations/exec.go
@@ -0,0 +1,372 @@
+package integrations
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// The executor: runs one manifest op against the service's pinned host. The
+// manifest is reviewed code; the args and token are member input — so every
+// arg is bounded and escaped into the slot its template put it in, and the
+// final URL is re-checked against auth.base's host before anything is sent.
+// A value can steer WHAT an op says, never WHERE it goes.
+
+const (
+ maxArgs = 16
+ maxArgBytes = 2048
+ maxRespBody = 256 << 10 // response read cap for un-projected ops
+ // A projected op may read much more: provider list responses run to
+ // hundreds of KB raw (a real /user/repos page is ~400KB), and the pick
+ // throws almost all of it away immediately — the projected result is what
+ // travels, not this buffer.
+ maxPickedRespBody = 4 << 20
+ execTimeout = 20 * time.Second
+)
+
+// purePlaceholderRE matches a template that is EXACTLY one placeholder — the
+// shape of an optional-arg slot like `"body": "{body}"`, whose key is dropped
+// from the JSON when the arg is absent (so optional means absent, not "").
+var purePlaceholderRE = regexp.MustCompile(`^\{[a-z0-9_]+\}$`)
+
+// StatusError is a non-2xx provider response. The message keeps the exact
+// shape errors always had; the code lets a caller distinguish "token expired"
+// (401 → refresh and retry) from "the request was wrong".
+type StatusError struct {
+ Service, Op string
+ Code int
+ Detail string
+}
+
+func (e *StatusError) Error() string {
+ return fmt.Sprintf("%s.%s: status %d: %s", e.Service, e.Op, e.Code, e.Detail)
+}
+
+// baseOverride returns the test-only base URL for a service, mirroring how
+// LCHAT_DNSIMPLE_BASE points the dnsimple client at a loopback httptest
+// server (internal/bot/dnsimple.go). Never set in production units.
+func baseOverride(id string) string {
+ return os.Getenv("LCHAT_INTEGRATIONS_BASE_" + strings.ToUpper(strings.ReplaceAll(id, "-", "_")))
+}
+
+// Execute runs one op with the member's args and token, returning the
+// response body as a string. Errors are values — the sidecar feeds them back
+// into the thread as an honest failure, never a silent gap.
+func Execute(m *Manifest, opID string, args map[string]string, token string) (string, error) {
+ op := m.Op(opID)
+ if op == nil {
+ // Absence is refusal: an op the manifest doesn't declare cannot run.
+ return "", fmt.Errorf("%s: unknown op %q", m.ID, opID)
+ }
+ if err := validateArgs(op, args); err != nil {
+ return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
+ }
+
+ base := m.Auth.Base
+ override := baseOverride(m.ID)
+ if override != "" {
+ base = override
+ }
+ baseURL, err := url.Parse(base)
+ if err != nil || baseURL.Host == "" {
+ return "", fmt.Errorf("%s.%s: bad base %q", m.ID, opID, base)
+ }
+
+ final := strings.TrimRight(base, "/") + renderPath(op.Path, args)
+ u, err := url.Parse(final)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: bad url: %w", m.ID, opID, err)
+ }
+ // Pin the host: re-check the URL we actually built, not just the inputs —
+ // if escaping ever failed to contain a value, this is the backstop.
+ // Skipped only under the env override (loopback test servers are http).
+ if override == "" {
+ if u.Scheme != "https" || u.Host != baseURL.Host {
+ return "", fmt.Errorf("%s.%s: url escaped its pinned host", m.ID, opID)
+ }
+ }
+
+ var body io.Reader
+ contentType := ""
+ switch {
+ case op.Encoding == "form":
+ vals := url.Values{}
+ for k, tmpl := range op.BodyForm {
+ vals.Set(k, substitute(tmpl, args))
+ }
+ body = strings.NewReader(vals.Encode())
+ contentType = "application/x-www-form-urlencoded"
+ case op.BodyJSON != nil:
+ rendered := substituteJSONMap(op.BodyJSON, args)
+ raw, err := json.Marshal(rendered)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: body: %w", m.ID, opID, err)
+ }
+ body = bytes.NewReader(raw)
+ contentType = "application/json"
+ }
+
+ req, err := http.NewRequest(op.Method, u.String(), body)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
+ }
+ // Default Accept first, then the manifest's headers — a manifest Accept
+ // (GitHub's vnd header) wins over the default, never the reverse.
+ req.Header.Set("Accept", "application/json")
+ for k, v := range m.Auth.Headers {
+ req.Header.Set(k, v)
+ }
+ scheme := m.Auth.Authorization
+ if scheme == "" {
+ scheme = "Bearer"
+ }
+ req.Header.Set("Authorization", scheme+" "+token)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+
+ client := &http.Client{
+ Timeout: execTimeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("too many redirects")
+ }
+ // A redirect is a new URL: hold it to the same pin. Off-host
+ // redirects are refused outright — an integration API that
+ // bounces us elsewhere is outside the manifest's contract.
+ if req.URL.Host != u.Host {
+ return errors.New("redirect off the pinned host blocked")
+ }
+ if override == "" && req.URL.Scheme != "https" {
+ return errors.New("redirect downgraded from https blocked")
+ }
+ return nil
+ },
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
+ }
+ defer resp.Body.Close()
+ readCap := int64(maxRespBody)
+ if op.Pick != nil {
+ readCap = maxPickedRespBody
+ }
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, readCap+1))
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: read: %w", m.ID, opID, err)
+ }
+ // Over the cap: refuse honestly — a silently truncated body would surface
+ // as a baffling JSON parse error (or worse, truncated data downstream).
+ if int64(len(raw)) > readCap {
+ return "", fmt.Errorf("%s.%s: response too large (over %d bytes)", m.ID, opID, readCap)
+ }
+ if resp.StatusCode/100 != 2 {
+ snippet := raw
+ if len(snippet) > 300 {
+ snippet = snippet[:300]
+ }
+ // Typed so a caller can react to the code (the sidecar refreshes an
+ // expired token on a 401) without parsing the message.
+ return "", &StatusError{Service: m.ID, Op: opID, Code: resp.StatusCode, Detail: string(snippet)}
+ }
+ if op.Pick != nil {
+ projected, err := project(raw, op.Pick)
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: %w", m.ID, opID, err)
+ }
+ return projected, nil
+ }
+ return string(raw), nil
+}
+
+// project applies an op's declared response projection (registry.go's Pick):
+// descend the root path, then keep only the named fields of the object — or of
+// each object of the array — found there. The manifest is the review surface,
+// so a response that doesn't match its declared shape is an honest error, never
+// a silent empty result.
+func project(raw []byte, p *Pick) (string, error) {
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ return "", fmt.Errorf("response isn't JSON: %w", err)
+ }
+ for _, key := range splitPath(p.Root) {
+ obj, ok := v.(map[string]any)
+ if !ok {
+ return "", fmt.Errorf("response has no %q envelope", p.Root)
+ }
+ if v, ok = obj[key]; !ok {
+ return "", fmt.Errorf("response has no %q envelope", p.Root)
+ }
+ }
+ if len(p.Fields) > 0 {
+ switch t := v.(type) {
+ case []any:
+ out := make([]any, 0, len(t))
+ for _, e := range t {
+ if obj, ok := e.(map[string]any); ok {
+ out = append(out, pickFields(obj, p.Fields))
+ }
+ }
+ v = out
+ case map[string]any:
+ v = pickFields(t, p.Fields)
+ default:
+ return "", fmt.Errorf("response %q isn't an object or array", p.Root)
+ }
+ }
+ out, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
+
+// pickFields projects one object down to the named dot-paths. A field the
+// response doesn't carry is omitted (provider APIs grow and shrink; the client
+// treats absence as "—"). The output key is the full dot-path, flat — so
+// "user.login" comes back as {"user.login": "octocat"}.
+func pickFields(obj map[string]any, fields []string) map[string]any {
+ out := make(map[string]any, len(fields))
+ for _, f := range fields {
+ v := any(obj)
+ found := true
+ for _, key := range splitPath(f) {
+ m, ok := v.(map[string]any)
+ if !ok {
+ found = false
+ break
+ }
+ if v, ok = m[key]; !ok {
+ found = false
+ break
+ }
+ }
+ if found {
+ out[f] = v
+ }
+ }
+ return out
+}
+
+// splitPath splits a validated pick path into keys; "" is the empty path.
+func splitPath(p string) []string {
+ if p == "" {
+ return nil
+ }
+ return strings.Split(p, ".")
+}
+
+// validateArgs bounds member input before any of it touches a template:
+// declared keys only, required keys present, values short and free of
+// control characters.
+func validateArgs(op *Op, args map[string]string) error {
+ if len(args) > maxArgs {
+ return fmt.Errorf("too many args (%d > %d)", len(args), maxArgs)
+ }
+ for k, v := range args {
+ if _, ok := op.Args[k]; !ok {
+ return fmt.Errorf("undeclared arg %q", k)
+ }
+ if len(v) > maxArgBytes {
+ return fmt.Errorf("arg %q too long (%d bytes)", k, len(v))
+ }
+ for i := 0; i < len(v); i++ {
+ if v[i] < 0x20 || v[i] == 0x7f {
+ return fmt.Errorf("arg %q contains control characters", k)
+ }
+ }
+ }
+ for k, spec := range op.Args {
+ if spec.Required && args[k] == "" {
+ return fmt.Errorf("missing required arg %q", k)
+ }
+ }
+ return nil
+}
+
+// renderPath substitutes `{arg}` into a path template, escaping each value
+// for the slot it lands in: path-segment escaping before the first "?",
+// query escaping after it. A value can never introduce an un-escaped
+// `/`, `?`, `#`, or `&` — it stays inside its segment or query slot.
+func renderPath(path string, args map[string]string) string {
+ pathPart, queryPart, hasQuery := strings.Cut(path, "?")
+ out := substituteWith(pathPart, args, escapePathValue)
+ if hasQuery {
+ out += "?" + substituteWith(queryPart, args, url.QueryEscape)
+ }
+ return out
+}
+
+// escapePathValue is url.PathEscape plus the sub-delims PathEscape leaves
+// alone (`&;=+,`). They're legal in a path segment, but our promise is
+// stronger than legality: a substituted value must not be able to smuggle
+// anything a server might parse as a delimiter.
+func escapePathValue(v string) string {
+ return strings.NewReplacer(
+ "&", "%26", ";", "%3B", "=", "%3D", "+", "%2B", ",", "%2C",
+ ).Replace(url.PathEscape(v))
+}
+
+// substitute replaces `{arg}` with the raw arg value (bodies — no URL
+// escaping; the encoder for the body format does its own). An absent
+// optional arg substitutes to "".
+func substitute(tmpl string, args map[string]string) string {
+ return substituteWith(tmpl, args, func(s string) string { return s })
+}
+
+func substituteWith(tmpl string, args map[string]string, escape func(string) string) string {
+ return placeholderRE.ReplaceAllStringFunc(tmpl, func(ph string) string {
+ return escape(args[ph[1:len(ph)-1]])
+ })
+}
+
+// substituteJSONMap deep-copies a body_json template with `{arg}` substituted
+// into string leaves. Keys whose template was exactly one placeholder and
+// substituted to "" are dropped — an optional arg the member didn't give is
+// omitted from the request, not sent as an empty string.
+func substituteJSONMap(m map[string]any, args map[string]string) map[string]any {
+ out := make(map[string]any, len(m))
+ for k, v := range m {
+ switch t := v.(type) {
+ case string:
+ s := substitute(t, args)
+ if s == "" && purePlaceholderRE.MatchString(t) {
+ continue
+ }
+ out[k] = s
+ case map[string]any:
+ out[k] = substituteJSONMap(t, args)
+ case []any:
+ out[k] = substituteJSONSlice(t, args)
+ default:
+ out[k] = v
+ }
+ }
+ return out
+}
+
+func substituteJSONSlice(s []any, args map[string]string) []any {
+ out := make([]any, len(s))
+ for i, v := range s {
+ switch t := v.(type) {
+ case string:
+ out[i] = substitute(t, args)
+ case map[string]any:
+ out[i] = substituteJSONMap(t, args)
+ case []any:
+ out[i] = substituteJSONSlice(t, args)
+ default:
+ out[i] = v
+ }
+ }
+ return out
+}
diff --git a/integrations/exec_test.go b/integrations/exec_test.go
new file mode 100644
index 0000000..d84b574
--- /dev/null
+++ b/integrations/exec_test.go
@@ -0,0 +1,306 @@
+package integrations
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// testManifest declares one service with a read op (path + query args), a
+// JSON write op with a nested body and an optional arg, and a form write op —
+// covering every encoding the executor supports.
+const testManifest = `{
+ "schema": 1,
+ "id": "testsvc",
+ "name": "TestSvc",
+ "category": "test",
+ "auth": {
+ "kind": "pat",
+ "base": "https://api.testsvc.example",
+ "authorization": "Token",
+ "headers": { "X-Api-Version": "7" }
+ },
+ "ops": [
+ { "id": "items.list", "label": "List", "access": "read", "method": "GET",
+ "path": "/v1/{account}/items?limit=10&q={q}",
+ "args": { "account": { "required": true }, "q": {} } },
+ { "id": "item.create", "label": "Create", "access": "write", "method": "POST",
+ "path": "/v1/items",
+ "args": { "title": { "required": true }, "note": {} },
+ "body_json": { "props": { "title": "{title}", "note": "{note}", "kind": "widget" } },
+ "confirm": "Create {title}?" },
+ { "id": "item.refund", "label": "Refund", "access": "write", "method": "POST",
+ "path": "/v1/refunds",
+ "args": { "charge": { "required": true } },
+ "body_form": { "charge": "{charge}", "reason": "requested" },
+ "encoding": "form",
+ "confirm": "Refund {charge}?" }
+ ]
+}`
+
+// captured is what the fake service saw for one request.
+type captured struct {
+ method, uri, auth, accept, contentType string
+ body []byte
+}
+
+// fakeService stands up an httptest server, points the executor's env
+// override at it (the LCHAT_DNSIMPLE_BASE pattern), and records each request.
+func fakeService(t *testing.T, status int, respBody string) (*Manifest, *captured) {
+ t.Helper()
+ m, err := Parse([]byte(testManifest))
+ if err != nil {
+ t.Fatalf("test manifest: %v", err)
+ }
+ got := &captured{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got.method = r.Method
+ got.uri = r.URL.RequestURI()
+ got.auth = r.Header.Get("Authorization")
+ got.accept = r.Header.Get("Accept")
+ got.contentType = r.Header.Get("Content-Type")
+ got.body, _ = io.ReadAll(r.Body)
+ w.WriteHeader(status)
+ io.WriteString(w, respBody)
+ }))
+ t.Cleanup(srv.Close)
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
+ return m, got
+}
+
+func TestExecuteGetSubstitutesPathAndQuery(t *testing.T) {
+ m, got := fakeService(t, 200, `{"ok":true}`)
+ body, err := Execute(m, "items.list", map[string]string{"account": "acct 1", "q": "a&b"}, "tok123")
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if body != `{"ok":true}` {
+ t.Errorf("body: %q", body)
+ }
+ // Path slot path-escaped, query slot query-escaped.
+ if got.uri != "/v1/acct%201/items?limit=10&q=a%26b" {
+ t.Errorf("uri: %q", got.uri)
+ }
+ if got.auth != "Token tok123" {
+ t.Errorf("authorization: %q", got.auth)
+ }
+ if got.accept != "application/json" {
+ t.Errorf("accept: %q", got.accept)
+ }
+ if got.method != "GET" {
+ t.Errorf("method: %q", got.method)
+ }
+}
+
+func TestExecutePostJSONNestedBodyDropsAbsentOptionals(t *testing.T) {
+ m, got := fakeService(t, 201, `{"id":1}`)
+ // "note" is optional and absent: its pure-placeholder leaf must be
+ // DROPPED, not sent as "".
+ if _, err := Execute(m, "item.create", map[string]string{"title": "A widget"}, "tk"); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if got.contentType != "application/json" {
+ t.Errorf("content-type: %q", got.contentType)
+ }
+ var sent struct {
+ Props map[string]any `json:"props"`
+ }
+ if err := json.Unmarshal(got.body, &sent); err != nil {
+ t.Fatalf("body %q: %v", got.body, err)
+ }
+ if sent.Props["title"] != "A widget" || sent.Props["kind"] != "widget" {
+ t.Errorf("props: %v", sent.Props)
+ }
+ if _, present := sent.Props["note"]; present {
+ t.Errorf("absent optional arg was sent: %v", sent.Props)
+ }
+}
+
+func TestExecutePostForm(t *testing.T) {
+ m, got := fakeService(t, 200, `{}`)
+ if _, err := Execute(m, "item.refund", map[string]string{"charge": "ch_1&x=2"}, "tk"); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if got.contentType != "application/x-www-form-urlencoded" {
+ t.Errorf("content-type: %q", got.contentType)
+ }
+ // url.Values encoding must contain the value, delimiters escaped.
+ if !strings.Contains(string(got.body), "charge=ch_1%26x%3D2") || !strings.Contains(string(got.body), "reason=requested") {
+ t.Errorf("form body: %q", got.body)
+ }
+}
+
+func TestExecuteArgValidation(t *testing.T) {
+ m, _ := fakeService(t, 200, `{}`)
+ if _, err := Execute(m, "items.list", map[string]string{}, "tk"); err == nil || !strings.Contains(err.Error(), "account") {
+ t.Errorf("missing required arg: %v", err)
+ }
+ if _, err := Execute(m, "items.list", map[string]string{"account": "a", "bogus": "x"}, "tk"); err == nil || !strings.Contains(err.Error(), "bogus") {
+ t.Errorf("undeclared arg: %v", err)
+ }
+ if _, err := Execute(m, "items.list", map[string]string{"account": "a\x00b"}, "tk"); err == nil {
+ t.Error("control chars accepted")
+ }
+ if _, err := Execute(m, "items.list", map[string]string{"account": strings.Repeat("a", 3000)}, "tk"); err == nil {
+ t.Error("oversized value accepted")
+ }
+}
+
+func TestExecuteValueCannotBreakOutOfItsSlot(t *testing.T) {
+ m, got := fakeService(t, 200, `{}`)
+ // A hostile value tries path traversal + query injection; it must arrive
+ // fully escaped inside its one path segment.
+ if _, err := Execute(m, "items.list", map[string]string{"account": "../admin?x=1&y=2#f"}, "tk"); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !strings.HasPrefix(got.uri, "/v1/..%2Fadmin%3Fx%3D1%26y%3D2%23f/items?") {
+ t.Errorf("hostile value not contained: %q", got.uri)
+ }
+ // No un-escaped delimiter appears between the template's own slashes.
+ seg := strings.TrimPrefix(got.uri, "/v1/")
+ seg = seg[:strings.Index(seg, "/items")]
+ for _, c := range []string{"/", "?", "#", "&"} {
+ if strings.Contains(seg, c) {
+ t.Errorf("un-escaped %q in substituted segment %q", c, seg)
+ }
+ }
+}
+
+func TestExecuteNon2xxSurfacesStatusAndBody(t *testing.T) {
+ m, _ := fakeService(t, 422, `{"message":"Validation Failed"}`)
+ _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk")
+ if err == nil || !strings.Contains(err.Error(), "testsvc.items.list: status 422") || !strings.Contains(err.Error(), "Validation Failed") {
+ t.Errorf("non-2xx error: %v", err)
+ }
+}
+
+func TestExecuteUnknownOp(t *testing.T) {
+ m, _ := fakeService(t, 200, `{}`)
+ if _, err := Execute(m, "nope.nothing", nil, "tk"); err == nil || !strings.Contains(err.Error(), "unknown op") {
+ t.Errorf("unknown op: %v", err)
+ }
+}
+
+// pickManifest declares ops with response projections: an enveloped list
+// (DNSimple's {"data": […]} shape), a bare object with a nested dot-path
+// field (GitHub's user.login shape), and a root-only descent.
+const pickManifest = `{
+ "schema": 1,
+ "id": "testsvc",
+ "name": "TestSvc",
+ "category": "test",
+ "auth": { "kind": "pat", "base": "https://api.testsvc.example" },
+ "ops": [
+ { "id": "rows.list", "label": "Rows", "access": "read", "method": "GET", "path": "/rows",
+ "pick": { "root": "data", "fields": ["name", "user.login"] } },
+ { "id": "me.get", "label": "Me", "access": "read", "method": "GET", "path": "/me",
+ "pick": { "fields": ["login", "plan.name"] } },
+ { "id": "env.get", "label": "Envelope", "access": "read", "method": "GET", "path": "/env",
+ "pick": { "root": "data.inner" } }
+ ]
+}`
+
+func fakePickService(t *testing.T, respBody string) *Manifest {
+ t.Helper()
+ m, err := Parse([]byte(pickManifest))
+ if err != nil {
+ t.Fatalf("pick manifest: %v", err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, respBody)
+ }))
+ t.Cleanup(srv.Close)
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
+ return m
+}
+
+func TestExecutePickProjectsArrayUnderRoot(t *testing.T) {
+ m := fakePickService(t, `{"data":[
+ {"name":"a.com","state":"registered","user":{"login":"amy","id":7},"huge":"`+strings.Repeat("x", 4096)+`"},
+ {"name":"b.com","user":{"id":8}},
+ "not-an-object"
+ ]}`)
+ body, err := Execute(m, "rows.list", nil, "tk")
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(body), &rows); err != nil {
+ t.Fatalf("projected body %q: %v", body, err)
+ }
+ // Undeclared fields dropped, nested dot-path flattened, absent field
+ // omitted, non-object element skipped.
+ if len(rows) != 2 {
+ t.Fatalf("rows: %v", rows)
+ }
+ if rows[0]["name"] != "a.com" || rows[0]["user.login"] != "amy" {
+ t.Errorf("row 0: %v", rows[0])
+ }
+ if _, present := rows[0]["state"]; present {
+ t.Errorf("undeclared field survived: %v", rows[0])
+ }
+ if _, present := rows[1]["user.login"]; present || rows[1]["name"] != "b.com" {
+ t.Errorf("row 1: %v", rows[1])
+ }
+ if strings.Contains(body, "xxxx") {
+ t.Error("unpicked bulk survived projection")
+ }
+}
+
+func TestExecutePickProjectsSingleObject(t *testing.T) {
+ m := fakePickService(t, `{"login":"octocat","plan":{"name":"pro","seats":9},"company":"GitHub"}`)
+ body, err := Execute(m, "me.get", nil, "tk")
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal([]byte(body), &got); err != nil {
+ t.Fatalf("projected body %q: %v", body, err)
+ }
+ if got["login"] != "octocat" || got["plan.name"] != "pro" || len(got) != 2 {
+ t.Errorf("projected: %v", got)
+ }
+}
+
+func TestExecutePickRootOnlyDescends(t *testing.T) {
+ m := fakePickService(t, `{"data":{"inner":{"whole":"thing"}}}`)
+ body, err := Execute(m, "env.get", nil, "tk")
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if body != `{"whole":"thing"}` {
+ t.Errorf("body: %q", body)
+ }
+}
+
+func TestExecutePickHonestErrors(t *testing.T) {
+ // A response that isn't JSON, and one missing the declared envelope: both
+ // must error (never a silent empty projection).
+ m := fakePickService(t, `<!doctype html>`)
+ if _, err := Execute(m, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "isn't JSON") {
+ t.Errorf("non-JSON response: %v", err)
+ }
+ m2 := fakePickService(t, `{"unexpected":true}`)
+ if _, err := Execute(m2, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "envelope") {
+ t.Errorf("missing envelope: %v", err)
+ }
+}
+
+func TestExecutePinsHostWithoutOverride(t *testing.T) {
+ // Without the env override the manifest base (https, real host) is used;
+ // no server listens there, so the request must fail — but it must fail
+ // on the network, not by the pin (which proves the pin passes for the
+ // manifest's own host). More importantly, a non-https base must be
+ // refused by the pin itself.
+ m, err := Parse([]byte(testManifest))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.Auth.Base = "http://api.testsvc.example" // simulate a bypassed loader
+ if _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk"); err == nil || !strings.Contains(err.Error(), "pinned") {
+ t.Errorf("http base without override: %v", err)
+ }
+}
diff --git a/integrations/registry.go b/integrations/registry.go
new file mode 100644
index 0000000..def14dd
--- /dev/null
+++ b/integrations/registry.go
@@ -0,0 +1,369 @@
+// Package integrations is the loader for the integrations registry
+// (registry/README.md): one JSON manifest per third-party service, embedded
+// into the binary the way recipes and knowledge are. A manifest is the WHOLE
+// of what an integration can do — the one API host its ops may touch and the
+// closed list of operations — so the loader fails closed: a manifest that
+// bends any rule never loads at all, and an op that isn't declared cannot be
+// expressed.
+package integrations
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "regexp"
+ "sort"
+ "sync"
+)
+
+//go:embed registry/manifests/*.json
+var manifestFS embed.FS
+
+// Manifest is one service's entry in the registry (schema v1 — see
+// registry/README.md for the annotated shape).
+type Manifest struct {
+ Schema int `json:"schema"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Category string `json:"category"`
+ Auth Auth `json:"auth"`
+ Ops []Op `json:"ops"`
+}
+
+// Auth describes how a member authenticates and — critically — the single
+// host every op is pinned to (exec.go enforces the pin at request time).
+type Auth struct {
+ Kind string `json:"kind"` // "pat" | "oauth" | "both"
+ Base string `json:"base"`
+ Authorization string `json:"authorization"` // header scheme; default Bearer
+ Headers map[string]string `json:"headers"`
+ PATHelp string `json:"pat_help"`
+ OAuth *OAuthInfo `json:"oauth"`
+}
+
+// OAuthInfo is present when the service supports the broker OAuth flow
+// (client secrets live on the broker, never here). The URLs are public
+// provider endpoints; the broker reads them from its embedded copy of this
+// manifest, so registering a new OAuth service is: manifest gains this block,
+// broker env gains the client id + secret.
+type OAuthInfo struct {
+ AuthorizeURL string `json:"authorize_url"`
+ TokenURL string `json:"token_url"` // the code→token exchange endpoint (broker-only)
+ Scopes []string `json:"scopes"`
+}
+
+// Op is one declared operation. Everything a call can vary is a named string
+// arg; `{arg}` placeholders in the path/body/confirm templates are the only
+// way member input reaches the wire.
+type Op struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Access string `json:"access"` // "read" | "write"
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Args map[string]ArgSpec `json:"args"`
+ BodyJSON map[string]any `json:"body_json"`
+ BodyForm map[string]string `json:"body_form"`
+ Encoding string `json:"encoding"` // "" | "json" | "form"
+ Confirm string `json:"confirm"` // required on writes
+ Pick *Pick `json:"pick"` // response projection (see Pick)
+}
+
+// ArgSpec declares one string argument.
+type ArgSpec struct {
+ Required bool `json:"required"`
+}
+
+// Pick bounds what an op's RESPONSE carries back into the chat. Provider APIs
+// answer with far more than an app needs (a GitHub repo object is ~100 fields),
+// and an op result travels inside one end-to-end-encrypted message with a hard
+// size cap — so the manifest declares the projection, making "what data can
+// flow back" as reviewable as "what request can go out". Root descends into an
+// envelope ("data" for DNSimple's {"data": …}); fields projects an object — or
+// each object of an array — down to the named dot-paths. Absent pick = the raw
+// body, still subject to the caller's size cap.
+type Pick struct {
+ Root string `json:"root"` // dot-path to the value of interest; "" = whole body
+ Fields []string `json:"fields"` // dot-paths kept per object; empty = keep the value whole
+}
+
+// Registry is the loaded, validated catalog.
+type Registry struct {
+ byID map[string]*Manifest
+}
+
+// Get returns the manifest for a service id, or nil.
+func (r *Registry) Get(id string) *Manifest { return r.byID[id] }
+
+// List returns every manifest, sorted by id (stable catalog order).
+func (r *Registry) List() []*Manifest {
+ out := make([]*Manifest, 0, len(r.byID))
+ for _, m := range r.byID {
+ out = append(out, m)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out
+}
+
+// Op returns the manifest's op by id, or nil — nil means the op cannot run,
+// full stop (the executor and the sidecar both treat absence as refusal).
+func (m *Manifest) Op(id string) *Op {
+ for i := range m.Ops {
+ if m.Ops[i].ID == id {
+ return &m.Ops[i]
+ }
+ }
+ return nil
+}
+
+var (
+ idRE = regexp.MustCompile(`^[a-z0-9-]+$`)
+ opIDRE = regexp.MustCompile(`^[a-z0-9]+(\.[a-z0-9]+)*$`)
+ // placeholderRE is the ONE definition of what a `{arg}` placeholder looks
+ // like — the validator and the executor must agree, or a hole opens
+ // between "validated" and "substituted".
+ placeholderRE = regexp.MustCompile(`\{([a-z0-9_]+)\}`)
+ // pickPathRE is a pick root/field: dot-separated plain JSON keys. No
+ // wildcards, no indexing — a projection names exactly what it keeps.
+ pickPathRE = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`)
+)
+
+var validMethods = map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true}
+
+// Parse unmarshals and validates one manifest. Exported so tests (and, later,
+// the public-registry CI) can reject a bad manifest without embedding it.
+func Parse(raw []byte) (*Manifest, error) {
+ var m Manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, fmt.Errorf("manifest: %w", err)
+ }
+ if err := validate(&m); err != nil {
+ return nil, err
+ }
+ return &m, nil
+}
+
+// validate enforces the rules in registry/README.md. Every check fails closed:
+// there is no "load with warnings" — a manifest is either wholly valid or
+// wholly absent.
+func validate(m *Manifest) error {
+ if m.Schema != 1 {
+ return fmt.Errorf("manifest %q: unsupported schema %d (want 1)", m.ID, m.Schema)
+ }
+ if !idRE.MatchString(m.ID) {
+ return fmt.Errorf("manifest: bad id %q", m.ID)
+ }
+ if err := validateBase(m.Auth.Base); err != nil {
+ return fmt.Errorf("manifest %q: %w", m.ID, err)
+ }
+ // An OAuth-capable service must spell both provider endpoints, https —
+ // the broker builds its flow from these.
+ if m.Auth.Kind == "oauth" || m.Auth.Kind == "both" {
+ o := m.Auth.OAuth
+ if o == nil {
+ return fmt.Errorf("manifest %q: auth.kind %q without auth.oauth", m.ID, m.Auth.Kind)
+ }
+ for name, u := range map[string]string{"authorize_url": o.AuthorizeURL, "token_url": o.TokenURL} {
+ parsed, err := url.Parse(u)
+ if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
+ return fmt.Errorf("manifest %q: auth.oauth.%s %q must be an https URL", m.ID, name, u)
+ }
+ }
+ }
+ seen := map[string]bool{}
+ for i := range m.Ops {
+ op := &m.Ops[i]
+ if !opIDRE.MatchString(op.ID) {
+ return fmt.Errorf("manifest %q: bad op id %q", m.ID, op.ID)
+ }
+ if seen[op.ID] {
+ return fmt.Errorf("manifest %q: duplicate op id %q", m.ID, op.ID)
+ }
+ seen[op.ID] = true
+ if op.Access != "read" && op.Access != "write" {
+ return fmt.Errorf("manifest %q: op %q: bad access %q", m.ID, op.ID, op.Access)
+ }
+ // Writes carry the consent chip's exact line: no confirm, no write.
+ if op.Access == "write" && op.Confirm == "" {
+ return fmt.Errorf("manifest %q: op %q: write op without confirm", m.ID, op.ID)
+ }
+ if !validMethods[op.Method] {
+ return fmt.Errorf("manifest %q: op %q: bad method %q", m.ID, op.ID, op.Method)
+ }
+ switch op.Encoding {
+ case "form":
+ if len(op.BodyForm) == 0 {
+ return fmt.Errorf("manifest %q: op %q: encoding form without body_form", m.ID, op.ID)
+ }
+ if op.BodyJSON != nil {
+ return fmt.Errorf("manifest %q: op %q: encoding form with body_json", m.ID, op.ID)
+ }
+ case "", "json":
+ if op.BodyForm != nil {
+ return fmt.Errorf("manifest %q: op %q: body_form requires encoding form", m.ID, op.ID)
+ }
+ default:
+ return fmt.Errorf("manifest %q: op %q: bad encoding %q", m.ID, op.ID, op.Encoding)
+ }
+ if err := validatePlaceholders(op); err != nil {
+ return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err)
+ }
+ if err := validatePick(op.Pick); err != nil {
+ return fmt.Errorf("manifest %q: op %q: %w", m.ID, op.ID, err)
+ }
+ }
+ return nil
+}
+
+// validatePick rejects a projection that names nothing or names it badly — an
+// empty pick would read as "keep everything" while looking like a bound.
+func validatePick(p *Pick) error {
+ if p == nil {
+ return nil
+ }
+ if p.Root == "" && len(p.Fields) == 0 {
+ return fmt.Errorf("pick with neither root nor fields")
+ }
+ if p.Root != "" && !pickPathRE.MatchString(p.Root) {
+ return fmt.Errorf("bad pick root %q", p.Root)
+ }
+ for _, f := range p.Fields {
+ if !pickPathRE.MatchString(f) {
+ return fmt.Errorf("bad pick field %q", f)
+ }
+ }
+ return nil
+}
+
+// validateBase requires an https URL that is a bare host: no path, query,
+// fragment, or credentials. The base is the executor's pin — anything beyond
+// the host would blur what "pinned" means.
+func validateBase(base string) error {
+ u, err := url.Parse(base)
+ if err != nil {
+ return fmt.Errorf("bad auth.base %q: %w", base, err)
+ }
+ if u.Scheme != "https" {
+ return fmt.Errorf("auth.base %q must be https", base)
+ }
+ if u.Host == "" {
+ return fmt.Errorf("auth.base %q has no host", base)
+ }
+ if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
+ return fmt.Errorf("auth.base %q must be scheme+host only", base)
+ }
+ return nil
+}
+
+// validatePlaceholders checks that every `{arg}` appearing anywhere member
+// input can flow — path, confirm, form values, JSON string leaves — names a
+// declared arg. An undeclared placeholder would substitute to nothing at run
+// time; refusing it at load time keeps the failure at review, not in a chat.
+func validatePlaceholders(op *Op) error {
+ check := func(where, s string) error {
+ for _, match := range placeholderRE.FindAllStringSubmatch(s, -1) {
+ if _, ok := op.Args[match[1]]; !ok {
+ return fmt.Errorf("%s references undeclared arg %q", where, match[1])
+ }
+ }
+ return nil
+ }
+ if err := check("path", op.Path); err != nil {
+ return err
+ }
+ if err := check("confirm", op.Confirm); err != nil {
+ return err
+ }
+ for k, v := range op.BodyForm {
+ if err := check("body_form."+k, v); err != nil {
+ return err
+ }
+ }
+ return checkJSONLeaves("body_json", op.BodyJSON, check)
+}
+
+// checkJSONLeaves walks a body_json template and runs check on every string
+// leaf (the only place a placeholder can live in JSON).
+func checkJSONLeaves(where string, v any, check func(where, s string) error) error {
+ switch t := v.(type) {
+ case string:
+ return check(where, t)
+ case map[string]any:
+ for k, e := range t {
+ if err := checkJSONLeaves(where+"."+k, e, check); err != nil {
+ return err
+ }
+ }
+ case []any:
+ for i, e := range t {
+ if err := checkJSONLeaves(fmt.Sprintf("%s[%d]", where, i), e, check); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// RenderConfirm substitutes `{arg}` into the op's confirm template. A missing
+// arg is left literal — the chip then reads visibly wrong instead of silently
+// asking consent for a blank.
+func (o *Op) RenderConfirm(args map[string]string) string {
+ return placeholderRE.ReplaceAllStringFunc(o.Confirm, func(ph string) string {
+ name := ph[1 : len(ph)-1]
+ if v, ok := args[name]; ok {
+ return v
+ }
+ return ph
+ })
+}
+
+var (
+ loadOnce sync.Once
+ loaded *Registry
+ loadErr error
+)
+
+// Load parses every embedded manifest once and caches the result. Any single
+// bad manifest fails the whole load — the registry is reviewed code, so a
+// partial catalog would mean shipping around a known-bad entry.
+func Load() (*Registry, error) {
+ loadOnce.Do(func() {
+ entries, err := manifestFS.ReadDir("registry/manifests")
+ if err != nil {
+ loadErr = err
+ return
+ }
+ reg := &Registry{byID: map[string]*Manifest{}}
+ for _, e := range entries {
+ raw, err := manifestFS.ReadFile("registry/manifests/" + e.Name())
+ if err != nil {
+ loadErr = err
+ return
+ }
+ m, err := Parse(raw)
+ if err != nil {
+ loadErr = fmt.Errorf("%s: %w", e.Name(), err)
+ return
+ }
+ if reg.byID[m.ID] != nil {
+ loadErr = fmt.Errorf("%s: duplicate manifest id %q", e.Name(), m.ID)
+ return
+ }
+ reg.byID[m.ID] = m
+ }
+ loaded = reg
+ })
+ return loaded, loadErr
+}
+
+// MustRegistry returns the embedded registry, panicking on a malformed
+// manifest: embedded content is code, so a bad manifest is a build bug that
+// must never limp into production.
+func MustRegistry() *Registry {
+ reg, err := Load()
+ if err != nil {
+ panic("integrations: " + err.Error())
+ }
+ return reg
+}
diff --git a/integrations/registry/README.md b/integrations/registry/README.md
new file mode 100644
index 0000000..1609ed1
--- /dev/null
+++ b/integrations/registry/README.md
@@ -0,0 +1,83 @@
+# 🤖 The integrations registry
+
+One JSON manifest per third-party service. A manifest is the WHOLE of what an
+integration can do on an Eleven Messenger instance: how a member authenticates
+to the service, the one API host its operations may touch, and the closed list
+of operations — each marked `read` or `write`, writes each carrying the
+confirmation line a person taps before it runs. An operation that isn't in the
+manifest cannot be expressed, the same fail-closed palette rule as bot recipes
+(`docs/bot-recipes.md`).
+
+This directory is destined to become its own public repository
+(`elevenmessenger/integrations`) so integrations can be added — and audited —
+without touching the messenger codebase: adding one is a PR with a manifest
+(plus, for OAuth services, registering the OAuth app on the connect broker,
+which is where client secrets live — `docs/connected-services.md`). Until that
+split, it lives here and is embedded into the binaries (`registry.go`), with
+the broker serving it at `GET /registry` so web clients read the same catalog.
+
+## Manifest schema (v1)
+
+```jsonc
+{
+ "schema": 1,
+ "id": "github", // lowercase slug, unique
+ "name": "GitHub",
+ "category": "development", // catalog grouping
+ "auth": {
+ "kind": "pat", // "pat" | "oauth" | "both"
+ "base": "https://api.github.com",// the ONLY host ops may reach (executor-pinned)
+ "authorization": "Bearer", // Authorization: <scheme> <token>
+ "headers": { "Accept": "…" }, // extra headers on every call
+ "pat_help": "…", // where a member mints a scoped token
+ "oauth": { "authorize_url": "…", "scopes": ["…"] } // when the broker has an app
+ },
+ "ops": [
+ {
+ "id": "repos.list", // namespace.verb, unique within the manifest
+ "label": "List your repositories",
+ "access": "read", // "read" | "write" — grants are scoped to this
+ "method": "GET",
+ "path": "/user/repos?per_page=20", // {arg} placeholders allowed
+ "args": { "owner": { "required": true } }, // string args only; validated
+ "body_json": { "title": "{title}" }, // JSON body template ({arg} in leaves)
+ "encoding": "json", // "json" (default) | "form" (Stripe-style)
+ "confirm": "Open “{title}”?", // REQUIRED on writes: the consent chip's line
+ "pick": { // response projection — what may flow BACK
+ "root": "data", // descend into an envelope first (optional)
+ "fields": ["full_name", "user.login"] // keep only these dot-paths per object
+ }
+ }
+ ]
+}
+```
+
+`pick` makes the response as reviewable as the request: provider APIs answer
+with far more than an app needs (a GitHub repo object is ~100 fields, several
+KB), and an op result travels inside one end-to-end-encrypted message with a
+hard size cap — so the manifest declares the fields that may flow back, and the
+executor drops everything else before the result ever leaves the sidecar.
+Applied to the object at `root` (or to each object of the array there); nested
+paths come back flattened (`"user.login": "octocat"`). An op without `pick`
+returns the raw body, still subject to the size cap.
+
+Rules the loader enforces (fail closed — a bad manifest never half-loads):
+
+- `id`/op ids well-formed; op ids unique; `access` ∈ read/write; every write
+ has a `confirm` template; `base` is https; methods ∈ GET/POST/PUT/PATCH/DELETE.
+- `{arg}` placeholders in `path`/`body_json`/`confirm` must name declared args.
+- Arg values are strings, bounded, and escaped into the URL — a value can
+ never break out of its path segment or query slot.
+- `pick` (when present) names at least a root or one field, all well-formed
+ dot-paths.
+
+## Trust model
+
+The manifest is reviewed CODE (this registry), never user content. The
+executor pins every request to `auth.base`'s host, so a manifest op can't be
+steered elsewhere. Member credentials stay client-sealed exactly as before
+(`docs/connected-services.md`): a bot receives a short-lived grant sealed to
+its own key, now carrying `access: "read" | "write"` — a read grant cannot run
+a write op, and grants are per-bot by construction (sealed to one keypair).
+Writes additionally require the member's tap on a confirmation chip naming the
+exact operation ("Register blip.com — this costs real money").
diff --git a/integrations/registry/manifests/dnsimple.json b/integrations/registry/manifests/dnsimple.json
new file mode 100644
index 0000000..e8c4a29
--- /dev/null
+++ b/integrations/registry/manifests/dnsimple.json
@@ -0,0 +1,96 @@
+{
+ "schema": 1,
+ "id": "dnsimple",
+ "name": "DNSimple",
+ "category": "domains",
+ "auth": {
+ "kind": "both",
+ "base": "https://api.dnsimple.com",
+ "authorization": "Bearer",
+ "pat_help": "https://support.dnsimple.com/articles/api-access-token/ — create a token scoped to the domains you want managed",
+ "oauth": {
+ "authorize_url": "https://dnsimple.com/oauth/authorize",
+ "scopes": [],
+ "token_url": "https://api.dnsimple.com/v2/oauth/access_token"
+ }
+ },
+ "ops": [
+ {
+ "id": "accounts.list",
+ "label": "List accounts",
+ "access": "read",
+ "method": "GET",
+ "path": "/v2/accounts",
+ "pick": {
+ "root": "data",
+ "fields": [
+ "id",
+ "email",
+ "plan_identifier"
+ ]
+ }
+ },
+ {
+ "id": "domains.list",
+ "label": "List domains",
+ "access": "read",
+ "method": "GET",
+ "path": "/v2/{account}/domains?per_page=100",
+ "args": {
+ "account": {
+ "required": true
+ }
+ },
+ "pick": {
+ "root": "data",
+ "fields": [
+ "name",
+ "state",
+ "auto_renew",
+ "expires_on",
+ "account_id"
+ ]
+ }
+ },
+ {
+ "id": "domain.check",
+ "label": "Check whether a domain is available",
+ "access": "read",
+ "method": "GET",
+ "path": "/v2/{account}/registrar/domains/{domain}/check",
+ "args": {
+ "account": {
+ "required": true
+ },
+ "domain": {
+ "required": true
+ }
+ },
+ "pick": {
+ "root": "data"
+ }
+ },
+ {
+ "id": "domain.register",
+ "label": "Register a domain",
+ "access": "write",
+ "method": "POST",
+ "path": "/v2/{account}/registrar/domains/{domain}/registrations",
+ "args": {
+ "account": {
+ "required": true
+ },
+ "domain": {
+ "required": true
+ },
+ "registrant_id": {
+ "required": true
+ }
+ },
+ "body_json": {
+ "registrant_id": "{registrant_id}"
+ },
+ "confirm": "Register {domain}? This buys the domain and costs real money."
+ }
+ ]
+}
diff --git a/integrations/registry/manifests/github.json b/integrations/registry/manifests/github.json
new file mode 100644
index 0000000..3a8e587
--- /dev/null
+++ b/integrations/registry/manifests/github.json
@@ -0,0 +1,116 @@
+{
+ "schema": 1,
+ "id": "github",
+ "name": "GitHub",
+ "category": "development",
+ "auth": {
+ "kind": "both",
+ "base": "https://api.github.com",
+ "authorization": "Bearer",
+ "headers": {
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28"
+ },
+ "pat_help": "https://github.com/settings/personal-access-tokens — create a fine-grained token limited to the repositories you want reachable",
+ "oauth": {
+ "authorize_url": "https://github.com/login/oauth/authorize",
+ "token_url": "https://github.com/login/oauth/access_token",
+ "scopes": []
+ }
+ },
+ "ops": [
+ {
+ "id": "viewer",
+ "label": "Check who the token belongs to",
+ "access": "read",
+ "method": "GET",
+ "path": "/user",
+ "pick": {
+ "fields": [
+ "login",
+ "name",
+ "html_url"
+ ]
+ }
+ },
+ {
+ "id": "repos.list",
+ "label": "List your repositories",
+ "access": "read",
+ "method": "GET",
+ "path": "/user/repos?sort=pushed&per_page=100",
+ "pick": {
+ "fields": [
+ "full_name",
+ "description",
+ "private",
+ "archived",
+ "fork",
+ "html_url",
+ "open_issues_count",
+ "stargazers_count",
+ "pushed_at"
+ ]
+ }
+ },
+ {
+ "id": "issues.list",
+ "label": "List a repository's open issues and pull requests",
+ "access": "read",
+ "method": "GET",
+ "path": "/repos/{owner}/{repo}/issues?state=open&per_page=50",
+ "args": {
+ "owner": {
+ "required": true
+ },
+ "repo": {
+ "required": true
+ }
+ },
+ "pick": {
+ "fields": [
+ "number",
+ "title",
+ "html_url",
+ "user.login",
+ "comments",
+ "created_at",
+ "updated_at",
+ "draft",
+ "pull_request.url"
+ ]
+ }
+ },
+ {
+ "id": "issue.create",
+ "label": "Open an issue",
+ "access": "write",
+ "method": "POST",
+ "path": "/repos/{owner}/{repo}/issues",
+ "args": {
+ "owner": {
+ "required": true
+ },
+ "repo": {
+ "required": true
+ },
+ "title": {
+ "required": true
+ },
+ "body": {}
+ },
+ "body_json": {
+ "title": "{title}",
+ "body": "{body}"
+ },
+ "pick": {
+ "fields": [
+ "number",
+ "title",
+ "html_url"
+ ]
+ },
+ "confirm": "Open the issue “{title}” in {owner}/{repo}?"
+ }
+ ]
+}
diff --git a/integrations/registry/manifests/hubspot.json b/integrations/registry/manifests/hubspot.json
new file mode 100644
index 0000000..f6f8bfb
--- /dev/null
+++ b/integrations/registry/manifests/hubspot.json
@@ -0,0 +1,53 @@
+{
+ "schema": 1,
+ "id": "hubspot",
+ "name": "HubSpot",
+ "category": "crm",
+ "auth": {
+ "kind": "pat",
+ "base": "https://api.hubapi.com",
+ "authorization": "Bearer",
+ "pat_help": "https://developers.hubspot.com/docs/api/private-apps — create a private app with only the CRM scopes you want reachable"
+ },
+ "ops": [
+ {
+ "id": "contacts.list",
+ "label": "List recent contacts",
+ "access": "read",
+ "method": "GET",
+ "path": "/crm/v3/objects/contacts?limit=10",
+ "pick": {
+ "root": "results",
+ "fields": [
+ "id",
+ "properties.email",
+ "properties.firstname",
+ "properties.lastname",
+ "createdAt"
+ ]
+ }
+ },
+ {
+ "id": "contact.create",
+ "label": "Create a contact",
+ "access": "write",
+ "method": "POST",
+ "path": "/crm/v3/objects/contacts",
+ "args": {
+ "email": {
+ "required": true
+ },
+ "firstname": {},
+ "lastname": {}
+ },
+ "body_json": {
+ "properties": {
+ "email": "{email}",
+ "firstname": "{firstname}",
+ "lastname": "{lastname}"
+ }
+ },
+ "confirm": "Create the HubSpot contact {email}?"
+ }
+ ]
+}
diff --git a/integrations/registry/manifests/stripe.json b/integrations/registry/manifests/stripe.json
new file mode 100644
index 0000000..e6e4b3d
--- /dev/null
+++ b/integrations/registry/manifests/stripe.json
@@ -0,0 +1,59 @@
+{
+ "schema": 1,
+ "id": "stripe",
+ "name": "Stripe",
+ "category": "payments",
+ "auth": {
+ "kind": "pat",
+ "base": "https://api.stripe.com",
+ "authorization": "Bearer",
+ "pat_help": "https://dashboard.stripe.com/apikeys — create a RESTRICTED key with only the permissions you want reachable"
+ },
+ "ops": [
+ {
+ "id": "balance.get",
+ "label": "Current balance",
+ "access": "read",
+ "method": "GET",
+ "path": "/v1/balance",
+ "pick": {
+ "root": "available"
+ }
+ },
+ {
+ "id": "charges.list",
+ "label": "Recent charges",
+ "access": "read",
+ "method": "GET",
+ "path": "/v1/charges?limit=10",
+ "pick": {
+ "root": "data",
+ "fields": [
+ "id",
+ "amount",
+ "currency",
+ "status",
+ "description",
+ "created"
+ ]
+ }
+ },
+ {
+ "id": "refund.create",
+ "label": "Refund a charge",
+ "access": "write",
+ "method": "POST",
+ "path": "/v1/refunds",
+ "args": {
+ "charge": {
+ "required": true
+ }
+ },
+ "body_form": {
+ "charge": "{charge}"
+ },
+ "encoding": "form",
+ "confirm": "Refund charge {charge} in full? This moves real money."
+ }
+ ]
+}
diff --git a/integrations/registry_test.go b/integrations/registry_test.go
new file mode 100644
index 0000000..50f0228
--- /dev/null
+++ b/integrations/registry_test.go
@@ -0,0 +1,135 @@
+package integrations
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMustRegistryLoadsEmbeddedManifests(t *testing.T) {
+ reg := MustRegistry()
+ list := reg.List()
+ if len(list) != 4 {
+ t.Fatalf("embedded manifests: got %d, want 4", len(list))
+ }
+ // List is sorted by id.
+ want := []string{"dnsimple", "github", "hubspot", "stripe"}
+ for i, m := range list {
+ if m.ID != want[i] {
+ t.Errorf("List()[%d] = %q, want %q", i, m.ID, want[i])
+ }
+ if reg.Get(m.ID) != m {
+ t.Errorf("Get(%q) doesn't return the listed manifest", m.ID)
+ }
+ }
+ if reg.Get("nope") != nil {
+ t.Error("Get(unknown) should be nil")
+ }
+ gh := reg.Get("github")
+ if op := gh.Op("issue.create"); op == nil || op.Access != "write" {
+ t.Fatalf("github issue.create: got %+v", gh.Op("issue.create"))
+ }
+ if gh.Op("bogus") != nil {
+ t.Error("Op(unknown) should be nil")
+ }
+ // The legacy dnsimple op id "list" must NOT be a manifest op — the
+ // sidecar's legacy path depends on it falling through (connect.go).
+ if reg.Get("dnsimple").Op("list") != nil {
+ t.Error(`dnsimple manifest declares op "list" — that shadows the legacy path`)
+ }
+}
+
+func TestEveryWriteOpHasConfirm(t *testing.T) {
+ for _, m := range MustRegistry().List() {
+ for _, op := range m.Ops {
+ if op.Access == "write" && op.Confirm == "" {
+ t.Errorf("%s.%s: write op without confirm", m.ID, op.ID)
+ }
+ }
+ }
+}
+
+// validManifest is a minimal manifest the validator accepts; the invalid
+// cases below are single mutations of this shape.
+const validManifest = `{
+ "schema": 1,
+ "id": "svc",
+ "name": "Svc",
+ "category": "test",
+ "auth": { "kind": "pat", "base": "https://api.svc.example", "authorization": "Bearer" },
+ "ops": [
+ { "id": "things.list", "label": "List", "access": "read", "method": "GET",
+ "path": "/v1/{account}/things?limit=10",
+ "args": { "account": { "required": true } } },
+ { "id": "thing.create", "label": "Create", "access": "write", "method": "POST",
+ "path": "/v1/things",
+ "args": { "title": { "required": true } },
+ "body_json": { "title": "{title}" },
+ "confirm": "Create {title}?" }
+ ]
+}`
+
+func TestParseValidManifest(t *testing.T) {
+ m, err := Parse([]byte(validManifest))
+ if err != nil {
+ t.Fatalf("valid manifest rejected: %v", err)
+ }
+ if m.ID != "svc" || len(m.Ops) != 2 {
+ t.Fatalf("parsed shape wrong: %+v", m)
+ }
+}
+
+func TestParseRejectsInvalidManifests(t *testing.T) {
+ // Each case is the valid manifest with one rule broken; every one must be
+ // rejected outright (fail closed — no partial load).
+ mutate := func(old, new string) string {
+ s := strings.Replace(validManifest, old, new, 1)
+ if s == validManifest {
+ t.Fatalf("mutation %q not applied", new)
+ }
+ return s
+ }
+ cases := []struct {
+ name string
+ json string
+ }{
+ {"bad schema", mutate(`"schema": 1`, `"schema": 2`)},
+ {"bad manifest id", mutate(`"id": "svc"`, `"id": "Svc!"`)},
+ {"unknown access", mutate(`"access": "read"`, `"access": "admin"`)},
+ {"write without confirm", mutate(`"confirm": "Create {title}?"`, `"confirm": ""`)},
+ {"undeclared placeholder in path", mutate(`"path": "/v1/{account}/things?limit=10"`, `"path": "/v1/{oops}/things"`)},
+ {"undeclared placeholder in body_json", mutate(`"body_json": { "title": "{title}" }`, `"body_json": { "title": "{oops}" }`)},
+ {"undeclared placeholder in confirm", mutate(`"confirm": "Create {title}?"`, `"confirm": "Create {oops}?"`)},
+ {"http base", mutate(`"base": "https://api.svc.example"`, `"base": "http://api.svc.example"`)},
+ {"base with a path", mutate(`"base": "https://api.svc.example"`, `"base": "https://api.svc.example/v2"`)},
+ {"base without host", mutate(`"base": "https://api.svc.example"`, `"base": "https://"`)},
+ {"duplicate op ids", mutate(`"id": "thing.create"`, `"id": "things.list"`)},
+ {"bad op id", mutate(`"id": "things.list"`, `"id": "Things.List"`)},
+ {"bad method", mutate(`"method": "GET"`, `"method": "TRACE"`)},
+ {"form without body_form", mutate(`"body_json": { "title": "{title}" },`, `"encoding": "form",`)},
+ {"bad encoding", mutate(`"body_json": { "title": "{title}" },`, `"body_json": { "title": "{title}" }, "encoding": "xml",`)},
+ {"empty pick", mutate(`"path": "/v1/things",`, `"path": "/v1/things", "pick": {},`)},
+ {"bad pick root", mutate(`"path": "/v1/things",`, `"path": "/v1/things", "pick": { "root": "data..x" },`)},
+ {"bad pick field", mutate(`"path": "/v1/things",`, `"path": "/v1/things", "pick": { "fields": ["ok", "no spaces"] },`)},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if _, err := Parse([]byte(c.json)); err == nil {
+ t.Errorf("%s: accepted, want rejection", c.name)
+ }
+ })
+ }
+}
+
+func TestRenderConfirm(t *testing.T) {
+ op := MustRegistry().Get("github").Op("issue.create")
+ got := op.RenderConfirm(map[string]string{"title": "Fix login", "owner": "paul", "repo": "lchat"})
+ if !strings.Contains(got, "Fix login") || !strings.Contains(got, "paul/lchat") {
+ t.Errorf("RenderConfirm: %q", got)
+ }
+ // A missing arg stays literal, so the chip reads visibly wrong instead of
+ // asking consent for a blank.
+ got = op.RenderConfirm(map[string]string{"owner": "paul"})
+ if !strings.Contains(got, "{title}") || !strings.Contains(got, "{repo}") {
+ t.Errorf("RenderConfirm with missing args: %q", got)
+ }
+}
diff --git a/integrations_api.go b/integrations_api.go
new file mode 100644
index 0000000..ee0fdb8
--- /dev/null
+++ b/integrations_api.go
@@ -0,0 +1,18 @@
+package main
+
+// The public integrations catalog (docs/integrations.md): the embedded,
+// reviewed registry served verbatim — like GET /api/apps — so the web client
+// can render the connect catalog and the exact consent lines the sidecar will
+// enforce. Manifests are reviewed public data; there is nothing secret here.
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "lchat/integrations"
+)
+
+func (s *Server) getIntegrations(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"schema": 1, "services": integrations.MustRegistry().List()})
+}
diff --git a/integrations_api_test.go b/integrations_api_test.go
new file mode 100644
index 0000000..e01d4fa
--- /dev/null
+++ b/integrations_api_test.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// The integrations catalog is public (like /api/apps): the embedded registry,
+// verbatim, so clients can render the connect catalog and the write ops'
+// consent lines.
+func TestIntegrationsEndpoint(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/api/integrations")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
+ t.Fatalf("content-type %q", ct)
+ }
+ var out struct {
+ Schema int `json:"schema"`
+ Services []struct {
+ ID string `json:"id"`
+ Ops []struct {
+ ID string `json:"id"`
+ Access string `json:"access"`
+ Confirm string `json:"confirm"`
+ } `json:"ops"`
+ } `json:"services"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Schema != 1 {
+ t.Errorf("schema = %d, want 1", out.Schema)
+ }
+ if len(out.Services) != 4 {
+ t.Fatalf("got %d services, want 4", len(out.Services))
+ }
+ // DNSimple carries a write op with its consent line — the shape the web
+ // catalog and consent chips depend on.
+ confirmedWrite := false
+ for _, svc := range out.Services {
+ if svc.ID != "dnsimple" {
+ continue
+ }
+ for _, op := range svc.Ops {
+ if op.Access == "write" && op.Confirm != "" {
+ confirmedWrite = true
+ }
+ }
+ }
+ if !confirmedWrite {
+ t.Fatal("dnsimple must expose a write op with a confirm line")
+ }
+}
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 602fcef..c12ac52 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -4,8 +4,11 @@ import (
"fmt"
"os"
"regexp"
+ "sort"
"strings"
"time"
+
+ "lchat/integrations"
)
// The model brain: the Claude-backed reply path of agent/runtime.mjs in Go.
@@ -122,6 +125,11 @@ func (r *Runtime) systemPrompt() string {
"Never tell someone a button is attached without calling the tool in that same reply. " +
"\"[note to self: …]\" lines in the history are added automatically where you called tools " +
"before — never write them yourself."
+ s += "\n\nYou can also propose operations on services a member has connected via Integrations " +
+ "(domains, code, payments) with the service_op tool — its description is the complete list of " +
+ "what each service can do. Writes always go through the member's confirmation tap; nothing runs " +
+ "from your proposal alone. If they haven't connected the service, the tap will tell them so — " +
+ "proposing is still fine, or point them at Integrations (+ New chat → Integrations) to connect."
if r.appGuide != "" {
s += "\n\nYou CAN build small personal mini-apps for members — private tools like trackers, " +
"lists, counters, tiny games — via the create_app tool. When someone asks for an app (or " +
@@ -142,15 +150,56 @@ func (r *Runtime) systemPrompt() string {
}
// replyTools is what the conversational path may call: the chip whitelist,
-// plus the app-builder when the instance serves the authoring guide.
+// connected-service op proposals (service_op — always offered; the tap tells
+// the member to connect first if they haven't), plus the app-builder when the
+// instance serves the authoring guide. Everything except create_app becomes a
+// CONSENT CHIP — the client whitelists, and nothing runs until a tap.
func (r *Runtime) replyTools() []anthTool {
- tools := chipTools()
+ tools := append(chipTools(), serviceOpTool())
if r.appGuide != "" {
tools = append(tools, createAppTool())
}
return tools
}
+// serviceOpTool is the connected-services proposal tool. Its description IS
+// the embedded registry — the closed list of ops the manifests declare is the
+// closed list the model can propose, and actionsFromToolUse re-checks every
+// proposal against the same registry before a chip ever renders.
+func serviceOpTool() anthTool {
+ var b strings.Builder
+ b.WriteString("Propose one operation on a service the member has connected (Integrations). This attaches " +
+ "a chip; READ ops run when tapped, WRITE ops render their confirmation line and NOTHING runs until " +
+ "the member taps it. Only propose ops listed here, with exactly their args:\n")
+ for _, m := range integrations.MustRegistry().List() {
+ for _, op := range m.Ops {
+ names := make([]string, 0, len(op.Args))
+ for name := range op.Args {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ b.WriteString(m.ID + " " + op.ID + " (" + op.Access)
+ if len(names) > 0 {
+ b.WriteString("; args: " + strings.Join(names, ", "))
+ }
+ b.WriteString(") — " + op.Label + "\n")
+ }
+ }
+ return anthTool{
+ Name: "service_op",
+ Description: b.String(),
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "service": map[string]any{"type": "string"},
+ "op": map[string]any{"type": "string"},
+ "args": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
+ },
+ "required": []any{"service", "op"},
+ },
+ }
+}
+
// systemBlocks marks the (stable, potentially large) system prompt cacheable —
// one trailing breakpoint caches everything; small prompts fall below the
// cache floor and are harmlessly uncached.
@@ -404,6 +453,46 @@ func actionsFromToolUse(content []anthBlock) []map[string]any {
out = append(out, map[string]any{"type": "open_theme_picker"})
case "open_profile":
out = append(out, map[string]any{"type": "open_profile"})
+ case "service_op":
+ // Fail at proposal, not at tap: a chip that would bounce at
+ // execution is worse than no chip, so the registry is re-consulted
+ // here and anything off-manifest is dropped.
+ service, _ := b.Input["service"].(string)
+ opID, _ := b.Input["op"].(string)
+ m := integrations.MustRegistry().Get(service)
+ if m == nil {
+ continue
+ }
+ op := m.Op(opID)
+ if op == nil {
+ continue
+ }
+ args := map[string]string{}
+ if in, _ := b.Input["args"].(map[string]any); in != nil {
+ for k, v := range in {
+ if s, ok := v.(string); ok { // only strings ride the wire
+ args[k] = s
+ }
+ }
+ }
+ valid := true
+ for k, v := range args {
+ if _, declared := op.Args[k]; !declared || len(v) > 2048 {
+ valid = false
+ break
+ }
+ }
+ if !valid {
+ continue
+ }
+ // A write chip's label is the manifest's rendered confirm line —
+ // the exact sentence the member consents to; reads use the op label.
+ label := op.Label
+ if op.Access == "write" {
+ label = op.RenderConfirm(args)
+ }
+ out = append(out, map[string]any{"type": "service_op", "service": service, "op": opID,
+ "args": args, "access": op.Access, "label": label})
}
}
return out
diff --git a/internal/bot/connect.go b/internal/bot/connect.go
index 4007348..237320e 100644
--- a/internal/bot/connect.go
+++ b/internal/bot/connect.go
@@ -1,9 +1,13 @@
package bot
import (
+ "errors"
+ "fmt"
"os"
+ "sync"
"time"
+ "lchat/integrations"
"lchat/internal/e2ee"
)
@@ -25,6 +29,7 @@ type connectConfig struct {
brokerURL string
brokerToken string
dnsimple *dnsimpleClient
+ refreshes *refreshGroup // serializes token refreshes per credential
}
// defaultBrokerURL is the shared connect broker; an instance sets only its
@@ -33,6 +38,13 @@ type connectConfig struct {
// LCHAT_CONNECT_BROKER_URL for a self-hosted broker.
const defaultBrokerURL = "https://connect.elevenmessenger.com"
+// maxOpResultBytes caps one registry-op result: it must fit inside a single
+// encrypted message (maxBlobBytes, 64 KB ciphertext), and base64 costs 4/3 —
+// 40 KB of result is ~54 KB encrypted, leaving envelope headroom. Manifests
+// keep real results below this via `pick` (a 100-repo GitHub page projects to
+// ~26 KB).
+const maxOpResultBytes = 40 << 10
+
func loadConnectConfig() *connectConfig {
brokerURL := os.Getenv("LCHAT_CONNECT_BROKER_URL")
if brokerURL == "" {
@@ -42,28 +54,36 @@ func loadConnectConfig() *connectConfig {
brokerURL: brokerURL,
brokerToken: os.Getenv("LCHAT_CONNECT_BROKER_TOKEN"),
dnsimple: newDNSimpleClient(os.Getenv("LCHAT_DNSIMPLE_BASE")),
+ refreshes: newRefreshGroup(),
}
}
// connectPayload is the decrypted shape of a connect.op / connect.oauth message.
type connectPayload struct {
- Type string `json:"type"`
- Service string `json:"service"`
- Op string `json:"op"`
- Domain string `json:"domain"`
- Grant string `json:"grant"` // sealed to this bot: {service, token, exp}
- RID string `json:"rid"` // request id for a requestData round-trip
- Code string `json:"code"` // OAuth
- State string `json:"state"` // OAuth
- UID string `json:"uid"` // sender's user id (to seal the token back)
- Name string `json:"name"`
+ Type string `json:"type"`
+ Service string `json:"service"`
+ Op string `json:"op"`
+ Domain string `json:"domain"`
+ Args map[string]string `json:"args"` // registry-op arguments (integrations.Execute validates them)
+ Grant string `json:"grant"` // sealed to this bot: {service, token, exp, access}
+ RID string `json:"rid"` // request id for a requestData round-trip
+ Code string `json:"code"` // OAuth
+ State string `json:"state"` // OAuth
+ UID string `json:"uid"` // sender's user id (to seal the token back)
+ Name string `json:"name"`
}
// grant is what a member sealed to the bot for one capability op.
type grant struct {
Service string `json:"service"`
Token string `json:"token"`
- Exp int64 `json:"exp"` // unix millis; 0 = no expiry
+ Exp int64 `json:"exp"` // unix millis; 0 = no expiry
+ Access string `json:"access"` // "read" | "write"; empty = legacy grant, read-only
+ // Expiring-token providers (GitHub Apps): the refresh token and the access
+ // token's own expiry ride along so the bot can rotate a stale pair mid-op
+ // and hand the new pair back (sealed to the member) on the result.
+ Refresh string `json:"refresh"`
+ TokenExp int64 `json:"token_expires_at"` // unix millis; 0 = non-expiring
}
// handleConnect runs a capability op against a sealed grant. It replies ONLY
@@ -79,7 +99,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload)
if p.RID == "" {
return
}
- out := map[string]any{"type": "connect.result", "service": "dnsimple", "op": p.Op, "rid": p.RID, "ts": time.Now().UnixMilli()}
+ out := map[string]any{"type": "connect.result", "service": p.Service, "op": p.Op, "rid": p.RID, "ts": time.Now().UnixMilli()}
for k, v := range result {
out[k] = v
}
@@ -91,6 +111,77 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload)
reply(map[string]any{"error": "That authorization expired — try again."})
return
}
+ // Registry-driven path: any service/op pair the integrations registry
+ // declares runs through the pinned executor. Anything else falls through
+ // to the legacy dnsimple-only path below — deliberately including the
+ // legacy op id "list", which the dnsimple manifest does NOT declare (its
+ // op is "domains.list"), so existing DNSimple-app clients keep working.
+ if m := integrations.MustRegistry().Get(p.Service); m != nil {
+ if op := m.Op(p.Op); op != nil {
+ if g.Service != p.Service {
+ reply(map[string]any{"error": "That authorization is for \"" + g.Service + "\", not " + p.Service + "."})
+ return
+ }
+ // A read grant cannot run a write op: the member scoped what they
+ // consented to, and an empty access is a legacy (read) grant.
+ if op.Access == "write" && g.Access != "write" {
+ reply(map[string]any{"error": "That authorization is read-only."})
+ return
+ }
+ // Expiring-token providers: rotate a stale pair before calling
+ // (with a little slack), and hand the fresh pair back on the
+ // result so the member's stored credential stays alive. Rotation
+ // is serialized per credential — providers' refresh tokens are
+ // single-use, so a concurrent second op shares the first refresh
+ // instead of burning a dead token.
+ token := g.Token
+ var renewed *tokenPair
+ if g.Refresh != "" && g.TokenExp != 0 && time.Now().UnixMilli() > g.TokenExp-60_000 {
+ pair, err := r.refreshGrant(&g, p.UID)
+ if err != nil {
+ reply(map[string]any{"error": err.Error()})
+ return
+ }
+ token, renewed = pair.Access, pair
+ }
+ data, err := integrations.Execute(m, p.Op, p.Args, token)
+ // A 401 on an un-refreshed expiring pair means the expiry stamp
+ // lied (revoked-and-reissued, clock skew): rotate once and retry.
+ var se *integrations.StatusError
+ if err != nil && errors.As(err, &se) && se.Code == 401 && g.Refresh != "" && renewed == nil {
+ pair, rerr := r.refreshGrant(&g, p.UID)
+ if rerr == nil {
+ token, renewed = pair.Access, pair
+ data, err = integrations.Execute(m, p.Op, p.Args, token)
+ }
+ }
+ if err != nil {
+ reply(map[string]any{"error": err.Error()})
+ return
+ }
+ // An op result rides inside ONE encrypted message (64 KB blob cap,
+ // hub.go), so cap what one op can push into a thread. Truncating
+ // would hand the app broken JSON — refuse honestly instead; ops
+ // bound their responses with the manifest's `pick` projection.
+ if len(data) > maxOpResultBytes {
+ reply(map[string]any{"error": "That result is too large to bring into the chat — narrow the request."})
+ return
+ }
+ out := map[string]any{"data": data}
+ // The renewed pair rides on the result, sealed to the MEMBER (never
+ // stored here) — the requesting device re-stores it, so the next op
+ // starts from the fresh pair.
+ if renewed != nil {
+ if pub := r.senderPublicKey(p.UID); pub != "" {
+ if sealed, err := e2ee.SealForPub(pub, renewed.sealable()); err == nil {
+ out["renewed"] = sealed
+ }
+ }
+ }
+ reply(out)
+ return
+ }
+ }
if g.Service != "dnsimple" {
reply(map[string]any{"error": "Can't act on \"" + g.Service + "\" yet."})
return
@@ -107,17 +198,26 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload)
reply(map[string]any{"domains": domains})
}
-// handleOAuthExchange finishes the confidential OAuth dance: run the broker
-// exchange (no secret on this host), seal the token to the MEMBER's public key
-// so only they can open it, hand it straight back, and keep no copy.
+// handleOAuthExchange finishes the confidential OAuth dance for any service
+// the broker has an app for: run the broker exchange (no secret on this
+// host), seal the token to the MEMBER's public key so only they can open it,
+// hand it straight back, and keep no copy. The service must be a registry
+// service (or legacy dnsimple) — the broker refuses anything it has no app
+// for regardless.
func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPayload) {
- if p.Service != "dnsimple" {
+ if p.Service == "" {
return
}
- token, err := r.connect.dnsimple.exchangeViaBroker(p.Code, p.State, r.connect.brokerURL, r.connect.brokerToken)
+ name := p.Service
+ if m := integrations.MustRegistry().Get(p.Service); m != nil {
+ name = m.Name
+ } else if p.Service != "dnsimple" {
+ return // not a service we know — stay silent (it isn't ours)
+ }
+ pair, err := exchangeViaBroker(p.Service, p.Code, p.State, r.connect.brokerURL, r.connect.brokerToken)
if err != nil {
- r.logf("connect: dnsimple exchange failed: %v", err)
- r.postReply(threadID, key, Reply{Text: "Couldn't finish connecting DNSimple: " + err.Error()})
+ r.logf("connect: %s exchange failed: %v", p.Service, err)
+ r.postReply(threadID, key, Reply{Text: "Couldn't finish connecting " + name + ": " + err.Error()})
return
}
pub := r.senderPublicKey(p.UID)
@@ -125,18 +225,70 @@ func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPay
r.postReply(threadID, key, Reply{Text: "Connected, but I couldn't find your key to hand it back — try again."})
return
}
- sealed, err := e2ee.SealForPub(pub, map[string]string{"secret": token})
+ // The whole pair (access + refresh + expiry, when the provider issues
+ // them) is sealed to the member; a non-expiring provider keeps the legacy
+ // {secret} shape.
+ sealed, err := e2ee.SealForPub(pub, pair.sealable())
if err != nil {
r.logf("connect: seal token: %v", err)
return
}
if err := r.sendPayload(threadID, key, map[string]any{
- "type": "connect.token", "service": "dnsimple", "sealed": sealed, "name": r.Session.Name, "ts": time.Now().UnixMilli(),
+ "type": "connect.token", "service": p.Service, "sealed": sealed, "name": r.Session.Name, "ts": time.Now().UnixMilli(),
}); err != nil {
r.logf("connect.token: %v", err)
}
}
+// refreshGrant rotates the grant's expiring pair via the broker. It refuses
+// to rotate when the fresh pair couldn't be handed back (no resolvable member
+// key) — rotation consumes the stored refresh token, so losing the result
+// would sever the connection outright.
+func (r *Runtime) refreshGrant(g *grant, uid string) (*tokenPair, error) {
+ if uid == "" || r.senderPublicKey(uid) == "" {
+ return nil, fmt.Errorf("that %s connection's token expired and I couldn't hand a fresh one back — reconnect it in Integrations", g.Service)
+ }
+ return r.connect.refreshes.do(g.Refresh, func() (*tokenPair, error) {
+ return refreshViaBroker(g.Service, g.Refresh, r.connect.brokerURL, r.connect.brokerToken)
+ })
+}
+
+// refreshGroup coalesces concurrent refreshes of the SAME credential: refresh
+// tokens are single-use (GitHub rotates one on every call), so two
+// near-simultaneous ops must share one rotation rather than the loser burning
+// a dead token. Nothing is retained once a flight lands — the fresh pair
+// lives only in the results handed to the waiting ops.
+type refreshGroup struct {
+ mu sync.Mutex
+ calls map[string]*refreshCall
+}
+
+type refreshCall struct {
+ done chan struct{}
+ pair *tokenPair
+ err error
+}
+
+func newRefreshGroup() *refreshGroup { return &refreshGroup{calls: map[string]*refreshCall{}} }
+
+func (rg *refreshGroup) do(key string, fn func() (*tokenPair, error)) (*tokenPair, error) {
+ rg.mu.Lock()
+ if c, ok := rg.calls[key]; ok {
+ rg.mu.Unlock()
+ <-c.done
+ return c.pair, c.err
+ }
+ c := &refreshCall{done: make(chan struct{})}
+ rg.calls[key] = c
+ rg.mu.Unlock()
+ c.pair, c.err = fn()
+ close(c.done)
+ rg.mu.Lock()
+ delete(rg.calls, key)
+ rg.mu.Unlock()
+ return c.pair, c.err
+}
+
// senderPublicKey looks up a roster member's public key by user id (to seal the
// OAuth token back to them).
func (r *Runtime) senderPublicKey(uid string) string {
diff --git a/internal/bot/connect_v2_test.go b/internal/bot/connect_v2_test.go
new file mode 100644
index 0000000..c8a4c97
--- /dev/null
+++ b/internal/bot/connect_v2_test.go
@@ -0,0 +1,496 @@
+package bot
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+
+ "lchat/internal/e2ee"
+)
+
+// Tests for the registry-driven connect path (integrations.Execute behind
+// handleConnect): grants carry access read/write, ops come from the embedded
+// manifests, and the reply is the same rid-matched connect.result the legacy
+// dnsimple path sends.
+
+// connectHarness is a Runtime wired to a real (loopback) websocket whose
+// server end decrypts every msg frame with the thread key and hands the
+// payload back — so a test observes exactly what a client in the thread
+// would decrypt, nothing less.
+type connectHarness struct {
+ rt *Runtime
+ key []byte
+ replies chan map[string]any
+}
+
+// newConnectHarness must run AFTER any t.Setenv the test needs, because
+// loadConnectConfig snapshots LCHAT_DNSIMPLE_BASE at construction.
+func newConnectHarness(t *testing.T) *connectHarness {
+ t.Helper()
+ kp, err := e2ee.GenerateUserKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := e2ee.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ replies := make(chan map[string]any, 8)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ c, err := websocket.Accept(w, req, nil)
+ if err != nil {
+ return
+ }
+ defer c.CloseNow()
+ for {
+ _, data, err := c.Read(context.Background())
+ if err != nil {
+ return
+ }
+ var frame struct {
+ Type string `json:"type"`
+ Blob string `json:"blob"`
+ }
+ if json.Unmarshal(data, &frame) != nil || frame.Type != "msg" {
+ continue
+ }
+ var payload map[string]any
+ if e2ee.DecryptJSON(key, frame.Blob, &payload) == nil {
+ replies <- payload
+ }
+ }
+ }))
+ t.Cleanup(srv.Close)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { conn.CloseNow() })
+ rt := &Runtime{
+ Session: &Session{Token: "tok", Name: "Claude", UserPub: kp.PublicKey, UserPriv: kp.PrivateKey},
+ connect: loadConnectConfig(),
+ conn: conn,
+ }
+ return &connectHarness{rt: rt, key: key, replies: replies}
+}
+
+// seal builds a capability grant sealed to the harness bot's own key — the
+// same blob shape a member's client seals (docs/connected-services.md).
+func (h *connectHarness) seal(t *testing.T, g map[string]any) string {
+ t.Helper()
+ sealed, err := e2ee.SealForPub(h.rt.Session.UserPub, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return sealed
+}
+
+func (h *connectHarness) waitReply(t *testing.T) map[string]any {
+ t.Helper()
+ select {
+ case p := <-h.replies:
+ return p
+ case <-time.After(5 * time.Second):
+ t.Fatal("no connect.result arrived")
+ return nil
+ }
+}
+
+// fakeGitHub records the one request the executor sends and answers with a
+// fixed body; the env override points the github manifest at it.
+type fakeGitHub struct {
+ method, uri, auth string
+ body []byte
+ hits int
+}
+
+func newFakeGitHub(t *testing.T, respBody string) *fakeGitHub {
+ t.Helper()
+ f := &fakeGitHub{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ f.hits++
+ f.method = r.Method
+ f.uri = r.URL.RequestURI()
+ f.auth = r.Header.Get("Authorization")
+ f.body, _ = io.ReadAll(r.Body)
+ io.WriteString(w, respBody)
+ }))
+ t.Cleanup(srv.Close)
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", srv.URL)
+ return f
+}
+
+func TestConnectRegistryReadOpWithReadGrant(t *testing.T) {
+ gh := newFakeGitHub(t, `[{"number":1}]`)
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issues.list",
+ Args: map[string]string{"owner": "paul", "repo": "lchat"}, Grant: grant, RID: "r1",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != nil {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if rep["service"] != "github" || rep["op"] != "issues.list" || rep["rid"] != "r1" {
+ t.Errorf("reply envelope: %v", rep)
+ }
+ if rep["data"] != `[{"number":1}]` {
+ t.Errorf("data: %v", rep["data"])
+ }
+ if gh.uri != "/repos/paul/lchat/issues?state=open&per_page=50" {
+ t.Errorf("uri: %q", gh.uri)
+ }
+ if gh.auth != "Bearer ghp_x" {
+ t.Errorf("authorization: %q", gh.auth)
+ }
+}
+
+func TestConnectWriteOpWithReadGrantRefused(t *testing.T) {
+ gh := newFakeGitHub(t, `{}`)
+ h := newConnectHarness(t)
+ // A legacy grant (no access field) is read-only too — same refusal.
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issue.create",
+ Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r2",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != "That authorization is read-only." {
+ t.Errorf("error: %v", rep["error"])
+ }
+ if gh.hits != 0 {
+ t.Errorf("refused write still reached the service (%d hits)", gh.hits)
+ }
+}
+
+func TestConnectWriteOpWithWriteGrant(t *testing.T) {
+ gh := newFakeGitHub(t, `{"number":7}`)
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_w", "exp": 0, "access": "write"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issue.create",
+ Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r3",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != nil {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if rep["data"] != `{"number":7}` {
+ t.Errorf("data: %v", rep["data"])
+ }
+ if gh.method != "POST" || gh.uri != "/repos/paul/lchat/issues" {
+ t.Errorf("request: %s %s", gh.method, gh.uri)
+ }
+ var sent map[string]any
+ if err := json.Unmarshal(gh.body, &sent); err != nil {
+ t.Fatalf("body %q: %v", gh.body, err)
+ }
+ if sent["title"] != "Hi" {
+ t.Errorf("title: %v", sent)
+ }
+ // The optional "body" arg was absent: its pure-placeholder key is dropped.
+ if _, present := sent["body"]; present {
+ t.Errorf("absent optional arg sent: %v", sent)
+ }
+}
+
+func TestConnectExpiredGrant(t *testing.T) {
+ gh := newFakeGitHub(t, `{}`)
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x",
+ "exp": time.Now().Add(-time.Minute).UnixMilli(), "access": "read"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issues.list",
+ Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r4",
+ })
+ rep := h.waitReply(t)
+ if e, _ := rep["error"].(string); !strings.Contains(e, "expired") {
+ t.Errorf("error: %v", rep["error"])
+ }
+ if gh.hits != 0 {
+ t.Errorf("expired grant still reached the service (%d hits)", gh.hits)
+ }
+}
+
+func TestConnectWrongServiceGrant(t *testing.T) {
+ gh := newFakeGitHub(t, `{}`)
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "stripe", "token": "sk_x", "exp": 0, "access": "read"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issues.list",
+ Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r5",
+ })
+ rep := h.waitReply(t)
+ if e, _ := rep["error"].(string); !strings.Contains(e, "stripe") {
+ t.Errorf("error: %v", rep["error"])
+ }
+ if gh.hits != 0 {
+ t.Errorf("wrong-service grant still reached the service (%d hits)", gh.hits)
+ }
+}
+
+func TestConnectUnknownOp(t *testing.T) {
+ gh := newFakeGitHub(t, `{}`)
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "bogus.op", Grant: grant, RID: "r6",
+ })
+ rep := h.waitReply(t)
+ if e, _ := rep["error"].(string); e == "" {
+ t.Errorf("unknown op should error: %v", rep)
+ }
+ if gh.hits != 0 {
+ t.Errorf("unknown op still reached the service (%d hits)", gh.hits)
+ }
+}
+
+func TestConnectOversizedResultRefusedHonestly(t *testing.T) {
+ // A result too big for one encrypted message must come back as an error,
+ // never truncated bytes (broken JSON masquerading as data).
+ var b strings.Builder
+ b.WriteString(`[`)
+ for i := 0; i < 400; i++ {
+ if i > 0 {
+ b.WriteString(",")
+ }
+ b.WriteString(`{"full_name":"` + strings.Repeat("x", 120) + `"}`)
+ }
+ b.WriteString(`]`)
+ newFakeGitHub(t, b.String())
+ h := newConnectHarness(t)
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r8",
+ })
+ rep := h.waitReply(t)
+ if e, _ := rep["error"].(string); !strings.Contains(e, "too large") {
+ t.Errorf("error: %v", rep["error"])
+ }
+ if rep["data"] != nil {
+ t.Errorf("oversized data leaked through: %d bytes", len(rep["data"].(string)))
+ }
+}
+
+// giveRoster points the harness runtime at a fake lchat server whose roster
+// holds one member ("u1" → the returned keypair's public key) — what the
+// refresh path needs to seal a rotated pair back to the member.
+func (h *connectHarness) giveRoster(t *testing.T) e2ee.Keypair {
+ t.Helper()
+ member, err := e2ee.GenerateUserKeypair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/roster" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"roster": []map[string]string{
+ {"user_id": "u1", "public_key": member.PublicKey},
+ }})
+ }))
+ t.Cleanup(srv.Close)
+ h.rt.Base = srv.URL
+ h.rt.client = &http.Client{Timeout: 5 * time.Second}
+ return member
+}
+
+// fakeBroker serves POST /api/refresh with a fixed rotated pair, recording
+// how many rotations ran and what refresh token each used.
+type fakeBroker struct {
+ hits int
+ refresh string
+ respErr bool
+ auth string
+ lastBody map[string]string
+}
+
+func newFakeBroker(t *testing.T) *fakeBroker {
+ t.Helper()
+ f := &fakeBroker{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/refresh" {
+ http.NotFound(w, r)
+ return
+ }
+ f.hits++
+ f.auth = r.Header.Get("Authorization")
+ var body map[string]string
+ json.NewDecoder(r.Body).Decode(&body)
+ f.lastBody = body
+ f.refresh = body["refresh_token"]
+ if f.respErr {
+ http.Error(w, `{"message":"refresh token already used"}`, http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800}`))
+ }))
+ t.Cleanup(srv.Close)
+ t.Setenv("LCHAT_CONNECT_BROKER_URL", srv.URL)
+ t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "btok")
+ return f
+}
+
+// An expired pair is rotated BEFORE the provider is called: the op runs with
+// the fresh access token, and the whole new pair rides back on the result,
+// sealed to the member (never kept by the bot).
+func TestConnectRefreshesExpiredTokenBeforeOp(t *testing.T) {
+ var gotAuth []string
+ gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = append(gotAuth, r.Header.Get("Authorization"))
+ io.WriteString(w, `[{"number":1}]`)
+ }))
+ t.Cleanup(gh.Close)
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL)
+ broker := newFakeBroker(t)
+ h := newConnectHarness(t) // after Setenv — the config snapshots the broker env
+ member := h.giveRoster(t)
+
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read",
+ "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "issues.list",
+ Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r9", UID: "u1",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != nil {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if broker.hits != 1 || broker.refresh != "ghr_old" || broker.auth != "Bearer btok" {
+ t.Fatalf("broker saw hits=%d refresh=%q auth=%q", broker.hits, broker.refresh, broker.auth)
+ }
+ if len(gotAuth) != 1 || gotAuth[0] != "Bearer ghu_new" {
+ t.Fatalf("provider auth: %v (stale token must never be sent)", gotAuth)
+ }
+ sealed, _ := rep["renewed"].(string)
+ if sealed == "" {
+ t.Fatal("result carries no renewed pair")
+ }
+ var renewed struct {
+ Secret string `json:"secret"`
+ Refresh string `json:"refresh"`
+ ExpiresAt int64 `json:"expires_at"`
+ }
+ if err := e2ee.OpenSealed(sealed, member.PrivateKey, &renewed); err != nil {
+ t.Fatalf("renewed pair not sealed to the member: %v", err)
+ }
+ if renewed.Secret != "ghu_new" || renewed.Refresh != "ghr_new" || renewed.ExpiresAt <= time.Now().UnixMilli() {
+ t.Fatalf("renewed pair: %+v", renewed)
+ }
+}
+
+// A 401 on a supposedly-live token (revoked and reissued, clock skew) rotates
+// once and retries; a second 401 does NOT loop.
+func TestConnectRefreshesOn401AndRetriesOnce(t *testing.T) {
+ var gotAuth []string
+ gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = append(gotAuth, r.Header.Get("Authorization"))
+ if r.Header.Get("Authorization") != "Bearer ghu_new" {
+ w.WriteHeader(http.StatusUnauthorized)
+ io.WriteString(w, `{"message":"Bad credentials"}`)
+ return
+ }
+ io.WriteString(w, `[]`)
+ }))
+ t.Cleanup(gh.Close)
+ t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL)
+ broker := newFakeBroker(t)
+ h := newConnectHarness(t)
+ h.giveRoster(t)
+
+ // The stamp says the token is alive — the 401 is the surprise.
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_revoked", "exp": 0, "access": "read",
+ "refresh": "ghr_old", "token_expires_at": time.Now().Add(time.Hour).UnixMilli()})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r10", UID: "u1",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != nil {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if broker.hits != 1 {
+ t.Fatalf("broker hits = %d, want exactly 1", broker.hits)
+ }
+ if len(gotAuth) != 2 || gotAuth[0] != "Bearer ghu_revoked" || gotAuth[1] != "Bearer ghu_new" {
+ t.Fatalf("provider auth sequence: %v", gotAuth)
+ }
+ if rep["renewed"] == nil {
+ t.Fatal("retried op must still hand the renewed pair back")
+ }
+}
+
+// Rotation consumes the single-use refresh token, so it must be REFUSED when
+// the fresh pair couldn't be handed back (no resolvable member key) — better
+// a failed op than a silently severed connection.
+func TestConnectRefusesRefreshWithoutMemberKey(t *testing.T) {
+ gh := newFakeGitHub(t, `[]`)
+ broker := newFakeBroker(t)
+ h := newConnectHarness(t)
+ h.giveRoster(t) // roster only knows "u1"
+
+ grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read",
+ "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r11", UID: "u-unknown",
+ })
+ rep := h.waitReply(t)
+ if e, _ := rep["error"].(string); !strings.Contains(e, "reconnect") {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if broker.hits != 0 {
+ t.Fatalf("refresh ran anyway (%d broker hits) — the pair is now lost", broker.hits)
+ }
+ if gh.hits != 0 {
+ t.Fatalf("expired token still reached the provider (%d hits)", gh.hits)
+ }
+}
+
+func TestConnectLegacyDNSimpleListStillWorks(t *testing.T) {
+ // The manifest's dnsimple op is "domains.list" — bare "list" is the
+ // legacy DNSimple-app protocol and must keep flowing through the old
+ // dnsimpleClient path (whoami → domains, price lookups best-effort).
+ dns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.URL.Path == "/v2/whoami":
+ io.WriteString(w, `{"data":{"account":{"id":1}}}`)
+ case r.URL.Path == "/v2/1/domains":
+ io.WriteString(w, `{"data":[{"name":"example.com","state":"registered","auto_renew":true,"expires_on":"2027-01-01","account_id":1}]}`)
+ default:
+ http.NotFound(w, r) // prices: best-effort, a 404 leaves price nil
+ }
+ }))
+ t.Cleanup(dns.Close)
+ t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL)
+ h := newConnectHarness(t) // after Setenv: the config snapshots the base
+ grant := h.seal(t, map[string]any{"service": "dnsimple", "token": "dns_x", "exp": 0})
+ h.rt.handleConnect("t1", h.key, &connectPayload{
+ Type: "connect.op", Service: "dnsimple", Op: "list", Grant: grant, RID: "r7",
+ })
+ rep := h.waitReply(t)
+ if rep["error"] != nil {
+ t.Fatalf("error: %v", rep["error"])
+ }
+ if rep["service"] != "dnsimple" || rep["op"] != "list" || rep["rid"] != "r7" {
+ t.Errorf("reply envelope: %v", rep)
+ }
+ domains, ok := rep["domains"].([]any)
+ if !ok || len(domains) != 1 {
+ t.Fatalf("domains: %v", rep["domains"])
+ }
+ if d, _ := domains[0].(map[string]any); d["name"] != "example.com" {
+ t.Errorf("domain row: %v", domains[0])
+ }
+}
diff --git a/internal/bot/dnsimple.go b/internal/bot/dnsimple.go
index b97dce1..0dc2837 100644
--- a/internal/bot/dnsimple.go
+++ b/internal/bot/dnsimple.go
@@ -159,38 +159,72 @@ func (c *dnsimpleClient) listDomains(token string) ([]Domain, error) {
return rows, nil
}
-// exchangeViaBroker swaps an authorization code for an access token via the
-// connect broker (docs/connected-services.md). DNSimple is confidential-only,
-// so the exchange needs the client_secret, which lives ONLY on the broker; this
-// sidecar authenticates with a per-instance bearer token and gets back just the
-// user's access token (which the caller seals to the member and drops).
-func (c *dnsimpleClient) exchangeViaBroker(code, state, brokerURL, brokerToken string) (string, error) {
+// tokenPair is what the broker's exchange/refresh answer: an access token,
+// and — for providers with expiring user tokens (GitHub Apps) — the rotated
+// refresh token and lifetime.
+type tokenPair struct {
+ Access string `json:"access_token"`
+ Refresh string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"` // seconds; 0 = non-expiring
+}
+
+// sealable renders the pair as the member-blob shape (docs/integrations.md):
+// what the client stores sealed to its own key and what a renewed pair rides
+// back as. Absent fields are omitted so non-expiring providers keep the
+// legacy {secret} shape.
+func (p *tokenPair) sealable() map[string]any {
+ out := map[string]any{"secret": p.Access}
+ if p.Refresh != "" {
+ out["refresh"] = p.Refresh
+ }
+ if p.ExpiresIn > 0 {
+ out["expires_at"] = time.Now().UnixMilli() + p.ExpiresIn*1000
+ }
+ return out
+}
+
+// exchangeViaBroker swaps an authorization code for a token pair via the
+// connect broker (docs/connected-services.md), for any service the broker has
+// an OAuth app for; refreshViaBroker rotates an expiring pair the same way.
+// These providers are confidential clients — both calls need the
+// client_secret, which lives ONLY on the broker; this sidecar authenticates
+// with a per-instance bearer token and gets back just the member's tokens
+// (which the caller seals to the member and drops).
+func exchangeViaBroker(service, code, state, brokerURL, brokerToken string) (*tokenPair, error) {
+ return brokerTokenCall("/api/exchange", map[string]string{"service": service, "code": code, "state": state}, brokerURL, brokerToken)
+}
+
+func refreshViaBroker(service, refresh, brokerURL, brokerToken string) (*tokenPair, error) {
+ return brokerTokenCall("/api/refresh", map[string]string{"service": service, "refresh_token": refresh}, brokerURL, brokerToken)
+}
+
+func brokerTokenCall(path string, payload map[string]string, brokerURL, brokerToken string) (*tokenPair, error) {
if brokerURL == "" || brokerToken == "" {
- return "", fmt.Errorf("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)")
+ return nil, fmt.Errorf("connect broker not configured on this sidecar (set LCHAT_CONNECT_BROKER_URL/_TOKEN)")
}
- body, _ := json.Marshal(map[string]string{"code": code, "state": state})
- req, err := http.NewRequest("POST", strings.TrimRight(brokerURL, "/")+"/api/exchange", bytes.NewReader(body))
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest("POST", strings.TrimRight(brokerURL, "/")+path, bytes.NewReader(body))
if err != nil {
- return "", err
+ return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+brokerToken)
- resp, err := c.http.Do(req)
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
- return "", err
+ return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var d struct {
- AccessToken string `json:"access_token"`
- Message string `json:"message"`
+ tokenPair
+ Message string `json:"message"`
}
_ = json.Unmarshal(raw, &d)
- if resp.StatusCode/100 != 2 || d.AccessToken == "" {
+ if resp.StatusCode/100 != 2 || d.Access == "" {
if d.Message != "" {
- return "", fmt.Errorf("%s", d.Message)
+ return nil, fmt.Errorf("%s", d.Message)
}
- return "", fmt.Errorf("broker exchange %d", resp.StatusCode)
+ return nil, fmt.Errorf("broker %s %d", strings.TrimPrefix(path, "/api/"), resp.StatusCode)
}
- return d.AccessToken, nil
+ return &d.tokenPair, nil
}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index ad0ab81..dab0481 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -514,7 +514,7 @@ public final class ThreadStore: ObservableObject {
nonisolated private static let appTitles: [String: String] = [
"todo": "To-do", "remind": "Reminders", "poll": "Poll",
"bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat",
- "connect": "Integrations", "dnsimple": "DNSimple", "admin": "Admin",
+ "connect": "Integrations", "dnsimple": "DNSimple", "github": "GitHub", "admin": "Admin",
]
// Each app's emoji face — the sidebar/list avatar, mirroring the `glyph`
@@ -523,7 +523,7 @@ public final class ThreadStore: ObservableObject {
nonisolated private static let appGlyphs: [String: String] = [
"todo": "✓", "remind": "⏰", "poll": "📊",
"bookmarks": "🔖", "board": "🎨", "chat": "💬", "admin": "🛠️",
- "connect": "🔌", "dnsimple": "🌐",
+ "connect": "🔌", "dnsimple": "🌐", "github": "🐙",
]
// A one-line "what you'd do here" blurb, shown as the sidebar subtitle for an
diff --git a/main.go b/main.go
index 46e6f35..88f2625 100644
--- a/main.go
+++ b/main.go
@@ -326,6 +326,9 @@ func (s *Server) handler() http.Handler {
// same imports as a compiled-in app. See customapps.go for the trust model.
mux.Handle("GET /static/apps-custom/{name}", noCache(http.HandlerFunc(s.serveCustomApp)))
mux.HandleFunc("GET /api/apps", s.getCustomApps)
+ // The integrations registry (public, like /api/apps): which services can
+ // be connected and exactly what each op may do. See integrations_api.go.
+ mux.HandleFunc("GET /api/integrations", s.getIntegrations)
// Installing/removing custom apps is admin-only, like the rest of /api/admin.
mux.HandleFunc("GET /api/admin/apps", s.listCustomAppsHandler)
mux.HandleFunc("PUT /api/admin/apps/{name}", s.putCustomAppHandler)
diff --git a/package.json b/package.json
index 6fe6a5f..abc1b47 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/event-vectors.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/event-vectors.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/github.test.mjs b/test/github.test.mjs
new file mode 100644
index 0000000..73add2d
--- /dev/null
+++ b/test/github.test.mjs
@@ -0,0 +1,93 @@
+// Fold tests for the GitHub companion app's reducers (pure, no DOM):
+// pick-projected rows normalise into plain fields, malformed rows drop, the
+// per-repo issues map stays bounded. node test/github.test.mjs
+import assert from "node:assert/strict";
+import { app } from "../web/static/apps/github.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} — ${e.message.split("\n")[0]}`);
+ }
+}
+
+const initial = () => app.reduce(undefined, { type: "noop" }) || { repos: [], fetchedAt: 0, issues: {} };
+const fold = (state, event) => app.reduce(state, event);
+
+await test("github.repos replaces the list and normalises projected rows", () => {
+ const s = fold(
+ { repos: [{ full_name: "old/gone" }], fetchedAt: 1, issues: {} },
+ {
+ type: "github.repos",
+ ts: 42,
+ repos: [
+ { full_name: "paul/lchat", description: "chat", private: true, archived: false, fork: false, html_url: "https://github.com/paul/lchat", open_issues_count: 3, stargazers_count: 7, pushed_at: "2026-07-01T00:00:00Z" },
+ { full_name: "paul/min" }, // sparse row survives with defaults
+ { description: "no name — dropped" },
+ "not-an-object",
+ ],
+ },
+ );
+ assert.equal(s.repos.length, 2);
+ assert.deepEqual(s.repos[0], {
+ full_name: "paul/lchat",
+ description: "chat",
+ private: true,
+ archived: false,
+ fork: false,
+ html_url: "https://github.com/paul/lchat",
+ open_issues: 3,
+ stars: 7,
+ pushed_at: "2026-07-01T00:00:00Z",
+ });
+ assert.equal(s.repos[1].full_name, "paul/min");
+ assert.equal(s.repos[1].open_issues, null); // absent count reads "—", never 0
+ assert.equal(s.fetchedAt, 42);
+});
+
+await test("a malformed github.repos folds to a no-op", () => {
+ const before = { repos: [{ full_name: "keep/me" }], fetchedAt: 5, issues: {} };
+ assert.equal(fold(before, { type: "github.repos", repos: "nope" }), before);
+});
+
+await test("github.issues normalises flattened dot-path keys into plain fields", () => {
+ const s = fold(initial(), {
+ type: "github.issues",
+ repo: "paul/lchat",
+ ts: 9,
+ issues: [
+ { number: 12, title: "Fix login", html_url: "https://github.com/paul/lchat/issues/12", "user.login": "amy", comments: 4, updated_at: "2026-07-08T00:00:00Z" },
+ { number: 13, title: "Ship it", "pull_request.url": "https://api.github.com/x", draft: true },
+ { title: "no number — dropped" },
+ ],
+ });
+ const items = s.issues["paul/lchat"].items;
+ assert.equal(items.length, 2);
+ assert.equal(items[0].by, "amy");
+ assert.equal(items[0].is_pr, false);
+ assert.equal(items[1].is_pr, true);
+ assert.equal(items[1].draft, true);
+});
+
+await test("the issues map is trimmed to the most recently fetched repos", () => {
+ let s = initial();
+ for (let i = 0; i < 9; i++) {
+ s = fold(s, { type: "github.issues", repo: `o/r${i}`, ts: i + 1, issues: [{ number: 1, title: "t" }] });
+ }
+ const keys = Object.keys(s.issues);
+ assert.equal(keys.length, 6); // bounded — the snapshot can't balloon
+ assert.ok(!keys.includes("o/r0") && !keys.includes("o/r1") && !keys.includes("o/r2")); // oldest dropped
+ assert.ok(keys.includes("o/r8"));
+});
+
+await test("the app is gated on the github connected service", () => {
+ assert.equal(app.requiresService, "github");
+ assert.deepEqual([...app.types].sort(), ["github.issues", "github.repos"]);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/serviceop.test.mjs b/test/serviceop.test.mjs
new file mode 100644
index 0000000..1a876bc
--- /dev/null
+++ b/test/serviceop.test.mjs
@@ -0,0 +1,101 @@
+// Tests for the service_op consent chip's sanitize bounds (ui.js) — the client
+// whitelist gate every registry-op chip must pass. node test/serviceop.test.mjs
+import assert from "node:assert/strict";
+import { sanitizeActions, CHIP_TYPES } from "../web/static/ui.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} — ${e.message.split("\n")[0]}`);
+ }
+}
+
+const valid = () => ({
+ type: "service_op",
+ service: "dnsimple",
+ op: "domain.check",
+ access: "read",
+ args: { account: "12345", domain: "blip.com" },
+ label: "Check blip.com",
+});
+
+await test("service_op is a whitelisted chip type", () => {
+ assert.ok(CHIP_TYPES.service_op);
+ assert.equal(typeof CHIP_TYPES.service_op.sanitize, "function");
+});
+
+await test("a valid chip passes with service/op/access/args copied", () => {
+ const [chip] = sanitizeActions([valid()]);
+ assert.deepEqual(chip, {
+ type: "service_op",
+ service: "dnsimple",
+ op: "domain.check",
+ access: "read",
+ args: { account: "12345", domain: "blip.com" },
+ label: "Check blip.com",
+ });
+});
+
+await test("a write op passes and keeps access=write", () => {
+ const a = { ...valid(), op: "domain.register", access: "write", args: { domain: "blip.com" } };
+ const [chip] = sanitizeActions([a]);
+ assert.equal(chip.access, "write");
+ assert.equal(chip.op, "domain.register");
+});
+
+await test("missing args defaults to an empty object", () => {
+ const a = valid();
+ delete a.args;
+ const [chip] = sanitizeActions([a]);
+ assert.deepEqual(chip.args, {});
+});
+
+await test("bad service slugs are rejected", () => {
+ for (const service of ["DNSimple", "dns imple", "dns/../imple", "", 42, "a".repeat(65)]) {
+ assert.deepEqual(sanitizeActions([{ ...valid(), service }]), [], `service=${JSON.stringify(service)}`);
+ }
+});
+
+await test("bad op ids are rejected", () => {
+ for (const op of ["Domain.Check", ".check", "domain.", "domain..check", "domain check", "", 7, "a".repeat(65)]) {
+ assert.deepEqual(sanitizeActions([{ ...valid(), op }]), [], `op=${JSON.stringify(op)}`);
+ }
+});
+
+await test("bad access is rejected", () => {
+ for (const access of ["admin", "READ", "", undefined, null]) {
+ assert.deepEqual(sanitizeActions([{ ...valid(), access }]), [], `access=${JSON.stringify(access)}`);
+ }
+});
+
+await test("non-string arg values are rejected", () => {
+ for (const args of [{ n: 5 }, { ok: true }, { deep: { a: "b" } }, { fn: null }, ["a"], "str"]) {
+ assert.deepEqual(sanitizeActions([{ ...valid(), args }]), [], `args=${JSON.stringify(args)}`);
+ }
+});
+
+await test("oversized args are rejected", () => {
+ const many = Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`k${i}`, "v"]));
+ assert.deepEqual(sanitizeActions([{ ...valid(), args: many }]), [], "17 keys");
+ assert.deepEqual(sanitizeActions([{ ...valid(), args: { big: "x".repeat(5000) } }]), [], "stringified > 4096");
+});
+
+await test("label is copied, with room for a whole consent line (capped at 160)", () => {
+ // A write's confirm sentence ("Register blip.com? This buys the domain and
+ // costs real money.") is longer than a normal chip label — service_op gives
+ // it room so consent text never cuts mid-warning, but still bounds it.
+ const [chip] = sanitizeActions([{ ...valid(), label: " " + "L".repeat(200) }]);
+ assert.equal(chip.label, "L".repeat(160));
+ const [bare] = sanitizeActions([{ ...valid(), label: undefined }]);
+ assert.equal(bare.label, undefined); // shell.js falls back to "Run <op> on <service>"
+ // Other chip types keep the tight 40-char cap.
+ const [plain] = sanitizeActions([{ type: "open_profile", label: "T".repeat(80) }]);
+ assert.equal(plain.label, "T".repeat(40));
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index 5e166ac..3c09594 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -1,18 +1,56 @@
// Integrations — the `connect` app (prototype) — docs/connected-services.md.
//
-// 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
+// A registry-driven catalog for linking third-party accounts so a bot can act
+// for you. Each service comes from the integrations registry (one reviewed
+// manifest per service, served at /api/integrations) and offers two ways in:
+// authorize on the provider's own site (OAuth PKCE — no key pasted, no client
+// secret exists), or paste a scoped token. Either way the credential is sealed
+// in your browser 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).
//
// Command: `/connect` opens this surface. (Listing domains is not a generic
// verb owned here — it belongs to the service you connected: the DNSimple app
// owns a context-local `/list` that only works inside its own thread.)
-import { defineApp } from "../apps.js";
+import { defineApp, appByName, openAppThread } from "../apps.js";
const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
+// When the registry can't be fetched, render what the surface always offered —
+// the DNSimple row — so nothing regresses on an old server or a flaky load.
+const FALLBACK_CATALOG = [
+ {
+ id: "dnsimple",
+ name: "DNSimple",
+ category: "domains",
+ auth: {
+ kind: "both",
+ pat_help: "https://support.dnsimple.com/articles/api-access-token/ — create a token scoped to the domains you want managed",
+ },
+ ops: [],
+ },
+];
+
+// The first http(s) URL inside a manifest's pat_help (the field is prose that
+// leads with the link). Only http(s) ever becomes an href.
+function patHelpURL(svc) {
+ const m = /https?:\/\/[^\s]+/.exec((svc.auth && svc.auth.pat_help) || "");
+ return m ? m[0] : "";
+}
+
+// One muted line of what connecting allows, straight from the manifest's closed
+// op list: "Reads: List domains, Check availability · Writes (always
+// confirmed): Register a domain".
+function opsSummary(svc) {
+ const label = (o) => o.label || o.id;
+ const reads = (svc.ops || []).filter((o) => o.access === "read").map(label);
+ const writes = (svc.ops || []).filter((o) => o.access === "write").map(label);
+ const parts = [];
+ if (reads.length) parts.push(`Reads: ${reads.join(", ")}`);
+ if (writes.length) parts.push(`Writes (always confirmed): ${writes.join(", ")}`);
+ return parts.join(" · ");
+}
+
export const app = defineApp({
name: "connect",
title: "Integrations",
@@ -46,21 +84,12 @@ export const app = defineApp({
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.";
+ sub.textContent = "Link an account so a bot can act for you — authorize on the provider's own site, or paste a scoped token. Either way the key is sealed in your browser; 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 list = document.createElement("div");
+ list.className = "connect-list";
+ wrap.appendChild(list);
const note = document.createElement("div");
note.className = "connect-note";
@@ -68,61 +97,232 @@ export const app = defineApp({
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;
+ let busy = false; // an OAuth start in flight
+ let catalog; // undefined until fetched; null/[] = fetch failed → fallback
+
+ // Whether a service can actually start an OAuth flow here (manifest says
+ // oauth-capable AND the broker has an app registered). null = unknown yet;
+ // the OAuth button renders only once this confirms, so a service whose app
+ // isn't registered offers paste-a-token alone instead of a dead button.
+ const oauthOK = {};
+ const oauthCapable = (svc) => {
+ const kind = (svc.auth && svc.auth.kind) || "";
+ return kind === "oauth" || kind === "both";
+ };
+
+ // One catalog row: name · category · status, then the action buttons and
+ // the (collapsed) paste-a-token detail. All text via textContent.
+ function buildRow(svc, on, pending) {
+ const item = document.createElement("div");
+ item.className = "connect-item";
+
+ const row = document.createElement("div");
+ row.className = "connect-row";
+ const name = document.createElement("span");
+ name.className = "connect-name";
+ name.textContent = svc.name || svc.id;
+ const cat = document.createElement("span");
+ cat.className = "connect-cat";
+ cat.textContent = svc.category || "";
+ const status = document.createElement("span");
+ status.className = "connect-status";
+ status.textContent = pending ? "Connecting…" : on ? "Connected ✓" : "Not connected";
+ status.classList.toggle("on", on && !pending);
+ row.append(name, cat, status);
+ item.appendChild(row);
+
+ const actions = document.createElement("div");
+ actions.className = "connect-actions";
+ item.appendChild(actions);
+
+ const detail = document.createElement("div");
+ detail.className = "connect-detail";
+ detail.hidden = true;
+ item.appendChild(detail);
+
+ const rowNote = document.createElement("div");
+ rowNote.className = "connect-rownote";
+ item.appendChild(rowNote);
+
+ // The golden path: authorize on the provider's own site whenever the
+ // broker has an OAuth app for this service (oauthOK, confirmed against
+ // the broker's config) — pasting a token is the fallback below.
+ if (!on && oauthOK[svc.id]) {
+ const oauthBtn = document.createElement("button");
+ oauthBtn.type = "button";
+ oauthBtn.className = "connect-btn";
+ oauthBtn.textContent = busy || pending ? "Connecting…" : `Connect with ${svc.name || svc.id}`;
+ oauthBtn.disabled = busy || pending;
+ oauthBtn.onclick = async () => {
+ if (busy || pending) return;
+ note.textContent = "";
+ busy = true;
+ paint();
+ const res = await host.startOAuth(svc.id);
+ busy = false;
+ paint();
+ if (!res.ok) note.textContent = res.error || "Couldn't start connecting.";
+ };
+ actions.appendChild(oauthBtn);
}
- // 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;
+
+ // Connected, and the service has a companion app (DNSimple's domains,
+ // GitHub's repos): the primary action becomes going there — the whole
+ // point of connecting. Created on first use, like any personal app.
+ if (on && appByName(svc.id)) {
+ const openBtn = document.createElement("button");
+ openBtn.type = "button";
+ openBtn.className = "connect-btn";
+ openBtn.textContent = `Open ${svc.name || svc.id}`;
+ openBtn.onclick = () => openAppThread(svc.id);
+ actions.appendChild(openBtn);
}
- 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) {
+
+ // Every service takes a pasted token — sealed to your own key by the
+ // host bridge before it leaves this page (the same storage path the
+ // OAuth completion uses).
+ const pasteBtn = document.createElement("button");
+ pasteBtn.type = "button";
+ pasteBtn.className = "connect-btn connect-secondary";
+ pasteBtn.textContent = on ? "Replace token" : "Paste a token";
+ pasteBtn.onclick = () => {
+ detail.hidden = !detail.hidden;
+ if (!detail.hidden) input.focus();
+ };
+ actions.appendChild(pasteBtn);
+
+ const tokenRow = document.createElement("div");
+ tokenRow.className = "connect-tokenrow";
+ const input = document.createElement("input");
+ input.type = "password";
+ input.className = "connect-input";
+ input.placeholder = `Paste your ${svc.name || svc.id} token`;
+ input.autocomplete = "off";
+ const save = document.createElement("button");
+ save.type = "button";
+ save.className = "connect-btn";
+ save.textContent = "Save";
+ save.onclick = async () => {
+ const v = input.value.trim();
+ if (!v) {
+ rowNote.textContent = "Paste a token first.";
+ return;
+ }
+ rowNote.textContent = "";
+ save.disabled = true;
+ save.textContent = "Saving…";
+ const res = await host.store(svc.id, v);
+ save.disabled = false;
+ save.textContent = "Save";
+ if (!res.ok) {
+ rowNote.textContent = res.error || "Couldn't save the token.";
+ return;
+ }
+ input.value = "";
+ note.textContent = `${svc.name || svc.id} connected — your token was sealed in your browser; the server can't read it.`;
+ paint();
+ // Best-effort verification: when the manifest declares a "viewer" read
+ // op (whoami-style), run it once through the bot so a typo'd token
+ // surfaces NOW, not on first real use. The token stays saved either
+ // way (they can Replace it); no bot online just means "couldn't check".
+ const viewer = (svc.ops || []).find((o) => o.id === "viewer" && o.access === "read");
+ if (viewer && host.requestOp) {
+ const check = await host.requestOp(svc.id, "viewer", {});
+ let who = "";
+ if (check.ok && typeof check.data === "string") {
+ try {
+ const d = JSON.parse(check.data);
+ who = d && (d.login || d.name) ? ` as ${d.login || d.name}` : "";
+ } catch {}
+ }
+ note.textContent = check.ok
+ ? `${svc.name || svc.id} connected${who} ✓ — your token was sealed in your browser; the server can't read it.`
+ : `${svc.name || svc.id} token saved, but it didn't verify: ${check.error || "no response"}. Double-check the token (Replace token to swap it).`;
+ }
+ };
+ tokenRow.append(input, save);
+ detail.appendChild(tokenRow);
+
+ const helpURL = patHelpURL(svc);
+ if (helpURL) {
+ const help = document.createElement("a");
+ help.className = "connect-help";
+ help.href = helpURL;
+ help.target = "_blank";
+ help.rel = "noopener noreferrer";
+ help.textContent = "Create a scoped token";
+ detail.appendChild(help);
+ }
+ const sum = opsSummary(svc);
+ if (sum) {
+ const ops = document.createElement("div");
+ ops.className = "connect-ops";
+ ops.textContent = sum;
+ detail.appendChild(ops);
+ }
+
+ if (on) {
+ const disc = document.createElement("button");
+ disc.type = "button";
+ disc.className = "connect-btn connect-cancel";
+ disc.textContent = "Disconnect";
+ disc.onclick = () => {
// Confirm first — disconnecting removes the stored token. Inline (no
// floating dialog), per the house style: the prompt occupies layout.
- note.textContent = "";
+ rowNote.textContent = "";
const confirm = document.createElement("div");
confirm.className = "connect-confirm";
const q = document.createElement("span");
- q.textContent = "Disconnect DNSimple? Your saved token will be removed from this server.";
+ q.textContent = `Disconnect ${svc.name || svc.id}? Your saved token will be removed from this server.`;
const yes = document.createElement("button");
yes.type = "button";
yes.className = "connect-btn connect-danger";
yes.textContent = "Disconnect";
- yes.onclick = () => host.disconnect("dnsimple"); // onChange repaints + notes on completion
+ yes.onclick = () => host.disconnect(svc.id); // onChange repaints + notes on completion
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "connect-btn connect-cancel";
cancel.textContent = "Cancel";
- cancel.onclick = () => (note.textContent = "");
+ cancel.onclick = () => (rowNote.textContent = "");
confirm.append(q, yes, cancel);
- note.appendChild(confirm);
- 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.";
- };
+ rowNote.appendChild(confirm);
+ };
+ actions.appendChild(disc);
+ }
+
+ return item;
+ }
+
+ // Repaints rebuild the list; a sequence counter keeps an older in-flight
+ // paint from appending duplicate rows over a newer one.
+ let paintSeq = 0;
+ async function paint() {
+ const seq = ++paintSeq;
+ if (!host) {
+ list.textContent = "";
+ const p = document.createElement("div");
+ p.className = "connect-ops";
+ p.textContent = "Connecting services isn't available here yet.";
+ list.appendChild(p);
+ return;
+ }
+ if (catalog === undefined) catalog = host.catalog ? await host.catalog() : null;
+ const connected = await host.list();
+ const services = catalog && catalog.length ? catalog : FALLBACK_CATALOG;
+ // Resolve which oauth-capable services the broker actually offers (each
+ // answer is cached by the bridge, so repaints are cheap) — and warm the
+ // config so a Connect click can open the consent page directly.
+ await Promise.all(
+ services.filter(oauthCapable).map(async (svc) => {
+ if (oauthOK[svc.id] === undefined && host.oauthReady) oauthOK[svc.id] = await host.oauthReady(svc.id);
+ }),
+ );
+ if (seq !== paintSeq) return; // superseded
+ // Mid-exchange (just came back from consent): that service's row shows a
+ // loading state, not a flash of "Not connected".
+ const pendingSvc = host.pending ? host.pending() : "";
+ list.textContent = "";
+ for (const svc of services) list.appendChild(buildRow(svc, connected.includes(svc.id), pendingSvc === svc.id && !connected.includes(svc.id)));
}
paint();
@@ -141,12 +341,24 @@ export const app = defineApp({
.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-list { display: flex; flex-direction: column; gap: 12px; }
+ .connect-item { padding: 14px 16px; border: 1px solid var(--line, #e3e3e8); border-radius: 12px; }
+ .connect-row { display: flex; align-items: center; gap: 10px; }
.connect-name { font-weight: 600; }
+ .connect-cat { font-size: 12px; color: var(--muted, #889); text-transform: capitalize; }
.connect-status { margin-left: auto; font-size: 13px; color: var(--muted, #889); }
.connect-status.on { color: #1a8f4c; }
+ .connect-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.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-secondary { background: transparent; color: var(--accent, #2f6fed); border: 1px solid var(--line, #e3e3e8); }
+ .connect-detail { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
+ .connect-tokenrow { display: flex; gap: 8px; }
+ .connect-input { flex: 1; min-width: 0; padding: 8px 10px; border: 1px solid var(--line, #e3e3e8); border-radius: 9px; font: inherit; background: transparent; color: inherit; }
+ .connect-help { font-size: 13px; color: var(--muted, #667); }
+ .connect-ops { font-size: 13px; line-height: 1.4; color: var(--muted, #667); }
+ .connect-rownote { margin-top: 8px; font-size: 13px; color: var(--muted, #667); }
+ .connect-rownote:empty { margin-top: 0; }
.connect-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted, #667); }
.connect-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.connect-confirm span { flex: 1 1 200px; color: var(--ink, #222); }
diff --git a/web/static/apps/github.js b/web/static/apps/github.js
new file mode 100644
index 0000000..4575127
--- /dev/null
+++ b/web/static/apps/github.js
@@ -0,0 +1,599 @@
+// The GitHub companion app (docs/connected-services.md § companion apps,
+// docs/integrations.md). Once you've linked GitHub in Integrations (a
+// fine-grained PAT), this app "installs": it appears in New chat and opens as
+// its own thread — your repositories, with a drill-in to any repo's open
+// issues and a form to open one.
+//
+// There is NO GitHub sidecar and no new bot: every fetch goes through the host
+// bridge (ctx.host.connected.requestOp), which seals your token to the
+// instance's EXISTING bot for one op — read-scoped for lists, write-scoped
+// only when you submit the new-issue form — and the bot answers with a
+// non-rendering connect.result. What comes back is bounded by the github
+// manifest's `pick` projection (integrations/registry/), so only the reviewed
+// fields ever ride into the thread. The server relays ciphertext throughout.
+//
+// It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so repo
+// and issue data never echoes into chat bubbles, previews, or notifications.
+import { defineApp } from "../apps.js";
+
+// Results arrive as pick-projected rows with flattened dot-path keys
+// ("user.login") — normalise to plain fields once, at fold time, and tolerate
+// anything malformed by dropping it.
+const normalizeRepos = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((r) => r && typeof r.full_name === "string")
+ .map((r) => ({
+ full_name: r.full_name,
+ description: typeof r.description === "string" ? r.description : "",
+ private: !!r.private,
+ archived: !!r.archived,
+ fork: !!r.fork,
+ html_url: typeof r.html_url === "string" ? r.html_url : "",
+ open_issues: typeof r.open_issues_count === "number" ? r.open_issues_count : null,
+ stars: typeof r.stargazers_count === "number" ? r.stargazers_count : null,
+ pushed_at: typeof r.pushed_at === "string" ? r.pushed_at : "",
+ }));
+
+const normalizeIssues = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((i) => i && typeof i.number === "number" && typeof i.title === "string")
+ .map((i) => ({
+ number: i.number,
+ title: i.title,
+ html_url: typeof i.html_url === "string" ? i.html_url : "",
+ by: typeof i["user.login"] === "string" ? i["user.login"] : "",
+ comments: typeof i.comments === "number" ? i.comments : 0,
+ updated_at: typeof i.updated_at === "string" ? i.updated_at : "",
+ is_pr: !!i["pull_request.url"],
+ draft: !!i.draft,
+ }));
+
+// "3d ago"-style relative stamp for pushed_at/updated_at; "" when unparsable.
+function ago(iso) {
+ const t = Date.parse(iso || "");
+ if (!t) return "";
+ const s = Math.max(0, (Date.now() - t) / 1000);
+ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
+ const d = new Date(t);
+ return d.toISOString().slice(0, 10);
+}
+
+// Bound the folded issues map: keep the most recently fetched handful of
+// repos so the snapshot never balloons (the repo list itself is one array).
+const MAX_ISSUE_REPOS = 6;
+function trimIssues(issues) {
+ const keys = Object.keys(issues);
+ if (keys.length <= MAX_ISSUE_REPOS) return issues;
+ keys.sort((a, b) => (issues[b].fetchedAt || 0) - (issues[a].fetchedAt || 0));
+ const out = {};
+ for (const k of keys.slice(0, MAX_ISSUE_REPOS)) out[k] = issues[k];
+ return out;
+}
+
+// Module-level UI state (transient to the surface, never folded): the open
+// repo (null = repo list), the live filter, and in-flight flags.
+let openRepo = null;
+let filterText = "";
+let busy = false;
+let autoLoading = false;
+
+export const app = defineApp({
+ name: "github",
+ title: "GitHub",
+ glyph: "🐙",
+ scope: "personal",
+ // Only listed in New chat once GitHub is linked (shell.js gates on this).
+ requiresService: "github",
+ initialState: () => ({ repos: [], fetchedAt: 0, issues: {} }),
+
+ // Two events: the fetched repo list (latest refresh replaces), and one
+ // repo's fetched open issues (keyed by full_name, trimmed to a handful).
+ events: {
+ "github.repos": (s, e) =>
+ Array.isArray(e.repos) ? { ...s, repos: normalizeRepos(e.repos), fetchedAt: e.ts || s.fetchedAt } : s,
+ "github.issues": (s, e) =>
+ typeof e.repo === "string" && Array.isArray(e.issues)
+ ? { ...s, issues: trimIssues({ ...s.issues, [e.repo]: { items: normalizeIssues(e.issues), fetchedAt: e.ts || Date.now() } }) }
+ : s,
+ },
+
+ styles: `
+.gh-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
+.gh-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
+.gh-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
+.gh-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); }
+.gh-btn:disabled { opacity: .6; cursor: default; }
+.gh-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
+.gh-filter { width: 100%; box-sizing: border-box; padding: 8px 12px; margin: 0 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; }
+.gh-filter:focus { outline: none; border-color: var(--accent); }
+.gh-scroll { overflow-x: auto; margin: 0 -4px; }
+.gh-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.gh-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
+.gh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
+.gh-table tbody tr:hover { background: var(--bubble-them); }
+.gh-table tbody tr.gh-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); }
+.gh-name { font-weight: 500; overflow-wrap: anywhere; }
+.gh-name a { color: var(--accent); text-decoration: none; }
+.gh-name a:hover { text-decoration: underline; }
+.gh-desc { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
+.gh-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; margin-left: 6px; vertical-align: 1px; white-space: nowrap; }
+.gh-c { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.gh-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.gh-empty { color: var(--muted); padding: 8px 0; }
+.gh-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
+.gh-form { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; }
+.gh-form input, .gh-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); color: var(--ink); font: inherit; }
+.gh-form textarea { min-height: 64px; resize: vertical; }
+.gh-form-row { display: flex; gap: 8px; justify-content: flex-end; }
+`,
+
+ render(state, el, ctx) {
+ const host = ctx.host && ctx.host.connected;
+ // appStateFor returns undefined for a thread with no folded state yet, so
+ // normalise before touching any field.
+ state = state || { repos: [], fetchedAt: 0, issues: {} };
+ const repos = normalizeRepos(state.repos);
+ const issues = state.issues || {};
+
+ const head = document.createElement("div");
+ head.className = "gh-head";
+ el.appendChild(head);
+
+ const note = document.createElement("div");
+ note.className = "gh-note";
+
+ // No privileged bridge (or a stale page that predates requestOp) — the
+ // native app can't seal grants yet; connected services are web-only.
+ if (!host || !host.requestOp) {
+ const title = document.createElement("h2");
+ title.className = "gh-title";
+ title.textContent = "GitHub";
+ head.appendChild(title);
+ const empty = document.createElement("div");
+ empty.className = "gh-empty";
+ empty.textContent = "GitHub needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
+ el.append(empty);
+ return;
+ }
+
+ const fetchRepos = async () => {
+ if (busy) return;
+ busy = true;
+ note.textContent = "";
+ paint();
+ let res;
+ try {
+ res = await host.requestOp("github", "repos.list", {});
+ } finally {
+ busy = false;
+ autoLoading = false;
+ }
+ if (res && res.ok && typeof res.data === "string") {
+ let rows;
+ try {
+ rows = JSON.parse(res.data);
+ } catch {
+ rows = null;
+ }
+ // Emit as an event: it folds into state (persists via the snapshot and
+ // syncs across devices) and re-renders with the fresh list.
+ if (rows) return ctx.emit({ type: "github.repos", repos: rows });
+ }
+ fail((res && res.error) || "Couldn't reach GitHub just now.", repos.length > 0);
+ };
+
+ const fetchIssues = async (fullName) => {
+ if (busy) return;
+ const [owner, repo] = fullName.split("/");
+ if (!owner || !repo) return;
+ busy = true;
+ note.textContent = "";
+ paint();
+ let res;
+ try {
+ res = await host.requestOp("github", "issues.list", { owner, repo });
+ } finally {
+ busy = false;
+ }
+ if (res && res.ok && typeof res.data === "string") {
+ let rows;
+ try {
+ rows = JSON.parse(res.data);
+ } catch {
+ rows = null;
+ }
+ if (rows) return ctx.emit({ type: "github.issues", repo: fullName, issues: rows });
+ }
+ const have = issues[fullName] && issues[fullName].items.length;
+ fail((res && res.error) || "Couldn't reach GitHub just now.", !!have);
+ };
+
+ // Loud failure in the main area when there's nothing to show; a quiet note
+ // when stale data is still on screen — a failed load must never look empty.
+ const fail = (msg, haveData) => {
+ if (haveData) {
+ note.textContent = `Couldn't refresh: ${msg}`;
+ } else {
+ listOrEmpty.className = "gh-empty";
+ listOrEmpty.textContent = `Couldn't load from GitHub: ${msg}`;
+ }
+ paint();
+ };
+
+ // ---- shared chrome: filter box + list area -----------------------------
+ const filter = document.createElement("input");
+ filter.className = "gh-filter";
+ filter.type = "search";
+ filter.value = filterText;
+ filter.hidden = true;
+ filter.addEventListener("input", () => {
+ filterText = filter.value;
+ activeIndex = 0;
+ drawList();
+ });
+ filter.addEventListener("keydown", (e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActive(activeIndex + 1);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActive(activeIndex - 1);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ enterActive();
+ } else if (e.key === "Escape" && filterText) {
+ e.preventDefault();
+ filter.value = "";
+ filterText = "";
+ activeIndex = 0;
+ drawList();
+ }
+ });
+
+ const listOrEmpty = document.createElement("div");
+
+ // Keyboard-nav state shared by both tables.
+ let activeIndex = 0;
+ let currentRows = [];
+ let rowEls = [];
+ const highlight = () => {
+ rowEls.forEach((tr, i) => tr.classList.toggle("gh-active", i === activeIndex));
+ const active = rowEls[activeIndex];
+ if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" });
+ };
+ const setActive = (i) => {
+ if (!currentRows.length) return;
+ activeIndex = Math.max(0, Math.min(i, currentRows.length - 1));
+ highlight();
+ };
+ const enterActive = () => {
+ const row = currentRows[activeIndex];
+ if (!row) return;
+ if (openRepo) {
+ if (row.html_url) window.open(row.html_url, "_blank", "noopener");
+ } else {
+ openIssues(row.full_name);
+ }
+ };
+ const restoreFocus = () => {
+ // Focus the filter so ↑/↓/Enter work immediately — but never steal focus
+ // (e.g. from the new-issue form or the chat composer).
+ if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true });
+ };
+
+ // The framework re-renders on fold; a pure view switch (repo list ⇄ one
+ // repo's issues) re-enters render by hand, reading fresh folded state.
+ const redraw = () => {
+ el.textContent = "";
+ app.render(ctx.getState(), el, ctx);
+ };
+ const openIssues = (fullName) => {
+ openRepo = fullName;
+ filterText = "";
+ activeIndex = 0;
+ redraw();
+ };
+
+ // ---- repo list view ----------------------------------------------------
+ const drawRepos = () => {
+ filter.placeholder = "Filter repositories… (↑ ↓ to move, Enter to open its issues)";
+ filter.hidden = !repos.length;
+ listOrEmpty.textContent = "";
+ if (!repos.length) {
+ currentRows = [];
+ rowEls = [];
+ listOrEmpty.className = "gh-empty";
+ listOrEmpty.textContent = state.fetchedAt ? "No repositories visible to this token." : "No repositories loaded yet — hit Refresh.";
+ return;
+ }
+ const q = filterText.trim().toLowerCase();
+ const rows = repos.filter((r) => !q || r.full_name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q));
+ currentRows = rows;
+ rowEls = [];
+ if (!rows.length) {
+ listOrEmpty.className = "gh-empty";
+ listOrEmpty.textContent = `No repositories match “${filterText.trim()}”.`;
+ return;
+ }
+ activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1));
+ listOrEmpty.className = "";
+ const scroll = document.createElement("div");
+ scroll.className = "gh-scroll";
+ const table = document.createElement("table");
+ table.className = "gh-table";
+ const thead = document.createElement("thead");
+ const hr = document.createElement("tr");
+ for (const [label, cls] of [["Repository", ""], ["Issues", "gh-c"], ["Stars", "gh-c"], ["Pushed", "gh-c"]]) {
+ const th = document.createElement("th");
+ th.textContent = label;
+ if (cls) th.className = cls;
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ table.appendChild(thead);
+ const tbody = document.createElement("tbody");
+ rows.forEach((r, i) => {
+ const tr = document.createElement("tr");
+ tr.onclick = () => setActive(i);
+ tr.ondblclick = () => openIssues(r.full_name);
+
+ const nameCell = document.createElement("td");
+ nameCell.className = "gh-name";
+ const open = document.createElement("a");
+ open.href = "#";
+ open.textContent = r.full_name;
+ open.onclick = (e) => {
+ e.preventDefault();
+ openIssues(r.full_name);
+ };
+ nameCell.appendChild(open);
+ for (const [flag, label] of [[r.private, "private"], [r.archived, "archived"], [r.fork, "fork"]]) {
+ if (!flag) continue;
+ const b = document.createElement("span");
+ b.className = "gh-badge";
+ b.textContent = label;
+ nameCell.appendChild(b);
+ }
+ if (r.description) {
+ const d = document.createElement("div");
+ d.className = "gh-desc";
+ d.textContent = r.description;
+ nameCell.appendChild(d);
+ }
+
+ const issuesCell = document.createElement("td");
+ issuesCell.className = "gh-c";
+ issuesCell.textContent = r.open_issues === null ? "—" : String(r.open_issues);
+ const starsCell = document.createElement("td");
+ starsCell.className = "gh-c";
+ starsCell.textContent = r.stars === null ? "—" : String(r.stars);
+ const whenCell = document.createElement("td");
+ whenCell.className = "gh-c gh-when";
+ whenCell.textContent = ago(r.pushed_at) || "—";
+
+ tr.append(nameCell, issuesCell, starsCell, whenCell);
+ rowEls.push(tr);
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ scroll.appendChild(table);
+ listOrEmpty.appendChild(scroll);
+ highlight();
+ restoreFocus();
+ };
+
+ // ---- issues view -------------------------------------------------------
+ const drawIssues = () => {
+ const entry = issues[openRepo];
+ const items = entry ? entry.items : [];
+ filter.placeholder = "Filter issues… (↑ ↓ to move, Enter to open on github.com)";
+ filter.hidden = !items.length;
+ listOrEmpty.textContent = "";
+ if (!items.length) {
+ currentRows = [];
+ rowEls = [];
+ listOrEmpty.className = "gh-empty";
+ listOrEmpty.textContent = entry ? "No open issues — clear!" : "Loading issues…";
+ return;
+ }
+ const q = filterText.trim().toLowerCase();
+ const rows = items.filter((i) => !q || i.title.toLowerCase().includes(q) || String(i.number).includes(q));
+ currentRows = rows;
+ rowEls = [];
+ if (!rows.length) {
+ listOrEmpty.className = "gh-empty";
+ listOrEmpty.textContent = `No issues match “${filterText.trim()}”.`;
+ return;
+ }
+ activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1));
+ listOrEmpty.className = "";
+ const scroll = document.createElement("div");
+ scroll.className = "gh-scroll";
+ const table = document.createElement("table");
+ table.className = "gh-table";
+ const thead = document.createElement("thead");
+ const hr = document.createElement("tr");
+ for (const [label, cls] of [["#", ""], ["Title", ""], ["By", ""], ["💬", "gh-c"], ["Updated", "gh-c"]]) {
+ const th = document.createElement("th");
+ th.textContent = label;
+ if (cls) th.className = cls;
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ table.appendChild(thead);
+ const tbody = document.createElement("tbody");
+ rows.forEach((it, i) => {
+ const tr = document.createElement("tr");
+ tr.onclick = () => setActive(i);
+
+ const numCell = document.createElement("td");
+ numCell.className = "gh-when";
+ numCell.textContent = `#${it.number}`;
+ const titleCell = document.createElement("td");
+ titleCell.className = "gh-name";
+ if (it.html_url) {
+ const a = document.createElement("a");
+ a.href = it.html_url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.textContent = it.title;
+ titleCell.appendChild(a);
+ } else {
+ titleCell.textContent = it.title;
+ }
+ if (it.is_pr) {
+ const b = document.createElement("span");
+ b.className = "gh-badge";
+ b.textContent = it.draft ? "draft PR" : "PR";
+ titleCell.appendChild(b);
+ }
+ const byCell = document.createElement("td");
+ byCell.className = "gh-when";
+ byCell.textContent = it.by || "—";
+ const cCell = document.createElement("td");
+ cCell.className = "gh-c";
+ cCell.textContent = it.comments ? String(it.comments) : "";
+ const whenCell = document.createElement("td");
+ whenCell.className = "gh-c gh-when";
+ whenCell.textContent = ago(it.updated_at) || "—";
+
+ tr.append(numCell, titleCell, byCell, cCell, whenCell);
+ rowEls.push(tr);
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ scroll.appendChild(table);
+ listOrEmpty.appendChild(scroll);
+ highlight();
+ restoreFocus();
+ };
+
+ // The new-issue form: your submission IS the consent for this one write —
+ // the grant minted for it is write-scoped and single-use (docs/integrations.md).
+ const buildIssueForm = () => {
+ const form = document.createElement("div");
+ form.className = "gh-form";
+ form.hidden = true;
+ const title = document.createElement("input");
+ title.placeholder = "Issue title";
+ title.maxLength = 256;
+ const body = document.createElement("textarea");
+ body.placeholder = "Describe it (optional)";
+ const row = document.createElement("div");
+ row.className = "gh-form-row";
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "gh-btn";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => (form.hidden = true);
+ const submit = document.createElement("button");
+ submit.type = "button";
+ submit.className = "gh-btn gh-btn-primary";
+ submit.textContent = "Open issue";
+ submit.onclick = async () => {
+ const t = title.value.trim();
+ if (!t) {
+ note.textContent = "Give the issue a title first.";
+ return;
+ }
+ const [owner, repo] = openRepo.split("/");
+ submit.disabled = true;
+ submit.textContent = "Opening…";
+ const res = await host.requestOp("github", "issue.create", { owner, repo, title: t, body: body.value.trim() }, "write");
+ submit.disabled = false;
+ submit.textContent = "Open issue";
+ if (!res.ok) {
+ note.textContent = `Couldn't open the issue: ${res.error}`;
+ return;
+ }
+ title.value = "";
+ body.value = "";
+ form.hidden = true;
+ let opened = "";
+ try {
+ const d = JSON.parse(res.data);
+ opened = d && d.number ? ` #${d.number}` : "";
+ } catch {}
+ note.textContent = `Issue${opened} opened ✓`;
+ fetchIssues(openRepo); // pull the fresh list so it shows up
+ };
+ row.append(cancel, submit);
+ form.append(title, body, row);
+ return form;
+ };
+
+ // ---- header + assembly, per view ---------------------------------------
+ const title = document.createElement("h2");
+ title.className = "gh-title";
+ const sub = document.createElement("span");
+ sub.className = "gh-sub";
+
+ let drawList;
+ if (openRepo) {
+ const back = document.createElement("button");
+ back.type = "button";
+ back.className = "gh-btn";
+ back.textContent = "‹ Repos";
+ back.onclick = () => {
+ openRepo = null;
+ filterText = "";
+ activeIndex = 0;
+ redraw();
+ };
+ title.textContent = openRepo;
+ const newIssue = document.createElement("button");
+ newIssue.type = "button";
+ newIssue.className = "gh-btn";
+ newIssue.textContent = "+ New issue";
+ const refresh = document.createElement("button");
+ refresh.type = "button";
+ refresh.className = "gh-btn";
+ refresh.textContent = "↻";
+ refresh.onclick = () => fetchIssues(openRepo);
+ head.append(back, title, sub, newIssue, refresh);
+
+ const form = buildIssueForm();
+ newIssue.onclick = () => {
+ form.hidden = !form.hidden;
+ if (!form.hidden) form.querySelector("input").focus();
+ };
+ el.append(form, filter, listOrEmpty, note);
+
+ drawList = drawIssues;
+ const entry = issues[openRepo];
+ // Opened with nothing (or stale > 2 min) → pull once, automatically.
+ if (!busy && (!entry || Date.now() - (entry.fetchedAt || 0) > 120000)) fetchIssues(openRepo);
+ } else {
+ title.textContent = "Your repositories";
+ const refresh = document.createElement("button");
+ refresh.type = "button";
+ refresh.className = "gh-btn";
+ refresh.textContent = "↻ Refresh";
+ refresh.onclick = fetchRepos;
+ head.append(title, sub, refresh);
+ el.append(filter, listOrEmpty, note);
+
+ drawList = drawRepos;
+ if (!state.fetchedAt && !busy && !autoLoading) {
+ autoLoading = true;
+ fetchRepos();
+ }
+ }
+
+ // Transient bits (busy label, count line) live outside folded state.
+ const paint = () => {
+ sub.textContent = busy
+ ? "loading…"
+ : openRepo
+ ? (issues[openRepo] ? `${issues[openRepo].items.length} open` : "")
+ : repos.length
+ ? `${repos.length} repositor${repos.length === 1 ? "y" : "ies"}`
+ : "";
+ };
+
+ drawList();
+ paint();
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index 01ed2d9..2b31d6e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -102,6 +102,7 @@ import "/static/apps/vault.js";
import "/static/apps/board.js";
import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";
+import "/static/apps/github.js";
import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app)
import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app)
@@ -225,7 +226,34 @@ const CHIP_RUNNERS = {
// DM). The tour renders as private notices via chat.js's appendNotice; the chip
// stays as-is (returns null), since the tour appears below it in the feed.
open_tutorial: () => (startTour((spec) => appendNotice(activeId, spec)), null),
+ // Connected-service op: the tap IS the consent (a write chip's label carries
+ // the manifest's confirm line). Lock the chip immediately so a double-tap
+ // can't run the op twice; the run is async and its outcome lands as a private
+ // notice in the thread the chip was tapped in.
+ service_op: (a) => {
+ const threadId = activeId;
+ requestOp(a.service, a.op, a.args, a.access).then((res) =>
+ appendNotice(threadId, { text: res.ok ? summarizeOpResult(a, res) : `Couldn't run that: ${res.error}` })
+ );
+ return a.access === "write" ? "Confirmed ✓" : "Requested ✓";
+ },
};
+
+// A small, safe line for a completed service op. res.data can be a large JSON
+// string from the provider — surface at most a name/id from it, never dump it.
+function summarizeOpResult(a, res) {
+ let text = `Done — ${a.op} on ${a.service} succeeded.`;
+ if (typeof res.data === "string" && res.data) {
+ try {
+ const parsed = JSON.parse(res.data);
+ const d = parsed && parsed.data ? parsed.data : parsed;
+ const v = d && !Array.isArray(d) && (d.name || d.title || d.id);
+ if (typeof v === "string" || typeof v === "number") text += ` (${String(v).slice(0, 200)})`;
+ if (Array.isArray(d)) text = `Done — ${a.op} on ${a.service} returned ${d.length} item${d.length === 1 ? "" : "s"}.`;
+ } catch {}
+ }
+ return text;
+}
const chipLabel = (a) =>
a.label ||
{
@@ -234,6 +262,7 @@ const chipLabel = (a) =>
open_theme_picker: "Open theme picker",
open_profile: "Edit my profile",
open_tutorial: "Take the tour",
+ service_op: `Run ${a.op} on ${a.service}`,
}[a.type];
function buildActionChips(actions) {
@@ -2590,15 +2619,85 @@ async function completeOAuth(pend, code) {
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);
+ // Expiring-token providers hand back {secret, refresh, expires_at}; store
+ // the whole pair (absent fields drop out of the JSON) so ops can rotate it.
+ await storeCredential(pend.service, opened);
+}
+
+// Seal a credential object to the member's OWN key and store the opaque blob —
+// the storage half of connecting, shared by the OAuth completion and the
+// renewed-pair path (a bot rotating an expired token mid-op).
+async function storeCredential(service, cred) {
const me = roster.find((e) => e.user_id === myUserId);
- const r = await fetch(`/api/connected/${encodeURIComponent(pend.service)}`, {
+ const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at });
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, blob: await sealForPub(me.public_key, { secret: opened.secret }) }),
+ body: JSON.stringify({ token: session.token, blob }),
});
if (!r.ok) throw new Error(`couldn't save the connection (${r.status})`);
}
+// The shared preamble of every connected-service op: locate the bot channel and
+// reopen the member's stored credential. Returns { target, key, opened } on
+// success, { error } otherwise. The reopened secret never leaves this module —
+// callers reseal it into a short-lived grant addressed to the bot's own key.
+async function openOpChannel(service) {
+ if (!session.userPriv) return { error: "connecting services isn't supported in the native app yet" };
+ const target = findBotThread();
+ if (!target) return { 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 { 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 { error: `you haven't connected ${service} yet` };
+ let opened;
+ try {
+ opened = await openSealed(got.blob, session.userPriv);
+ } catch {
+ return { error: "couldn't reopen your stored credential" };
+ }
+ return { target, key, opened };
+}
+
+// Run one registry op through the bot (the generalized sibling of the legacy
+// dnsimple-shaped requestData below): seal a short-lived, ACCESS-SCOPED grant
+// to the bot's key — a "read" grant cannot run a write op — send `connect.op`
+// with the manifest's named args, and await the structured `connect.result`
+// matched by rid. Used by the service_op consent chip and exposed to apps as
+// ctx.host.connected.requestOp.
+async function requestOp(service, op, args, access, ttlMs = 120000) {
+ const ch = await openOpChannel(service);
+ if (ch.error) return { ok: false, error: ch.error };
+ const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
+ const grant = await sealForPub(ch.target.bot.public_key, {
+ service,
+ token: ch.opened.secret,
+ exp: Date.now() + ttlMs,
+ access: access || "read",
+ // Expiring-token providers: the refresh token + expiry ride in the grant
+ // so the bot can rotate a stale pair mid-op (absent fields drop out).
+ refresh: ch.opened.refresh,
+ token_expires_at: ch.opened.expires_at,
+ });
+ const payload = { type: "connect.op", service, op, args: args || {}, grant, rid, name: session.name, ts: Date.now() };
+ if (myUserId) payload.uid = myUserId;
+ ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
+ const res = await awaitConnectResult(ch.target.thread.id, ch.key, rid);
+ if (!res) return { ok: false, error: "the bot didn't respond — is it running?" };
+ if (res.error) return { ok: false, error: res.error };
+ // The bot rotated the pair mid-op and sealed the fresh one to us: re-store
+ // it (best-effort — the op itself already succeeded) so the next op starts
+ // from the live pair. Providers' refresh tokens are single-use, so this
+ // device (the one that triggered the rotation) is the one that persists it.
+ if (res.renewed) {
+ try {
+ await storeCredential(service, await openSealed(res.renewed, session.userPriv));
+ } catch {}
+ }
+ return { ok: true, ...res };
+}
+
setHostBridge({
connected: {
async store(service, secret) {
@@ -2618,15 +2717,16 @@ setHostBridge({
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".
+ // The service whose returned OAuth code is being exchanged (or waiting to
+ // be), else "" — so the surface can show that row as "Connecting…" instead
+ // of flashing "Not connected". Truthy/falsy for old callers.
pending() {
- if (oauthCompleting) return true;
try {
const p = JSON.parse(sessionStorage.getItem("lchat_oauth") || "null");
- return !!(p && p.code);
+ if (oauthCompleting) return (p && p.service) || "dnsimple";
+ return p && p.code ? p.service || "dnsimple" : "";
} catch {
- return false;
+ return "";
}
},
async disconnect(service) {
@@ -2638,6 +2738,13 @@ setHostBridge({
prepare(service) {
loadOAuthCfg(service);
},
+ // Whether the broker has an OAuth app for this service — the catalog only
+ // renders a "Connect with X" button when the flow can actually start
+ // (otherwise the row offers paste-a-token alone).
+ async oauthReady(service) {
+ const cfg = await loadOAuthCfg(service);
+ return !!(cfg && cfg.client_id && cfg.authorize_url && cfg.redirect_uri);
+ },
// 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
@@ -2649,25 +2756,13 @@ setHostBridge({
},
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 ch = await openOpChannel(service);
+ if (ch.error) return { ok: false, error: ch.error };
+ const grant = await sealForPub(ch.target.bot.public_key, { service, token: ch.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" };
+ ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
+ return { ok: true, bot: ch.target.bot.label || "the bot" };
})();
},
// Like request(), but a request/response round-trip: the bot answers with a
@@ -2677,31 +2772,49 @@ setHostBridge({
// token is sealed to the bot for one op, opened only in its trust domain.
requestData(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" };
- }
+ // Legacy dnsimple wire shape (`domain: arg`, un-scoped grant) — kept
+ // byte-identical for the existing DNSimple companion; new callers use
+ // requestOp with named args + an access-scoped grant.
+ const ch = await openOpChannel(service);
+ if (ch.error) return { ok: false, error: ch.error };
const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
- const grant = await sealForPub(target.bot.public_key, { service, token: opened.secret, exp: Date.now() + ttlMs });
+ const grant = await sealForPub(ch.target.bot.public_key, { service, token: ch.opened.secret, exp: Date.now() + ttlMs });
const payload = { type: "connect.op", service, op, domain: arg, grant, rid, 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) }));
- const res = await awaitConnectResult(target.thread.id, key, rid);
+ ws.send(JSON.stringify({ type: "msg", thread_id: ch.target.thread.id, blob: await encrypt(ch.key, payload) }));
+ const res = await awaitConnectResult(ch.target.thread.id, ch.key, rid);
if (!res) return { ok: false, error: "the bot didn't respond — is it running?" };
if (res.error) return { ok: false, error: res.error };
return { ok: true, ...res };
})();
},
+ // The registry-driven op runner: manifest-named args + an access-scoped
+ // grant. Same request/response round-trip as requestData.
+ requestOp,
+ // The integrations catalog the instance serves (the manifest registry):
+ // [{ id, name, category, auth, ops }, …]. null when the fetch fails, so the
+ // surface can fall back rather than render an empty catalog.
+ async catalog() {
+ try {
+ const r = await fetch(`/api/integrations?token=${encodeURIComponent(session.token)}`);
+ if (!r.ok) return null;
+ const j = await r.json();
+ return Array.isArray(j.services) ? j.services : null;
+ } catch {
+ return null;
+ }
+ },
+ // Whether one service has a stored credential (the {connected} bit only —
+ // never the blob).
+ async status(service) {
+ try {
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
+ const got = r.ok ? await r.json() : {};
+ return { connected: !!got.connected };
+ } catch {
+ return { connected: false };
+ }
+ },
},
});
diff --git a/web/static/ui.js b/web/static/ui.js
index 64a0d8b..de80863 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -221,10 +221,41 @@ export const CHIP_TYPES = {
open_theme_picker: {},
open_profile: {},
open_tutorial: {}, // starts Eleven's guided tour (e.g. the welcome DM's "Take the tour")
+ // Connected-service op (the integrations registry, docs/integrations.md): a
+ // bot proposes running one manifest operation and the tap is the member's
+ // consent — for writes, the chip's label carries the manifest's confirm line
+ // ("Register blip.com? This buys the domain and costs real money."), so the
+ // label may run longer than a normal chip's. This sanitize only bounds the
+ // shape; the bot and the pinned executor re-validate everything against the
+ // manifest, and the grant minted on tap is scoped to `access`, so a tampered
+ // chip can't escalate a read into a write.
+ service_op: {
+ longLabel: true, // a consent sentence must render whole, never cut mid-warning
+ sanitize: (a, chip) => {
+ if (typeof a.service !== "string" || a.service.length > 64 || !/^[a-z0-9-]+$/.test(a.service)) return false;
+ if (typeof a.op !== "string" || a.op.length > 64 || !/^[a-z0-9]+(\.[a-z0-9]+)*$/.test(a.op)) return false;
+ if (a.access !== "read" && a.access !== "write") return false;
+ const args = a.args === undefined || a.args === null ? {} : a.args;
+ if (typeof args !== "object" || Array.isArray(args)) return false;
+ const keys = Object.keys(args);
+ if (keys.length > 16 || keys.some((k) => typeof args[k] !== "string")) return false;
+ try {
+ if (JSON.stringify(args).length > 4096) return false;
+ } catch {
+ return false;
+ }
+ chip.service = a.service;
+ chip.op = a.op;
+ chip.access = a.access;
+ chip.args = args;
+ return true;
+ },
+ },
};
// sanitizeActions coerces an untrusted payload `actions` array to renderable
-// chips: whitelist the type, validate the param, cap the count and label size.
+// chips: whitelist the type, validate the param (plus the type's own sanitize
+// hook, when it has one), cap the count and label size.
export function sanitizeActions(actions) {
if (!Array.isArray(actions)) return [];
const out = [];
@@ -239,7 +270,8 @@ export function sanitizeActions(actions) {
if (typeof v !== "string" || !spec.valid(v)) continue;
chip[spec.param] = v;
}
- if (typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, 40);
+ if (spec.sanitize && !spec.sanitize(a, chip)) continue;
+ if (typeof a.label === "string" && a.label.trim()) chip.label = a.label.trim().slice(0, spec.longLabel ? 160 : 40);
out.push(chip);
}
return out;