eleven / messenger Public

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>
Paul Campbell 200928b2ba10f44bcbe794bcb4cf3fbb4e7afdcf parent e94f1b2
14 files changed, +302 −26
  • broker/broker_test.go +2 −2
  • changelog/2026-07-12-linear-integration.md +7 −0
  • docs/integrations.md +19 −2
  • integrations/exec.go +19 −1
  • integrations/exec_test.go +98 −0
  • integrations/registry.go +1 −1
  • integrations/registry/README.md +1 −1
  • integrations/registry/manifests/linear.json +120 −0
  • integrations/registry_test.go +3 −3
  • integrations_api_test.go +2 −2
  • internal/bot/connect.go +6 −2
  • internal/bot/knowledge.md +5 −4
  • internal/bot/research.go +1 −1
  • web/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" },