@desplega.ai/agent-swarm 1.113.0 → 1.115.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 (237) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
  3. package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
  4. package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
  5. package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
  6. package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
  7. package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
  8. package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
  9. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
  10. package/dist/cli-0pygq9r1.js +429 -0
  11. package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
  12. package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
  13. package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
  14. package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
  15. package/dist/cli-52fg7s7x.js +269 -0
  16. package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
  17. package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
  18. package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
  19. package/dist/cli-86fdnerz.js +405 -0
  20. package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
  21. package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
  22. package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
  23. package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
  24. package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
  25. package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
  26. package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
  27. package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
  28. package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
  29. package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
  30. package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
  31. package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
  32. package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
  33. package/dist/cli-jkhwmmgg.js +136 -0
  34. package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
  35. package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
  36. package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
  37. package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
  38. package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
  39. package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
  40. package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
  41. package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
  42. package/dist/cli-t0vqevnm.js +393 -0
  43. package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
  44. package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
  45. package/dist/cli-wh91zby6.js +187222 -0
  46. package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
  47. package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
  48. package/dist/cli.js +32 -15
  49. package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
  50. package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
  51. package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
  52. package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
  53. package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
  54. package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
  55. package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
  56. package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
  57. package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
  58. package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
  59. package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
  60. package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
  61. package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
  62. package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
  63. package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
  64. package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
  65. package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
  66. package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
  67. package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
  68. package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
  69. package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
  70. package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
  71. package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
  72. package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
  73. package/dist/rbac-roles-kfesjy9y.js +28 -0
  74. package/dist/rbac-roles-mwcwhbxc.js +283 -0
  75. package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
  76. package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
  77. package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
  78. package/openapi.json +1327 -20
  79. package/package.json +3 -3
  80. package/src/agentmail/handlers.ts +16 -6
  81. package/src/agentmail/templates.ts +25 -5
  82. package/src/be/audit-user.ts +28 -4
  83. package/src/be/db-queries/oauth.ts +42 -0
  84. package/src/be/db.ts +314 -28
  85. package/src/be/identity.ts +79 -0
  86. package/src/be/mcp-proxy.ts +132 -0
  87. package/src/be/migrations/109_rbac_roles.sql +79 -0
  88. package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
  89. package/src/be/migrations/112_script_connections_graphql.sql +96 -0
  90. package/src/be/migrations/113_task_routing_affinity.sql +9 -0
  91. package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
  92. package/src/be/modelsdev-cache.json +135 -0
  93. package/src/be/oauth-credential-bindings.ts +35 -0
  94. package/src/be/oauth-refresh-sweep.ts +159 -0
  95. package/src/be/rbac-audit.ts +52 -13
  96. package/src/be/rbac-roles.ts +424 -0
  97. package/src/be/script-connections.ts +459 -40
  98. package/src/be/script-credential-broker.ts +29 -3
  99. package/src/be/scripts/typecheck.ts +51 -7
  100. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
  101. package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
  102. package/src/be/users.ts +24 -0
  103. package/src/cli.tsx +17 -0
  104. package/src/github/handlers.ts +10 -9
  105. package/src/github/templates.ts +45 -9
  106. package/src/gitlab/handlers.ts +20 -8
  107. package/src/gitlab/templates.ts +18 -6
  108. package/src/heartbeat/heartbeat.ts +143 -21
  109. package/src/http/all-routes.ts +3 -0
  110. package/src/http/approval-requests.ts +12 -0
  111. package/src/http/config.ts +44 -10
  112. package/src/http/core.ts +30 -3
  113. package/src/http/favorites.ts +1 -5
  114. package/src/http/index.ts +25 -1
  115. package/src/http/mcp-oauth.ts +2 -0
  116. package/src/http/mcp-servers.ts +44 -3
  117. package/src/http/oauth-generic.ts +105 -0
  118. package/src/http/poll.ts +20 -3
  119. package/src/http/script-connection-proxy.ts +116 -0
  120. package/src/http/script-connections.ts +1657 -0
  121. package/src/http/scripts.ts +27 -7
  122. package/src/http/skills.ts +11 -0
  123. package/src/http/tasks.ts +27 -13
  124. package/src/http/x.ts +6 -2
  125. package/src/integrations/kapso/inbound.ts +23 -6
  126. package/src/jira/sync.ts +111 -17
  127. package/src/jira/templates.ts +10 -2
  128. package/src/mcp-client/http-client.ts +194 -0
  129. package/src/oauth/app-validation.ts +16 -0
  130. package/src/oauth/ensure-token.ts +69 -14
  131. package/src/oauth/mcp-wrapper.ts +15 -0
  132. package/src/oauth/wrapper.ts +48 -20
  133. package/src/prompts/session-templates.ts +3 -3
  134. package/src/providers/codex-models.ts +100 -30
  135. package/src/providers/modelsdev-reasoning.json +51 -0
  136. package/src/providers/pi-mono-mcp-client.ts +2 -133
  137. package/src/providers/reasoning-effort.ts +11 -5
  138. package/src/rbac/admission.ts +68 -0
  139. package/src/rbac/index.ts +2 -0
  140. package/src/rbac/legacy-policy.ts +12 -0
  141. package/src/rbac/permissions.ts +48 -0
  142. package/src/scheduler/scheduler.ts +9 -1
  143. package/src/scripts-runtime/api-client.ts +151 -19
  144. package/src/scripts-runtime/api-types.ts +38 -6
  145. package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
  146. package/src/scripts-runtime/credential-broker/types.ts +8 -0
  147. package/src/scripts-runtime/ctx.ts +11 -1
  148. package/src/scripts-runtime/eval-harness.ts +5 -1
  149. package/src/scripts-runtime/executors/types.ts +2 -1
  150. package/src/scripts-runtime/loader.ts +3 -1
  151. package/src/scripts-runtime/mcp-client.ts +87 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/types/stdlib.d.ts +6 -0
  154. package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
  155. package/src/server-user.ts +114 -59
  156. package/src/server.ts +12 -0
  157. package/src/slack/assistant.ts +14 -6
  158. package/src/slack/enrich.ts +17 -0
  159. package/src/slack/handlers.ts +8 -22
  160. package/src/slack/responses.ts +91 -1
  161. package/src/slack/thread-buffer.ts +6 -3
  162. package/src/slack/watcher.ts +9 -6
  163. package/src/tasks/worker-follow-up.ts +100 -3
  164. package/src/tests/agentmail-handlers.test.ts +32 -3
  165. package/src/tests/approval-requests.test.ts +61 -0
  166. package/src/tests/audit-user.test.ts +47 -1
  167. package/src/tests/codex-adapter.test.ts +46 -23
  168. package/src/tests/credential-broker.test.ts +5 -2
  169. package/src/tests/delete-page-tool.test.ts +197 -0
  170. package/src/tests/github-handlers.test.ts +28 -2
  171. package/src/tests/gitlab-handlers.test.ts +125 -0
  172. package/src/tests/heartbeat.test.ts +48 -3
  173. package/src/tests/identity.test.ts +117 -0
  174. package/src/tests/jira-sync.test.ts +99 -1
  175. package/src/tests/kapso-inbound.test.ts +7 -0
  176. package/src/tests/mcp-tools-user.test.ts +60 -6
  177. package/src/tests/model-control.test.ts +8 -2
  178. package/src/tests/oauth-credential-bindings.test.ts +490 -0
  179. package/src/tests/oauth-refresh-sweep.test.ts +160 -0
  180. package/src/tests/pool-affinity.test.ts +401 -0
  181. package/src/tests/prompt-template-remaining.test.ts +2 -2
  182. package/src/tests/rbac-admission-e2e.test.ts +386 -0
  183. package/src/tests/rbac-admission.test.ts +492 -0
  184. package/src/tests/rbac-e2e-helpers.ts +3 -0
  185. package/src/tests/rbac-engine.test.ts +50 -0
  186. package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
  187. package/src/tests/rbac-roles.test.ts +328 -0
  188. package/src/tests/reasoning-effort.test.ts +15 -2
  189. package/src/tests/schedule-target-type.test.ts +50 -0
  190. package/src/tests/scheduled-tasks.test.ts +40 -0
  191. package/src/tests/script-apis-mcp.test.ts +37 -13
  192. package/src/tests/script-connections-http.test.ts +1158 -0
  193. package/src/tests/script-connections-mcp.test.ts +894 -0
  194. package/src/tests/script-connections.test.ts +754 -6
  195. package/src/tests/scripts-external-api.test.ts +49 -1
  196. package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
  197. package/src/tests/scripts-runtime.test.ts +291 -0
  198. package/src/tests/seed-scripts.test.ts +36 -0
  199. package/src/tests/slack-identity-resolution.test.ts +37 -3
  200. package/src/tests/slack-inline-output.test.ts +65 -0
  201. package/src/tests/slack-thread-buffer.test.ts +28 -0
  202. package/src/tests/tool-annotations.test.ts +1 -0
  203. package/src/tests/user-token-rest-auth.test.ts +140 -1
  204. package/src/tests/workflow-swarm-script.test.ts +44 -0
  205. package/src/tests/workflow-triggers-v2.test.ts +300 -1
  206. package/src/tools/credential-bindings/tool.ts +277 -18
  207. package/src/tools/delete-page.ts +145 -0
  208. package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
  209. package/src/tools/request-human-input.ts +2 -0
  210. package/src/tools/resolve-user.ts +77 -27
  211. package/src/tools/script-apis.ts +62 -3
  212. package/src/tools/script-connections/tool.ts +275 -31
  213. package/src/tools/send-task.ts +15 -1
  214. package/src/tools/task-action.ts +34 -2
  215. package/src/tools/templates.ts +53 -1
  216. package/src/tools/tool-config.ts +2 -1
  217. package/src/tools/utils.ts +2 -0
  218. package/src/tools/workflows/create-workflow.ts +8 -1
  219. package/src/tools/workflows/update-workflow.ts +11 -2
  220. package/src/types.ts +90 -11
  221. package/src/utils/scoped-resource.ts +25 -0
  222. package/src/workflows/executors/human-in-the-loop.ts +1 -0
  223. package/src/workflows/executors/swarm-script.ts +7 -1
  224. package/src/workflows/triggers.ts +146 -18
  225. package/templates/skills/swarm-scripts/content.md +33 -8
  226. package/dist/cli-bgef578c.js +0 -771
  227. package/dist/cli-c9mtm09b.js +0 -43
  228. package/dist/cli-ceewqajw.js +0 -1698
  229. package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
  230. package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
  231. package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
  232. package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
  233. package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
  234. package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
  235. package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
  236. package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
  237. package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
@@ -0,0 +1,1657 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { resolveHttpAuditUserId } from "@/be/audit-user";
4
+ import { normalizeDate } from "@/be/date-utils";
5
+ import { getAgentById, getDb } from "@/be/db";
6
+ import {
7
+ deleteOAuthTokens,
8
+ getOAuthApp,
9
+ getOAuthTokens,
10
+ upsertOAuthApp,
11
+ } from "@/be/db-queries/oauth";
12
+ import {
13
+ getOAuthBindingTokenStatus,
14
+ getOAuthProviderConfig,
15
+ type OAuthBindingTokenStatus,
16
+ } from "@/be/oauth-credential-bindings";
17
+ import {
18
+ getScriptConnectionById,
19
+ listRelationalCredentialBindings,
20
+ listScriptConnections,
21
+ refreshScriptConnection,
22
+ type ScriptConnectionKind,
23
+ type ScriptConnectionRecord,
24
+ type ScriptCredentialBindingRecord,
25
+ setScriptConnectionEnabled,
26
+ upsertCredentialBinding,
27
+ upsertScriptConnection,
28
+ } from "@/be/script-connections";
29
+ import { assertOAuthAppUrlsSafe, assertOAuthProviderIsNotReserved } from "@/oauth/app-validation";
30
+ import { forceRefreshTokenOrThrow } from "@/oauth/ensure-token";
31
+ import { assertUrlSafe, publicEndpointSsrfOptions } from "@/oauth/mcp-wrapper";
32
+ import { buildAuthorizationUrl } from "@/oauth/wrapper";
33
+ import { can } from "@/rbac";
34
+ import {
35
+ CredentialBindingSchema,
36
+ placeholderForConfigKey,
37
+ } from "@/scripts-runtime/credential-broker";
38
+ import type { OAuthApp } from "@/tracker/types";
39
+ import { getPublicMcpBaseUrl } from "@/utils/constants";
40
+ import { getRequestAuth } from "@/utils/request-auth-context";
41
+ import { resolveScopedResourceId, scopedResourceScopeIdSchema } from "@/utils/scoped-resource";
42
+ import { scrubSecrets } from "@/utils/secret-scrubber";
43
+ import { route } from "./route-def";
44
+ import { json, jsonError } from "./utils";
45
+
46
+ const providerSchema = z
47
+ .string()
48
+ .min(1)
49
+ .max(255)
50
+ .regex(/^[A-Za-z0-9_-]+$/);
51
+
52
+ const scopeSchema = z.enum(["global", "agent", "repo"]);
53
+ const connectionKindSchema = z.enum(["openapi", "graphql", "mcp"]);
54
+
55
+ const idParamsSchema = z.object({ id: z.string().uuid() });
56
+ const providerParamsSchema = z.object({ provider: providerSchema });
57
+
58
+ const listConnectionsQuerySchema = z.object({
59
+ kind: connectionKindSchema.optional(),
60
+ scope: scopeSchema.optional(),
61
+ scopeId: z.string().optional(),
62
+ });
63
+
64
+ const connectionBaseBodySchema = z.object({
65
+ id: z.string().uuid().optional(),
66
+ slug: z.string().min(1).max(80),
67
+ displayName: z.string().max(160).optional(),
68
+ scope: scopeSchema.optional(),
69
+ scopeId: scopedResourceScopeIdSchema.nullable().optional(),
70
+ allowedHosts: z.array(z.string().min(1)).optional(),
71
+ credentialBindingId: z.string().uuid().nullable().optional(),
72
+ configKey: z.string().min(1).max(255).optional(),
73
+ headerTemplate: z.string().min(1).optional(),
74
+ queryTemplate: z.string().min(1).optional(),
75
+ authKind: z.enum(["config", "oauth"]).optional(),
76
+ oauthProvider: providerSchema.optional(),
77
+ enabled: z.boolean().optional(),
78
+ });
79
+
80
+ const upsertConnectionBodySchema = z.discriminatedUnion("kind", [
81
+ connectionBaseBodySchema.extend({
82
+ kind: z.literal("openapi"),
83
+ baseUrl: z.string().url(),
84
+ openapiSpecUrl: z.string().url().optional(),
85
+ openapiSpecJson: z.string().optional(),
86
+ }),
87
+ connectionBaseBodySchema.extend({
88
+ kind: z.literal("graphql"),
89
+ baseUrl: z.string().url(),
90
+ allowedHosts: z.array(z.string().min(1)).min(1),
91
+ }),
92
+ connectionBaseBodySchema.extend({
93
+ kind: z.literal("mcp"),
94
+ mcpServerId: z.string().uuid(),
95
+ }),
96
+ ]);
97
+
98
+ const disableConnectionBodySchema = z.object({ enabled: z.boolean() });
99
+
100
+ const credentialBindingBodySchema = z.object({
101
+ id: z.string().uuid().optional(),
102
+ configKey: z.string().min(1).max(255),
103
+ allowedHosts: z.array(z.string().min(1)).min(1),
104
+ headerTemplate: z.string().min(1).optional(),
105
+ queryTemplate: z.string().min(1).optional(),
106
+ scope: scopeSchema.default("global").optional(),
107
+ scopeId: scopedResourceScopeIdSchema.nullable().optional(),
108
+ active: z.boolean().default(true).optional(),
109
+ authKind: z.enum(["config", "oauth"]).default("config").optional(),
110
+ oauthProvider: providerSchema.optional(),
111
+ });
112
+
113
+ const oauthAppBodySchema = z.object({
114
+ provider: providerSchema,
115
+ clientId: z.string().min(1),
116
+ clientSecret: z.string().min(1).optional(),
117
+ authorizeUrl: z.string().url(),
118
+ tokenUrl: z.string().url(),
119
+ scopes: z.array(z.string().min(1)).default([]).optional(),
120
+ extraParams: z.record(z.string(), z.string()).optional(),
121
+ tokenAuthStyle: z.enum(["body", "basic"]).optional(),
122
+ tokenBodyFormat: z.enum(["form", "json"]).optional(),
123
+ });
124
+
125
+ const discoverOAuthAppBodySchema = z.object({
126
+ url: z.string().url(),
127
+ });
128
+
129
+ const listConnectionsRoute = route({
130
+ method: "get",
131
+ path: "/api/script-connections",
132
+ pattern: ["api", "script-connections"],
133
+ operationId: "script_connections_list",
134
+ summary: "List script connections",
135
+ description:
136
+ "Dashboard read of OpenAPI, GraphQL, and MCP script connections with credential summaries.",
137
+ tags: ["Script Connections"],
138
+ query: listConnectionsQuerySchema,
139
+ responses: {
140
+ 200: { description: "Script connections" },
141
+ 400: { description: "Validation error" },
142
+ },
143
+ });
144
+
145
+ const getConnectionRoute = route({
146
+ method: "get",
147
+ path: "/api/script-connections/{id}",
148
+ pattern: ["api", "script-connections", null],
149
+ operationId: "script_connections_get",
150
+ summary: "Get script connection detail",
151
+ tags: ["Script Connections"],
152
+ params: idParamsSchema,
153
+ responses: {
154
+ 200: { description: "Script connection detail" },
155
+ 404: { description: "Script connection not found" },
156
+ },
157
+ });
158
+
159
+ const upsertConnectionRoute = route({
160
+ method: "post",
161
+ path: "/api/script-connections",
162
+ pattern: ["api", "script-connections"],
163
+ operationId: "script_connections_upsert",
164
+ summary: "Create or update a script connection",
165
+ tags: ["Script Connections"],
166
+ body: upsertConnectionBodySchema,
167
+ responses: {
168
+ 200: { description: "Saved script connection" },
169
+ 400: { description: "Validation or generation error" },
170
+ 403: { description: "Only the lead agent can manage script connections" },
171
+ },
172
+ rbac: { permission: "script-connection.manage" },
173
+ });
174
+
175
+ const refreshConnectionRoute = route({
176
+ method: "post",
177
+ path: "/api/script-connections/{id}/refresh",
178
+ pattern: ["api", "script-connections", null, "refresh"],
179
+ operationId: "script_connections_refresh",
180
+ summary: "Refresh a script connection",
181
+ tags: ["Script Connections"],
182
+ params: idParamsSchema,
183
+ responses: {
184
+ 200: { description: "Refreshed script connection" },
185
+ 400: { description: "Connection cannot be refreshed" },
186
+ 403: { description: "Only the lead agent can manage script connections" },
187
+ 404: { description: "Script connection not found" },
188
+ },
189
+ rbac: { permission: "script-connection.manage" },
190
+ });
191
+
192
+ const setConnectionEnabledRoute = route({
193
+ method: "post",
194
+ path: "/api/script-connections/{id}/disable",
195
+ pattern: ["api", "script-connections", null, "disable"],
196
+ operationId: "script_connections_set_enabled",
197
+ summary: "Enable or disable a script connection",
198
+ tags: ["Script Connections"],
199
+ params: idParamsSchema,
200
+ body: disableConnectionBodySchema,
201
+ responses: {
202
+ 200: { description: "Updated script connection" },
203
+ 403: { description: "Only the lead agent can manage script connections" },
204
+ 404: { description: "Script connection not found" },
205
+ },
206
+ rbac: { permission: "script-connection.manage" },
207
+ });
208
+
209
+ const listCredentialBindingsRoute = route({
210
+ method: "get",
211
+ path: "/api/credential-bindings",
212
+ pattern: ["api", "credential-bindings"],
213
+ operationId: "credential_bindings_list",
214
+ summary: "List script credential bindings",
215
+ tags: ["Script Connections"],
216
+ responses: {
217
+ 200: { description: "Credential bindings" },
218
+ },
219
+ });
220
+
221
+ const upsertCredentialBindingRoute = route({
222
+ method: "post",
223
+ path: "/api/credential-bindings",
224
+ pattern: ["api", "credential-bindings"],
225
+ operationId: "credential_bindings_upsert",
226
+ summary: "Create or update a script credential binding",
227
+ tags: ["Script Connections"],
228
+ body: credentialBindingBodySchema,
229
+ responses: {
230
+ 200: { description: "Saved credential binding" },
231
+ 400: { description: "Validation error" },
232
+ 403: { description: "Only the lead agent can manage script connections" },
233
+ },
234
+ rbac: { permission: "script-connection.manage" },
235
+ });
236
+
237
+ const listOAuthAppsRoute = route({
238
+ method: "get",
239
+ path: "/api/oauth-apps",
240
+ pattern: ["api", "oauth-apps"],
241
+ operationId: "oauth_apps_list",
242
+ summary: "List OAuth apps for script credential bindings",
243
+ tags: ["Script Connections"],
244
+ responses: {
245
+ 200: { description: "OAuth apps without client secrets" },
246
+ },
247
+ });
248
+
249
+ const upsertOAuthAppRoute = route({
250
+ method: "post",
251
+ path: "/api/oauth-apps",
252
+ pattern: ["api", "oauth-apps"],
253
+ operationId: "oauth_apps_upsert",
254
+ summary: "Create or update an OAuth app for script credential bindings",
255
+ tags: ["Script Connections"],
256
+ body: oauthAppBodySchema,
257
+ responses: {
258
+ 200: { description: "Saved OAuth app without client secret" },
259
+ 400: { description: "Validation error" },
260
+ 403: { description: "Only the lead agent can manage script connections" },
261
+ },
262
+ rbac: { permission: "script-connection.manage" },
263
+ });
264
+
265
+ const discoverOAuthAppRoute = route({
266
+ method: "post",
267
+ path: "/api/oauth-apps/discover",
268
+ pattern: ["api", "oauth-apps", "discover"],
269
+ operationId: "oauth_apps_discover",
270
+ summary: "Discover OAuth endpoints from provider metadata",
271
+ tags: ["Script Connections"],
272
+ body: discoverOAuthAppBodySchema,
273
+ responses: {
274
+ 200: { description: "Discovered OAuth metadata" },
275
+ 400: { description: "Discovery failed" },
276
+ 403: { description: "Only the lead agent can manage script connections" },
277
+ },
278
+ rbac: { permission: "script-connection.manage" },
279
+ });
280
+
281
+ const deleteOAuthAppRoute = route({
282
+ method: "delete",
283
+ path: "/api/oauth-apps/{provider}",
284
+ pattern: ["api", "oauth-apps", null],
285
+ operationId: "oauth_apps_delete",
286
+ summary: "Delete an OAuth app and its tokens",
287
+ tags: ["Script Connections"],
288
+ params: providerParamsSchema,
289
+ responses: {
290
+ 200: { description: "OAuth app deleted" },
291
+ 403: { description: "Only the lead agent can manage script connections" },
292
+ 404: { description: "OAuth app not found" },
293
+ },
294
+ rbac: { permission: "script-connection.manage" },
295
+ });
296
+
297
+ const authorizeUrlRoute = route({
298
+ method: "post",
299
+ path: "/api/oauth-apps/{provider}/authorize-url",
300
+ pattern: ["api", "oauth-apps", null, "authorize-url"],
301
+ operationId: "oauth_apps_authorize_url",
302
+ summary: "Build an OAuth authorization URL",
303
+ tags: ["Script Connections"],
304
+ params: providerParamsSchema,
305
+ responses: {
306
+ 200: { description: "OAuth authorization URL" },
307
+ 403: { description: "Only the lead agent can manage script connections" },
308
+ 404: { description: "OAuth app not found" },
309
+ },
310
+ rbac: { permission: "script-connection.manage" },
311
+ });
312
+
313
+ const integrationsCatalogRoute = route({
314
+ method: "get",
315
+ path: "/api/integrations-catalog",
316
+ pattern: ["api", "integrations-catalog"],
317
+ operationId: "integrations_catalog_list",
318
+ summary: "Proxy integrations.sh catalog entries",
319
+ tags: ["Script Connections"],
320
+ responses: {
321
+ 200: { description: "Integrations catalog entries" },
322
+ 502: { description: "Catalog upstream unavailable" },
323
+ },
324
+ });
325
+
326
+ const surfaceDomainSchema = z
327
+ .string()
328
+ .min(1)
329
+ .max(255)
330
+ .regex(/^[a-z0-9.-]+$/i);
331
+
332
+ const integrationsSurfaceRoute = route({
333
+ method: "get",
334
+ path: "/api/integrations-catalog/{domain}/surface",
335
+ pattern: ["api", "integrations-catalog", null, "surface"],
336
+ operationId: "integrations_catalog_surface",
337
+ summary: "Proxy integrations.sh per-domain surface details (trimmed for the Add Connection flow)",
338
+ tags: ["Script Connections"],
339
+ params: z.object({ domain: surfaceDomainSchema }),
340
+ responses: {
341
+ 200: { description: "Trimmed integration surface details for a domain" },
342
+ 404: { description: "No surface data for this domain" },
343
+ 502: { description: "Surface upstream unavailable" },
344
+ },
345
+ });
346
+
347
+ const disconnectOAuthAppRoute = route({
348
+ method: "delete",
349
+ path: "/api/oauth-apps/{provider}/tokens",
350
+ pattern: ["api", "oauth-apps", null, "tokens"],
351
+ operationId: "oauth_app_disconnect",
352
+ summary:
353
+ "Disconnect an OAuth app: delete stored tokens (best-effort remote revocation when a revocation endpoint is known)",
354
+ tags: ["Script Connections"],
355
+ params: providerParamsSchema,
356
+ responses: {
357
+ 200: { description: "Disconnect result" },
358
+ 403: { description: "Only the lead agent can manage script connections" },
359
+ 404: { description: "OAuth app not found" },
360
+ },
361
+ rbac: { permission: "script-connection.manage" },
362
+ });
363
+
364
+ const refreshOAuthAppTokensRoute = route({
365
+ method: "post",
366
+ path: "/api/oauth-apps/{provider}/refresh",
367
+ pattern: ["api", "oauth-apps", null, "refresh"],
368
+ operationId: "oauth_app_refresh_tokens",
369
+ summary: "Force-refresh the stored OAuth tokens for a provider (never returns token values)",
370
+ tags: ["Script Connections"],
371
+ params: providerParamsSchema,
372
+ responses: {
373
+ 200: { description: "Refresh result with token status and new expiry" },
374
+ 400: { description: "No stored tokens or provider does not support refresh" },
375
+ 403: { description: "Only the lead agent can manage script connections" },
376
+ 404: { description: "OAuth app not found" },
377
+ 502: { description: "Provider token endpoint rejected the refresh" },
378
+ },
379
+ rbac: { permission: "script-connection.manage" },
380
+ });
381
+
382
+ type BindingSummary = {
383
+ id: string;
384
+ configKey: string;
385
+ authKind: "config" | "oauth";
386
+ oauthProvider?: string;
387
+ tokenStatus?: OAuthBindingTokenStatus;
388
+ };
389
+
390
+ type DecoratedBinding = ScriptCredentialBindingRecord & {
391
+ tokenStatus?: OAuthBindingTokenStatus;
392
+ };
393
+
394
+ type DecoratedConnection = Omit<
395
+ ScriptConnectionRecord,
396
+ "openapiSpecJson" | "generatedTypes" | "generatedRuntimeJson"
397
+ > & {
398
+ operationCount: number;
399
+ toolCount: number;
400
+ credentialBinding: BindingSummary | null;
401
+ };
402
+
403
+ type ConnectionOperationParameter = {
404
+ name: string;
405
+ in: string;
406
+ required: boolean;
407
+ schema?: unknown;
408
+ };
409
+
410
+ type ConnectionDetail = DecoratedConnection & {
411
+ operations: Array<{
412
+ name: string;
413
+ method: string;
414
+ path: string;
415
+ parameters?: ConnectionOperationParameter[];
416
+ hasBody?: boolean;
417
+ successStatus?: string;
418
+ requestBodySchema?: unknown;
419
+ responseSchema?: unknown;
420
+ }>;
421
+ tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
422
+ graphql: boolean;
423
+ generatedTypes: string;
424
+ specSummary?: { title?: string; version?: string; pathCount: number };
425
+ specPreview?: { json: string; truncated: boolean };
426
+ };
427
+
428
+ type OAuthAppRow = {
429
+ id: string;
430
+ provider: string;
431
+ clientId: string;
432
+ authorizeUrl: string;
433
+ tokenUrl: string;
434
+ redirectUri: string;
435
+ scopes: string;
436
+ metadata: string;
437
+ tokenExpiresAt: string | null;
438
+ tokenUpdatedAt: string | null;
439
+ createdAt: string;
440
+ updatedAt: string;
441
+ };
442
+
443
+ type IntegrationsCatalogEntry = {
444
+ id: string;
445
+ kind: string;
446
+ slug: string;
447
+ name: string;
448
+ description: string;
449
+ url: string;
450
+ icon: string | null;
451
+ domain: string;
452
+ categories: string[];
453
+ feeds: string[];
454
+ };
455
+
456
+ const DISCOVERY_TIMEOUT_MS = 10_000;
457
+ const INTEGRATIONS_CATALOG_TIMEOUT_MS = 15_000;
458
+ const INTEGRATIONS_CATALOG_TTL_MS = 60 * 60 * 1000;
459
+ const SPEC_PREVIEW_MAX_BYTES = 50 * 1024;
460
+
461
+ let integrationsCatalogCache: {
462
+ expiresAtMs: number;
463
+ payload: { entries: IntegrationsCatalogEntry[]; cachedAt: string };
464
+ } | null = null;
465
+
466
+ type IntegrationsSurfaceMechanics = {
467
+ in: string;
468
+ headerName: string | null;
469
+ scheme: string | null;
470
+ };
471
+
472
+ type IntegrationsSurfaceEntry = {
473
+ type: string;
474
+ name: string;
475
+ url: string | null;
476
+ docs: string | null;
477
+ /** OpenAPI spec URL advertised by http surfaces (may be YAML). */
478
+ spec: string | null;
479
+ auth: {
480
+ required: boolean;
481
+ credentialIds: string[];
482
+ mechanics: IntegrationsSurfaceMechanics | null;
483
+ };
484
+ };
485
+
486
+ type IntegrationsSurfaceCredential = {
487
+ type: string;
488
+ label: string;
489
+ generateUrl: string | null;
490
+ setup: string | null;
491
+ };
492
+
493
+ type IntegrationsSurfacePayload = {
494
+ domain: string;
495
+ summary: string;
496
+ surfaces: IntegrationsSurfaceEntry[];
497
+ credentials: Record<string, IntegrationsSurfaceCredential>;
498
+ };
499
+
500
+ const INTEGRATIONS_SURFACE_CACHE_MAX_ENTRIES = 200;
501
+ const integrationsSurfaceCache = new Map<
502
+ string,
503
+ { expiresAtMs: number; payload: IntegrationsSurfacePayload }
504
+ >();
505
+
506
+ class SurfaceNotFoundError extends Error {}
507
+
508
+ function singleHeader(req: IncomingMessage, name: string): string | undefined {
509
+ const raw = req.headers[name];
510
+ return Array.isArray(raw) ? raw[0] : raw;
511
+ }
512
+
513
+ function ensureConnectionAdmin(
514
+ req: IncomingMessage,
515
+ res: ServerResponse,
516
+ agentId: string | undefined,
517
+ ): boolean {
518
+ const auth = getRequestAuth(req);
519
+ if (auth?.kind === "operator" || auth?.kind === "user") return true;
520
+
521
+ const callerAgentId = agentId ?? singleHeader(req, "x-agent-id");
522
+ const agent = callerAgentId ? getAgentById(callerAgentId) : undefined;
523
+ const decision = can({
524
+ principal: {
525
+ kind: "agent",
526
+ agentId: callerAgentId ?? "",
527
+ isLead: agent?.isLead ?? false,
528
+ },
529
+ verb: "script-connection.manage",
530
+ resource: { kind: "none" },
531
+ source: "http",
532
+ });
533
+ if (!decision.allow) {
534
+ jsonError(res, "Only the lead can manage script connections.", 403);
535
+ return false;
536
+ }
537
+ return true;
538
+ }
539
+
540
+ function tokenStatusForBinding(
541
+ binding: ScriptCredentialBindingRecord,
542
+ ): OAuthBindingTokenStatus | undefined {
543
+ return binding.authKind === "oauth" && binding.oauthProvider
544
+ ? getOAuthBindingTokenStatus(binding.oauthProvider)
545
+ : undefined;
546
+ }
547
+
548
+ function decorateBinding(binding: ScriptCredentialBindingRecord): DecoratedBinding {
549
+ const tokenStatus = tokenStatusForBinding(binding);
550
+ return tokenStatus ? { ...binding, tokenStatus } : binding;
551
+ }
552
+
553
+ function bindingSummary(binding: ScriptCredentialBindingRecord | undefined): BindingSummary | null {
554
+ if (!binding) return null;
555
+ const tokenStatus = tokenStatusForBinding(binding);
556
+ return {
557
+ id: binding.id,
558
+ configKey: binding.configKey,
559
+ authKind: binding.authKind ?? "config",
560
+ ...(binding.oauthProvider ? { oauthProvider: binding.oauthProvider } : {}),
561
+ ...(tokenStatus ? { tokenStatus } : {}),
562
+ };
563
+ }
564
+
565
+ function runtimeCounts(connection: ScriptConnectionRecord): {
566
+ operationCount: number;
567
+ toolCount: number;
568
+ } {
569
+ if (!connection.generatedRuntimeJson) {
570
+ return { operationCount: 0, toolCount: 0 };
571
+ }
572
+ try {
573
+ const runtime = JSON.parse(connection.generatedRuntimeJson) as {
574
+ operations?: unknown;
575
+ tools?: unknown;
576
+ kind?: unknown;
577
+ };
578
+ const operationCount = Array.isArray(runtime.operations)
579
+ ? runtime.operations.length
580
+ : connection.kind === "graphql"
581
+ ? 1
582
+ : 0;
583
+ const toolCount = Array.isArray(runtime.tools) ? runtime.tools.length : 0;
584
+ return { operationCount, toolCount };
585
+ } catch {
586
+ return { operationCount: 0, toolCount: 0 };
587
+ }
588
+ }
589
+
590
+ function parseRecord(value: string | null): Record<string, unknown> | null {
591
+ if (!value) return null;
592
+ try {
593
+ const parsed = JSON.parse(value);
594
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
595
+ ? (parsed as Record<string, unknown>)
596
+ : null;
597
+ } catch {
598
+ return null;
599
+ }
600
+ }
601
+
602
+ function connectionDetail(connection: ScriptConnectionRecord): ConnectionDetail {
603
+ const decorated = decorateConnections([connection])[0];
604
+ if (!decorated) throw new Error("Failed to decorate connection detail.");
605
+
606
+ const runtime = parseRecord(connection.generatedRuntimeJson);
607
+ const operations = Array.isArray(runtime?.operations)
608
+ ? runtime.operations
609
+ .filter((operation): operation is Record<string, unknown> => {
610
+ return operation !== null && typeof operation === "object" && !Array.isArray(operation);
611
+ })
612
+ .map((operation) => ({
613
+ name: String(operation.name ?? ""),
614
+ method: String(operation.method ?? ""),
615
+ path: String(operation.path ?? ""),
616
+ ...(Array.isArray(operation.parameters)
617
+ ? {
618
+ parameters: operation.parameters
619
+ .filter((param): param is Record<string, unknown> => {
620
+ return param !== null && typeof param === "object" && !Array.isArray(param);
621
+ })
622
+ .map((param) => ({
623
+ name: String(param.name ?? ""),
624
+ in: String(param.in ?? "query"),
625
+ required: param.required === true,
626
+ ...(param.schema !== undefined ? { schema: param.schema } : {}),
627
+ }))
628
+ .filter((param) => param.name),
629
+ }
630
+ : {}),
631
+ ...(typeof operation.hasBody === "boolean" ? { hasBody: operation.hasBody } : {}),
632
+ ...(typeof operation.successStatus === "string"
633
+ ? { successStatus: operation.successStatus }
634
+ : {}),
635
+ ...(operation.requestBodySchema !== undefined
636
+ ? { requestBodySchema: operation.requestBodySchema }
637
+ : {}),
638
+ ...(operation.responseSchema !== undefined
639
+ ? { responseSchema: operation.responseSchema }
640
+ : {}),
641
+ }))
642
+ .filter((operation) => operation.name && operation.method && operation.path)
643
+ : [];
644
+ const tools = Array.isArray(runtime?.tools)
645
+ ? runtime.tools
646
+ .filter((tool): tool is Record<string, unknown> => {
647
+ return tool !== null && typeof tool === "object" && !Array.isArray(tool);
648
+ })
649
+ .map((tool) => ({
650
+ name: String(tool.name ?? ""),
651
+ ...(typeof tool.description === "string" ? { description: tool.description } : {}),
652
+ ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
653
+ }))
654
+ .filter((tool) => tool.name)
655
+ : [];
656
+
657
+ const detail: ConnectionDetail = {
658
+ ...decorated,
659
+ operations,
660
+ tools,
661
+ graphql: connection.kind === "graphql",
662
+ generatedTypes: connection.generatedTypes ?? "",
663
+ };
664
+
665
+ if (connection.kind === "openapi" && connection.openapiSpecJson) {
666
+ try {
667
+ const spec = JSON.parse(connection.openapiSpecJson) as Record<string, unknown>;
668
+ const info =
669
+ spec.info && typeof spec.info === "object" && !Array.isArray(spec.info)
670
+ ? (spec.info as Record<string, unknown>)
671
+ : {};
672
+ const paths =
673
+ spec.paths && typeof spec.paths === "object" && !Array.isArray(spec.paths)
674
+ ? (spec.paths as Record<string, unknown>)
675
+ : {};
676
+ const pretty = JSON.stringify(spec, null, 2);
677
+ const truncated = pretty.length > SPEC_PREVIEW_MAX_BYTES;
678
+ detail.specSummary = {
679
+ ...(typeof info.title === "string" ? { title: info.title } : {}),
680
+ ...(typeof info.version === "string" ? { version: info.version } : {}),
681
+ pathCount: Object.keys(paths).length,
682
+ };
683
+ detail.specPreview = {
684
+ json: truncated ? pretty.slice(0, SPEC_PREVIEW_MAX_BYTES) : pretty,
685
+ truncated,
686
+ };
687
+ } catch {
688
+ detail.specPreview = {
689
+ json: connection.openapiSpecJson.slice(0, SPEC_PREVIEW_MAX_BYTES),
690
+ truncated: connection.openapiSpecJson.length > SPEC_PREVIEW_MAX_BYTES,
691
+ };
692
+ }
693
+ }
694
+
695
+ return detail;
696
+ }
697
+
698
+ function decorateConnections(connections: ScriptConnectionRecord[]): DecoratedConnection[] {
699
+ const bindings = new Map(
700
+ listRelationalCredentialBindings({ includeInactive: true }).map((binding) => [
701
+ binding.id,
702
+ binding,
703
+ ]),
704
+ );
705
+ return connections.map((connection) => {
706
+ const {
707
+ openapiSpecJson: _openapiSpecJson,
708
+ generatedTypes: _generatedTypes,
709
+ generatedRuntimeJson: _generatedRuntimeJson,
710
+ ...safeConnection
711
+ } = connection;
712
+ return {
713
+ ...safeConnection,
714
+ ...runtimeCounts(connection),
715
+ credentialBinding: bindingSummary(
716
+ connection.credentialBindingId ? bindings.get(connection.credentialBindingId) : undefined,
717
+ ),
718
+ };
719
+ });
720
+ }
721
+
722
+ function listConnections(query: z.infer<typeof listConnectionsQuerySchema>): DecoratedConnection[] {
723
+ const connections = listScriptConnections({
724
+ includeDisabled: true,
725
+ allScopes: true,
726
+ kind: query.kind as ScriptConnectionKind | undefined,
727
+ }).filter((connection) => {
728
+ if (query.scope && connection.scope !== query.scope) return false;
729
+ if (query.scopeId && connection.scopeId !== query.scopeId) return false;
730
+ return true;
731
+ });
732
+ return decorateConnections(connections);
733
+ }
734
+
735
+ function connectionScopeId(
736
+ scope: "global" | "agent" | "repo" | undefined,
737
+ scopeId?: string | null,
738
+ subject = "connections",
739
+ ) {
740
+ return resolveScopedResourceId(scope, scopeId, subject);
741
+ }
742
+
743
+ function validateCredentialTemplate(input: {
744
+ configKey: string;
745
+ headerTemplate?: string;
746
+ queryTemplate?: string;
747
+ requireTemplate?: boolean;
748
+ }) {
749
+ if (input.requireTemplate && !input.headerTemplate && !input.queryTemplate) {
750
+ throw new Error("At least one of headerTemplate or queryTemplate is required.");
751
+ }
752
+ const placeholder = placeholderForConfigKey(input.configKey);
753
+ if (input.headerTemplate && !input.headerTemplate.includes(placeholder)) {
754
+ throw new Error(`headerTemplate must include ${placeholder}.`);
755
+ }
756
+ if (input.queryTemplate && !input.queryTemplate.includes(placeholder)) {
757
+ throw new Error(`queryTemplate must include ${placeholder}.`);
758
+ }
759
+ }
760
+
761
+ function maybeCreateInlineBinding(
762
+ data: z.infer<typeof upsertConnectionBodySchema>,
763
+ resolvedScope?: "global" | "agent" | "repo",
764
+ resolvedScopeId?: string | null,
765
+ ) {
766
+ if (data.credentialBindingId || !data.configKey) return data.credentialBindingId ?? null;
767
+
768
+ const scope = resolvedScope ?? data.scope ?? "global";
769
+ const scopeId =
770
+ resolvedScopeId !== undefined
771
+ ? resolvedScopeId
772
+ : connectionScopeId(scope, data.scopeId, "bindings");
773
+ const allowedHosts =
774
+ data.allowedHosts ?? ("baseUrl" in data ? [new URL(data.baseUrl).hostname] : []);
775
+ const authKind = data.authKind ?? "config";
776
+ const placeholder = placeholderForConfigKey(data.configKey);
777
+ const headerTemplate =
778
+ data.headerTemplate ??
779
+ (data.queryTemplate ? undefined : `Authorization: Bearer ${placeholder}`);
780
+
781
+ validateCredentialTemplate({
782
+ configKey: data.configKey,
783
+ headerTemplate,
784
+ queryTemplate: data.queryTemplate,
785
+ });
786
+ if (authKind === "oauth" && !data.oauthProvider) {
787
+ throw new Error("oauthProvider is required for oauth credential bindings.");
788
+ }
789
+
790
+ return upsertCredentialBinding({
791
+ configKey: data.configKey,
792
+ allowedHosts,
793
+ headerTemplate,
794
+ queryTemplate: data.queryTemplate,
795
+ scope,
796
+ scopeId,
797
+ active: true,
798
+ authKind,
799
+ oauthProvider: data.oauthProvider ?? null,
800
+ }).id;
801
+ }
802
+
803
+ function parseMetadata(metadata: string): Record<string, unknown> {
804
+ try {
805
+ const parsed = JSON.parse(metadata);
806
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
807
+ ? (parsed as Record<string, unknown>)
808
+ : {};
809
+ } catch {
810
+ return {};
811
+ }
812
+ }
813
+
814
+ function parseScopes(scopes: string): string[] {
815
+ return scopes
816
+ .split(",")
817
+ .map((scope) => scope.trim())
818
+ .filter(Boolean);
819
+ }
820
+
821
+ function sanitizeOAuthApp(row: OAuthAppRow) {
822
+ const metadata = parseMetadata(row.metadata);
823
+ const extraParams =
824
+ metadata.extraParams &&
825
+ typeof metadata.extraParams === "object" &&
826
+ !Array.isArray(metadata.extraParams)
827
+ ? Object.fromEntries(
828
+ Object.entries(metadata.extraParams as Record<string, unknown>).filter(
829
+ (entry): entry is [string, string] => typeof entry[1] === "string",
830
+ ),
831
+ )
832
+ : undefined;
833
+ return {
834
+ id: row.id,
835
+ provider: row.provider,
836
+ clientId: row.clientId,
837
+ authorizeUrl: row.authorizeUrl,
838
+ tokenUrl: row.tokenUrl,
839
+ redirectUri: row.redirectUri,
840
+ scopes: parseScopes(row.scopes),
841
+ extraParams,
842
+ tokenAuthStyle: metadata.tokenAuthStyle === "basic" ? "basic" : "body",
843
+ tokenBodyFormat: metadata.tokenBodyFormat === "json" ? "json" : "form",
844
+ tokenStatus: getOAuthBindingTokenStatus(row.provider),
845
+ expiresAt: row.tokenExpiresAt,
846
+ lastRefreshedAt: normalizeDate(row.tokenUpdatedAt),
847
+ createdAt: row.createdAt,
848
+ updatedAt: row.updatedAt,
849
+ };
850
+ }
851
+
852
+ function listOAuthApps() {
853
+ const rows = getDb()
854
+ .prepare<OAuthAppRow, []>(
855
+ `SELECT a.id, a.provider, a.clientId, a.authorizeUrl, a.tokenUrl, a.redirectUri,
856
+ a.scopes, a.metadata, t.expiresAt AS tokenExpiresAt,
857
+ t.updatedAt AS tokenUpdatedAt, a.createdAt, a.updatedAt
858
+ FROM oauth_apps a
859
+ LEFT JOIN oauth_tokens t ON t.provider = a.provider
860
+ ORDER BY a.provider ASC`,
861
+ )
862
+ .all();
863
+ return rows.map(sanitizeOAuthApp);
864
+ }
865
+
866
+ /**
867
+ * Best-effort RFC 7009 token revocation. Returns true when a revocation
868
+ * request was attempted (a revocationUrl is configured), false otherwise.
869
+ * Network/HTTP failures are logged (scrubbed) and never fail the caller.
870
+ */
871
+ async function attemptRemoteRevocation(app: OAuthApp, accessToken: string): Promise<boolean> {
872
+ const metadata = parseMetadata(app.metadata);
873
+ const revocationUrl =
874
+ typeof metadata.revocationUrl === "string" ? metadata.revocationUrl : undefined;
875
+ if (!revocationUrl) return false;
876
+
877
+ const body = new URLSearchParams({
878
+ token: accessToken,
879
+ token_type_hint: "access_token",
880
+ });
881
+ const headers: Record<string, string> = {
882
+ "content-type": "application/x-www-form-urlencoded",
883
+ };
884
+ if (metadata.tokenAuthStyle === "basic") {
885
+ headers.authorization = `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`;
886
+ } else {
887
+ body.set("client_id", app.clientId);
888
+ body.set("client_secret", app.clientSecret);
889
+ }
890
+
891
+ const controller = new AbortController();
892
+ const timeout = setTimeout(() => controller.abort(), 5_000);
893
+ try {
894
+ await fetch(revocationUrl, {
895
+ method: "POST",
896
+ headers,
897
+ body: body.toString(),
898
+ signal: controller.signal,
899
+ });
900
+ } catch (err) {
901
+ console.warn(
902
+ scrubSecrets(
903
+ `OAuth token revocation request failed for provider ${app.provider}: ${
904
+ err instanceof Error ? err.message : String(err)
905
+ }`,
906
+ ),
907
+ );
908
+ } finally {
909
+ clearTimeout(timeout);
910
+ }
911
+ return true;
912
+ }
913
+
914
+ function genericOAuthRedirectUri(provider: string): string {
915
+ return `${getPublicMcpBaseUrl()}/api/oauth/${encodeURIComponent(provider)}/callback`;
916
+ }
917
+
918
+ function oauthDiscoveryUrls(inputUrl: string): string[] {
919
+ const parsed = new URL(inputUrl);
920
+ const pathname = parsed.pathname.replace(/\/+$/, "");
921
+ const base = `${parsed.origin}${pathname === "/" ? "" : pathname}`;
922
+ return [
923
+ `${base}/.well-known/oauth-authorization-server`,
924
+ `${base}/.well-known/openid-configuration`,
925
+ parsed.toString(),
926
+ ].filter((url, index, urls) => urls.indexOf(url) === index);
927
+ }
928
+
929
+ async function fetchJsonMetadata(url: string, signal: AbortSignal): Promise<unknown> {
930
+ let current = assertUrlSafe(url, publicEndpointSsrfOptions());
931
+ let response: Response | null = null;
932
+ for (let hop = 0; hop <= 5; hop += 1) {
933
+ response = await fetch(current, {
934
+ headers: { accept: "application/json" },
935
+ signal,
936
+ redirect: "manual",
937
+ });
938
+ if (response.status < 300 || response.status >= 400 || response.status === 304) break;
939
+ const location = response.headers.get("location");
940
+ if (!location) {
941
+ throw new Error(`HTTP ${response.status} redirect missing Location header`);
942
+ }
943
+ current = assertUrlSafe(new URL(location, current).toString(), publicEndpointSsrfOptions());
944
+ if (hop === 5) throw new Error("OAuth discovery exceeded 5 redirects.");
945
+ }
946
+ if (!response) throw new Error("OAuth discovery failed before receiving a response.");
947
+ if (!response.ok) {
948
+ throw new Error(`HTTP ${response.status}`);
949
+ }
950
+ const text = await response.text();
951
+ try {
952
+ return JSON.parse(text) as unknown;
953
+ } catch {
954
+ throw new Error("Response was not JSON.");
955
+ }
956
+ }
957
+
958
+ function extractOAuthDiscovery(metadata: unknown) {
959
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
960
+ const record = metadata as Record<string, unknown>;
961
+ if (
962
+ typeof record.authorization_endpoint !== "string" ||
963
+ typeof record.token_endpoint !== "string"
964
+ ) {
965
+ return null;
966
+ }
967
+ const scopes = Array.isArray(record.scopes_supported)
968
+ ? record.scopes_supported.filter((scope): scope is string => typeof scope === "string")
969
+ : [];
970
+ return {
971
+ authorizeUrl: record.authorization_endpoint,
972
+ tokenUrl: record.token_endpoint,
973
+ scopes,
974
+ };
975
+ }
976
+
977
+ async function discoverOAuthApp(url: string) {
978
+ const controller = new AbortController();
979
+ const timeout = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
980
+ const failures: string[] = [];
981
+ try {
982
+ for (const candidate of oauthDiscoveryUrls(url)) {
983
+ try {
984
+ const metadata = await fetchJsonMetadata(candidate, controller.signal);
985
+ const discovered = extractOAuthDiscovery(metadata);
986
+ if (discovered) {
987
+ assertOAuthAppUrlsSafe(discovered);
988
+ return { ...discovered, sourceUrl: candidate };
989
+ }
990
+ failures.push(`${candidate}: missing authorization_endpoint or token_endpoint`);
991
+ } catch (error) {
992
+ if (error instanceof DOMException && error.name === "AbortError") {
993
+ throw new Error("OAuth discovery timed out after 10s.");
994
+ }
995
+ if (isUrlSafetyError(error)) throw error;
996
+ failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
997
+ }
998
+ }
999
+ } finally {
1000
+ clearTimeout(timeout);
1001
+ }
1002
+ throw new Error(`OAuth discovery failed. ${failures.join(" ")}`);
1003
+ }
1004
+
1005
+ function isUrlSafetyError(error: unknown): boolean {
1006
+ if (!(error instanceof Error)) return false;
1007
+ return (
1008
+ error.message.startsWith("Refusing ") ||
1009
+ error.message.startsWith("Invalid URL:") ||
1010
+ error.message.startsWith("Missing hostname:")
1011
+ );
1012
+ }
1013
+
1014
+ function stringFromCatalogEntry(entry: Record<string, unknown>, keys: string[]): string {
1015
+ for (const key of keys) {
1016
+ const value = entry[key];
1017
+ if (typeof value === "string") return value;
1018
+ }
1019
+ return "";
1020
+ }
1021
+
1022
+ function stringArrayFromCatalogEntry(entry: Record<string, unknown>, key: string): string[] {
1023
+ const value = entry[key];
1024
+ if (!Array.isArray(value)) return [];
1025
+ return value.filter((item): item is string => typeof item === "string");
1026
+ }
1027
+
1028
+ function normalizeCatalogEntry(entry: unknown): IntegrationsCatalogEntry | null {
1029
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
1030
+ const record = entry as Record<string, unknown>;
1031
+ const kind = stringFromCatalogEntry(record, ["kind", "type"]);
1032
+ if (!kind || kind === "cli") return null;
1033
+ const slug = stringFromCatalogEntry(record, ["slug", "id", "name"]);
1034
+ const name = stringFromCatalogEntry(record, ["name", "title"]) || slug;
1035
+ const domain = stringFromCatalogEntry(record, ["domain", "hostname"]);
1036
+ return {
1037
+ id: stringFromCatalogEntry(record, ["id", "slug"]) || slug || name,
1038
+ kind,
1039
+ slug,
1040
+ name,
1041
+ description: stringFromCatalogEntry(record, ["description", "summary"]),
1042
+ url: stringFromCatalogEntry(record, ["url", "homepage", "baseUrl"]),
1043
+ icon: stringFromCatalogEntry(record, ["icon", "logo"]) || null,
1044
+ domain,
1045
+ categories: stringArrayFromCatalogEntry(record, "categories"),
1046
+ feeds: stringArrayFromCatalogEntry(record, "feeds"),
1047
+ };
1048
+ }
1049
+
1050
+ function catalogEntriesFromPayload(payload: unknown): unknown[] {
1051
+ if (Array.isArray(payload)) return payload;
1052
+ if (!payload || typeof payload !== "object") return [];
1053
+ const record = payload as Record<string, unknown>;
1054
+ for (const key of ["entries", "integrations", "data", "items"]) {
1055
+ if (Array.isArray(record[key])) return record[key] as unknown[];
1056
+ }
1057
+ return [];
1058
+ }
1059
+
1060
+ async function fetchIntegrationsCatalog() {
1061
+ const now = Date.now();
1062
+ if (integrationsCatalogCache && integrationsCatalogCache.expiresAtMs > now) {
1063
+ return integrationsCatalogCache.payload;
1064
+ }
1065
+
1066
+ const controller = new AbortController();
1067
+ const timeout = setTimeout(() => controller.abort(), INTEGRATIONS_CATALOG_TIMEOUT_MS);
1068
+ try {
1069
+ const response = await fetch("https://integrations.sh/api.json", {
1070
+ headers: { accept: "application/json" },
1071
+ signal: controller.signal,
1072
+ });
1073
+ if (!response.ok) {
1074
+ throw new Error(`integrations.sh returned HTTP ${response.status}`);
1075
+ }
1076
+ const payload = (await response.json()) as unknown;
1077
+ const entries = catalogEntriesFromPayload(payload)
1078
+ .map(normalizeCatalogEntry)
1079
+ .filter((entry): entry is IntegrationsCatalogEntry => Boolean(entry));
1080
+ const cachedAt = new Date().toISOString();
1081
+ integrationsCatalogCache = {
1082
+ expiresAtMs: now + INTEGRATIONS_CATALOG_TTL_MS,
1083
+ payload: { entries, cachedAt },
1084
+ };
1085
+ return integrationsCatalogCache.payload;
1086
+ } catch (error) {
1087
+ if (error instanceof DOMException && error.name === "AbortError") {
1088
+ throw new Error("Timed out fetching integrations catalog after 15s.");
1089
+ }
1090
+ throw error;
1091
+ } finally {
1092
+ clearTimeout(timeout);
1093
+ }
1094
+ }
1095
+
1096
+ function stringOrNull(value: unknown): string | null {
1097
+ return typeof value === "string" && value ? value : null;
1098
+ }
1099
+
1100
+ // Trim the upstream surface payload to what the Add Connection flow needs.
1101
+ // CLI surfaces are dropped (connections are http/mcp only) and the credentials
1102
+ // map is narrowed to ids referenced by the retained surfaces.
1103
+ function trimSurfacePayload(domain: string, payload: unknown): IntegrationsSurfacePayload {
1104
+ const record = (payload && typeof payload === "object" ? payload : {}) as Record<string, unknown>;
1105
+ const rawSurfaces = Array.isArray(record.surfaces) ? record.surfaces : [];
1106
+ const surfaces: IntegrationsSurfaceEntry[] = [];
1107
+ const referencedCredentialIds = new Set<string>();
1108
+
1109
+ for (const raw of rawSurfaces) {
1110
+ if (!raw || typeof raw !== "object") continue;
1111
+ const surface = raw as Record<string, unknown>;
1112
+ const type = typeof surface.type === "string" ? surface.type : "";
1113
+ if (type !== "http" && type !== "mcp") continue;
1114
+
1115
+ const auth = (surface.auth && typeof surface.auth === "object" ? surface.auth : {}) as Record<
1116
+ string,
1117
+ unknown
1118
+ >;
1119
+ const entries = Array.isArray(auth.entries) ? auth.entries : [];
1120
+ const credentialIds: string[] = [];
1121
+ let mechanics: IntegrationsSurfaceMechanics | null = null;
1122
+ for (const entry of entries) {
1123
+ if (!entry || typeof entry !== "object") continue;
1124
+ const uses = Array.isArray((entry as Record<string, unknown>).use)
1125
+ ? ((entry as Record<string, unknown>).use as unknown[])
1126
+ : [];
1127
+ for (const use of uses) {
1128
+ if (!use || typeof use !== "object") continue;
1129
+ const useRecord = use as Record<string, unknown>;
1130
+ const id = typeof useRecord.id === "string" ? useRecord.id : "";
1131
+ if (id && !credentialIds.includes(id)) credentialIds.push(id);
1132
+ const rawMechanics = (
1133
+ useRecord.mechanics && typeof useRecord.mechanics === "object" ? useRecord.mechanics : {}
1134
+ ) as Record<string, unknown>;
1135
+ const mechanicsIn = typeof rawMechanics.in === "string" ? rawMechanics.in : "";
1136
+ // Prefer the first header mechanics (that is what the credential
1137
+ // header-template prefill can use); fall back to any positioned use.
1138
+ if (
1139
+ mechanicsIn &&
1140
+ (!mechanics || (mechanics.in !== "header" && mechanicsIn === "header"))
1141
+ ) {
1142
+ mechanics = {
1143
+ in: mechanicsIn,
1144
+ headerName: stringOrNull(rawMechanics.headerName),
1145
+ scheme: stringOrNull(rawMechanics.scheme),
1146
+ };
1147
+ }
1148
+ }
1149
+ }
1150
+ for (const id of credentialIds) referencedCredentialIds.add(id);
1151
+ surfaces.push({
1152
+ type,
1153
+ name: typeof surface.name === "string" ? surface.name : "",
1154
+ url: stringOrNull(surface.url),
1155
+ docs: stringOrNull(surface.docs),
1156
+ spec: stringOrNull(surface.spec),
1157
+ auth: { required: auth.status === "required", credentialIds, mechanics },
1158
+ });
1159
+ }
1160
+
1161
+ const rawCredentials = (
1162
+ record.credentials && typeof record.credentials === "object" ? record.credentials : {}
1163
+ ) as Record<string, unknown>;
1164
+ const credentials: Record<string, IntegrationsSurfaceCredential> = {};
1165
+ for (const [id, raw] of Object.entries(rawCredentials)) {
1166
+ if (!referencedCredentialIds.has(id) || !raw || typeof raw !== "object") continue;
1167
+ const credential = raw as Record<string, unknown>;
1168
+ credentials[id] = {
1169
+ type: typeof credential.type === "string" ? credential.type : "unknown",
1170
+ label: typeof credential.label === "string" ? credential.label : id,
1171
+ generateUrl: stringOrNull(credential.generateUrl),
1172
+ setup: stringOrNull(credential.setup),
1173
+ };
1174
+ }
1175
+
1176
+ return {
1177
+ domain: typeof record.domain === "string" && record.domain ? record.domain : domain,
1178
+ summary: typeof record.summary === "string" ? record.summary : "",
1179
+ surfaces,
1180
+ credentials,
1181
+ };
1182
+ }
1183
+
1184
+ async function fetchIntegrationsSurface(domain: string): Promise<IntegrationsSurfacePayload> {
1185
+ const cacheKey = domain.toLowerCase();
1186
+ const now = Date.now();
1187
+ const cached = integrationsSurfaceCache.get(cacheKey);
1188
+ if (cached && cached.expiresAtMs > now) return cached.payload;
1189
+ integrationsSurfaceCache.delete(cacheKey);
1190
+
1191
+ const controller = new AbortController();
1192
+ const timeout = setTimeout(() => controller.abort(), INTEGRATIONS_CATALOG_TIMEOUT_MS);
1193
+ try {
1194
+ const response = await fetch(
1195
+ `https://integrations.sh/api/${encodeURIComponent(cacheKey)}/surface`,
1196
+ { headers: { accept: "application/json" }, signal: controller.signal },
1197
+ );
1198
+ if (response.status === 404) {
1199
+ throw new SurfaceNotFoundError(`No integration surface found for ${domain}.`);
1200
+ }
1201
+ if (!response.ok) {
1202
+ throw new Error(`integrations.sh returned HTTP ${response.status}`);
1203
+ }
1204
+ const payload = trimSurfacePayload(cacheKey, (await response.json()) as unknown);
1205
+ if (integrationsSurfaceCache.size >= INTEGRATIONS_SURFACE_CACHE_MAX_ENTRIES) {
1206
+ const oldestKey = integrationsSurfaceCache.keys().next().value;
1207
+ if (oldestKey !== undefined) integrationsSurfaceCache.delete(oldestKey);
1208
+ }
1209
+ integrationsSurfaceCache.set(cacheKey, {
1210
+ expiresAtMs: now + INTEGRATIONS_CATALOG_TTL_MS,
1211
+ payload,
1212
+ });
1213
+ return payload;
1214
+ } catch (error) {
1215
+ if (error instanceof DOMException && error.name === "AbortError") {
1216
+ throw new Error("Timed out fetching integration surface after 15s.");
1217
+ }
1218
+ throw error;
1219
+ } finally {
1220
+ clearTimeout(timeout);
1221
+ }
1222
+ }
1223
+
1224
+ function deleteOAuthApp(provider: string): boolean {
1225
+ const existing = getOAuthApp(provider);
1226
+ if (!existing) return false;
1227
+ const tx = getDb().transaction(() => {
1228
+ deleteOAuthTokens(provider);
1229
+ getDb().query("DELETE FROM oauth_apps WHERE provider = ?").run(provider);
1230
+ });
1231
+ tx();
1232
+ return true;
1233
+ }
1234
+
1235
+ async function refreshHttpConnection(
1236
+ id: string,
1237
+ userId: string | null,
1238
+ agentId: string | undefined,
1239
+ ): Promise<ScriptConnectionRecord | null> {
1240
+ return refreshScriptConnection(id, userId, agentId);
1241
+ }
1242
+
1243
+ export async function handleScriptConnections(
1244
+ req: IncomingMessage,
1245
+ res: ServerResponse,
1246
+ pathSegments: string[],
1247
+ queryParams: URLSearchParams,
1248
+ agentId: string | undefined,
1249
+ ): Promise<boolean> {
1250
+ if (listConnectionsRoute.match(req.method, pathSegments)) {
1251
+ const parsed = await listConnectionsRoute.parse(req, res, pathSegments, queryParams);
1252
+ if (!parsed) return true;
1253
+ json(res, { connections: listConnections(parsed.query) });
1254
+ return true;
1255
+ }
1256
+
1257
+ if (getConnectionRoute.match(req.method, pathSegments)) {
1258
+ const parsed = await getConnectionRoute.parse(req, res, pathSegments, queryParams);
1259
+ if (!parsed) return true;
1260
+ const connection =
1261
+ listScriptConnections({ includeDisabled: true, allScopes: true }).find(
1262
+ (candidate) => candidate.id === parsed.params.id,
1263
+ ) ?? null;
1264
+ if (!connection) {
1265
+ jsonError(res, "Script connection not found.", 404);
1266
+ return true;
1267
+ }
1268
+ json(res, { connection: connectionDetail(connection) });
1269
+ return true;
1270
+ }
1271
+
1272
+ if (upsertConnectionRoute.match(req.method, pathSegments)) {
1273
+ const parsed = await upsertConnectionRoute.parse(req, res, pathSegments, queryParams);
1274
+ if (!parsed) return true;
1275
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1276
+
1277
+ try {
1278
+ if (
1279
+ parsed.body.kind === "openapi" &&
1280
+ Boolean(parsed.body.openapiSpecJson) &&
1281
+ Boolean(parsed.body.openapiSpecUrl)
1282
+ ) {
1283
+ jsonError(res, "Provide exactly one of openapiSpecJson or openapiSpecUrl.", 400);
1284
+ return true;
1285
+ }
1286
+ const existingConnection = parsed.body.id ? getScriptConnectionById(parsed.body.id) : null;
1287
+ const existingOpenapiConnection =
1288
+ existingConnection?.kind === "openapi" ? existingConnection : null;
1289
+ if (
1290
+ parsed.body.kind === "openapi" &&
1291
+ !parsed.body.openapiSpecJson &&
1292
+ !parsed.body.openapiSpecUrl &&
1293
+ !existingOpenapiConnection
1294
+ ) {
1295
+ jsonError(res, "Provide exactly one of openapiSpecJson or openapiSpecUrl.", 400);
1296
+ return true;
1297
+ }
1298
+
1299
+ const scopeWasProvided = Object.hasOwn(parsed.body, "scope");
1300
+ const scopeIdWasProvided = Object.hasOwn(parsed.body, "scopeId");
1301
+ const enabledWasProvided = Object.hasOwn(parsed.body, "enabled");
1302
+ const scope = (scopeWasProvided ? parsed.body.scope : existingConnection?.scope) ?? "global";
1303
+ const scopeIdInput = scopeIdWasProvided
1304
+ ? parsed.body.scopeId
1305
+ : existingConnection && scope === existingConnection.scope
1306
+ ? existingConnection.scopeId
1307
+ : null;
1308
+ const scopeId = connectionScopeId(scope, scopeIdInput);
1309
+ const enabled = enabledWasProvided
1310
+ ? parsed.body.enabled !== false
1311
+ : (existingConnection?.enabled ?? true);
1312
+ const credentialBindingId = maybeCreateInlineBinding(parsed.body, scope, scopeId);
1313
+ const userId = resolveHttpAuditUserId(req, agentId);
1314
+ const openapiSpecUrl =
1315
+ parsed.body.kind === "openapi" ? parsed.body.openapiSpecUrl : undefined;
1316
+ const openapiSpecJson =
1317
+ parsed.body.kind === "openapi" ? parsed.body.openapiSpecJson : undefined;
1318
+ const openapiSpecUrlChanged =
1319
+ parsed.body.kind === "openapi" &&
1320
+ Boolean(openapiSpecUrl) &&
1321
+ openapiSpecUrl !== existingOpenapiConnection?.openapiSpecSource;
1322
+ const reuseExistingOpenapiSpec =
1323
+ parsed.body.kind === "openapi" &&
1324
+ Boolean(existingOpenapiConnection) &&
1325
+ openapiSpecJson === undefined &&
1326
+ !openapiSpecUrlChanged;
1327
+
1328
+ const connection = await upsertScriptConnection({
1329
+ id: parsed.body.id,
1330
+ slug: parsed.body.slug,
1331
+ displayName: parsed.body.displayName,
1332
+ kind: parsed.body.kind,
1333
+ scope,
1334
+ scopeId,
1335
+ baseUrl: "baseUrl" in parsed.body ? parsed.body.baseUrl : null,
1336
+ allowedHosts:
1337
+ parsed.body.allowedHosts ??
1338
+ ("baseUrl" in parsed.body ? [new URL(parsed.body.baseUrl).hostname] : []),
1339
+ credentialBindingId,
1340
+ openapiSpecSourceKind:
1341
+ reuseExistingOpenapiSpec && !openapiSpecUrl
1342
+ ? existingOpenapiConnection?.openapiSpecSourceKind
1343
+ : undefined,
1344
+ openapiSpecSource:
1345
+ reuseExistingOpenapiSpec && !openapiSpecUrl
1346
+ ? existingOpenapiConnection?.openapiSpecSource
1347
+ : undefined,
1348
+ openapiSpecUrl,
1349
+ openapiSpecJson:
1350
+ parsed.body.kind === "openapi"
1351
+ ? (openapiSpecJson ??
1352
+ (reuseExistingOpenapiSpec
1353
+ ? (existingOpenapiConnection?.openapiSpecJson ?? undefined)
1354
+ : undefined))
1355
+ : undefined,
1356
+ openapiSpecEtag: reuseExistingOpenapiSpec
1357
+ ? existingOpenapiConnection?.openapiSpecEtag
1358
+ : undefined,
1359
+ openapiSpecFetchedAt: reuseExistingOpenapiSpec
1360
+ ? existingOpenapiConnection?.openapiSpecFetchedAt
1361
+ : undefined,
1362
+ mcpServerId: parsed.body.kind === "mcp" ? parsed.body.mcpServerId : null,
1363
+ enabled,
1364
+ agentId,
1365
+ userId,
1366
+ });
1367
+
1368
+ json(res, { connection: decorateConnections([connection])[0] });
1369
+ } catch (err) {
1370
+ jsonError(res, err instanceof Error ? err.message : String(err), 400);
1371
+ }
1372
+ return true;
1373
+ }
1374
+
1375
+ if (refreshConnectionRoute.match(req.method, pathSegments)) {
1376
+ const parsed = await refreshConnectionRoute.parse(req, res, pathSegments, queryParams);
1377
+ if (!parsed) return true;
1378
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1379
+ try {
1380
+ const refreshed = await refreshHttpConnection(
1381
+ parsed.params.id,
1382
+ resolveHttpAuditUserId(req, agentId),
1383
+ agentId,
1384
+ );
1385
+ if (!refreshed) {
1386
+ jsonError(res, "Script connection not found.", 404);
1387
+ return true;
1388
+ }
1389
+ json(res, { connection: decorateConnections([refreshed])[0] });
1390
+ } catch (err) {
1391
+ jsonError(res, err instanceof Error ? err.message : String(err), 400);
1392
+ }
1393
+ return true;
1394
+ }
1395
+
1396
+ if (setConnectionEnabledRoute.match(req.method, pathSegments)) {
1397
+ const parsed = await setConnectionEnabledRoute.parse(req, res, pathSegments, queryParams);
1398
+ if (!parsed) return true;
1399
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1400
+ const updated = setScriptConnectionEnabled(
1401
+ parsed.params.id,
1402
+ parsed.body.enabled,
1403
+ resolveHttpAuditUserId(req, agentId),
1404
+ );
1405
+ if (!updated) {
1406
+ jsonError(res, "Script connection not found.", 404);
1407
+ return true;
1408
+ }
1409
+ json(res, { connection: decorateConnections([updated])[0] });
1410
+ return true;
1411
+ }
1412
+
1413
+ if (listCredentialBindingsRoute.match(req.method, pathSegments)) {
1414
+ json(res, {
1415
+ bindings: listRelationalCredentialBindings({ includeInactive: true }).map(decorateBinding),
1416
+ });
1417
+ return true;
1418
+ }
1419
+
1420
+ if (upsertCredentialBindingRoute.match(req.method, pathSegments)) {
1421
+ const parsed = await upsertCredentialBindingRoute.parse(req, res, pathSegments, queryParams);
1422
+ if (!parsed) return true;
1423
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1424
+
1425
+ try {
1426
+ const scope = parsed.body.scope ?? "global";
1427
+ const scopeId = connectionScopeId(scope, parsed.body.scopeId, "bindings");
1428
+ if (!parsed.body.headerTemplate && !parsed.body.queryTemplate) {
1429
+ jsonError(res, "At least one of headerTemplate or queryTemplate is required.", 400);
1430
+ return true;
1431
+ }
1432
+ if ((parsed.body.authKind ?? "config") === "oauth" && !parsed.body.oauthProvider) {
1433
+ jsonError(res, "oauthProvider is required for oauth credential bindings.", 400);
1434
+ return true;
1435
+ }
1436
+ validateCredentialTemplate({
1437
+ configKey: parsed.body.configKey,
1438
+ headerTemplate: parsed.body.headerTemplate,
1439
+ queryTemplate: parsed.body.queryTemplate,
1440
+ requireTemplate: true,
1441
+ });
1442
+ const nextBinding = CredentialBindingSchema.parse({
1443
+ configKey: parsed.body.configKey,
1444
+ allowedHosts: parsed.body.allowedHosts,
1445
+ headerTemplate: parsed.body.headerTemplate,
1446
+ queryTemplate: parsed.body.queryTemplate,
1447
+ scope,
1448
+ scopeId,
1449
+ active: parsed.body.active ?? true,
1450
+ authKind: parsed.body.authKind ?? "config",
1451
+ oauthProvider: parsed.body.oauthProvider,
1452
+ });
1453
+ const binding = upsertCredentialBinding({
1454
+ id: parsed.body.id,
1455
+ configKey: nextBinding.configKey,
1456
+ allowedHosts: nextBinding.allowedHosts,
1457
+ headerTemplate: nextBinding.headerTemplate,
1458
+ queryTemplate: nextBinding.queryTemplate,
1459
+ scope: nextBinding.scope,
1460
+ scopeId: nextBinding.scopeId ?? null,
1461
+ active: nextBinding.active,
1462
+ authKind: nextBinding.authKind,
1463
+ oauthProvider: nextBinding.oauthProvider ?? null,
1464
+ userId: resolveHttpAuditUserId(req, agentId),
1465
+ });
1466
+ json(res, { binding: decorateBinding(binding) });
1467
+ } catch (err) {
1468
+ jsonError(res, err instanceof Error ? err.message : String(err), 400);
1469
+ }
1470
+ return true;
1471
+ }
1472
+
1473
+ if (listOAuthAppsRoute.match(req.method, pathSegments)) {
1474
+ json(res, { oauthApps: listOAuthApps() });
1475
+ return true;
1476
+ }
1477
+
1478
+ if (upsertOAuthAppRoute.match(req.method, pathSegments)) {
1479
+ const parsed = await upsertOAuthAppRoute.parse(req, res, pathSegments, queryParams);
1480
+ if (!parsed) return true;
1481
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1482
+
1483
+ try {
1484
+ assertOAuthProviderIsNotReserved(parsed.body.provider);
1485
+ assertOAuthAppUrlsSafe(parsed.body);
1486
+ const existing = getOAuthApp(parsed.body.provider);
1487
+ const clientSecret = parsed.body.clientSecret ?? existing?.clientSecret;
1488
+ if (!clientSecret) {
1489
+ jsonError(res, "clientSecret is required when creating a new OAuth app.", 400);
1490
+ return true;
1491
+ }
1492
+
1493
+ const redirectUri = genericOAuthRedirectUri(parsed.body.provider);
1494
+ upsertOAuthApp(parsed.body.provider, {
1495
+ clientId: parsed.body.clientId,
1496
+ clientSecret,
1497
+ authorizeUrl: parsed.body.authorizeUrl,
1498
+ tokenUrl: parsed.body.tokenUrl,
1499
+ redirectUri,
1500
+ scopes: (parsed.body.scopes ?? []).join(","),
1501
+ ...(parsed.body.extraParams || parsed.body.tokenAuthStyle || parsed.body.tokenBodyFormat
1502
+ ? {
1503
+ metadata: JSON.stringify({
1504
+ ...(parsed.body.extraParams ? { extraParams: parsed.body.extraParams } : {}),
1505
+ ...(parsed.body.tokenAuthStyle
1506
+ ? { tokenAuthStyle: parsed.body.tokenAuthStyle }
1507
+ : {}),
1508
+ ...(parsed.body.tokenBodyFormat
1509
+ ? { tokenBodyFormat: parsed.body.tokenBodyFormat }
1510
+ : {}),
1511
+ }),
1512
+ }
1513
+ : {}),
1514
+ });
1515
+ const app = listOAuthApps().find((row) => row.provider === parsed.body.provider);
1516
+ json(res, { oauthApp: app });
1517
+ } catch (err) {
1518
+ jsonError(res, err instanceof Error ? err.message : String(err), 400);
1519
+ }
1520
+ return true;
1521
+ }
1522
+
1523
+ if (discoverOAuthAppRoute.match(req.method, pathSegments)) {
1524
+ const parsed = await discoverOAuthAppRoute.parse(req, res, pathSegments, queryParams);
1525
+ if (!parsed) return true;
1526
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1527
+ try {
1528
+ const discovered = await discoverOAuthApp(parsed.body.url);
1529
+ json(res, discovered);
1530
+ } catch (err) {
1531
+ jsonError(res, err instanceof Error ? err.message : String(err), 400);
1532
+ }
1533
+ return true;
1534
+ }
1535
+
1536
+ if (deleteOAuthAppRoute.match(req.method, pathSegments)) {
1537
+ const parsed = await deleteOAuthAppRoute.parse(req, res, pathSegments, queryParams);
1538
+ if (!parsed) return true;
1539
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1540
+ if (!deleteOAuthApp(parsed.params.provider)) {
1541
+ jsonError(res, `OAuth app ${parsed.params.provider} not found.`, 404);
1542
+ return true;
1543
+ }
1544
+ json(res, { success: true });
1545
+ return true;
1546
+ }
1547
+
1548
+ if (authorizeUrlRoute.match(req.method, pathSegments)) {
1549
+ const parsed = await authorizeUrlRoute.parse(req, res, pathSegments, queryParams);
1550
+ if (!parsed) return true;
1551
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1552
+
1553
+ const config = getOAuthProviderConfig(parsed.params.provider);
1554
+ if (!config) {
1555
+ jsonError(res, `OAuth app ${parsed.params.provider} is not configured.`, 404);
1556
+ return true;
1557
+ }
1558
+ const result = await buildAuthorizationUrl(config);
1559
+ json(res, { authorizeUrl: result.url, redirectUri: config.redirectUri });
1560
+ return true;
1561
+ }
1562
+
1563
+ if (integrationsCatalogRoute.match(req.method, pathSegments)) {
1564
+ try {
1565
+ json(res, await fetchIntegrationsCatalog());
1566
+ } catch (err) {
1567
+ jsonError(
1568
+ res,
1569
+ `Failed to fetch integrations catalog: ${err instanceof Error ? err.message : String(err)}`,
1570
+ 502,
1571
+ );
1572
+ }
1573
+ return true;
1574
+ }
1575
+
1576
+ if (integrationsSurfaceRoute.match(req.method, pathSegments)) {
1577
+ const parsed = await integrationsSurfaceRoute.parse(req, res, pathSegments, queryParams);
1578
+ if (!parsed) return true;
1579
+ try {
1580
+ json(res, await fetchIntegrationsSurface(parsed.params.domain));
1581
+ } catch (err) {
1582
+ if (err instanceof SurfaceNotFoundError) {
1583
+ jsonError(res, err.message, 404);
1584
+ return true;
1585
+ }
1586
+ jsonError(
1587
+ res,
1588
+ `Failed to fetch integration surface: ${err instanceof Error ? err.message : String(err)}`,
1589
+ 502,
1590
+ );
1591
+ }
1592
+ return true;
1593
+ }
1594
+
1595
+ if (disconnectOAuthAppRoute.match(req.method, pathSegments)) {
1596
+ const parsed = await disconnectOAuthAppRoute.parse(req, res, pathSegments, queryParams);
1597
+ if (!parsed) return true;
1598
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1599
+
1600
+ const app = getOAuthApp(parsed.params.provider);
1601
+ if (!app) {
1602
+ jsonError(res, `OAuth app ${parsed.params.provider} is not configured.`, 404);
1603
+ return true;
1604
+ }
1605
+ const tokens = getOAuthTokens(parsed.params.provider);
1606
+ if (!tokens) {
1607
+ json(res, { disconnected: false, message: "no stored tokens" });
1608
+ return true;
1609
+ }
1610
+ const revocationAttempted = await attemptRemoteRevocation(app, tokens.accessToken);
1611
+ deleteOAuthTokens(parsed.params.provider);
1612
+ json(res, { disconnected: true, revocationAttempted });
1613
+ return true;
1614
+ }
1615
+
1616
+ if (refreshOAuthAppTokensRoute.match(req.method, pathSegments)) {
1617
+ const parsed = await refreshOAuthAppTokensRoute.parse(req, res, pathSegments, queryParams);
1618
+ if (!parsed) return true;
1619
+ if (!ensureConnectionAdmin(req, res, agentId)) return true;
1620
+
1621
+ const provider = parsed.params.provider;
1622
+ if (!getOAuthApp(provider)) {
1623
+ jsonError(res, `OAuth app ${provider} is not configured.`, 404);
1624
+ return true;
1625
+ }
1626
+ const tokens = getOAuthTokens(provider);
1627
+ if (!tokens) {
1628
+ jsonError(res, "Nothing to refresh — authorize first.", 400);
1629
+ return true;
1630
+ }
1631
+ if (!tokens.refreshToken) {
1632
+ jsonError(
1633
+ res,
1634
+ `OAuth app ${provider} does not support refresh (no refresh token stored).`,
1635
+ 400,
1636
+ );
1637
+ return true;
1638
+ }
1639
+
1640
+ try {
1641
+ await forceRefreshTokenOrThrow(provider);
1642
+ } catch (err) {
1643
+ const message = err instanceof Error ? err.message : String(err);
1644
+ jsonError(res, scrubSecrets(`Token refresh failed: ${message}`), 502);
1645
+ return true;
1646
+ }
1647
+
1648
+ json(res, {
1649
+ refreshed: true,
1650
+ tokenStatus: getOAuthBindingTokenStatus(provider),
1651
+ expiresAt: getOAuthTokens(provider)?.expiresAt ?? null,
1652
+ });
1653
+ return true;
1654
+ }
1655
+
1656
+ return false;
1657
+ }