@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,4 +1,5 @@
1
- import { ensureTokenOrThrow } from "./ensure-token";
1
+ import { listKeepAliveAuthorizations } from "../be/db-queries/oauth";
2
+ import { ensureAuthorizationTokenOrThrow } from "./ensure-token";
2
3
 
3
4
  // Keep refresh tokens warm without constantly rotating strict-rotation
4
5
  // providers. Reactive callers still refresh access tokens before API use.
@@ -6,8 +7,6 @@ const KEEPALIVE_INTERVAL_MS = 12 * 60 * 60 * 1000;
6
7
  const KEEPALIVE_BUFFER_MS = 10 * 60 * 1000;
7
8
  const STARTUP_KEEPALIVE_DELAY_MS = 10_000;
8
9
 
9
- const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
10
-
11
10
  let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
12
11
  let startupKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null;
13
12
  let inflightKeepalive: Promise<void> | null = null;
@@ -41,16 +40,22 @@ function scheduleKeepaliveRun(trigger: "startup" | "interval" | "manual"): Promi
41
40
  */
42
41
  async function runKeepalive(trigger: "startup" | "interval" | "manual" = "manual"): Promise<void> {
43
42
  console.log(`[OAuth Keepalive] Running ${trigger} token refresh check`);
44
- for (const provider of KEEPALIVE_PROVIDERS) {
45
- console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
43
+ const authorizations = listKeepAliveAuthorizations();
44
+ for (const authorization of authorizations) {
45
+ const label = authorization.displayName?.trim() || authorization.provider;
46
+ const named =
47
+ authorization.label && authorization.label !== "default"
48
+ ? `${label} (${authorization.label})`
49
+ : label;
50
+ console.log(`[OAuth Keepalive] Running scheduled token refresh for ${named}...`);
46
51
  try {
47
- await ensureTokenOrThrow(provider, KEEPALIVE_BUFFER_MS);
48
- console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
52
+ await ensureAuthorizationTokenOrThrow(authorization.authorizationId, KEEPALIVE_BUFFER_MS);
53
+ console.log(`[OAuth Keepalive] ${named} token check completed successfully`);
49
54
  } catch (err) {
50
55
  const message = err instanceof Error ? err.message : String(err);
51
- console.error(`[OAuth Keepalive] Failed to refresh ${provider} token: ${message}`);
56
+ console.error(`[OAuth Keepalive] Failed to refresh ${named} token: ${message}`);
52
57
  await notifySlack(
53
- `⚠️ *OAuth Keepalive Failed*\nProvider: \`${provider}\`\nError: ${message}\n\nManual re-authorization may be required.`,
58
+ `⚠️ *OAuth Keepalive Failed*\nApp: \`${named}\`\nError: ${message}\n\nManual re-authorization may be required.`,
54
59
  );
55
60
  }
56
61
  }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Curated OAuth presets.
3
+ *
4
+ * Pure, static data — NO database or network access — so this module can be
5
+ * imported from HTTP handlers, MCP tools, and CLI code alike. Each preset
6
+ * generalizes the provider-specific OAuth quirks (endpoints, scope joining,
7
+ * token-endpoint auth style/body format, refresh-token rotation, and any
8
+ * authorization-URL extra params) that used to live in hardcoded per-provider
9
+ * builders. Client credentials are NEVER shipped here: customers always bring
10
+ * their own `clientId`/`clientSecret`.
11
+ *
12
+ * The `setupHints` on each preset are human-readable operator notes surfaced by
13
+ * pickers and by the app-creation response.
14
+ */
15
+
16
+ export interface OAuthPreset {
17
+ /** Stable preset identifier referenced by the catalog manifest + pickers. */
18
+ id: string;
19
+ /** Human-friendly provider name. */
20
+ displayName: string;
21
+ /** Default provider slug used when the caller omits one. */
22
+ provider: string;
23
+ authorizeUrl: string;
24
+ tokenUrl: string;
25
+ /** RFC 7009 revocation endpoint, when the provider exposes one. */
26
+ revocationUrl?: string;
27
+ /** Identity-capture hint endpoint (OIDC userinfo or equivalent). */
28
+ userinfoUrl?: string;
29
+ /** Sensible default scopes; customers routinely override/extend these. */
30
+ scopes: string[];
31
+ /**
32
+ * How `scopes` are joined in the authorization URL. RFC 6749 default is a
33
+ * space; some providers (Linear, Slack) require a comma.
34
+ */
35
+ scopeSeparator?: string;
36
+ /** `"basic"` when the token endpoint needs HTTP Basic client auth. */
37
+ tokenAuthStyle?: "body" | "basic";
38
+ /** `"json"` when the token endpoint needs a JSON (not form) request body. */
39
+ tokenBodyFormat?: "form" | "json";
40
+ /** Provider rotates refresh tokens on every refresh (fail loudly if absent). */
41
+ requiresRefreshTokenRotation?: boolean;
42
+ /** Extra params appended to the authorization URL (e.g. Google offline access). */
43
+ extraParams?: Record<string, string>;
44
+ /** Operator-facing quirk notes surfaced in pickers + the creation response. */
45
+ setupHints: string[];
46
+ }
47
+
48
+ /**
49
+ * Fields a caller may supply explicitly to override a preset. Every field is
50
+ * optional; anything omitted is filled from the preset.
51
+ */
52
+ export interface OAuthPresetOverrides {
53
+ provider?: string;
54
+ authorizeUrl?: string;
55
+ tokenUrl?: string;
56
+ revocationUrl?: string | null;
57
+ userinfoUrl?: string | null;
58
+ scopes?: string[];
59
+ scopeSeparator?: string;
60
+ tokenAuthStyle?: "body" | "basic";
61
+ tokenBodyFormat?: "form" | "json";
62
+ requiresRefreshTokenRotation?: boolean;
63
+ extraParams?: Record<string, string>;
64
+ }
65
+
66
+ /** Result of merging a preset with explicit overrides. */
67
+ export interface HydratedOAuthApp {
68
+ provider: string;
69
+ authorizeUrl: string;
70
+ tokenUrl: string;
71
+ revocationUrl?: string | null;
72
+ userinfoUrl?: string | null;
73
+ scopes: string[];
74
+ scopeSeparator?: string;
75
+ tokenAuthStyle?: "body" | "basic";
76
+ tokenBodyFormat?: "form" | "json";
77
+ requiresRefreshTokenRotation?: boolean;
78
+ extraParams?: Record<string, string>;
79
+ source: "curated-prefill";
80
+ setupHints: string[];
81
+ }
82
+
83
+ const OAUTH_PRESETS: readonly OAuthPreset[] = [
84
+ {
85
+ id: "google",
86
+ displayName: "Google",
87
+ provider: "google",
88
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
89
+ tokenUrl: "https://oauth2.googleapis.com/token",
90
+ revocationUrl: "https://oauth2.googleapis.com/revoke",
91
+ userinfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
92
+ scopes: ["openid", "email", "profile"],
93
+ scopeSeparator: " ",
94
+ tokenAuthStyle: "body",
95
+ tokenBodyFormat: "form",
96
+ extraParams: { access_type: "offline", prompt: "consent" },
97
+ setupHints: [
98
+ "Google only returns a refresh token when access_type=offline and prompt=consent are sent — this preset includes both so refresh tokens exist at all.",
99
+ "The default scopes cover identity only. Add the product scopes you actually need (e.g. Gmail: https://www.googleapis.com/auth/gmail.modify).",
100
+ "Create the OAuth client in Google Cloud Console → APIs & Services → Credentials and add the swarm redirect URI to the client's authorized redirect URIs.",
101
+ ],
102
+ },
103
+ {
104
+ id: "slack",
105
+ displayName: "Slack",
106
+ provider: "slack",
107
+ authorizeUrl: "https://slack.com/oauth/v2/authorize",
108
+ tokenUrl: "https://slack.com/api/oauth.v2.access",
109
+ revocationUrl: "https://slack.com/api/auth.revoke",
110
+ userinfoUrl: "https://slack.com/api/auth.test",
111
+ scopes: ["channels:history", "channels:read", "chat:write"],
112
+ scopeSeparator: ",",
113
+ tokenAuthStyle: "body",
114
+ tokenBodyFormat: "form",
115
+ setupHints: [
116
+ "Slack expects comma-separated scopes in the authorize URL — this preset sets that separator.",
117
+ "The listed scopes are bot-token scopes; add user-token scopes separately in the Slack app config if you need them.",
118
+ "Register the app at api.slack.com/apps and add the swarm redirect URI under OAuth & Permissions.",
119
+ ],
120
+ },
121
+ {
122
+ id: "github",
123
+ displayName: "GitHub",
124
+ provider: "github",
125
+ authorizeUrl: "https://github.com/login/oauth/authorize",
126
+ tokenUrl: "https://github.com/login/oauth/access_token",
127
+ userinfoUrl: "https://api.github.com/user",
128
+ scopes: ["repo", "read:org", "read:user"],
129
+ scopeSeparator: " ",
130
+ tokenAuthStyle: "body",
131
+ tokenBodyFormat: "form",
132
+ setupHints: [
133
+ "Classic GitHub OAuth apps issue long-lived tokens with no expiry and no refresh token; use a GitHub App instead if you need rotation.",
134
+ "GitHub's token endpoint returns form-encoded data unless the request sends Accept: application/json.",
135
+ "Register at github.com/settings/developers → OAuth Apps and set the swarm redirect URI as the Authorization callback URL.",
136
+ ],
137
+ },
138
+ {
139
+ id: "jira",
140
+ displayName: "Jira (Atlassian)",
141
+ provider: "jira",
142
+ authorizeUrl: "https://auth.atlassian.com/authorize",
143
+ tokenUrl: "https://auth.atlassian.com/oauth/token",
144
+ scopes: ["read:jira-work", "write:jira-work", "read:jira-user", "offline_access"],
145
+ scopeSeparator: " ",
146
+ tokenAuthStyle: "body",
147
+ tokenBodyFormat: "form",
148
+ requiresRefreshTokenRotation: true,
149
+ extraParams: { audience: "api.atlassian.com", prompt: "consent" },
150
+ setupHints: [
151
+ "Atlassian 3LO rotates refresh tokens on every refresh — this preset enables rotation enforcement so a refresh that omits a new token fails loudly instead of silently invalidating the grant.",
152
+ "Include the offline_access scope or you won't receive a refresh token; audience=api.atlassian.com is required and set here.",
153
+ "Refresh tokens expire after 90 days of inactivity — the swarm keep-alive sweep refreshes idle grants to stay ahead of this.",
154
+ "Jira is a reserved provider: configure it through the dedicated tracker integration flow, not the generic OAuth apps route.",
155
+ ],
156
+ },
157
+ {
158
+ id: "linear",
159
+ displayName: "Linear",
160
+ provider: "linear",
161
+ authorizeUrl: "https://linear.app/oauth/authorize",
162
+ tokenUrl: "https://api.linear.app/oauth/token",
163
+ revocationUrl: "https://api.linear.app/oauth/revoke",
164
+ scopes: ["read", "write"],
165
+ scopeSeparator: ",",
166
+ tokenAuthStyle: "body",
167
+ tokenBodyFormat: "form",
168
+ extraParams: { actor: "app" },
169
+ setupHints: [
170
+ "Linear requires comma-separated scopes in the authorize URL — this preset sets that separator.",
171
+ "actor=app makes the swarm act as the application; set actor=user to act on behalf of the authorizing user.",
172
+ "Linear is a reserved provider: configure it through the dedicated tracker integration flow, not the generic OAuth apps route.",
173
+ ],
174
+ },
175
+ {
176
+ id: "notion",
177
+ displayName: "Notion",
178
+ provider: "notion",
179
+ authorizeUrl: "https://api.notion.com/v1/oauth/authorize",
180
+ tokenUrl: "https://api.notion.com/v1/oauth/token",
181
+ scopes: [],
182
+ scopeSeparator: " ",
183
+ tokenAuthStyle: "basic",
184
+ tokenBodyFormat: "json",
185
+ extraParams: { owner: "user" },
186
+ setupHints: [
187
+ "Notion's token endpoint requires HTTP Basic client authentication and a JSON request body — this preset sets tokenAuthStyle=basic and tokenBodyFormat=json.",
188
+ "Notion has no OAuth scopes; capability is configured on the integration in Notion's developer settings.",
189
+ "owner=user is required to receive a user-scoped token and is set here.",
190
+ ],
191
+ },
192
+ ];
193
+
194
+ /** All curated presets (stable order). */
195
+ export function listOAuthPresets(): OAuthPreset[] {
196
+ return OAUTH_PRESETS.map((preset) => ({ ...preset }));
197
+ }
198
+
199
+ /** All valid preset ids, for validation error messages + pickers. */
200
+ export function listOAuthPresetIds(): string[] {
201
+ return OAUTH_PRESETS.map((preset) => preset.id);
202
+ }
203
+
204
+ /** Look up a preset by id, or `null` when unknown. */
205
+ export function getOAuthPreset(id: string): OAuthPreset | null {
206
+ return OAUTH_PRESETS.find((preset) => preset.id === id) ?? null;
207
+ }
208
+
209
+ function mergeExtraParams(
210
+ base: Record<string, string> | undefined,
211
+ override: Record<string, string> | undefined,
212
+ ): Record<string, string> | undefined {
213
+ if (!base && !override) return undefined;
214
+ return { ...(base ?? {}), ...(override ?? {}) };
215
+ }
216
+
217
+ /**
218
+ * Merge a preset with explicit caller overrides. Explicit fields always win;
219
+ * `extraParams` merges per-key (override keys replace preset keys). The result
220
+ * is always tagged `source: 'curated-prefill'`.
221
+ */
222
+ export function hydrateOAuthAppFromPreset(
223
+ preset: OAuthPreset,
224
+ overrides: OAuthPresetOverrides = {},
225
+ ): HydratedOAuthApp {
226
+ return {
227
+ provider: overrides.provider ?? preset.provider,
228
+ authorizeUrl: overrides.authorizeUrl ?? preset.authorizeUrl,
229
+ tokenUrl: overrides.tokenUrl ?? preset.tokenUrl,
230
+ revocationUrl: overrides.revocationUrl ?? preset.revocationUrl,
231
+ userinfoUrl: overrides.userinfoUrl ?? preset.userinfoUrl,
232
+ scopes: overrides.scopes ?? [...preset.scopes],
233
+ scopeSeparator: overrides.scopeSeparator ?? preset.scopeSeparator,
234
+ tokenAuthStyle: overrides.tokenAuthStyle ?? preset.tokenAuthStyle,
235
+ tokenBodyFormat: overrides.tokenBodyFormat ?? preset.tokenBodyFormat,
236
+ requiresRefreshTokenRotation:
237
+ overrides.requiresRefreshTokenRotation ?? preset.requiresRefreshTokenRotation,
238
+ extraParams: mergeExtraParams(preset.extraParams, overrides.extraParams),
239
+ source: "curated-prefill",
240
+ setupHints: [...preset.setupHints],
241
+ };
242
+ }
@@ -1,5 +1,12 @@
1
1
  import * as oauth from "oauth4webapi";
2
- import { storeOAuthTokens, updateOAuthTokensAfterRefresh } from "../be/db-queries/oauth";
2
+ import {
3
+ consumeOAuthPending,
4
+ createOAuthPending,
5
+ getOAuthAppIdByProvider,
6
+ type OAuthPendingFlow,
7
+ storeOAuthTokens,
8
+ updateOAuthTokensAfterRefresh,
9
+ } from "../be/db-queries/oauth";
3
10
 
4
11
  // ─── Types ───────────────────────────────────────────────────────────────────
5
12
 
@@ -45,46 +52,50 @@ export interface OAuthProviderConfig {
45
52
  tokenBodyFormat?: "form" | "json";
46
53
  }
47
54
 
48
- interface PendingState {
49
- codeVerifier: string;
50
- config: OAuthProviderConfig;
51
- createdAt: number;
52
- }
53
-
54
- // ─── In-memory pending state (PKCE code verifiers keyed by state) ────────────
55
-
56
- const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
57
- const pendingStates = new Map<string, PendingState>();
58
-
59
- /** Remove expired entries from the pending state map */
60
- function cleanupExpiredStates(): void {
61
- const now = Date.now();
62
- for (const [key, entry] of pendingStates) {
63
- if (now - entry.createdAt > STATE_TTL_MS) {
64
- pendingStates.delete(key);
65
- }
66
- }
55
+ /**
56
+ * Options controlling how the pending PKCE session is keyed. When `appId` is
57
+ * omitted it is resolved from `config.provider` (single-app-per-provider
58
+ * callers). `label` selects which authorization the callback upserts into,
59
+ * enabling N labeled authorizations per app.
60
+ */
61
+ export interface BuildAuthorizationUrlOptions {
62
+ appId?: string;
63
+ label?: string;
64
+ flow?: OAuthPendingFlow;
65
+ finalRedirect?: string | null;
66
+ userId?: string | null;
67
+ nonce?: string | null;
67
68
  }
68
69
 
69
70
  // ─── Public API ──────────────────────────────────────────────────────────────
70
71
 
71
72
  /**
72
- * Build an OAuth 2.0 authorization URL with PKCE (S256).
73
- * Stores the pending state + code verifier in-memory for later exchange.
73
+ * Build an OAuth 2.0 authorization URL with PKCE (S256). Persists a DB-backed
74
+ * pending row (encrypted code verifier) keyed by `state` for later exchange by
75
+ * the static callback handler — survives process restarts.
74
76
  */
75
77
  export async function buildAuthorizationUrl(
76
78
  config: OAuthProviderConfig,
79
+ options: BuildAuthorizationUrlOptions = {},
77
80
  ): Promise<{ url: string; state: string; codeVerifier: string }> {
78
- cleanupExpiredStates();
79
-
80
81
  const state = oauth.generateRandomState();
81
82
  const codeVerifier = oauth.generateRandomCodeVerifier();
82
83
  const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
83
84
 
84
- pendingStates.set(state, {
85
+ const appId = options.appId ?? getOAuthAppIdByProvider(config.provider);
86
+ if (!appId) {
87
+ throw new Error(`OAuth app ${config.provider} is not configured`);
88
+ }
89
+ createOAuthPending({
90
+ state,
91
+ appId,
92
+ label: options.label ?? "default",
93
+ flow: options.flow ?? "generic",
85
94
  codeVerifier,
86
- config,
87
- createdAt: Date.now(),
95
+ nonce: options.nonce ?? null,
96
+ redirectUri: config.redirectUri,
97
+ finalRedirect: options.finalRedirect ?? null,
98
+ userId: options.userId ?? null,
88
99
  });
89
100
 
90
101
  const url = new URL(config.authorizeUrl);
@@ -106,11 +117,6 @@ export async function buildAuthorizationUrl(
106
117
  return { url: url.toString(), state, codeVerifier };
107
118
  }
108
119
 
109
- /**
110
- * Exchange an authorization code for tokens.
111
- * Validates the state against our pending map, calls the token endpoint,
112
- * and persists tokens via storeOAuthTokens().
113
- */
114
120
  /**
115
121
  * Build headers + body for a token-endpoint request, honoring the provider's
116
122
  * client-auth style (body params vs HTTP Basic) and body encoding (form vs JSON).
@@ -123,7 +129,11 @@ function tokenRequestInit(
123
129
  const bodyParams = useBasic
124
130
  ? params
125
131
  : { ...params, client_id: config.clientId, client_secret: config.clientSecret };
126
- const headers: Record<string, string> = {};
132
+ // Always request a JSON token response. RFC 6749 responses are JSON, but some
133
+ // providers (notably GitHub) default to form-encoded and only return JSON when
134
+ // Accept: application/json is sent — without this the unconditional
135
+ // response.json() in exchange/refresh would throw on their token payload.
136
+ const headers: Record<string, string> = { Accept: "application/json" };
127
137
  if (useBasic) {
128
138
  headers.Authorization = `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`;
129
139
  }
@@ -135,71 +145,158 @@ function tokenRequestInit(
135
145
  return { headers, body: new URLSearchParams(bodyParams).toString() };
136
146
  }
137
147
 
148
+ export interface OAuthTokenResponse {
149
+ accessToken: string;
150
+ refreshToken?: string;
151
+ expiresIn?: number;
152
+ scope?: string;
153
+ tokenType?: string;
154
+ /** OIDC id_token, when the provider returns one (used for identity capture). */
155
+ idToken?: string;
156
+ }
157
+
158
+ /**
159
+ * Exchange an authorization code for tokens. Pure protocol mechanics — does NOT
160
+ * touch the DB. The caller (static callback handler) consumes the pending row,
161
+ * passes the stored `codeVerifier` + `redirectUri`, and persists the tokens
162
+ * onto the target authorization.
163
+ */
164
+ export async function exchangeAuthorizationCode(
165
+ config: OAuthProviderConfig,
166
+ params: { code: string; codeVerifier: string; redirectUri: string },
167
+ ): Promise<OAuthTokenResponse> {
168
+ const response = await fetch(config.tokenUrl, {
169
+ method: "POST",
170
+ ...tokenRequestInit(config, {
171
+ grant_type: "authorization_code",
172
+ redirect_uri: params.redirectUri,
173
+ code: params.code,
174
+ code_verifier: params.codeVerifier,
175
+ }),
176
+ });
177
+
178
+ if (!response.ok) {
179
+ const errorText = await response.text();
180
+ throw new Error(`Token exchange failed (${response.status}): ${errorText}`);
181
+ }
182
+
183
+ const data = (await response.json()) as {
184
+ access_token: string;
185
+ token_type?: string;
186
+ expires_in?: number;
187
+ scope?: string;
188
+ refresh_token?: string;
189
+ id_token?: string;
190
+ };
191
+
192
+ return {
193
+ accessToken: data.access_token,
194
+ refreshToken: data.refresh_token,
195
+ expiresIn: data.expires_in,
196
+ scope: data.scope,
197
+ tokenType: data.token_type,
198
+ idToken: data.id_token,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Backward-compatible code exchange for provider-string callers (tracker
204
+ * callbacks). Consumes the DB-backed pending row by `state`, exchanges the
205
+ * code, and persists onto the provider's `default` authorization. New
206
+ * multi-authorization flows use {@link exchangeAuthorizationCode} directly.
207
+ */
138
208
  export async function exchangeCode(
139
209
  config: OAuthProviderConfig,
140
210
  code: string,
141
211
  state: string,
142
- ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
143
- const pending = pendingStates.get(state);
212
+ ): Promise<OAuthTokenResponse> {
213
+ const pending = consumeOAuthPending(state);
144
214
  if (!pending) {
145
215
  throw new Error("Invalid or expired OAuth state");
146
216
  }
147
- pendingStates.delete(state);
148
-
149
- const { codeVerifier } = pending;
217
+ const tokens = await exchangeAuthorizationCode(config, {
218
+ code,
219
+ codeVerifier: pending.codeVerifier,
220
+ redirectUri: pending.redirectUri,
221
+ });
222
+ // No `expires_in` → non-expiring token: persist NULL (never proactively
223
+ // refresh) rather than fabricating an expiry. See oauth-callback.ts.
224
+ const expiresAt = tokens.expiresIn
225
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
226
+ : null;
227
+ storeOAuthTokens(config.provider, {
228
+ accessToken: tokens.accessToken,
229
+ refreshToken: tokens.refreshToken ?? null,
230
+ expiresAt,
231
+ scope: tokens.scope ?? null,
232
+ });
233
+ return tokens;
234
+ }
150
235
 
151
- // Build token request manually — Linear doesn't use standard OAuth discovery
236
+ /**
237
+ * Refresh a token using the `refresh_token` grant. Pure protocol mechanics — no
238
+ * DB writes. The caller persists onto the target authorization (id-keyed). Used
239
+ * by the per-authorization refresh endpoint.
240
+ */
241
+ export async function refreshTokenGrant(
242
+ config: OAuthProviderConfig,
243
+ refreshToken: string,
244
+ ): Promise<OAuthTokenResponse> {
152
245
  const response = await fetch(config.tokenUrl, {
153
246
  method: "POST",
154
247
  ...tokenRequestInit(config, {
155
- grant_type: "authorization_code",
156
- redirect_uri: config.redirectUri,
157
- code,
158
- code_verifier: codeVerifier,
248
+ grant_type: "refresh_token",
249
+ refresh_token: refreshToken,
159
250
  }),
160
251
  });
161
252
 
162
253
  if (!response.ok) {
163
254
  const errorText = await response.text();
164
- throw new Error(`Token exchange failed (${response.status}): ${errorText}`);
255
+ throw new Error(`Token refresh failed (${response.status}): ${errorText}`);
165
256
  }
166
257
 
167
258
  const data = (await response.json()) as {
168
259
  access_token: string;
169
- token_type: string;
260
+ token_type?: string;
170
261
  expires_in?: number;
171
262
  scope?: string;
172
263
  refresh_token?: string;
173
264
  };
174
265
 
175
- // Persist tokens
176
- const expiresAt = data.expires_in
177
- ? new Date(Date.now() + data.expires_in * 1000).toISOString()
178
- : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // default 24h
179
-
180
- storeOAuthTokens(config.provider, {
181
- accessToken: data.access_token,
182
- refreshToken: data.refresh_token ?? null,
183
- expiresAt,
184
- scope: data.scope ?? null,
185
- });
266
+ if (typeof data.access_token !== "string" || data.access_token.length === 0) {
267
+ throw new Error(`Token refresh failed: ${config.provider} response missing access_token`);
268
+ }
186
269
 
187
270
  return {
188
271
  accessToken: data.access_token,
189
272
  refreshToken: data.refresh_token,
190
273
  expiresIn: data.expires_in,
191
274
  scope: data.scope,
275
+ tokenType: data.token_type,
192
276
  };
193
277
  }
194
278
 
195
279
  /**
196
- * Refresh an access token using a stored refresh token.
197
- * Persists the new tokens via storeOAuthTokens().
280
+ * Call the token endpoint to exchange a refresh token, validate the response
281
+ * (access token present; rotated refresh token present when the provider
282
+ * requires rotation), and return the normalized result WITHOUT persisting.
283
+ *
284
+ * Persistence is the caller's job — provider-string callers persist via
285
+ * {@link refreshAccessToken}; authorization-keyed callers
286
+ * (src/oauth/ensure-token.ts) persist against a specific authorization id.
287
+ * Throws on any HTTP error, missing access token, or a missing rotated refresh
288
+ * token.
198
289
  */
199
- export async function refreshAccessToken(
290
+ export async function performTokenRefreshRequest(
200
291
  config: OAuthProviderConfig,
201
292
  refreshToken: string,
202
- ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
293
+ ): Promise<{
294
+ accessToken: string;
295
+ refreshToken?: string;
296
+ expiresIn?: number;
297
+ scope?: string;
298
+ expiresAt: string | null;
299
+ }> {
203
300
  const response = await fetch(config.tokenUrl, {
204
301
  method: "POST",
205
302
  ...tokenRequestInit(config, {
@@ -234,17 +331,42 @@ export async function refreshAccessToken(
234
331
  );
235
332
  }
236
333
 
334
+ // No `expires_in` → non-expiring token stays non-expiring across refreshes:
335
+ // NULL, not a fabricated 24h expiry that would re-arm the sweep against a
336
+ // token the provider never expires.
237
337
  const expiresAt = data.expires_in
238
338
  ? new Date(Date.now() + data.expires_in * 1000).toISOString()
239
- : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
339
+ : null;
240
340
 
241
- const nextRefreshToken = data.refresh_token ?? refreshToken;
341
+ return {
342
+ accessToken: data.access_token,
343
+ refreshToken: data.refresh_token,
344
+ expiresIn: data.expires_in,
345
+ scope: data.scope,
346
+ expiresAt,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Refresh an access token using a stored refresh token.
352
+ * Persists the new tokens via updateOAuthTokensAfterRefresh() (provider-keyed
353
+ * default-authorization path).
354
+ */
355
+ export async function refreshAccessToken(
356
+ config: OAuthProviderConfig,
357
+ refreshToken: string,
358
+ expectedTokenVersion?: number,
359
+ ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
360
+ const refreshed = await performTokenRefreshRequest(config, refreshToken);
361
+
362
+ const nextRefreshToken = refreshed.refreshToken ?? refreshToken;
242
363
  try {
243
364
  updateOAuthTokensAfterRefresh(config.provider, refreshToken, {
244
- accessToken: data.access_token,
365
+ accessToken: refreshed.accessToken,
245
366
  refreshToken: nextRefreshToken,
246
- expiresAt,
247
- scope: data.scope ?? null,
367
+ expiresAt: refreshed.expiresAt,
368
+ scope: refreshed.scope ?? null,
369
+ ...(expectedTokenVersion !== undefined ? { expectedTokenVersion } : {}),
248
370
  });
249
371
  } catch (err) {
250
372
  const message = err instanceof Error ? err.message : String(err);
@@ -255,19 +377,18 @@ export async function refreshAccessToken(
255
377
  }
256
378
 
257
379
  return {
258
- accessToken: data.access_token,
259
- refreshToken: data.refresh_token,
260
- expiresIn: data.expires_in,
261
- scope: data.scope,
380
+ accessToken: refreshed.accessToken,
381
+ refreshToken: refreshed.refreshToken,
382
+ expiresIn: refreshed.expiresIn,
383
+ scope: refreshed.scope,
262
384
  };
263
385
  }
264
386
 
265
387
  // ─── Test helpers (exported for unit tests only) ─────────────────────────────
266
388
 
267
- export function _getPendingState(state: string): PendingState | undefined {
268
- return pendingStates.get(state);
269
- }
270
-
271
- export function _clearPendingStates(): void {
272
- pendingStates.clear();
273
- }
389
+ /**
390
+ * Deprecated no-op retained for backward compatibility. Pending PKCE state now
391
+ * lives in the `oauth_pending` table (per-test DBs isolate it), so there is no
392
+ * in-memory map to clear.
393
+ */
394
+ export function _clearPendingStates(): void {}