server+web: Linear integration — first GraphQL service, PAT + OAuth (#802)
* server: Linear joins the integrations registry — first GraphQL service, bare-token auth
A linear.json manifest (viewer / teams.list / issues.list / issue.create)
plus the one generic executor addition it needed: authorization "none"
sends the bare token (Linear rejects a Bearer prefix on personal API keys).
GraphQL needed no special-casing — ops POST /graphql with a static query in
body_json and member input only in variables; pick's dot-path root descends
the data envelope. Query text keeps a space inside every brace so nothing
can parse as a {arg} placeholder (the fail-closed loader rejects any slip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+web: Linear OAuth — the credential's kind picks the header scheme
Linear is the first mixed-scheme provider: pasted personal keys go in the
Authorization header bare, OAuth tokens want Bearer. The sealed credential
now records its kind (pat|oauth) at connect time, grants carry it, and
ExecuteAs sends an oauth-kind token as Bearer (RFC 6750) whatever the
manifest's pasted-key scheme says — empty kind (legacy credentials) keeps
today's behavior. Manifest flips to kind both: authorize/token URLs plus
scopes read vs read+issues:create mapped to the connection cap. The broker
lights the Connect button the moment LCHAT_OAUTH_LINEAR_CLIENT_ID/_SECRET
land in its env; Linear's 24h tokens ride the existing pair rotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+302
−26
broker/broker_test.go+2 −2changelog/2026-07-12-linear-integration.md+7 −0docs/integrations.md+19 −2integrations/exec.go+19 −1integrations/exec_test.go+98 −0integrations/registry.go+1 −1integrations/registry/README.md+1 −1integrations/registry/manifests/linear.json+120 −0integrations/registry_test.go+3 −3integrations_api_test.go+2 −2internal/bot/connect.go+6 −2internal/bot/knowledge.md+5 −4internal/bot/research.go+1 −1web/static/shell.js+18 −7
diff --git a/broker/broker_test.go b/broker/broker_test.go| index 73cc6f3..b2c0ab5 100644 |
| --- a/broker/broker_test.go |
| +++ b/broker/broker_test.go |
| @@ -386,8 +386,8 @@ func TestRegistry(t *testing.T) { |
| 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)) |
| + if out.Schema != 1 || len(out.Services) != 5 { |
| + t.Fatalf("registry = schema %d, %d services (want 1, 5)", out.Schema, len(out.Services)) |
| } |
| } |
diff --git a/changelog/2026-07-12-linear-integration.md b/changelog/2026-07-12-linear-integration.md| new file mode 100644 |
| index 0000000..fe654ef |
| --- /dev/null |
| +++ b/changelog/2026-07-12-linear-integration.md |
| @@ -0,0 +1,7 @@ |
| +🤖 Linear joins Integrations. Connect your Linear account with one tap on |
| +Linear's own site (where offered), or paste a personal API key — Integrations |
| +tells you where to make one. Either way, Eleven can then look up your teams |
| +and each team's active issues — or, with your tap of approval on each one, |
| +open a new issue for you straight from a chat. As with every connected |
| +service, your key stays locked to you: the server only ever stores a sealed |
| +box it cannot open. |
diff --git a/docs/integrations.md b/docs/integrations.md| index 83a64c5..bcdaf09 100644 |
| --- a/docs/integrations.md |
| +++ b/docs/integrations.md |
| @@ -148,8 +148,25 @@ can do, growable by PR without touching this codebase. The pipeline: |
| ## What's built (this branch) |
| -1. `integrations/registry/` — schema + README + four manifests (DNSimple, |
| - GitHub, Stripe, HubSpot; reads with `pick` projections, confirmed writes). |
| +1. `integrations/registry/` — schema + README + five manifests (DNSimple, |
| + GitHub, Stripe, HubSpot, Linear; reads with `pick` projections, confirmed |
| + writes). Linear is the first GraphQL service and needs no executor |
| + special-casing: each op is a POST to `/graphql` whose `body_json` carries a |
| + static query string plus member input in `variables`, `pick`'s dot-path |
| + root descends the `data.…` envelope, and its personal API keys ride |
| + `"authorization": "none"` (Linear rejects a Bearer prefix on them — the |
| + header is the bare key). Linear is also the first **mixed-scheme** service — |
| + its OAuth tokens DO want Bearer — which is why the sealed credential now |
| + records its `kind` ("pat" | "oauth") at connect time: grants carry it, and |
| + the executor (`ExecuteAs`) sends an OAuth token as Bearer per RFC 6750 |
| + whatever the manifest's pasted-key scheme says (empty kind = legacy |
| + credential = manifest scheme, unchanged). Its 24-hour OAuth tokens ride the |
| + expiring-pair rotation below unchanged; `scopes`/`scopes_write` map the |
| + connection cap to Linear's `read` vs `read issues:create`. One authoring |
| + rule keeps GraphQL text out of the |
| + placeholder machinery: every brace in query text keeps a space inside |
| + (`{ nodes ` never `{nodes`), so nothing can parse as a `{arg}` placeholder — |
| + the fail-closed loader rejects any slip. |
| 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. |
diff --git a/integrations/exec.go b/integrations/exec.go| index 037e29a..41156b1 100644 |
| --- a/integrations/exec.go |
| +++ b/integrations/exec.go |
| @@ -61,6 +61,15 @@ func baseOverride(id string) string { |
| // 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) { |
| + return ExecuteAs(m, opID, args, token, "") |
| +} |
| + |
| +// ExecuteAs is Execute plus the credential's kind ("pat" | "oauth"; "" for |
| +// legacy credentials stored before kinds existed). An OAuth token is a Bearer |
| +// token by definition (RFC 6750), whatever scheme the manifest picked for |
| +// pasted keys — Linear's personal API keys go bare while its OAuth tokens |
| +// carry Bearer. Any other kind keeps the manifest's scheme. |
| +func ExecuteAs(m *Manifest, opID string, args map[string]string, token, tokenKind string) (string, error) { |
| op := m.Op(opID) |
| if op == nil { |
| // Absence is refusal: an op the manifest doesn't declare cannot run. |
| @@ -128,7 +137,16 @@ func Execute(m *Manifest, opID string, args map[string]string, token string) (st |
| if scheme == "" { |
| scheme = "Bearer" |
| } |
| - req.Header.Set("Authorization", scheme+" "+token) |
| + if tokenKind == "oauth" { |
| + scheme = "Bearer" |
| + } |
| + if scheme == "none" { |
| + // Bare-token APIs: Linear rejects a Bearer prefix on personal API |
| + // keys — the header value is the token alone. |
| + req.Header.Set("Authorization", token) |
| + } else { |
| + req.Header.Set("Authorization", scheme+" "+token) |
| + } |
| if contentType != "" { |
| req.Header.Set("Content-Type", contentType) |
| } |
diff --git a/integrations/exec_test.go b/integrations/exec_test.go| index d84b574..9197e49 100644 |
| --- a/integrations/exec_test.go |
| +++ b/integrations/exec_test.go |
| @@ -184,6 +184,104 @@ func TestExecuteUnknownOp(t *testing.T) { |
| } |
| } |
| +// graphqlManifest pins the Linear pattern: a bare-token Authorization header |
| +// ("none" — Linear rejects a Bearer prefix on personal API keys) and GraphQL |
| +// ops whose query text is full of braces that must pass through the |
| +// placeholder machinery untouched, member input riding only in the variables |
| +// map. Every brace in query text keeps a space inside (`{ nodes ` not |
| +// `{nodes`) so nothing can read as a `{arg}` placeholder. |
| +const graphqlManifest = `{ |
| + "schema": 1, |
| + "id": "testsvc", |
| + "name": "TestSvc", |
| + "category": "test", |
| + "auth": { "kind": "pat", "base": "https://api.testsvc.example", "authorization": "none" }, |
| + "ops": [ |
| + { "id": "issues.list", "label": "Issues", "access": "read", "method": "POST", "path": "/graphql", |
| + "args": { "team": { "required": true } }, |
| + "body_json": { |
| + "query": "query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }", |
| + "variables": { "team": "{team}" } |
| + }, |
| + "pick": { "root": "data.issues.nodes", "fields": ["identifier", "title"] } } |
| + ] |
| +}` |
| + |
| +func TestExecuteGraphQLBareTokenAndBracePassthrough(t *testing.T) { |
| + m, err := Parse([]byte(graphqlManifest)) |
| + if err != nil { |
| + t.Fatalf("graphql manifest: %v", err) |
| + } |
| + got := &captured{} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + got.auth = r.Header.Get("Authorization") |
| + got.body, _ = io.ReadAll(r.Body) |
| + io.WriteString(w, `{"data":{"issues":{"nodes":[{"identifier":"ENG-1","title":"Fix login","estimate":3}]}}}`) |
| + })) |
| + t.Cleanup(srv.Close) |
| + t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) |
| + |
| + body, err := Execute(m, "issues.list", map[string]string{"team": "ENG"}, "lin_api_tok") |
| + if err != nil { |
| + t.Fatalf("Execute: %v", err) |
| + } |
| + // "none" sends the token alone — no scheme prefix. |
| + if got.auth != "lin_api_tok" { |
| + t.Errorf("authorization: %q", got.auth) |
| + } |
| + var sent struct { |
| + Query string `json:"query"` |
| + Variables map[string]string `json:"variables"` |
| + } |
| + if err := json.Unmarshal(got.body, &sent); err != nil { |
| + t.Fatalf("body %q: %v", got.body, err) |
| + } |
| + // The query's GraphQL braces arrive byte-identical (none is a |
| + // placeholder); the member's value rides only in variables. |
| + if sent.Query != `query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }` { |
| + t.Errorf("query mangled: %q", sent.Query) |
| + } |
| + if sent.Variables["team"] != "ENG" { |
| + t.Errorf("variables: %v", sent.Variables) |
| + } |
| + // The dot-path pick root descends the GraphQL data envelope; undeclared |
| + // fields (estimate) are dropped. |
| + if body != `[{"identifier":"ENG-1","title":"Fix login"}]` { |
| + t.Errorf("projected body: %q", body) |
| + } |
| +} |
| + |
| +func TestExecuteAsOAuthForcesBearer(t *testing.T) { |
| + // Mixed-scheme providers: the manifest's "none" scheme is for pasted |
| + // keys; a credential of kind "oauth" is a Bearer token by definition |
| + // (RFC 6750), so ExecuteAs must override the manifest for it — and leave |
| + // "pat" and legacy (empty) kinds on the manifest's bare-token scheme. |
| + m, err := Parse([]byte(graphqlManifest)) |
| + if err != nil { |
| + t.Fatalf("graphql manifest: %v", err) |
| + } |
| + var auth string |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + auth = r.Header.Get("Authorization") |
| + io.WriteString(w, `{"data":{"issues":{"nodes":[]}}}`) |
| + })) |
| + t.Cleanup(srv.Close) |
| + t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) |
| + |
| + for kind, want := range map[string]string{ |
| + "oauth": "Bearer lin_oauth_tok", |
| + "pat": "lin_oauth_tok", |
| + "": "lin_oauth_tok", |
| + } { |
| + if _, err := ExecuteAs(m, "issues.list", map[string]string{"team": "ENG"}, "lin_oauth_tok", kind); err != nil { |
| + t.Fatalf("ExecuteAs(kind=%q): %v", kind, err) |
| + } |
| + if auth != want { |
| + t.Errorf("kind %q: authorization %q, want %q", kind, auth, want) |
| + } |
| + } |
| +} |
| + |
| // 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. |
diff --git a/integrations/registry.go b/integrations/registry.go| index 106c244..de19928 100644 |
| --- a/integrations/registry.go |
| +++ b/integrations/registry.go |
| @@ -36,7 +36,7 @@ type Manifest struct { |
| type Auth struct { |
| Kind string `json:"kind"` // "pat" | "oauth" | "both" |
| Base string `json:"base"` |
| - Authorization string `json:"authorization"` // header scheme; default Bearer |
| + Authorization string `json:"authorization"` // header scheme; default Bearer; "none" = bare token |
| Headers map[string]string `json:"headers"` |
| PATHelp string `json:"pat_help"` |
| OAuth *OAuthInfo `json:"oauth"` |
diff --git a/integrations/registry/README.md b/integrations/registry/README.md| index 1609ed1..f65f57e 100644 |
| --- a/integrations/registry/README.md |
| +++ b/integrations/registry/README.md |
| @@ -27,7 +27,7 @@ the broker serving it at `GET /registry` so web clients read the same catalog. |
| "auth": { |
| "kind": "pat", // "pat" | "oauth" | "both" |
| "base": "https://api.github.com",// the ONLY host ops may reach (executor-pinned) |
| - "authorization": "Bearer", // Authorization: <scheme> <token> |
| + "authorization": "Bearer", // Authorization: <scheme> <token>; "none" sends the bare token (Linear) |
| "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 |
diff --git a/integrations/registry/manifests/linear.json b/integrations/registry/manifests/linear.json| new file mode 100644 |
| index 0000000..11a07f1 |
| --- /dev/null |
| +++ b/integrations/registry/manifests/linear.json |
| @@ -0,0 +1,120 @@ |
| +{ |
| + "schema": 1, |
| + "id": "linear", |
| + "name": "Linear", |
| + "category": "development", |
| + "auth": { |
| + "kind": "both", |
| + "base": "https://api.linear.app", |
| + "authorization": "none", |
| + "pat_help": "https://linear.app/settings/account/security — create a personal API key under Security & access; the key acts as your Linear account, so keep the connection read-only unless you want issue creation", |
| + "oauth": { |
| + "authorize_url": "https://linear.app/oauth/authorize", |
| + "token_url": "https://api.linear.app/oauth/token", |
| + "scopes": ["read"], |
| + "scopes_write": ["read", "issues:create"] |
| + } |
| + }, |
| + "ops": [ |
| + { |
| + "id": "viewer", |
| + "label": "Check who the key belongs to", |
| + "access": "read", |
| + "method": "POST", |
| + "path": "/graphql", |
| + "body_json": { |
| + "query": "query { viewer { name displayName email url } }" |
| + }, |
| + "pick": { |
| + "root": "data.viewer", |
| + "fields": [ |
| + "name", |
| + "displayName", |
| + "email", |
| + "url" |
| + ] |
| + } |
| + }, |
| + { |
| + "id": "teams.list", |
| + "label": "List your teams", |
| + "access": "read", |
| + "method": "POST", |
| + "path": "/graphql", |
| + "body_json": { |
| + "query": "query { teams(first: 50) { nodes { id key name } } }" |
| + }, |
| + "pick": { |
| + "root": "data.teams.nodes", |
| + "fields": [ |
| + "id", |
| + "key", |
| + "name" |
| + ] |
| + } |
| + }, |
| + { |
| + "id": "issues.list", |
| + "label": "List a team's active issues", |
| + "access": "read", |
| + "method": "POST", |
| + "path": "/graphql", |
| + "args": { |
| + "team": { |
| + "required": true |
| + } |
| + }, |
| + "body_json": { |
| + "query": "query($team: String!) { issues(first: 50, filter: { team: { key: { eq: $team } }, state: { type: { nin: [\"completed\", \"canceled\"] } } }, orderBy: updatedAt) { nodes { identifier title url priorityLabel updatedAt state { name } assignee { displayName } } } }", |
| + "variables": { |
| + "team": "{team}" |
| + } |
| + }, |
| + "pick": { |
| + "root": "data.issues.nodes", |
| + "fields": [ |
| + "identifier", |
| + "title", |
| + "url", |
| + "priorityLabel", |
| + "updatedAt", |
| + "state.name", |
| + "assignee.displayName" |
| + ] |
| + } |
| + }, |
| + { |
| + "id": "issue.create", |
| + "label": "Create an issue", |
| + "access": "write", |
| + "method": "POST", |
| + "path": "/graphql", |
| + "args": { |
| + "team_id": { |
| + "required": true |
| + }, |
| + "title": { |
| + "required": true |
| + }, |
| + "description": {} |
| + }, |
| + "body_json": { |
| + "query": "mutation($teamId: String!, $title: String!, $description: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description }) { issue { identifier title url } } }", |
| + "variables": { |
| + "teamId": "{team_id}", |
| + "title": "{title}", |
| + "description": "{description}" |
| + } |
| + }, |
| + "pick": { |
| + "root": "data.issueCreate.issue", |
| + "fields": [ |
| + "identifier", |
| + "title", |
| + "url" |
| + ] |
| + }, |
| + "confirm": "Create the Linear issue “{title}” in the team with id {team_id}?" |
| + } |
| + ] |
| +} |
diff --git a/integrations/registry_test.go b/integrations/registry_test.go| index 40ccc14..9b1e267 100644 |
| --- a/integrations/registry_test.go |
| +++ b/integrations/registry_test.go |
| @@ -8,11 +8,11 @@ import ( |
| func TestMustRegistryLoadsEmbeddedManifests(t *testing.T) { |
| reg := MustRegistry() |
| list := reg.List() |
| - if len(list) != 4 { |
| - t.Fatalf("embedded manifests: got %d, want 4", len(list)) |
| + if len(list) != 5 { |
| + t.Fatalf("embedded manifests: got %d, want 5", len(list)) |
| } |
| // List is sorted by id. |
| - want := []string{"dnsimple", "github", "hubspot", "stripe"} |
| + want := []string{"dnsimple", "github", "hubspot", "linear", "stripe"} |
| for i, m := range list { |
| if m.ID != want[i] { |
| t.Errorf("List()[%d] = %q, want %q", i, m.ID, want[i]) |
diff --git a/integrations_api_test.go b/integrations_api_test.go| index e01d4fa..7876e3a 100644 |
| --- a/integrations_api_test.go |
| +++ b/integrations_api_test.go |
| @@ -40,8 +40,8 @@ func TestIntegrationsEndpoint(t *testing.T) { |
| 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)) |
| + if len(out.Services) != 5 { |
| + t.Fatalf("got %d services, want 5", len(out.Services)) |
| } |
| // DNSimple carries a write op with its consent line — the shape the web |
| // catalog and consent chips depend on. |
diff --git a/internal/bot/connect.go b/internal/bot/connect.go| index a83c9b2..e1102e4 100644 |
| --- a/internal/bot/connect.go |
| +++ b/internal/bot/connect.go |
| @@ -86,6 +86,10 @@ type grant struct { |
| // 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 |
| + // Kind is how the credential was born ("pat" | "oauth"; empty = legacy). |
| + // Mixed-scheme providers need it: Linear's pasted keys go in the header |
| + // bare, its OAuth tokens as Bearer — ExecuteAs picks per token. |
| + Kind string `json:"kind"` |
| } |
| // handleConnect runs a capability op against a sealed grant. It replies ONLY |
| @@ -157,7 +161,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| } |
| token, renewed = pair.Access, pair |
| } |
| - data, err := integrations.Execute(m, p.Op, p.Args, token) |
| + data, err := integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind) |
| // 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 |
| @@ -165,7 +169,7 @@ func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) |
| 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) |
| + data, err = integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind) |
| } |
| } |
| if err != nil { |
diff --git a/internal/bot/knowledge.md b/internal/bot/knowledge.md| index 9752db5..9135cae 100644 |
| --- a/internal/bot/knowledge.md |
| +++ b/internal/bot/knowledge.md |
| @@ -458,10 +458,11 @@ A personal app-thread is presented one of two ways, and **the app chooses**: |
| a note, or who you saved it from. |
| - **Connected services (Integrations)** — wire up a third-party account |
| - (GitHub, DNSimple, Stripe, HubSpot) *without* handing the server a usable |
| - credential. Open **Integrations** (+ New chat → Integrations): connect by |
| - authorizing on the provider's own site where offered (GitHub, DNSimple), or |
| - by pasting a scoped token. Either way the key is locked to *you* — the |
| + (GitHub, DNSimple, Stripe, HubSpot, Linear) *without* handing the server a |
| + usable credential. Open **Integrations** (+ New chat → Integrations): connect |
| + by authorizing on the provider's own site where offered (GitHub, DNSimple), or |
| + by pasting a scoped token (Linear connects with a personal API key). Either |
| + way the key is locked to *you* — the |
| server stores a box only you can open. Once connected, a service can |
| install its own app (GitHub's repositories and issues; DNSimple's domains), |
| and the connected row's **Open** button goes straight there. |
diff --git a/internal/bot/research.go b/internal/bot/research.go| index afcacb4..b0f2367 100644 |
| --- a/internal/bot/research.go |
| +++ b/internal/bot/research.go |
| @@ -292,7 +292,7 @@ func (r *Runtime) runResearchOp(uid string, opened map[string]*grant, renewed ma |
| renewed[svc] = pair |
| token = pair.Access |
| } |
| - data, err := integrations.Execute(m, opID, args, token) |
| + data, err := integrations.ExecuteAs(m, opID, args, token, g.Kind) |
| if err != nil { |
| return svc + "." + opID + " failed: " + err.Error() |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index a79f85d..4bbe51d 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3833,8 +3833,10 @@ async function completeOAuth(pend, code) { |
| 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. |
| - // The access cap chosen before the consent trip rides into the credential. |
| - await storeCredential(pend.service, { ...opened, cap: pend.cap }); |
| + // The access cap chosen before the consent trip rides into the credential, |
| + // as does the credential's kind — mixed-scheme providers (Linear) send an |
| + // OAuth token as Bearer where a pasted key goes in the header bare. |
| + await storeCredential(pend.service, { ...opened, cap: pend.cap, kind: "oauth" }); |
| } |
| // Seal a credential object to the member's OWN key and store the opaque blob — |
| @@ -3845,7 +3847,7 @@ async function completeOAuth(pend, code) { |
| // can read or change it. |
| async function storeCredential(service, cred) { |
| const me = roster.find((e) => e.user_id === myUserId); |
| - const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at, cap: cred.cap }); |
| + const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at, cap: cred.cap, kind: cred.kind }); |
| const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| @@ -3902,6 +3904,10 @@ async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| // 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, |
| + // The credential's kind rides too: mixed-scheme providers (Linear) need |
| + // the executor to send an OAuth token as Bearer where a pasted key goes |
| + // in the header bare. |
| + kind: ch.opened.kind, |
| }); |
| const payload = { type: "connect.op", service, op, args: args || {}, grant, rid, name: session.name, ts: Date.now() }; |
| // Chip-originated ops are the bot's own proposals: tagging them lets the |
| @@ -3920,8 +3926,9 @@ async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| if (res.renewed) { |
| try { |
| // The rotated pair inherits the connection's cap — rotation refreshes |
| - // the token, never the member's access choice. |
| - await storeCredential(service, { ...(await openSealed(res.renewed, session.userPriv)), cap: ch.opened.cap }); |
| + // the token, never the member's access choice. A rotated pair is an |
| + // OAuth pair by construction (only they carry refresh tokens). |
| + await storeCredential(service, { ...(await openSealed(res.renewed, session.userPriv)), cap: ch.opened.cap, kind: "oauth" }); |
| } catch {} |
| } |
| return { ok: true, ...res }; |
| @@ -3953,6 +3960,7 @@ async function requestResearch(services, goal) { |
| access: "read", |
| refresh: ch.opened.refresh, |
| token_expires_at: ch.opened.expires_at, |
| + kind: ch.opened.kind, |
| }); |
| } |
| const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12))); |
| @@ -3977,7 +3985,8 @@ async function pollResearchRenewals(threadId, key, rid, caps) { |
| if (!res || !res.renewed) continue; |
| for (const [service, sealed] of Object.entries(res.renewed)) { |
| try { |
| - await storeCredential(service, { ...(await openSealed(sealed, session.userPriv)), cap: caps[service] }); |
| + // Rotated pairs are OAuth pairs by construction. |
| + await storeCredential(service, { ...(await openSealed(sealed, session.userPriv)), cap: caps[service], kind: "oauth" }); |
| } catch {} |
| } |
| return; |
| @@ -3992,7 +4001,9 @@ setHostBridge({ |
| if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" }; |
| const me = roster.find((e) => e.user_id === myUserId); |
| if (!me || !me.public_key) return { ok: false, error: "your encryption key isn't ready yet" }; |
| - const blob = await sealForPub(me.public_key, { secret, cap: cap || "read" }); |
| + // A pasted token is a personal key — kind "pat" keeps the manifest's |
| + // header scheme (Linear: bare) where OAuth tokens force Bearer. |
| + const blob = await sealForPub(me.public_key, { secret, cap: cap || "read", kind: "pat" }); |
| const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |