@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
@@ -11,8 +11,11 @@ let _bridgeServer: McpServer | null = null;
11
11
  function getBridgeServer(): McpServer {
12
12
  if (!_bridgeServer) {
13
13
  // Always full tool surface: the bridge is how scripts reach every SDK
14
- // tool, including when SCRIPTS_ONLY_MCP trims the external MCP server.
15
- _bridgeServer = createServer({ scriptsOnly: false });
14
+ // tool, including when SCRIPTS_ONLY_MCP trims the external MCP server or
15
+ // CAPABILITIES trims the externally exposed tool groups. Capability flags
16
+ // shape the agents' MCP tool list, not what scripts can do — the scripts
17
+ // surface is governed by SDK_ALLOWLIST instead.
18
+ _bridgeServer = createServer({ scriptsOnly: false, fullSurface: true });
16
19
  }
17
20
  return _bridgeServer;
18
21
  }
@@ -6,7 +6,6 @@ import {
6
6
  applyMcpOAuthRefresh,
7
7
  consumeMcpOAuthPending,
8
8
  deleteMcpOAuthToken,
9
- gcMcpOAuthPending,
10
9
  getMcpOAuthToken,
11
10
  insertMcpOAuthPending,
12
11
  setMcpServerAuthMethod,
@@ -407,6 +406,103 @@ async function prepareAuthorizeFlow(
407
406
  return built.url;
408
407
  }
409
408
 
409
+ // ─── Callback completion (shared with the static /api/oauth/callback route) ──
410
+
411
+ interface OAuthCallbackParams {
412
+ code?: string;
413
+ state?: string;
414
+ error?: string;
415
+ error_description?: string;
416
+ }
417
+
418
+ /**
419
+ * Complete an MCP-flow OAuth callback: consume the `mcp` pending row, exchange
420
+ * the code, upsert the token, and flip `authMethod=oauth`. Shared by the legacy
421
+ * `/api/mcp-oauth/callback` route and the unified `/api/oauth/callback` route.
422
+ * Returns false (without writing a response) when no `mcp` pending row matches
423
+ * `state`, so the unified handler can report a single invalid-state error.
424
+ */
425
+ export async function completeMcpOAuthCallback(
426
+ res: ServerResponse,
427
+ query: OAuthCallbackParams,
428
+ ): Promise<boolean> {
429
+ const state = query.state;
430
+ if (!state) return false;
431
+ const pending = consumeMcpOAuthPending(state);
432
+ if (!pending) return false;
433
+
434
+ const dashboardBaseUrl = pending.finalRedirect ?? defaultFinalRedirect(pending.mcpServerId);
435
+
436
+ if (query.error) {
437
+ const target = new URL(dashboardBaseUrl);
438
+ target.searchParams.set("oauth", "error");
439
+ target.searchParams.set("error", query.error);
440
+ if (query.error_description) {
441
+ target.searchParams.set("error_description", query.error_description);
442
+ }
443
+ res.writeHead(302, { Location: target.toString() });
444
+ res.end();
445
+ return true;
446
+ }
447
+
448
+ if (!query.code) {
449
+ jsonError(res, "Missing authorization code", 400);
450
+ return true;
451
+ }
452
+
453
+ try {
454
+ const tokens = await exchangeCodeForTokens({
455
+ tokenUrl: pending.tokenUrl,
456
+ clientId: pending.dcrClientId ?? "",
457
+ clientSecret: pending.dcrClientSecret ?? undefined,
458
+ redirectUri: pending.redirectUri,
459
+ code: query.code,
460
+ codeVerifier: pending.codeVerifier,
461
+ resource: pending.resourceUrl,
462
+ });
463
+ const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
464
+ const clientSource =
465
+ existing?.clientSource ??
466
+ (pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
467
+
468
+ upsertMcpOAuthToken({
469
+ mcpServerId: pending.mcpServerId,
470
+ userId: pending.userId,
471
+ accessToken: tokens.access_token,
472
+ ...(tokens.refresh_token != null ? { refreshToken: tokens.refresh_token } : {}),
473
+ tokenType: tokens.token_type ?? "Bearer",
474
+ expiresAt: computeExpiresAt(tokens.expires_in),
475
+ scope: tokens.scope ?? pending.scopes ?? null,
476
+ resourceUrl: pending.resourceUrl,
477
+ authorizationServerIssuer: pending.authorizationServerIssuer,
478
+ authorizeUrl: pending.authorizeUrl,
479
+ tokenUrl: pending.tokenUrl,
480
+ revocationUrl: pending.revocationUrl,
481
+ dcrClientId: pending.dcrClientId,
482
+ dcrClientSecret: pending.dcrClientSecret,
483
+ clientSource,
484
+ lastRefreshedAt: new Date().toISOString(),
485
+ });
486
+
487
+ // Flip authMethod=oauth so resolveSecrets picks this up.
488
+ setMcpServerAuthMethod(pending.mcpServerId, "oauth");
489
+
490
+ const target = new URL(dashboardBaseUrl);
491
+ target.searchParams.set("oauth", "success");
492
+ res.writeHead(302, { Location: target.toString() });
493
+ res.end();
494
+ } catch (err) {
495
+ const message = err instanceof Error ? err.message : String(err);
496
+ console.error("[mcp-oauth] callback exchange failed:", message);
497
+ const target = new URL(dashboardBaseUrl);
498
+ target.searchParams.set("oauth", "error");
499
+ target.searchParams.set("error_description", message);
500
+ res.writeHead(302, { Location: target.toString() });
501
+ res.end();
502
+ }
503
+ return true;
504
+ }
505
+
410
506
  // ─── Handler ─────────────────────────────────────────────────────────────────
411
507
 
412
508
  export async function handleMcpOAuth(
@@ -420,85 +516,13 @@ export async function handleMcpOAuth(
420
516
  const parsed = await callbackRoute.parse(req, res, pathSegments, queryParams);
421
517
  if (!parsed) return true;
422
518
 
423
- const state = parsed.query.state;
424
- if (!state) {
519
+ if (!parsed.query.state) {
425
520
  jsonError(res, "Missing state parameter", 400);
426
521
  return true;
427
522
  }
428
- const pending = consumeMcpOAuthPending(state);
429
- if (!pending) {
523
+ const handled = await completeMcpOAuthCallback(res, parsed.query);
524
+ if (!handled) {
430
525
  jsonError(res, "Invalid or expired OAuth state", 400);
431
- return true;
432
- }
433
-
434
- const dashboardBaseUrl = pending.finalRedirect ?? defaultFinalRedirect(pending.mcpServerId);
435
-
436
- if (parsed.query.error) {
437
- const target = new URL(dashboardBaseUrl);
438
- target.searchParams.set("oauth", "error");
439
- target.searchParams.set("error", parsed.query.error);
440
- if (parsed.query.error_description) {
441
- target.searchParams.set("error_description", parsed.query.error_description);
442
- }
443
- res.writeHead(302, { Location: target.toString() });
444
- res.end();
445
- return true;
446
- }
447
-
448
- if (!parsed.query.code) {
449
- jsonError(res, "Missing authorization code", 400);
450
- return true;
451
- }
452
-
453
- try {
454
- const tokens = await exchangeCodeForTokens({
455
- tokenUrl: pending.tokenUrl,
456
- clientId: pending.dcrClientId ?? "",
457
- clientSecret: pending.dcrClientSecret ?? undefined,
458
- redirectUri: pending.redirectUri,
459
- code: parsed.query.code,
460
- codeVerifier: pending.codeVerifier,
461
- resource: pending.resourceUrl,
462
- });
463
- const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
464
- const clientSource =
465
- existing?.clientSource ??
466
- (pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
467
-
468
- upsertMcpOAuthToken({
469
- mcpServerId: pending.mcpServerId,
470
- userId: pending.userId,
471
- accessToken: tokens.access_token,
472
- refreshToken: tokens.refresh_token ?? null,
473
- tokenType: tokens.token_type ?? "Bearer",
474
- expiresAt: computeExpiresAt(tokens.expires_in),
475
- scope: tokens.scope ?? pending.scopes ?? null,
476
- resourceUrl: pending.resourceUrl,
477
- authorizationServerIssuer: pending.authorizationServerIssuer,
478
- authorizeUrl: pending.authorizeUrl,
479
- tokenUrl: pending.tokenUrl,
480
- revocationUrl: pending.revocationUrl,
481
- dcrClientId: pending.dcrClientId,
482
- dcrClientSecret: pending.dcrClientSecret,
483
- clientSource,
484
- lastRefreshedAt: new Date().toISOString(),
485
- });
486
-
487
- // Flip authMethod=oauth so resolveSecrets picks this up.
488
- setMcpServerAuthMethod(pending.mcpServerId, "oauth");
489
-
490
- const target = new URL(dashboardBaseUrl);
491
- target.searchParams.set("oauth", "success");
492
- res.writeHead(302, { Location: target.toString() });
493
- res.end();
494
- } catch (err) {
495
- const message = err instanceof Error ? err.message : String(err);
496
- console.error("[mcp-oauth] callback exchange failed:", message);
497
- const target = new URL(dashboardBaseUrl);
498
- target.searchParams.set("oauth", "error");
499
- target.searchParams.set("error_description", message);
500
- res.writeHead(302, { Location: target.toString() });
501
- res.end();
502
526
  }
503
527
  return true;
504
528
  }
@@ -639,6 +663,7 @@ export async function handleMcpOAuth(
639
663
  refreshToken: refreshed.refresh_token ?? undefined,
640
664
  expiresAt: computeExpiresAt(refreshed.expires_in),
641
665
  scope: refreshed.scope ?? null,
666
+ expectedTokenVersion: existing.tokenVersion,
642
667
  });
643
668
  json(res, {
644
669
  ok: true,
@@ -764,31 +789,8 @@ export async function handleMcpOAuth(
764
789
  return false;
765
790
  }
766
791
 
767
- // ─── Pending garbage collector ───────────────────────────────────────────────
768
-
769
- let gcTimer: ReturnType<typeof setInterval> | null = null;
770
-
771
- export function startMcpOAuthPendingGc(intervalMs = 5 * 60 * 1000): void {
772
- if (gcTimer) return;
773
- gcTimer = setInterval(() => {
774
- try {
775
- const removed = gcMcpOAuthPending();
776
- if (removed > 0) {
777
- console.debug(`[mcp-oauth] GC removed ${removed} expired pending session(s)`);
778
- }
779
- } catch (err) {
780
- console.error("[mcp-oauth] GC failed:", err);
781
- }
782
- }, intervalMs);
783
- if (typeof gcTimer?.unref === "function") gcTimer.unref();
784
- }
785
-
786
- export function stopMcpOAuthPendingGc(): void {
787
- if (gcTimer) {
788
- clearInterval(gcTimer);
789
- gcTimer = null;
790
- }
791
- }
792
+ // Pending garbage collection now runs through the unified GC in
793
+ // `oauth-callback.ts` (`startOAuthPendingGc`), which sweeps all flows.
792
794
 
793
795
  // Expose internal helpers for the resolveSecrets extension in mcp-servers.ts.
794
796
  export { ensureMcpToken };
@@ -0,0 +1,332 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { gcMcpOAuthPending } from "@/be/db-queries/mcp-oauth";
4
+ import {
5
+ consumeOAuthPending,
6
+ gcOAuthPending,
7
+ getOAuthAppById,
8
+ updateAuthorizationIdentity,
9
+ upsertAuthorization,
10
+ } from "@/be/db-queries/oauth";
11
+ import { resolveAndStoreJiraCloudId } from "@/jira/oauth";
12
+ import { captureLinearAppUserId } from "@/linear/oauth";
13
+ import { oauthAppRowToProviderConfig } from "@/oauth/ensure-token";
14
+ import { captureIdentity } from "@/oauth/identity-capture";
15
+ import { exchangeAuthorizationCode } from "@/oauth/wrapper";
16
+ import { getPublicMcpBaseUrl } from "@/utils/constants";
17
+ import { registerVolatileSecret, scrubSecrets } from "@/utils/secret-scrubber";
18
+ import { completeMcpOAuthCallback } from "./mcp-oauth";
19
+ import { route } from "./route-def";
20
+ import { json, jsonError } from "./utils";
21
+
22
+ // ─── The single static callback + redirect-uri display ───────────────────────
23
+
24
+ /** The constant, state-keyed OAuth redirect target for all flows. */
25
+ export function staticOAuthCallbackUri(): string {
26
+ return `${getPublicMcpBaseUrl()}/api/oauth/callback`;
27
+ }
28
+
29
+ const callbackRoute = route({
30
+ method: "get",
31
+ path: "/api/oauth/callback",
32
+ pattern: ["api", "oauth", "callback"],
33
+ operationId: "oauth_static_callback",
34
+ summary: "Single static OAuth redirect target (state-keyed, all flows)",
35
+ tags: ["OAuth"],
36
+ auth: { apiKey: false },
37
+ query: z.object({
38
+ code: z.string().optional(),
39
+ state: z.string().optional(),
40
+ error: z.string().optional(),
41
+ error_description: z.string().optional(),
42
+ }),
43
+ responses: {
44
+ 200: { description: "OAuth authorization completed" },
45
+ 302: { description: "Redirect back to the final destination" },
46
+ 400: { description: "Missing or invalid OAuth callback parameters" },
47
+ 404: { description: "OAuth app not configured" },
48
+ 502: { description: "Token exchange failed" },
49
+ },
50
+ });
51
+
52
+ const redirectUriRoute = route({
53
+ method: "get",
54
+ path: "/api/oauth/redirect-uri",
55
+ pattern: ["api", "oauth", "redirect-uri"],
56
+ operationId: "oauth_redirect_uri",
57
+ summary: "The static OAuth callback URL to register with providers (pre-creation display)",
58
+ tags: ["OAuth"],
59
+ responses: {
60
+ 200: { description: "{ redirectUri: string }" },
61
+ },
62
+ });
63
+
64
+ interface OAuthCallbackParams {
65
+ code?: string;
66
+ state?: string;
67
+ error?: string;
68
+ error_description?: string;
69
+ }
70
+
71
+ /** Escape a value for safe interpolation into the success HTML page. */
72
+ function escapeHtml(value: string): string {
73
+ return value
74
+ .replace(/&/g, "&amp;")
75
+ .replace(/</g, "&lt;")
76
+ .replace(/>/g, "&gt;")
77
+ .replace(/"/g, "&quot;")
78
+ .replace(/'/g, "&#39;");
79
+ }
80
+
81
+ function sendAuthorizedHtml(res: ServerResponse, provider: string, label: string): void {
82
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
83
+ res.end(`<!DOCTYPE html>
84
+ <html>
85
+ <head><title>OAuth Authorized</title></head>
86
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
87
+ <main style="text-align: center;">
88
+ <h1>${escapeHtml(provider)} authorized</h1>
89
+ <p>Connected the "${escapeHtml(label)}" authorization. You can close this tab.</p>
90
+ </main>
91
+ </body>
92
+ </html>`);
93
+ }
94
+
95
+ /**
96
+ * 302 to a caller-supplied `finalRedirect`. Rejects non-http(s) schemes
97
+ * (javascript:/data:) so the redirect target can never carry script; an origin
98
+ * allowlist is out of scope for step-4 (noted for a follow-up). Falls back to a
99
+ * plain error page when the target is unsafe.
100
+ */
101
+ function redirectWith(res: ServerResponse, base: string, params: Record<string, string>): void {
102
+ let target: URL;
103
+ try {
104
+ target = new URL(base);
105
+ } catch {
106
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
107
+ res.end("Invalid redirect target.");
108
+ return;
109
+ }
110
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
111
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
112
+ res.end("Unsupported redirect scheme.");
113
+ return;
114
+ }
115
+ for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
116
+ res.writeHead(302, { Location: target.toString() });
117
+ res.end();
118
+ }
119
+
120
+ /**
121
+ * Provider-specific post-processing for a completed `flow='tracker'`
122
+ * authorization, folded onto the unified callback from the retired dedicated
123
+ * tracker callbacks:
124
+ *
125
+ * - **jira**: resolve the workspace `cloudId`/`siteUrl` into `oauth_apps`
126
+ * metadata. Required for the Jira REST API — failures propagate to the caller.
127
+ * - **linear**: capture the bot `appUserId` (best-effort — swallowed here so it
128
+ * never fails the OAuth callback).
129
+ */
130
+ async function runTrackerCallbackPostProcess(provider: string, accessToken: string): Promise<void> {
131
+ if (provider === "jira") {
132
+ await resolveAndStoreJiraCloudId(accessToken);
133
+ return;
134
+ }
135
+ if (provider === "linear") {
136
+ try {
137
+ await captureLinearAppUserId(accessToken);
138
+ } catch (err) {
139
+ console.warn(
140
+ scrubSecrets(
141
+ `[Linear] Failed to capture appUserId during OAuth completion (non-fatal): ${
142
+ err instanceof Error ? err.message : String(err)
143
+ }`,
144
+ ),
145
+ );
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Complete a generic/tracker OAuth callback: consume the pending row, exchange
152
+ * the code against the app's token endpoint, upsert the `(appId, label)`
153
+ * authorization, and capture account identity (best-effort). Returns
154
+ * `{ handled: false }` (without writing a response) when no generic/tracker
155
+ * pending row matches `state`, so callers can fall through to the MCP flow.
156
+ */
157
+ export async function completeGenericOAuthCallback(
158
+ res: ServerResponse,
159
+ query: OAuthCallbackParams,
160
+ ): Promise<{ handled: boolean }> {
161
+ const state = query.state;
162
+ if (!state) return { handled: false };
163
+
164
+ const pending = consumeOAuthPending(state);
165
+ if (!pending) return { handled: false };
166
+
167
+ const app = getOAuthAppById(pending.appId);
168
+ if (!app) {
169
+ if (pending.finalRedirect) {
170
+ redirectWith(res, pending.finalRedirect, { oauth: "error", error: "app_not_found" });
171
+ } else {
172
+ jsonError(res, "OAuth app is no longer configured", 404);
173
+ }
174
+ return { handled: true };
175
+ }
176
+
177
+ if (query.error) {
178
+ const description = query.error_description ?? query.error;
179
+ if (pending.finalRedirect) {
180
+ redirectWith(res, pending.finalRedirect, {
181
+ oauth: "error",
182
+ error: query.error,
183
+ error_description: description,
184
+ });
185
+ } else {
186
+ jsonError(res, description, 400);
187
+ }
188
+ return { handled: true };
189
+ }
190
+
191
+ if (!query.code) {
192
+ if (pending.finalRedirect) {
193
+ redirectWith(res, pending.finalRedirect, { oauth: "error", error: "missing_code" });
194
+ } else {
195
+ jsonError(res, "Missing authorization code", 400);
196
+ }
197
+ return { handled: true };
198
+ }
199
+
200
+ try {
201
+ const config = oauthAppRowToProviderConfig(app);
202
+ const tokens = await exchangeAuthorizationCode(config, {
203
+ code: query.code,
204
+ codeVerifier: pending.codeVerifier,
205
+ redirectUri: pending.redirectUri,
206
+ });
207
+ // No `expires_in` means the provider issued a non-expiring token (e.g. the
208
+ // GitHub OAuth preset, which returns a long-lived token and no refresh
209
+ // token). Store NULL — a fabricated expiry would later drive the sweep /
210
+ // resolveOAuthBindingToken to mark the authorization refresh-failed (no
211
+ // refresh token to rotate) while the token is still perfectly valid. NULL
212
+ // means "does not expire / never proactively refresh" throughout.
213
+ const expiresAt = tokens.expiresIn
214
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
215
+ : null;
216
+
217
+ const authorization = upsertAuthorization({
218
+ appId: pending.appId,
219
+ label: pending.label,
220
+ accessToken: tokens.accessToken,
221
+ ...(tokens.refreshToken != null ? { refreshToken: tokens.refreshToken } : {}),
222
+ ...(tokens.tokenType ? { tokenType: tokens.tokenType } : {}),
223
+ expiresAt,
224
+ ...(tokens.scope != null ? { scope: tokens.scope } : {}),
225
+ ...(pending.userId ? { userId: pending.userId, connectedByUserId: pending.userId } : {}),
226
+ status: "active",
227
+ });
228
+
229
+ const identity = await captureIdentity({
230
+ userinfoUrl: app.userinfoUrl,
231
+ accessToken: tokens.accessToken,
232
+ idToken: tokens.idToken,
233
+ });
234
+ if (identity) {
235
+ updateAuthorizationIdentity(authorization.id, {
236
+ accountEmail: identity.accountEmail,
237
+ identityJson: identity.identityJson,
238
+ });
239
+ }
240
+
241
+ // Tracker (Linear/Jira) flows fold onto this unified handler; run the
242
+ // provider-specific post-exchange step that used to live in the dedicated
243
+ // tracker callbacks. Jira cloudId resolution is REQUIRED (jiraFetch depends
244
+ // on it) so a failure surfaces via the outer catch; Linear appUserId
245
+ // capture is best-effort and swallowed inside the helper.
246
+ if (pending.flow === "tracker") {
247
+ await runTrackerCallbackPostProcess(app.provider, tokens.accessToken);
248
+ }
249
+
250
+ if (pending.finalRedirect) {
251
+ redirectWith(res, pending.finalRedirect, { oauth: "success" });
252
+ } else {
253
+ sendAuthorizedHtml(res, app.provider, pending.label);
254
+ }
255
+ } catch (err) {
256
+ const rawMessage = err instanceof Error ? err.message : String(err);
257
+ // The provider token-endpoint body can echo back secrets — scrub before it
258
+ // reaches the log AND the browser/finalRedirect. The posted client_secret
259
+ // and PKCE code verifier are DB-sourced (not in scrubSecrets' env/shape
260
+ // cache), so register them as volatile first, like ensure-token.ts does.
261
+ if (app.clientSecret) registerVolatileSecret(app.clientSecret, "oauth-client-secret");
262
+ if (pending.codeVerifier) registerVolatileSecret(pending.codeVerifier, "oauth-code-verifier");
263
+ const message = scrubSecrets(rawMessage);
264
+ console.warn(`[oauth] callback exchange failed for ${app.provider}: ${message}`);
265
+ if (pending.finalRedirect) {
266
+ redirectWith(res, pending.finalRedirect, { oauth: "error", error_description: message });
267
+ } else {
268
+ jsonError(res, `Token exchange failed: ${message}`, 502);
269
+ }
270
+ }
271
+ return { handled: true };
272
+ }
273
+
274
+ // ─── Handler ─────────────────────────────────────────────────────────────────
275
+
276
+ export async function handleOAuthCallback(
277
+ req: IncomingMessage,
278
+ res: ServerResponse,
279
+ pathSegments: string[],
280
+ queryParams: URLSearchParams,
281
+ ): Promise<boolean> {
282
+ if (redirectUriRoute.match(req.method, pathSegments)) {
283
+ json(res, { redirectUri: staticOAuthCallbackUri() });
284
+ return true;
285
+ }
286
+
287
+ if (!callbackRoute.match(req.method, pathSegments)) return false;
288
+
289
+ const parsed = await callbackRoute.parse(req, res, pathSegments, queryParams);
290
+ if (!parsed) return true;
291
+
292
+ if (!parsed.query.state) {
293
+ jsonError(res, "Missing state parameter", 400);
294
+ return true;
295
+ }
296
+
297
+ const generic = await completeGenericOAuthCallback(res, parsed.query);
298
+ if (generic.handled) return true;
299
+
300
+ // Not a generic/tracker pending — try the MCP flow (same static callback).
301
+ const mcpHandled = await completeMcpOAuthCallback(res, parsed.query);
302
+ if (!mcpHandled) {
303
+ jsonError(res, "Invalid or expired OAuth state", 400);
304
+ }
305
+ return true;
306
+ }
307
+
308
+ // ─── Unified pending garbage collector (all flows) ───────────────────────────
309
+
310
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
311
+
312
+ export function startOAuthPendingGc(intervalMs = 5 * 60 * 1000): void {
313
+ if (gcTimer) return;
314
+ gcTimer = setInterval(() => {
315
+ try {
316
+ const removed = gcOAuthPending() + gcMcpOAuthPending();
317
+ if (removed > 0) {
318
+ console.debug(`[oauth] GC removed ${removed} expired pending session(s)`);
319
+ }
320
+ } catch (err) {
321
+ console.error("[oauth] pending GC failed:", err);
322
+ }
323
+ }, intervalMs);
324
+ if (typeof gcTimer?.unref === "function") gcTimer.unref();
325
+ }
326
+
327
+ export function stopOAuthPendingGc(): void {
328
+ if (gcTimer) {
329
+ clearInterval(gcTimer);
330
+ gcTimer = null;
331
+ }
332
+ }
@@ -1,18 +1,23 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getOAuthProviderConfig } from "@/be/oauth-credential-bindings";
4
- import { exchangeCode } from "@/oauth/wrapper";
3
+ import { completeGenericOAuthCallback } from "./oauth-callback";
5
4
  import { route } from "./route-def";
6
5
  import { jsonError } from "./utils";
7
6
 
8
- const DEDICATED_CALLBACK_PROVIDERS = new Set(["linear"]);
9
-
7
+ /**
8
+ * Legacy per-provider OAuth redirect target. Retained as a thin compatibility
9
+ * wrapper over the unified, state-keyed callback: everything routes through
10
+ * {@link completeGenericOAuthCallback} by `state`, so the `{provider}` path
11
+ * param is now purely cosmetic. New apps use the single static
12
+ * `/api/oauth/callback` instead. The old linear/jira 409 asymmetry is gone —
13
+ * tracker flows resolve through the same handler.
14
+ */
10
15
  const genericOAuthCallbackRoute = route({
11
16
  method: "get",
12
17
  path: "/api/oauth/{provider}/callback",
13
18
  pattern: ["api", "oauth", null, "callback"],
14
19
  operationId: "oauth_generic_callback",
15
- summary: "Generic OAuth redirect target for script credential bindings",
20
+ summary: "Legacy per-provider OAuth redirect target (delegates to the static callback)",
16
21
  tags: ["OAuth"],
17
22
  auth: { apiKey: false },
18
23
  params: z.object({
@@ -32,25 +37,10 @@ const genericOAuthCallbackRoute = route({
32
37
  200: { description: "OAuth authorization completed" },
33
38
  400: { description: "Missing or invalid OAuth callback parameters" },
34
39
  404: { description: "OAuth app not configured" },
35
- 409: { description: "Provider has a dedicated OAuth callback" },
36
40
  502: { description: "Token exchange failed" },
37
41
  },
38
42
  });
39
43
 
40
- function sendAuthorizedHtml(res: ServerResponse, provider: string): void {
41
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
42
- res.end(`<!DOCTYPE html>
43
- <html>
44
- <head><title>OAuth Authorized</title></head>
45
- <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
46
- <main style="text-align: center;">
47
- <h1>${provider} authorized</h1>
48
- <p>You can close this tab.</p>
49
- </main>
50
- </body>
51
- </html>`);
52
- }
53
-
54
44
  export async function handleGenericOAuth(
55
45
  req: IncomingMessage,
56
46
  res: ServerResponse,
@@ -62,44 +52,14 @@ export async function handleGenericOAuth(
62
52
  const parsed = await genericOAuthCallbackRoute.parse(req, res, pathSegments, queryParams);
63
53
  if (!parsed) return true;
64
54
 
65
- const { provider } = parsed.params;
66
- if (DEDICATED_CALLBACK_PROVIDERS.has(provider)) {
67
- jsonError(
68
- res,
69
- "Provider linear uses the dedicated OAuth flow at /api/trackers/linear/authorize and /api/trackers/linear/callback.",
70
- 409,
71
- );
72
- return true;
73
- }
74
-
75
- if (parsed.query.error) {
76
- jsonError(res, parsed.query.error_description ?? parsed.query.error, 400);
77
- return true;
78
- }
79
-
80
- if (!parsed.query.code || !parsed.query.state) {
55
+ if (!parsed.query.state) {
81
56
  jsonError(res, "Missing code or state parameter", 400);
82
57
  return true;
83
58
  }
84
59
 
85
- const config = getOAuthProviderConfig(provider);
86
- if (!config) {
87
- jsonError(res, `OAuth app ${provider} is not configured`, 404);
88
- return true;
89
- }
90
-
91
- try {
92
- await exchangeCode(config, parsed.query.code, parsed.query.state);
93
- sendAuthorizedHtml(res, provider);
94
- } catch (err) {
95
- const message = err instanceof Error ? err.message : String(err);
96
- const status = message.includes("Invalid or expired OAuth state") ? 400 : 502;
97
- jsonError(
98
- res,
99
- status === 400 ? "Invalid or expired OAuth state" : `Token exchange failed: ${message}`,
100
- status,
101
- );
60
+ const { handled } = await completeGenericOAuthCallback(res, parsed.query);
61
+ if (!handled) {
62
+ jsonError(res, "Invalid or expired OAuth state", 400);
102
63
  }
103
-
104
64
  return true;
105
65
  }