@desplega.ai/agent-swarm 1.119.6 → 1.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
  3. package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
  4. package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
  5. package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
  6. package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
  7. package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
  8. package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
  9. package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
  10. package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
  11. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
  12. package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
  13. package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
  14. package/dist/cli-1728qh70.js +791 -0
  15. package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
  16. package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
  17. package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
  18. package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
  19. package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
  20. package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
  21. package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
  22. package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
  23. package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
  24. package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
  25. package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
  26. package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
  27. package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
  28. package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
  29. package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
  30. package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
  31. package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
  32. package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
  33. package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
  34. package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
  35. package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
  36. package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
  37. package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
  38. package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
  39. package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
  40. package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
  41. package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
  42. package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
  43. package/dist/cli-s8s499xy.js +81 -0
  44. package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
  45. package/dist/cli-tv7vekye.js +367 -0
  46. package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
  47. package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
  48. package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
  49. package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
  50. package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
  51. package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
  52. package/dist/cli.js +26 -21
  53. package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
  54. package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
  55. package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
  56. package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
  57. package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
  58. package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
  59. package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
  60. package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
  61. package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
  62. package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
  63. package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
  64. package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
  65. package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
  66. package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
  67. package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
  68. package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
  69. package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
  70. package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
  71. package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
  72. package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
  73. package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
  74. package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
  75. package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
  76. package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
  77. package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
  78. package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
  79. package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
  80. package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
  81. package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
  82. package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
  83. package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
  84. package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
  85. package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
  86. package/openapi.json +2671 -1811
  87. package/package.json +10 -5
  88. package/plugin/commands/swarm-chat.md +2 -0
  89. package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
  90. package/src/be/connection-bindings-blob-migration.ts +123 -0
  91. package/src/be/db-queries/mcp-oauth.ts +409 -247
  92. package/src/be/db-queries/oauth.ts +813 -131
  93. package/src/be/db.ts +97 -6
  94. package/src/be/migrations/117_unified_oauth.sql +624 -0
  95. package/src/be/migrations/119_agent_avatar.sql +7 -0
  96. package/src/be/migrations/120_task_title.sql +3 -0
  97. package/src/be/oauth-credential-bindings.ts +48 -14
  98. package/src/be/oauth-encryption-backfill.ts +84 -0
  99. package/src/be/oauth-refresh-sweep.ts +38 -23
  100. package/src/be/script-connections.ts +1130 -163
  101. package/src/be/script-credential-broker.ts +37 -27
  102. package/src/be/scripts/typecheck.ts +3 -2
  103. package/src/be/seed-capabilities.ts +62 -0
  104. package/src/be/seed-pricing.ts +1 -1
  105. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  106. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  107. package/src/be/vendored-openapi.ts +98 -0
  108. package/src/commands/runner.ts +69 -6
  109. package/src/http/agents.ts +23 -5
  110. package/src/http/all-routes.ts +1 -0
  111. package/src/http/core.ts +8 -5
  112. package/src/http/index.ts +18 -5
  113. package/src/http/mcp-bridge.ts +5 -2
  114. package/src/http/mcp-oauth.ts +103 -101
  115. package/src/http/oauth-callback.ts +332 -0
  116. package/src/http/oauth-generic.ts +14 -54
  117. package/src/http/poll.ts +5 -1
  118. package/src/http/script-connections.ts +651 -170
  119. package/src/http/scripts.ts +4 -2
  120. package/src/http/sessions.ts +1 -1
  121. package/src/http/tasks.ts +35 -0
  122. package/src/http/trackers/jira.ts +22 -34
  123. package/src/http/trackers/linear.ts +21 -36
  124. package/src/http/x.ts +6 -2
  125. package/src/jira/app.ts +12 -6
  126. package/src/jira/oauth.ts +48 -48
  127. package/src/linear/app.ts +44 -2
  128. package/src/linear/oauth.ts +12 -37
  129. package/src/oauth/app-validation.ts +27 -9
  130. package/src/oauth/ensure-mcp-token.ts +1 -0
  131. package/src/oauth/ensure-token.ts +331 -108
  132. package/src/oauth/identity-capture.ts +103 -0
  133. package/src/oauth/keepalive.ts +14 -9
  134. package/src/oauth/presets.ts +242 -0
  135. package/src/oauth/wrapper.ts +197 -76
  136. package/src/prompts/base-prompt.ts +48 -4
  137. package/src/prompts/session-templates.ts +13 -1
  138. package/src/providers/claude-managed-models.ts +8 -0
  139. package/src/rbac/legacy-policy.ts +2 -1
  140. package/src/rbac/permissions.ts +7 -3
  141. package/src/scripts-runtime/credential-broker/broker.ts +51 -8
  142. package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
  143. package/src/scripts-runtime/credential-broker/index.ts +1 -1
  144. package/src/scripts-runtime/credential-broker/types.ts +55 -8
  145. package/src/scripts-runtime/egress-secrets.ts +6 -2
  146. package/src/scripts-runtime/eval-harness.ts +2 -2
  147. package/src/scripts-runtime/executors/types.ts +2 -1
  148. package/src/scripts-runtime/loader.ts +3 -0
  149. package/src/scripts-runtime/types/stdlib.d.ts +11 -0
  150. package/src/server.ts +230 -126
  151. package/src/tests/agent-avatar-resolution.test.ts +123 -0
  152. package/src/tests/agents-list-model-display.test.ts +10 -0
  153. package/src/tests/base-prompt.test.ts +72 -0
  154. package/src/tests/claude-managed-adapter.test.ts +6 -0
  155. package/src/tests/connection-embedded-auth.test.ts +709 -0
  156. package/src/tests/context-window.test.ts +1 -0
  157. package/src/tests/credential-broker.test.ts +123 -125
  158. package/src/tests/db-queries-oauth.test.ts +126 -1
  159. package/src/tests/ensure-token.test.ts +44 -2
  160. package/src/tests/list-endpoint-slimming.test.ts +45 -0
  161. package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
  162. package/src/tests/mcp-oauth-queries.test.ts +154 -8
  163. package/src/tests/oauth-access-token-tool.test.ts +37 -1
  164. package/src/tests/oauth-callback-flow.test.ts +534 -0
  165. package/src/tests/oauth-credential-bindings.test.ts +50 -22
  166. package/src/tests/oauth-presets.test.ts +275 -0
  167. package/src/tests/oauth-refresh-failure.test.ts +263 -0
  168. package/src/tests/oauth-refresh-sweep.test.ts +83 -3
  169. package/src/tests/oauth-wrapper.test.ts +77 -55
  170. package/src/tests/prompt-template-session.test.ts +6 -5
  171. package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
  172. package/src/tests/rbac-engine.test.ts +2 -1
  173. package/src/tests/script-connections-http.test.ts +356 -11
  174. package/src/tests/script-connections.test.ts +102 -0
  175. package/src/tests/sdk-allowlist.test.ts +4 -1
  176. package/src/tests/seed-capabilities.test.ts +92 -0
  177. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  178. package/src/tests/sessions.test.ts +46 -0
  179. package/src/tests/spec-base-url.test.ts +266 -0
  180. package/src/tests/status.test.ts +3 -4
  181. package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
  182. package/src/tests/task-title-route.test.ts +106 -0
  183. package/src/tests/tool-annotations.test.ts +6 -3
  184. package/src/tests/tracker-fold.test.ts +412 -0
  185. package/src/tests/unified-oauth-migration.test.ts +455 -0
  186. package/src/tests/vendored-openapi.test.ts +263 -0
  187. package/src/tools/credential-bindings/tool.ts +232 -77
  188. package/src/tools/oauth-access-token.ts +55 -6
  189. package/src/tools/script-connections/tool.ts +73 -53
  190. package/src/tools/swarm-config/set-config.ts +24 -50
  191. package/src/tools/update-profile.ts +15 -5
  192. package/src/tracker/types.ts +12 -0
  193. package/src/types.ts +29 -0
  194. package/src/utils/context-window.ts +1 -0
  195. package/src/workflows/executors/swarm-script.ts +6 -2
  196. package/vendored-openapi/README.md +22 -0
  197. package/vendored-openapi/github.json +7759 -0
  198. package/vendored-openapi/gmail.json +393 -0
  199. package/vendored-openapi/jira.json +3396 -0
  200. package/vendored-openapi/linear.json +252 -0
  201. package/vendored-openapi/manifest.json +128 -0
  202. package/vendored-openapi/slack.json +2895 -0
  203. package/dist/cli-0pygq9r1.js +0 -429
  204. package/dist/cli-8hhms4hp.js +0 -393
  205. package/dist/cli-z9gzq07x.js +0 -27
  206. package/src/scripts-runtime/credential-broker/store.ts +0 -72
  207. package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
@@ -1,12 +1,17 @@
1
1
  import {
2
2
  acquireOAuthRefreshLock,
3
+ getAuthorizationById,
4
+ getDefaultAuthorizationIdForProvider,
3
5
  getOAuthApp,
4
- getOAuthTokens,
5
- isTokenExpiringSoon,
6
+ getOAuthAppById,
7
+ markAuthorizationRefreshFailed,
8
+ type OAuthAuthorization,
6
9
  releaseOAuthRefreshLock,
10
+ updateAuthorizationTokens,
7
11
  } from "../be/db-queries/oauth";
8
- import type { OAuthApp, OAuthTokens } from "../tracker/types";
9
- import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
12
+ import type { OAuthApp } from "../tracker/types";
13
+ import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
14
+ import { type OAuthProviderConfig, performTokenRefreshRequest } from "./wrapper";
10
15
 
11
16
  function parseMetadata(metadataJson: string | null | undefined): Record<string, unknown> {
12
17
  try {
@@ -31,9 +36,11 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
31
36
  */
32
37
  export function oauthAppRowToProviderConfig(app: OAuthApp): OAuthProviderConfig {
33
38
  const metadata = parseMetadata(app.metadata);
34
- const extraParams =
35
- stringRecord(metadata.extraParams) ??
36
- (typeof metadata.actor === "string" ? { actor: metadata.actor } : undefined);
39
+ const liftedExtraParams = stringRecord(parseMetadata(app.extraParamsJson));
40
+ const extraParams = {
41
+ ...(typeof metadata.actor === "string" ? { actor: metadata.actor } : {}),
42
+ ...liftedExtraParams,
43
+ };
37
44
 
38
45
  return {
39
46
  provider: app.provider,
@@ -46,37 +53,66 @@ export function oauthAppRowToProviderConfig(app: OAuthApp): OAuthProviderConfig
46
53
  .split(",")
47
54
  .map((scope) => scope.trim())
48
55
  .filter(Boolean),
49
- extraParams,
50
- // Standard OAuth wants space-separated scopes; the wrapper's comma
51
- // default exists only for Linear, which has its own dedicated flow.
52
- scopeSeparator: " ",
53
- ...(metadata.tokenAuthStyle === "basic" ? { tokenAuthStyle: "basic" as const } : {}),
54
- ...(metadata.tokenBodyFormat === "json" ? { tokenBodyFormat: "json" as const } : {}),
55
- requiresRefreshTokenRotation: app.provider === "jira",
56
+ extraParams: Object.keys(extraParams).length > 0 ? extraParams : undefined,
57
+ scopeSeparator: app.scopeSeparator,
58
+ tokenAuthStyle: app.tokenAuthStyle,
59
+ tokenBodyFormat: app.tokenBodyFormat,
60
+ requiresRefreshTokenRotation: app.requiresRefreshTokenRotation,
56
61
  };
57
62
  }
58
63
 
59
- const refreshLocks = new Map<string, Promise<void>>();
60
- const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
61
- const REFRESH_LOCK_WAIT_MS = 30 * 1000;
62
- const REFRESH_LOCK_POLL_MS = 250;
64
+ // ─── Typed refresh error ─────────────────────────────────────────────────────
65
+
66
+ export type OAuthRefreshFailureReason = "refresh_rejected" | "lock_timeout" | "no_refresh_token";
63
67
 
64
68
  /**
65
- * Build an OAuthProviderConfig from the oauth_apps table for any provider.
69
+ * Thrown by the authorization-keyed refresh path when an expiring authorization
70
+ * cannot be refreshed. Carries structured context so downstream surfaces (the
71
+ * credential broker → script failedBindings, HTTP endpoints, alerting) can
72
+ * report *which* authorization broke and *why* instead of a silent drop.
73
+ *
74
+ * Shape precedent: {@link CodexOAuthRefreshError} in
75
+ * src/providers/codex-oauth/storage.ts.
66
76
  */
67
- function getOAuthConfig(provider: string): OAuthProviderConfig | null {
68
- const app = getOAuthApp(provider);
69
- return app ? oauthAppRowToProviderConfig(app) : null;
77
+ export class OAuthRefreshError extends Error {
78
+ constructor(
79
+ public readonly authorizationId: string,
80
+ public readonly appId: string,
81
+ public readonly reason: OAuthRefreshFailureReason,
82
+ public readonly authorizationLabel: string,
83
+ message?: string,
84
+ ) {
85
+ super(message ?? `OAuth authorization '${authorizationLabel}' refresh failed (${reason})`);
86
+ this.name = "OAuthRefreshError";
87
+ }
70
88
  }
71
89
 
72
- async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>): Promise<T> {
73
- const previous = refreshLocks.get(provider) ?? Promise.resolve();
90
+ function authorizationLabelFor(app: OAuthApp, authorization: OAuthAuthorization): string {
91
+ const base = app.displayName?.trim() || app.provider;
92
+ return authorization.label && authorization.label !== "default"
93
+ ? `${base} (${authorization.label})`
94
+ : base;
95
+ }
96
+
97
+ // ─── Refresh locks (in-process queue + cross-process DB lock) ─────────────────
98
+
99
+ const refreshLocks = new Map<string, Promise<void>>();
100
+ const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
101
+ const REFRESH_LOCK_WAIT_MS = 30 * 1000;
102
+ const REFRESH_LOCK_POLL_MS = 250;
103
+ const DEFAULT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
104
+
105
+ async function withAuthorizationRefreshLock<T>(
106
+ authorizationId: string,
107
+ fn: () => Promise<T>,
108
+ ): Promise<T> {
109
+ const previous = refreshLocks.get(authorizationId) ?? Promise.resolve();
74
110
  let release!: () => void;
75
111
  const current = new Promise<void>((resolve) => {
76
112
  release = resolve;
77
113
  });
78
114
  const next = previous.catch(() => undefined).then(() => current);
79
- refreshLocks.set(provider, next);
115
+ refreshLocks.set(authorizationId, next);
80
116
 
81
117
  await previous.catch(() => undefined);
82
118
 
@@ -84,8 +120,8 @@ async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>
84
120
  return await fn();
85
121
  } finally {
86
122
  release();
87
- if (refreshLocks.get(provider) === next) {
88
- refreshLocks.delete(provider);
123
+ if (refreshLocks.get(authorizationId) === next) {
124
+ refreshLocks.delete(authorizationId);
89
125
  }
90
126
  }
91
127
  }
@@ -94,120 +130,307 @@ function sleep(ms: number): Promise<void> {
94
130
  return new Promise((resolve) => setTimeout(resolve, ms));
95
131
  }
96
132
 
97
- function tokenRowChanged(current: OAuthTokens | null, observed: OAuthTokens | null): boolean {
98
- if (!observed) return current !== null;
99
- if (!current) return true;
100
- return (
101
- current.accessToken !== observed.accessToken ||
102
- current.refreshToken !== observed.refreshToken ||
103
- current.expiresAt !== observed.expiresAt
104
- );
105
- }
106
-
107
133
  /**
108
- * Ensure a valid OAuth token exists for the given provider.
109
- * If the token is expiring soon, attempt to refresh it.
110
- * Call this before any API interaction with an OAuth-protected service.
134
+ * An authorization needs a refresh when it is expiring within `bufferMs`, is
135
+ * already in a non-terminal broken state (`refresh-failed` / `expired`), or
136
+ * when `force` is set so the sweep and on-demand resolution retry broken
137
+ * rows each pass and self-heal once the provider recovers.
111
138
  *
112
- * Reactive variant never throws. Refresh failures are logged so a single
113
- * dead-token incident doesn't tear down an unrelated request path. Use
114
- * {@link ensureTokenOrThrow} from keepalive contexts where you want a dead
115
- * refresh token to surface as an alert.
116
- *
117
- * @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
118
- * Keepalive callers should pass a larger value (e.g. 13h) to force
119
- * a proactive refresh well before the token actually expires.
139
+ * A NULL `expiresAt` means the provider issued a non-expiring token (e.g. the
140
+ * GitHub OAuth preset, which has no refresh token). It is treated as "does not
141
+ * expire / never proactively refresh" NOT as expired. Refreshing it would
142
+ * mark it `refresh-failed` (no refresh token to rotate) while the token is
143
+ * still valid. Only an explicit `force` (manual refresh) or a broken status
144
+ * refreshes such a row.
120
145
  */
121
- export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
122
- try {
123
- await ensureTokenOrThrow(provider, bufferMs);
124
- } catch (err) {
125
- console.error(
126
- `[OAuth] Failed to refresh ${provider} token:`,
127
- err instanceof Error ? err.message : err,
128
- );
129
- }
146
+ function authorizationNeedsRefresh(
147
+ authorization: OAuthAuthorization,
148
+ bufferMs = DEFAULT_REFRESH_BUFFER_MS,
149
+ force = false,
150
+ ): boolean {
151
+ if (authorization.status === "refresh-failed" || authorization.status === "expired") return true;
152
+ if (force) return true;
153
+ if (!authorization.expiresAt) return false;
154
+ const expiresAt = new Date(authorization.expiresAt).getTime();
155
+ if (Number.isNaN(expiresAt)) return true;
156
+ return expiresAt - Date.now() < bufferMs;
130
157
  }
131
158
 
132
- /**
133
- * Buffer large enough that any stored expiry counts as "expiring soon",
134
- * turning {@link ensureTokenOrThrow} into an unconditional refresh.
135
- */
136
- const FORCE_REFRESH_BUFFER_MS = 1000 * 60 * 60 * 24 * 365 * 100; // ~100 years
159
+ // ─── Refresh notification (cached provider-client invalidation) ──────────────
160
+ //
161
+ // A background sweep or another request path can rotate an authorization's
162
+ // access token while a module-level SDK client still holds the stale one (the
163
+ // Linear `LinearClient` cache is the canonical case). Listeners registered here
164
+ // fire after every successful persist so those caches can invalidate — closing
165
+ // the staleness gap that used to require a reactive `resetLinearClient()` call
166
+ // on the same code path as the refresh.
167
+
168
+ export interface AuthorizationRefreshedEvent {
169
+ authorizationId: string;
170
+ appId: string;
171
+ provider: string;
172
+ }
173
+
174
+ type AuthorizationRefreshedListener = (event: AuthorizationRefreshedEvent) => void;
175
+
176
+ const authorizationRefreshedListeners = new Set<AuthorizationRefreshedListener>();
137
177
 
138
178
  /**
139
- * Force a refresh of the stored token regardless of remaining lifetime,
140
- * going through the exact same in-process queue + DB refresh-lock path as
141
- * {@link ensureTokenOrThrow}. Used by the manual refresh endpoint and the
142
- * background refresh sweep's keep-alive path.
143
- *
144
- * Like {@link ensureTokenOrThrow}, stays silent (no throw) when the provider
145
- * has no app config or no stored refresh token — callers that need those to
146
- * be errors must pre-check.
179
+ * Subscribe to successful authorization token refreshes. Returns an
180
+ * unsubscribe function. Listeners must be cheap + non-throwing (throws are
181
+ * caught and logged so one bad listener can't fail a refresh).
147
182
  */
148
- export async function forceRefreshTokenOrThrow(provider: string): Promise<void> {
149
- await ensureTokenOrThrow(provider, FORCE_REFRESH_BUFFER_MS);
183
+ export function onAuthorizationRefreshed(listener: AuthorizationRefreshedListener): () => void {
184
+ authorizationRefreshedListeners.add(listener);
185
+ return () => {
186
+ authorizationRefreshedListeners.delete(listener);
187
+ };
150
188
  }
151
189
 
190
+ function notifyAuthorizationRefreshed(event: AuthorizationRefreshedEvent): void {
191
+ for (const listener of authorizationRefreshedListeners) {
192
+ try {
193
+ listener(event);
194
+ } catch (err) {
195
+ console.error(
196
+ scrubSecrets(
197
+ `[OAuth] authorization-refreshed listener failed for ${event.provider}: ${
198
+ err instanceof Error ? err.message : String(err)
199
+ }`,
200
+ ),
201
+ );
202
+ }
203
+ }
204
+ }
205
+
206
+ // ─── Authorization-keyed core ────────────────────────────────────────────────
207
+
152
208
  /**
153
- * Strict variant of {@link ensureToken}: throws on refresh failure of a
154
- * configured provider so callers (keepalive, alerting) can react.
209
+ * Strict authorization-keyed refresh. Refreshes the given authorization if it
210
+ * is expiring (or already broken), persisting `status='active'` +
211
+ * `lastRefreshedAt` on success and `status='refresh-failed'` +
212
+ * `lastErrorMessage` (scrubbed) + a thrown {@link OAuthRefreshError} on
213
+ * failure.
155
214
  *
156
- * Stays silent (no throw) when the provider isn't configured or no refresh
157
- * token is stored — those are "not connected" states, not failures, and
158
- * shouldn't page anyone.
215
+ * Stays silent (no throw, no status change) when the authorization is missing,
216
+ * revoked, or has no provider-facing app config — those are "not connected"
217
+ * states, not refresh failures.
159
218
  */
160
- export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
161
- if (!isTokenExpiringSoon(provider, bufferMs)) return;
162
- const observedTokens = getOAuthTokens(provider);
219
+ export async function ensureAuthorizationTokenOrThrow(
220
+ authorizationId: string,
221
+ bufferMs?: number,
222
+ force = false,
223
+ ): Promise<void> {
224
+ const initial = getAuthorizationById(authorizationId);
225
+ if (!initial || initial.status === "revoked") return;
226
+ if (!authorizationNeedsRefresh(initial, bufferMs, force)) return;
163
227
 
164
- await withProviderRefreshLock(provider, async () => {
228
+ await withAuthorizationRefreshLock(authorizationId, async () => {
165
229
  const waitStartedAt = Date.now();
166
230
 
167
- while (isTokenExpiringSoon(provider, bufferMs)) {
168
- const tokens = getOAuthTokens(provider);
169
- if (tokenRowChanged(tokens, observedTokens)) return;
231
+ while (true) {
232
+ const current = getAuthorizationById(authorizationId);
233
+ if (!current || current.status === "revoked") return;
234
+ // Another caller refreshed (tokenVersion bumped) since this call loaded
235
+ // the row — its work satisfies ours. Without this check a waiter that
236
+ // force-refreshes would rotate again immediately after the winner,
237
+ // burning provider refresh calls.
238
+ if (current.tokenVersion !== initial.tokenVersion) return;
239
+ if (!authorizationNeedsRefresh(current, bufferMs, force)) return;
240
+
241
+ const app = getOAuthAppById(current.appId);
242
+ // No provider-facing app config (missing, or a DCR/MCP app): nothing this
243
+ // path can refresh — leave status untouched.
244
+ if (!app || app.mcpServerId !== null) return;
170
245
 
171
- const config = getOAuthConfig(provider);
172
- if (!config || !tokens?.refreshToken) {
173
- console.warn(
174
- `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
175
- );
176
- return;
246
+ if (!current.refreshToken) {
247
+ const label = authorizationLabelFor(app, current);
248
+ const message = `OAuth authorization '${label}' cannot refresh: no refresh token stored`;
249
+ markAuthorizationRefreshFailed(authorizationId, message);
250
+ throw new OAuthRefreshError(authorizationId, app.id, "no_refresh_token", label, message);
177
251
  }
178
252
 
179
- const lockOwner = acquireOAuthRefreshLock(provider, REFRESH_LOCK_TTL_MS);
253
+ const lockOwner = acquireOAuthRefreshLock(`authz:${authorizationId}`, REFRESH_LOCK_TTL_MS);
180
254
  if (!lockOwner) {
181
255
  if (Date.now() - waitStartedAt > REFRESH_LOCK_WAIT_MS) {
182
- throw new Error(`Timed out waiting for ${provider} OAuth token refresh lock`);
256
+ const label = authorizationLabelFor(app, current);
257
+ const message = `Timed out waiting for OAuth refresh lock for '${label}'`;
258
+ markAuthorizationRefreshFailed(authorizationId, message);
259
+ throw new OAuthRefreshError(authorizationId, app.id, "lock_timeout", label, message);
183
260
  }
184
261
  await sleep(REFRESH_LOCK_POLL_MS);
185
262
  continue;
186
263
  }
187
264
 
188
265
  try {
189
- const lockedTokens = getOAuthTokens(provider);
190
- if (
191
- !isTokenExpiringSoon(provider, bufferMs) ||
192
- tokenRowChanged(lockedTokens, observedTokens)
193
- ) {
194
- return;
195
- }
266
+ // Re-read under the cross-process lock: another node may have refreshed
267
+ // (tokenVersion bumped → no longer expiring) or revoked while we waited.
268
+ const locked = getAuthorizationById(authorizationId);
269
+ if (!locked || locked.status === "revoked") return;
270
+ if (locked.tokenVersion !== initial.tokenVersion) return;
271
+ if (!authorizationNeedsRefresh(locked, bufferMs, force)) return;
272
+
273
+ const lockedApp = getOAuthAppById(locked.appId);
274
+ if (!lockedApp || lockedApp.mcpServerId !== null) return;
196
275
 
197
- const lockedConfig = getOAuthConfig(provider);
198
- if (!lockedConfig || !lockedTokens?.refreshToken) {
199
- console.warn(
200
- `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
276
+ if (!locked.refreshToken) {
277
+ const label = authorizationLabelFor(lockedApp, locked);
278
+ const message = `OAuth authorization '${label}' cannot refresh: no refresh token stored`;
279
+ markAuthorizationRefreshFailed(authorizationId, message);
280
+ throw new OAuthRefreshError(
281
+ authorizationId,
282
+ lockedApp.id,
283
+ "no_refresh_token",
284
+ label,
285
+ message,
201
286
  );
202
- return;
203
287
  }
204
288
 
205
- await refreshAccessToken(lockedConfig, lockedTokens.refreshToken);
206
- console.log(`[OAuth] ${provider} token refreshed successfully`);
207
- return;
289
+ const config = oauthAppRowToProviderConfig(lockedApp);
290
+ try {
291
+ const refreshed = await performTokenRefreshRequest(config, locked.refreshToken);
292
+ const persisted = updateAuthorizationTokens(authorizationId, {
293
+ accessToken: refreshed.accessToken,
294
+ refreshToken: refreshed.refreshToken ?? locked.refreshToken,
295
+ expiresAt: refreshed.expiresAt,
296
+ scope: refreshed.scope ?? null,
297
+ expectedTokenVersion: locked.tokenVersion,
298
+ });
299
+ if (!persisted) {
300
+ // Optimistic-concurrency loss to a concurrent writer — re-evaluate.
301
+ continue;
302
+ }
303
+ notifyAuthorizationRefreshed({
304
+ authorizationId,
305
+ appId: lockedApp.id,
306
+ provider: lockedApp.provider,
307
+ });
308
+ return;
309
+ } catch (err) {
310
+ const label = authorizationLabelFor(lockedApp, locked);
311
+ // The provider token endpoint can echo the submitted refresh_token /
312
+ // client_secret back in an invalid_grant body (which lands in
313
+ // err.message). scrubSecrets only knows env/vendor-fixed shapes, so
314
+ // register this attempt's own secrets as volatile before scrubbing —
315
+ // otherwise they'd be persisted to lastErrorMessage and surfaced in
316
+ // the UI tooltip.
317
+ if (locked.refreshToken)
318
+ registerVolatileSecret(locked.refreshToken, "oauth-refresh-token");
319
+ if (locked.accessToken) registerVolatileSecret(locked.accessToken, "oauth-access-token");
320
+ if (lockedApp.clientSecret)
321
+ registerVolatileSecret(lockedApp.clientSecret, "oauth-client-secret");
322
+ const message = scrubSecrets(err instanceof Error ? err.message : String(err));
323
+ markAuthorizationRefreshFailed(authorizationId, message);
324
+ if (err instanceof OAuthRefreshError) throw err;
325
+ throw new OAuthRefreshError(
326
+ authorizationId,
327
+ lockedApp.id,
328
+ "refresh_rejected",
329
+ label,
330
+ message,
331
+ );
332
+ }
208
333
  } finally {
209
- releaseOAuthRefreshLock(provider, lockOwner);
334
+ releaseOAuthRefreshLock(`authz:${authorizationId}`, lockOwner);
210
335
  }
211
336
  }
212
337
  });
213
338
  }
339
+
340
+ /**
341
+ * Reactive authorization-keyed refresh — never throws. Refresh failures are
342
+ * persisted (`refresh-failed`) by {@link ensureAuthorizationTokenOrThrow} and
343
+ * logged here so a single dead-token incident doesn't tear down an unrelated
344
+ * request path.
345
+ */
346
+ export async function ensureAuthorizationToken(
347
+ authorizationId: string,
348
+ bufferMs?: number,
349
+ ): Promise<void> {
350
+ try {
351
+ await ensureAuthorizationTokenOrThrow(authorizationId, bufferMs);
352
+ } catch (err) {
353
+ console.error(
354
+ scrubSecrets(
355
+ `[OAuth] Failed to refresh authorization ${authorizationId}: ${err instanceof Error ? err.message : String(err)}`,
356
+ ),
357
+ );
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Force a refresh of a specific authorization regardless of remaining lifetime,
363
+ * through the same lock + persistence path. Used by the manual refresh endpoint
364
+ * and the background sweep.
365
+ */
366
+ export async function forceRefreshAuthorizationOrThrow(authorizationId: string): Promise<void> {
367
+ // `force` refreshes regardless of remaining lifetime — including non-expiring
368
+ // (NULL expiresAt) tokens, which the buffer path deliberately skips.
369
+ await ensureAuthorizationTokenOrThrow(authorizationId, undefined, true);
370
+ }
371
+
372
+ // ─── Provider-string compatibility wrappers ──────────────────────────────────
373
+ //
374
+ // Resolve a provider to its migrated `default` authorization and delegate to
375
+ // the authorization-keyed core. Tracker callers migrate off these in step-8.
376
+
377
+ /**
378
+ * Reactive per-provider refresh — never throws. See {@link ensureToken} history:
379
+ * refresh failures are logged, not surfaced.
380
+ */
381
+ export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
382
+ try {
383
+ await ensureTokenOrThrow(provider, bufferMs);
384
+ } catch (err) {
385
+ console.error(
386
+ `[OAuth] Failed to refresh ${provider} token:`,
387
+ err instanceof Error ? err.message : err,
388
+ );
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Strict per-provider refresh. Delegates to the default authorization.
394
+ *
395
+ * Stays silent (no throw) when the provider isn't connected (no app / no
396
+ * default authorization) or the default authorization has no stored refresh
397
+ * token — those are "not connected" states, preserved for legacy tracker /
398
+ * oauth-access-token callers. A genuine provider rejection still throws.
399
+ */
400
+ export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
401
+ const authorizationId = getDefaultAuthorizationIdForProvider(provider);
402
+ if (!authorizationId) return;
403
+ try {
404
+ await ensureAuthorizationTokenOrThrow(authorizationId, bufferMs);
405
+ } catch (err) {
406
+ if (err instanceof OAuthRefreshError && err.reason === "no_refresh_token") {
407
+ // Legacy provider-string semantics: a provider with no refresh token is
408
+ // "not connected", not a refresh failure — stay silent.
409
+ return;
410
+ }
411
+ throw err;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Force-refresh a provider's default authorization. Silent when not connected.
417
+ */
418
+ export async function forceRefreshTokenOrThrow(provider: string): Promise<void> {
419
+ const authorizationId = getDefaultAuthorizationIdForProvider(provider);
420
+ if (!authorizationId) return;
421
+ try {
422
+ await forceRefreshAuthorizationOrThrow(authorizationId);
423
+ } catch (err) {
424
+ if (err instanceof OAuthRefreshError && err.reason === "no_refresh_token") return;
425
+ throw err;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Build an OAuthProviderConfig from the oauth_apps table for any provider.
431
+ * Retained for callers that only need the provider config (not a refresh).
432
+ */
433
+ export function getOAuthConfig(provider: string): OAuthProviderConfig | null {
434
+ const app = getOAuthApp(provider);
435
+ return app ? oauthAppRowToProviderConfig(app) : null;
436
+ }
@@ -0,0 +1,103 @@
1
+ import { scrubSecrets } from "@/utils/secret-scrubber";
2
+ import { assertOAuthEgressUrlSafe } from "./app-validation";
3
+
4
+ /**
5
+ * Best-effort account identity captured after a successful token exchange.
6
+ * Display-only — never used for authorization decisions.
7
+ */
8
+ export interface CapturedIdentity {
9
+ accountEmail: string | null;
10
+ /** JSON string of the raw identity claims we surfaced (email/login/sub). */
11
+ identityJson: string;
12
+ }
13
+
14
+ const IDENTITY_FETCH_TIMEOUT_MS = 5_000;
15
+
16
+ function pickString(value: unknown): string | null {
17
+ return typeof value === "string" && value.length > 0 ? value : null;
18
+ }
19
+
20
+ /** Extract the display-relevant identity fields from a claims/userinfo object. */
21
+ function extractIdentity(source: Record<string, unknown>): CapturedIdentity | null {
22
+ const email = pickString(source.email);
23
+ const login = pickString(source.login) ?? pickString(source.preferred_username);
24
+ const sub = pickString(source.sub) ?? pickString(source.id);
25
+ if (!email && !login && !sub) return null;
26
+ const claims: Record<string, string> = {};
27
+ if (email) claims.email = email;
28
+ if (login) claims.login = login;
29
+ if (sub) claims.sub = sub;
30
+ return { accountEmail: email, identityJson: JSON.stringify(claims) };
31
+ }
32
+
33
+ /** Decode a JWT id_token payload without signature verification (display-only). */
34
+ function decodeIdToken(idToken: string): CapturedIdentity | null {
35
+ const parts = idToken.split(".");
36
+ const payloadSegment = parts[1];
37
+ if (!payloadSegment) return null;
38
+ try {
39
+ const payload = Buffer.from(
40
+ payloadSegment.replace(/-/g, "+").replace(/_/g, "/"),
41
+ "base64",
42
+ ).toString("utf8");
43
+ const parsed = JSON.parse(payload);
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
45
+ return extractIdentity(parsed as Record<string, unknown>);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Capture the connected account's identity after a token exchange. Prefers a
53
+ * `userinfoUrl` probe (Bearer), falling back to decoding an OIDC `id_token`.
54
+ * All failures are swallowed (scrubbed log) — identity capture must NEVER fail
55
+ * the OAuth callback. Returns null when nothing could be captured.
56
+ */
57
+ export async function captureIdentity(input: {
58
+ userinfoUrl?: string | null;
59
+ accessToken: string;
60
+ idToken?: string | null;
61
+ }): Promise<CapturedIdentity | null> {
62
+ try {
63
+ if (input.userinfoUrl) {
64
+ // Fail-closed host re-check at egress: we are about to send a live bearer.
65
+ assertOAuthEgressUrlSafe(input.userinfoUrl);
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), IDENTITY_FETCH_TIMEOUT_MS);
68
+ try {
69
+ const response = await fetch(input.userinfoUrl, {
70
+ headers: {
71
+ authorization: `Bearer ${input.accessToken}`,
72
+ accept: "application/json",
73
+ },
74
+ signal: controller.signal,
75
+ // Never follow a redirect: a public userinfoUrl could 302 the bearer
76
+ // toward an internal metadata endpoint. A 3xx is not `.ok`, so it is
77
+ // simply ignored below.
78
+ redirect: "manual",
79
+ });
80
+ if (response.ok) {
81
+ const data = (await response.json()) as unknown;
82
+ if (data && typeof data === "object" && !Array.isArray(data)) {
83
+ const identity = extractIdentity(data as Record<string, unknown>);
84
+ if (identity) return identity;
85
+ }
86
+ }
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ if (input.idToken) {
93
+ return decodeIdToken(input.idToken);
94
+ }
95
+ } catch (err) {
96
+ console.warn(
97
+ scrubSecrets(
98
+ `[oauth] identity capture failed: ${err instanceof Error ? err.message : String(err)}`,
99
+ ),
100
+ );
101
+ }
102
+ return null;
103
+ }