@opengeni/api-router 0.30.1 → 1.0.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 (436) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/api-websocket.d.ts +18 -0
  4. package/dist/app.d.ts +50 -2
  5. package/dist/app.js +12 -1
  6. package/dist/artifact-catalog-cursor.d.ts +27 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
  10. package/dist/auth/managed-auth.d.ts +102 -3
  11. package/dist/auth/managed-email.d.ts +29 -0
  12. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  13. package/dist/auth/new-signups-gate.d.ts +23 -0
  14. package/dist/auth/organization-user-setup.d.ts +80 -0
  15. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-state-authority.d.ts +1 -27
  19. package/dist/chunk-6TZ5LG3O.js +123948 -0
  20. package/dist/chunk-6TZ5LG3O.js.map +1 -0
  21. package/dist/chunk-OGQDXVXY.js +18 -0
  22. package/dist/chunk-OGQDXVXY.js.map +1 -0
  23. package/dist/claude-subscription-account-conflicts.d.ts +1 -0
  24. package/dist/claude-subscription-account-usage.d.ts +39 -0
  25. package/dist/claude-subscription-oauth.d.ts +25 -0
  26. package/dist/claude-subscription-profile.d.ts +9 -0
  27. package/dist/claude-subscription-usage.d.ts +44 -0
  28. package/dist/claude-workspace-connection.d.ts +15 -0
  29. package/dist/codemode.d.ts +32 -1
  30. package/dist/company-brain-okf.d.ts +24 -0
  31. package/dist/connected-machine-computer-access.d.ts +11 -0
  32. package/dist/connection-ownership.d.ts +54 -0
  33. package/dist/controller-data-plane.d.ts +32 -0
  34. package/dist/editable-artifact-office-import.d.ts +7 -4
  35. package/dist/editable-artifact-source-session.d.ts +3 -0
  36. package/dist/editable-artifact-websocket.d.ts +10 -18
  37. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  38. package/dist/fatal-process-boundary.d.ts +25 -0
  39. package/dist/github-access.d.ts +31 -2
  40. package/dist/http/acting-person.d.ts +24 -0
  41. package/dist/http/api-contract-compat.d.ts +19 -0
  42. package/dist/http/api-error.d.ts +27 -1
  43. package/dist/http/cors.d.ts +17 -0
  44. package/dist/http/deprecation.d.ts +42 -0
  45. package/dist/http/interaction-control-error.d.ts +13 -0
  46. package/dist/http/keyed-admission.d.ts +12 -0
  47. package/dist/http/local-browser-boundary.d.ts +72 -0
  48. package/dist/http/metrics-listener.d.ts +22 -0
  49. package/dist/http/path-identifier.d.ts +17 -0
  50. package/dist/http/registered-route-label.d.ts +16 -0
  51. package/dist/http/request-body.d.ts +27 -0
  52. package/dist/http/request-source.d.ts +52 -0
  53. package/dist/http/sse.d.ts +17 -6
  54. package/dist/http/unmatched-route.d.ts +18 -0
  55. package/dist/http/user-content.d.ts +36 -0
  56. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  57. package/dist/index.d.ts +10 -3
  58. package/dist/index.js +1129 -166
  59. package/dist/index.js.map +1 -1
  60. package/dist/integration-endpoint-test.d.ts +20 -0
  61. package/dist/integrations/api-integrations.d.ts +2 -2
  62. package/dist/integrations/atlassian.d.ts +29 -3
  63. package/dist/integrations/connect-authority.d.ts +1 -0
  64. package/dist/integrations/connect-callback-return.d.ts +6 -0
  65. package/dist/integrations/fiken.d.ts +16 -2
  66. package/dist/integrations/github-app-connect.d.ts +47 -0
  67. package/dist/integrations/github-installation-proof.d.ts +3 -0
  68. package/dist/integrations/github-lens-connect.d.ts +116 -0
  69. package/dist/integrations/github-skill-source.d.ts +1 -5
  70. package/dist/integrations/google-drive.d.ts +25 -0
  71. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  72. package/dist/integrations/marketplace-plugin.d.ts +2691 -0
  73. package/dist/integrations/oauth-client.d.ts +178 -18
  74. package/dist/integrations/oauth-profiles.d.ts +222 -0
  75. package/dist/integrations/oauth-return-path.d.ts +1 -0
  76. package/dist/integrations/personal-github-repositories.d.ts +84 -0
  77. package/dist/integrations/personal-github.d.ts +42 -0
  78. package/dist/integrations/pr-review-provider.d.ts +22 -0
  79. package/dist/integrations/provider-oauth.d.ts +19 -1
  80. package/dist/integrations/slack-app-home.d.ts +24 -0
  81. package/dist/integrations/slack-bot.d.ts +367 -12
  82. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  83. package/dist/integrations/slack-install.d.ts +20 -0
  84. package/dist/integrations/slack-interactions.d.ts +113 -10
  85. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  86. package/dist/integrations/slack-routing.d.ts +120 -0
  87. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  88. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  89. package/dist/integrations/social-oauth.d.ts +7 -0
  90. package/dist/interaction-agent-access.d.ts +15 -0
  91. package/dist/interaction-frame-proxy.d.ts +68 -0
  92. package/dist/interaction-metrics.d.ts +2 -0
  93. package/dist/mcp/action-catalog-types.d.ts +9 -0
  94. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  95. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  96. package/dist/mcp/child-read-evidence.d.ts +9 -0
  97. package/dist/mcp/command-read.d.ts +24 -0
  98. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  99. package/dist/mcp/contract-input.d.ts +11 -0
  100. package/dist/mcp/documents.d.ts +7 -5
  101. package/dist/mcp/files.d.ts +2 -2
  102. package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
  103. package/dist/mcp/knowledge-entries.d.ts +5 -0
  104. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  105. package/dist/mcp/receipts.d.ts +9 -0
  106. package/dist/mcp/request-abort.d.ts +19 -0
  107. package/dist/mcp/scheduled-task-input.d.ts +1189 -0
  108. package/dist/mcp/scheduled-task-view.d.ts +22 -4
  109. package/dist/mcp/server.d.ts +255 -3
  110. package/dist/mcp/session-event-view.d.ts +94 -0
  111. package/dist/mcp/session-view.d.ts +66 -2
  112. package/dist/mcp/session-wait.d.ts +174 -0
  113. package/dist/mcp-oauth.d.ts +52 -0
  114. package/dist/model-catalog.d.ts +11 -31
  115. package/dist/observability.d.ts +3 -1
  116. package/dist/organization-mcp.d.ts +52 -0
  117. package/dist/organization-recovery-notifications.d.ts +41 -0
  118. package/dist/routes/analytics-consent.d.ts +26 -0
  119. package/dist/routes/api-integrations.d.ts +25 -0
  120. package/dist/routes/api-keys.d.ts +17 -0
  121. package/dist/routes/artifact-catalog.d.ts +6 -0
  122. package/dist/routes/automations.d.ts +19 -0
  123. package/dist/routes/billing.d.ts +49 -0
  124. package/dist/routes/browser-sessions.d.ts +71 -3
  125. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  126. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  127. package/dist/routes/client-errors.d.ts +146 -0
  128. package/dist/routes/codex.d.ts +76 -2
  129. package/dist/routes/company-brain.d.ts +3 -0
  130. package/dist/routes/company-profile.d.ts +2 -1
  131. package/dist/routes/computer-sessions.d.ts +14 -0
  132. package/dist/routes/connect.d.ts +5 -0
  133. package/dist/routes/editable-artifacts.d.ts +2 -0
  134. package/dist/routes/external-identity-links.d.ts +6 -0
  135. package/dist/routes/feedback.d.ts +3 -0
  136. package/dist/routes/files.d.ts +4 -0
  137. package/dist/routes/github-browser-pages.d.ts +12 -0
  138. package/dist/routes/github.d.ts +2 -0
  139. package/dist/routes/insights-response-cache.d.ts +10 -0
  140. package/dist/routes/insights-usage.d.ts +12 -0
  141. package/dist/routes/insights.d.ts +30 -1
  142. package/dist/routes/knowledge.d.ts +3 -0
  143. package/dist/routes/managed-auth-session-sets.d.ts +26 -0
  144. package/dist/routes/managed-onboarding.d.ts +29 -0
  145. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  146. package/dist/routes/model-connection-access.d.ts +3 -0
  147. package/dist/routes/organization-integration-policy.d.ts +3 -0
  148. package/dist/routes/organization-integrations.d.ts +6 -0
  149. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  150. package/dist/routes/organization-model-providers.d.ts +3 -0
  151. package/dist/routes/organization-recovery.d.ts +19 -0
  152. package/dist/routes/organization-service-accounts.d.ts +3 -0
  153. package/dist/routes/organization-sessions.d.ts +26 -0
  154. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  155. package/dist/routes/personal-github.d.ts +3 -0
  156. package/dist/routes/pr-review-github.d.ts +3 -0
  157. package/dist/routes/pr-review.d.ts +3 -0
  158. package/dist/routes/session-artifact-associations.d.ts +4 -0
  159. package/dist/routes/session-history-imports.d.ts +9 -0
  160. package/dist/routes/sessions.d.ts +44 -6
  161. package/dist/routes/skill-content.d.ts +4 -0
  162. package/dist/routes/skill-install-authority.d.ts +6 -0
  163. package/dist/routes/subscription-account-pools.d.ts +34 -0
  164. package/dist/routes/subscription-pool-access.d.ts +16 -0
  165. package/dist/routes/supergrok.d.ts +6 -1
  166. package/dist/routes/usage-allowances.d.ts +12 -0
  167. package/dist/routes/user-resource-authorities.d.ts +3 -0
  168. package/dist/routes/workspace-artifacts.d.ts +2 -1
  169. package/dist/routes/workspace-capture.d.ts +10 -2
  170. package/dist/routes/workspace-integrations.d.ts +35 -0
  171. package/dist/routes/workspace-learning.d.ts +3 -0
  172. package/dist/routes/workspace-model-providers.d.ts +4 -0
  173. package/dist/routes/workspaces.d.ts +29 -0
  174. package/dist/sandbox/auth-callout.d.ts +8 -0
  175. package/dist/sandbox/channel-a.d.ts +19 -1
  176. package/dist/sandbox/connection-authority.d.ts +3 -0
  177. package/dist/sandbox/enrollment.d.ts +9 -1
  178. package/dist/sandbox/machines.d.ts +5 -2
  179. package/dist/sandbox/metrics-ingestion.d.ts +28 -20
  180. package/dist/sandbox/rematerialize.d.ts +2 -0
  181. package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
  182. package/dist/sandbox/viewer.d.ts +26 -1
  183. package/dist/sandbox-file-artifacts.d.ts +50 -0
  184. package/dist/scheduled-task-deletion.d.ts +13 -0
  185. package/dist/session-create-observability.d.ts +5 -0
  186. package/dist/session-event-view-MPS2OZD3.js +292 -0
  187. package/dist/session-event-view-MPS2OZD3.js.map +1 -0
  188. package/dist/site-session-origin.d.ts +13 -0
  189. package/dist/site-uploads.d.ts +41 -0
  190. package/dist/slack-reaction-files.d.ts +1 -1
  191. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  192. package/dist/temporal-schedule-sync.d.ts +26 -0
  193. package/dist/transcription/providers/xai-subscription.d.ts +1 -0
  194. package/dist/transcription/service.d.ts +2 -1
  195. package/dist/work-discovery-observability.d.ts +33 -0
  196. package/dist/workspace-artifact-content.d.ts +28 -0
  197. package/dist/workspace-artifact-provenance.d.ts +7 -0
  198. package/dist/workspace-delete-observability.d.ts +10 -0
  199. package/dist/workspace-deletion.d.ts +6 -0
  200. package/dist/workspace-state-projection.d.ts +3 -5
  201. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  202. package/dist/workspace-tool-gateway.d.ts +123 -0
  203. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  204. package/package.json +23 -19
  205. package/src/access-grant-rls.ts +75 -0
  206. package/src/admission-trace.ts +14 -0
  207. package/src/api-websocket.ts +23 -0
  208. package/src/app.ts +1974 -256
  209. package/src/artifact-catalog-cursor.ts +60 -0
  210. package/src/auth/managed-auth-attempt-context.ts +70 -0
  211. package/src/auth/managed-auth-rate-limits.ts +252 -0
  212. package/src/auth/managed-auth-session-adapter.ts +222 -0
  213. package/src/auth/managed-auth.ts +536 -60
  214. package/src/auth/managed-email.ts +174 -0
  215. package/src/auth/managed-sign-in-notifications.ts +81 -0
  216. package/src/auth/new-signups-gate.ts +49 -0
  217. package/src/auth/organization-user-setup.ts +307 -0
  218. package/src/auth/signup-funnel-metrics.ts +164 -0
  219. package/src/browser-controller-authority.ts +1 -186
  220. package/src/browser-ephemeral.ts +39 -0
  221. package/src/browser-state-authority.ts +1 -236
  222. package/src/claude-subscription-account-conflicts.ts +12 -0
  223. package/src/claude-subscription-account-usage.ts +48 -0
  224. package/src/claude-subscription-oauth.ts +362 -0
  225. package/src/claude-subscription-profile.ts +73 -0
  226. package/src/claude-subscription-usage.ts +97 -0
  227. package/src/claude-workspace-connection.ts +82 -0
  228. package/src/codemode.ts +194 -30
  229. package/src/codex-realtime.ts +8 -2
  230. package/src/company-brain-okf.ts +340 -0
  231. package/src/connected-machine-computer-access.ts +33 -0
  232. package/src/connection-ownership.ts +199 -0
  233. package/src/controller-data-plane.ts +81 -0
  234. package/src/editable-artifact-native-kernel.ts +26 -15
  235. package/src/editable-artifact-office-import.ts +77 -9
  236. package/src/editable-artifact-production.ts +23 -7
  237. package/src/editable-artifact-source-session.ts +123 -0
  238. package/src/editable-artifact-websocket.ts +27 -21
  239. package/src/editable-artifact-workspace-files.ts +31 -6
  240. package/src/fatal-process-boundary.ts +246 -0
  241. package/src/gateway-realtime.ts +1 -1
  242. package/src/github-access.ts +147 -5
  243. package/src/http/acting-person.ts +71 -0
  244. package/src/http/api-contract-compat.ts +49 -0
  245. package/src/http/api-error.ts +115 -1
  246. package/src/http/auth.ts +48 -0
  247. package/src/http/cors.ts +67 -0
  248. package/src/http/deprecation.ts +118 -0
  249. package/src/http/interaction-control-error.ts +164 -0
  250. package/src/http/keyed-admission.ts +29 -0
  251. package/src/http/local-browser-boundary.ts +343 -0
  252. package/src/http/metrics-listener.ts +57 -0
  253. package/src/http/path-identifier.ts +54 -0
  254. package/src/http/registered-route-label.ts +34 -0
  255. package/src/http/request-body.ts +127 -0
  256. package/src/http/request-source.ts +178 -0
  257. package/src/http/sse.ts +486 -132
  258. package/src/http/unmatched-route.ts +60 -0
  259. package/src/http/user-content.ts +158 -0
  260. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  261. package/src/index.ts +157 -52
  262. package/src/integration-endpoint-test.ts +291 -0
  263. package/src/integrations/api-integrations.ts +98 -11
  264. package/src/integrations/atlassian.ts +474 -136
  265. package/src/integrations/connect-authority.ts +2 -0
  266. package/src/integrations/connect-callback-return.ts +86 -0
  267. package/src/integrations/fiken.ts +354 -64
  268. package/src/integrations/github-app-connect.ts +435 -0
  269. package/src/integrations/github-installation-proof.ts +60 -0
  270. package/src/integrations/github-lens-connect.ts +76 -0
  271. package/src/integrations/github-skill-source.ts +2 -142
  272. package/src/integrations/google-drive.ts +820 -222
  273. package/src/integrations/integration-commit-authority.ts +69 -0
  274. package/src/integrations/marketplace-plugin.ts +48 -0
  275. package/src/integrations/oauth-client.ts +1356 -459
  276. package/src/integrations/oauth-profiles.ts +537 -0
  277. package/src/integrations/oauth-return-path.ts +18 -0
  278. package/src/integrations/personal-github-repositories.ts +674 -0
  279. package/src/integrations/personal-github.ts +825 -0
  280. package/src/integrations/pr-review-provider.ts +246 -0
  281. package/src/integrations/provider-oauth.ts +250 -22
  282. package/src/integrations/slack-app-home.ts +300 -0
  283. package/src/integrations/slack-bot.ts +963 -239
  284. package/src/integrations/slack-file-upload-flow.ts +147 -0
  285. package/src/integrations/slack-install.ts +83 -0
  286. package/src/integrations/slack-interactions.ts +3170 -474
  287. package/src/integrations/slack-mrkdwn.ts +193 -0
  288. package/src/integrations/slack-routing.ts +337 -0
  289. package/src/integrations/slack-session-defaults.ts +224 -0
  290. package/src/integrations/slack-task-file-upload.ts +323 -0
  291. package/src/integrations/social-oauth.ts +213 -15
  292. package/src/interaction-agent-access.ts +48 -0
  293. package/src/interaction-frame-proxy.ts +409 -0
  294. package/src/interaction-metrics.ts +30 -0
  295. package/src/mcp/action-catalog-types.ts +9 -0
  296. package/src/mcp/action-catalog.gen.ts +6696 -0
  297. package/src/mcp/capability-account-readiness.ts +17 -0
  298. package/src/mcp/child-read-evidence.ts +19 -0
  299. package/src/mcp/command-read.ts +109 -0
  300. package/src/mcp/company-profile-agent-admin.ts +198 -0
  301. package/src/mcp/contract-input.ts +141 -0
  302. package/src/mcp/documents.ts +65 -377
  303. package/src/mcp/editable-artifacts.ts +12 -6
  304. package/src/mcp/files.ts +80 -38
  305. package/src/mcp/first-party-tool-permissions.ts +305 -0
  306. package/src/mcp/knowledge-entries.ts +322 -0
  307. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  308. package/src/mcp/receipts.ts +34 -0
  309. package/src/mcp/request-abort.ts +44 -0
  310. package/src/mcp/scheduled-task-input.ts +76 -0
  311. package/src/mcp/scheduled-task-view.ts +44 -3
  312. package/src/mcp/server.ts +3030 -1267
  313. package/src/mcp/session-event-view.ts +383 -0
  314. package/src/mcp/session-view.ts +84 -3
  315. package/src/mcp/session-wait.ts +665 -0
  316. package/src/mcp-oauth.ts +1123 -0
  317. package/src/model-catalog.ts +91 -333
  318. package/src/observability.ts +15 -1
  319. package/src/organization-mcp.ts +397 -0
  320. package/src/organization-recovery-notifications.ts +103 -0
  321. package/src/routes/analytics-consent.ts +123 -0
  322. package/src/routes/api-integrations.ts +364 -74
  323. package/src/routes/api-keys.ts +336 -10
  324. package/src/routes/artifact-catalog.ts +279 -0
  325. package/src/routes/automations.ts +768 -0
  326. package/src/routes/billing.ts +461 -46
  327. package/src/routes/browser-sessions.ts +839 -131
  328. package/src/routes/canonical-human-identities.ts +90 -14
  329. package/src/routes/capabilities.ts +107 -6
  330. package/src/routes/channels.ts +20 -3
  331. package/src/routes/claude-subscription-accounts.ts +334 -0
  332. package/src/routes/claude-subscription-oauth.ts +142 -0
  333. package/src/routes/client-errors.ts +362 -0
  334. package/src/routes/codex.ts +833 -116
  335. package/src/routes/company-brain.ts +322 -0
  336. package/src/routes/company-profile.ts +65 -1
  337. package/src/routes/computer-sessions.ts +557 -106
  338. package/src/routes/connect.ts +1938 -0
  339. package/src/routes/connections.ts +1040 -349
  340. package/src/routes/documents.ts +488 -287
  341. package/src/routes/editable-artifacts.ts +79 -9
  342. package/src/routes/enrollments.ts +135 -39
  343. package/src/routes/environments.ts +154 -58
  344. package/src/routes/external-identity-links.ts +201 -0
  345. package/src/routes/feedback.ts +113 -0
  346. package/src/routes/files.ts +337 -39
  347. package/src/routes/github-browser-pages.ts +162 -0
  348. package/src/routes/github.ts +446 -144
  349. package/src/routes/insights-response-cache.ts +83 -0
  350. package/src/routes/insights-usage.ts +345 -0
  351. package/src/routes/insights.ts +153 -19
  352. package/src/routes/install.ts +133 -38
  353. package/src/routes/integration-facets.ts +107 -33
  354. package/src/routes/interaction-resources.ts +30 -3
  355. package/src/routes/knowledge.ts +488 -0
  356. package/src/routes/machines.ts +313 -15
  357. package/src/routes/managed-auth-session-sets.ts +1218 -0
  358. package/src/routes/managed-onboarding.ts +337 -0
  359. package/src/routes/managed-sign-in-methods.ts +433 -0
  360. package/src/routes/memory-slack-publications.ts +3 -2
  361. package/src/routes/model-connection-access.ts +208 -0
  362. package/src/routes/organization-integration-policy.ts +116 -0
  363. package/src/routes/organization-integrations.ts +326 -0
  364. package/src/routes/organization-mcp-connections.ts +178 -0
  365. package/src/routes/organization-memberships.ts +1019 -27
  366. package/src/routes/organization-model-providers.ts +293 -0
  367. package/src/routes/organization-recovery.ts +325 -0
  368. package/src/routes/organization-service-accounts.ts +128 -0
  369. package/src/routes/organization-sessions.ts +350 -0
  370. package/src/routes/personal-github-git-broker.ts +785 -0
  371. package/src/routes/personal-github.ts +370 -0
  372. package/src/routes/plugins.ts +251 -22
  373. package/src/routes/pr-review-github.ts +881 -0
  374. package/src/routes/pr-review.ts +622 -0
  375. package/src/routes/preference-registry.ts +12 -93
  376. package/src/routes/rigs.ts +167 -50
  377. package/src/routes/scheduled-tasks.ts +346 -41
  378. package/src/routes/session-artifact-associations.ts +87 -0
  379. package/src/routes/session-history-imports.ts +280 -0
  380. package/src/routes/sessions.ts +2306 -264
  381. package/src/routes/skill-content.ts +297 -0
  382. package/src/routes/skill-install-authority.ts +64 -0
  383. package/src/routes/skills.ts +62 -9
  384. package/src/routes/social.ts +46 -18
  385. package/src/routes/subscription-account-pools.ts +436 -0
  386. package/src/routes/subscription-pool-access.ts +126 -0
  387. package/src/routes/supergrok.ts +219 -303
  388. package/src/routes/transcription-recordings.ts +38 -3
  389. package/src/routes/transcriptions.ts +4 -0
  390. package/src/routes/usage-allowances.ts +378 -0
  391. package/src/routes/user-resource-authorities.ts +138 -0
  392. package/src/routes/video-generation.ts +14 -10
  393. package/src/routes/workspace-artifacts.ts +275 -69
  394. package/src/routes/workspace-capture.ts +21 -0
  395. package/src/routes/workspace-integrations.ts +576 -0
  396. package/src/routes/workspace-learning.ts +159 -0
  397. package/src/routes/workspace-model-providers.ts +148 -0
  398. package/src/routes/workspace-state.ts +38 -25
  399. package/src/routes/workspaces.ts +1004 -127
  400. package/src/sandbox/auth-callout.ts +96 -19
  401. package/src/sandbox/channel-a.ts +323 -24
  402. package/src/sandbox/connection-authority.ts +3 -0
  403. package/src/sandbox/enrollment.ts +81 -6
  404. package/src/sandbox/machines.ts +218 -76
  405. package/src/sandbox/metrics-ingestion.ts +411 -62
  406. package/src/sandbox/rematerialize.ts +121 -19
  407. package/src/sandbox/session-attach-variable-sets.ts +131 -0
  408. package/src/sandbox/viewer.ts +280 -25
  409. package/src/sandbox-file-artifacts.ts +413 -0
  410. package/src/scheduled-task-deletion.ts +138 -0
  411. package/src/session-create-observability.ts +46 -0
  412. package/src/session-realtime-context.ts +6 -3
  413. package/src/site-session-origin.ts +48 -0
  414. package/src/site-uploads.ts +224 -0
  415. package/src/slack-reaction-files.ts +16 -5
  416. package/src/temporal-schedule-cleanup.ts +13 -0
  417. package/src/temporal-schedule-sync.ts +74 -0
  418. package/src/transcription/providers/codex-subscription.ts +2 -0
  419. package/src/transcription/providers/openai.ts +1 -0
  420. package/src/transcription/providers/xai-subscription.ts +49 -3
  421. package/src/transcription/segmenter.ts +8 -2
  422. package/src/transcription/service.ts +43 -6
  423. package/src/work-discovery-observability.ts +121 -0
  424. package/src/workspace-artifact-content.ts +144 -0
  425. package/src/workspace-artifact-provenance.ts +104 -0
  426. package/src/workspace-delete-observability.ts +75 -0
  427. package/src/workspace-deletion.ts +73 -0
  428. package/src/workspace-state-projection.ts +18 -148
  429. package/src/workspace-tool-gateway-observability.ts +100 -0
  430. package/src/workspace-tool-gateway.ts +870 -0
  431. package/src/workspace-webhook-dispatch.ts +190 -0
  432. package/src/xai-subscription-auth.ts +9 -2
  433. package/dist/chunk-2PQMSRCB.js +0 -49759
  434. package/dist/chunk-2PQMSRCB.js.map +0 -1
  435. package/dist/routes/packs.d.ts +0 -3
  436. package/src/routes/packs.ts +0 -729
@@ -1,60 +1,154 @@
1
+ import { safeReturnPath } from "./oauth-return-path";
2
+ import { assertOrganizationIntegrationAllowed, stableJson } from "@opengeni/contracts";
3
+ import {
4
+ withOrganizationIntegrationAcquisition,
5
+ withOrganizationIntegrationPolicyFence,
6
+ } from "@opengeni/db/organization-integration-policy";
1
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
9
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
10
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
11
  import {
6
- OPENGENI_PERSONAL_SLACK_MCP_URL,
7
12
  OAuthStartResponse,
8
13
  selectCanonicalPersonalSlackConnection,
9
14
  type ConnectionOwnership,
10
15
  type OAuthStartRequest,
11
16
  } from "@opengeni/contracts";
12
- import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
17
+ import { requireEnvironmentEncryption, integrationKeyForConnectProvider } from "@opengeni/core";
18
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
13
19
  import type { Observability } from "@opengeni/observability";
14
20
  import {
15
21
  consumeIntegrationOAuthStateNonce,
22
+ claimConnectOperation,
23
+ finishConnectOperation,
24
+ getConnectAttempt,
16
25
  createConnection,
17
26
  decryptEnvironmentValue,
18
27
  encryptEnvironmentValue,
19
28
  getConnectionMetadata,
20
- getWorkspaceGrant,
29
+ getGlobalCatalogOAuthProfile,
21
30
  listConnectionsMetadata,
22
31
  loadIntegrationOAuthClient,
32
+ loadIntegrationOAuthPendingState,
23
33
  normalizeBearerScheme,
24
34
  replaceIntegrationOAuthClientIfCurrent,
25
35
  storeIntegrationOAuthClient,
36
+ storeIntegrationOAuthPendingState,
26
37
  updateConnection,
27
38
  withDatabaseStatementTimeout,
28
39
  type Database,
29
40
  } from "@opengeni/db";
30
- import { createSignedState, readSignedState } from "@opengeni/github";
41
+ import { createSignedState, inspectSignedState, readSignedState } from "@opengeni/github";
31
42
  import {
32
43
  DestinationPolicyError,
44
+ McpOAuthDiscoveryError,
33
45
  OAUTH_MAX_RESPONSE_BYTES,
34
46
  RequestDeadlineError,
35
47
  isLocalTestEnvironment,
48
+ parseMcpOAuthChallenge,
36
49
  pinnedFetch,
37
50
  readResponseJsonBounded,
51
+ resolveMcpOAuthDiscovery,
38
52
  validateHttpUrl,
53
+ type McpAuthorizationServerMetadata,
54
+ type McpOAuthChallenge,
55
+ type McpOAuthDiscoveryMode,
56
+ type McpOAuthMetadataFetchResult,
57
+ type McpProtectedResourceMetadata,
39
58
  } from "@opengeni/network";
40
59
  import { Buffer } from "node:buffer";
41
- import { createHash, randomBytes } from "node:crypto";
60
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
42
61
  import { HTTPException } from "hono/http-exception";
62
+ import {
63
+ assertConnectionOwnershipAllowedForPrincipal,
64
+ personalOwnerStateAccepted,
65
+ personalOwnerVerifiedInState,
66
+ PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
67
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
68
+ } from "../connection-ownership";
43
69
  import { ApiHttpError } from "../http/api-error";
70
+ import { requireConnectOwnerAuthority } from "./connect-authority";
71
+ import {
72
+ DEFAULT_OAUTH_PROFILE,
73
+ OFFICIAL_GMAIL_MCP_URL,
74
+ DEPLOYMENT_MANAGED_CLIENTS,
75
+ builtInOAuthProfileByKey,
76
+ assertAuthorizationServerNotReserved,
77
+ assertAuthorizationServerPins,
78
+ builtInOAuthProfileFor,
79
+ catalogMcpUrlKey,
80
+ defaultOwnershipFor,
81
+ deploymentManagedClientFor,
82
+ oauthProfileFromCatalog,
83
+ type OAuthProviderProfile,
84
+ type PinnableAuthorizationServer,
85
+ } from "./oauth-profiles";
44
86
  import { canonicalProviderDomain } from "./provider-domain";
45
87
 
46
88
  export const oauthStateTtlMs = 10 * 60 * 1000;
47
- export const OFFICIAL_SLACK_MCP_URL = OPENGENI_PERSONAL_SLACK_MCP_URL;
48
- export const OFFICIAL_GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
49
- export const OFFICIAL_GMAIL_MCP_SCOPES = [
50
- "https://www.googleapis.com/auth/gmail.readonly",
51
- "https://www.googleapis.com/auth/gmail.compose",
52
- "https://www.googleapis.com/auth/gmail.modify",
53
- ] as const;
54
- const SLACK_OAUTH_ORIGIN = "https://slack.com";
55
- const SLACK_MCP_ORIGIN = "https://mcp.slack.com";
56
- const GOOGLE_OAUTH_ISSUER_ORIGIN = "https://accounts.google.com";
57
- const GOOGLE_TOKEN_ORIGIN = "https://oauth2.googleapis.com";
89
+
90
+ /**
91
+ * Workspace-free landing for a callback whose state names no trustworthy
92
+ * workspace. The web app resolves it to the viewer's current workspace
93
+ * integrations page and keeps the callback outcome parameters.
94
+ */
95
+ export const INTEGRATIONS_FALLBACK_PATH = "/integrations";
96
+
97
+ const WORKSPACE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
98
+
99
+ /** The web integrations page for one workspace (the Plugins surface). */
100
+ export function workspaceIntegrationsPath(workspaceId: string): string {
101
+ return `/workspaces/${encodeURIComponent(workspaceId)}/plugins`;
102
+ }
103
+
104
+ /**
105
+ * The integrations page for a workspace id read from untrusted input (a URL
106
+ * parameter or an unverified-age state), or null unless it is a workspace UUID.
107
+ * A link target only: it grants nothing.
108
+ */
109
+ export function workspaceIntegrationsPathForUntrusted(candidate: unknown): string | null {
110
+ return typeof candidate === "string" && WORKSPACE_ID_PATTERN.test(candidate)
111
+ ? workspaceIntegrationsPath(candidate)
112
+ : null;
113
+ }
114
+
115
+ /**
116
+ * Where a callback returns, and why, when its own signed state is unusable.
117
+ *
118
+ * A correctly signed state still names its workspace, even when it is too old
119
+ * or its flow rejected it, so the browser returns to that workspace's
120
+ * integrations page; an aged one is reported as expired. Anything unsigned,
121
+ * tampered, or signed with another secret returns to
122
+ * {@link INTEGRATIONS_FALLBACK_PATH}. Display routing only: nothing here
123
+ * authorizes, resumes, or replays the callback.
124
+ */
125
+ export function oauthStateFailureReturn(
126
+ settings: Settings,
127
+ rawState: string | undefined,
128
+ nowMs = Date.now(),
129
+ ): { returnPath: string; reason: "state_expired" | "state_invalid" } {
130
+ let payload: ReturnType<typeof inspectSignedState> = null;
131
+ try {
132
+ payload = rawState
133
+ ? inspectSignedState(rawState, requireIntegrationsStateSecret(settings))
134
+ : null;
135
+ } catch {
136
+ payload = null;
137
+ }
138
+ return {
139
+ returnPath:
140
+ workspaceIntegrationsPathForUntrusted(payload?.workspaceId) ?? INTEGRATIONS_FALLBACK_PATH,
141
+ reason:
142
+ payload !== null && nowMs - payload.iat * 1000 > oauthStateTtlMs
143
+ ? "state_expired"
144
+ : "state_invalid",
145
+ };
146
+ }
147
+ export {
148
+ OFFICIAL_GMAIL_MCP_SCOPES,
149
+ OFFICIAL_GMAIL_MCP_URL,
150
+ OFFICIAL_SLACK_MCP_URL,
151
+ } from "./oauth-profiles";
58
152
  export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
59
153
 
60
154
  type OAuthClientDeps = {
@@ -66,41 +160,32 @@ type OAuthClientDeps = {
66
160
  };
67
161
 
68
162
  export type OAuthStartContext = {
163
+ /** Server-selected dedicated adapter identity; never copied from request metadata. */
164
+ integrationKey?: "gmail" | "slack-personal";
165
+ connectAttemptId?: string;
166
+ externalContinuation?: ExternalActorContinuation;
69
167
  accountId: string;
70
168
  workspaceId: string;
71
169
  subjectId: string;
170
+ /**
171
+ * False for every principal that cannot own a personal Connection (API keys,
172
+ * the configured key, services, agent attempts). Resolved by the route from
173
+ * the live authenticated principal, never inferred here.
174
+ */
175
+ personalOwnershipAllowed: boolean;
72
176
  requestUrl: string;
73
177
  payload: OAuthStartRequest;
74
178
  };
75
179
 
76
180
  export type OAuthCallbackResult = {
77
181
  redirectTo: string;
182
+ exactReturn?: boolean;
78
183
  };
79
184
 
80
- type WwwAuthenticateChallenge = {
81
- resourceMetadata?: string;
82
- scope?: string[];
83
- error?: string;
84
- };
85
-
86
- type ProtectedResourceMetadata = {
87
- resource?: string;
88
- authorizationServers: string[];
89
- scopesSupported: string[];
90
- raw: Record<string, unknown>;
91
- };
92
-
93
- type AuthorizationServerMetadata = {
94
- issuer: string;
95
- authorizationServer: string;
96
- authorizationEndpoint: string;
97
- tokenEndpoint: string;
98
- registrationEndpoint?: string;
99
- clientIdMetadataDocumentSupported: boolean;
100
- tokenEndpointAuthMethodsSupported: string[];
101
- codeChallengeMethodsSupported: string[];
102
- raw: Record<string, unknown>;
103
- };
185
+ type WwwAuthenticateChallenge = McpOAuthChallenge;
186
+ type ProtectedResourceMetadata = McpProtectedResourceMetadata;
187
+ type AuthorizationServerMetadata = McpAuthorizationServerMetadata;
188
+ type OAuthDiscoveryMode = McpOAuthDiscoveryMode | "provider_oauth_metadata";
104
189
 
105
190
  type OAuthClientRegistration = {
106
191
  method: "operator" | "manual" | "cimd" | "dcr";
@@ -112,10 +197,16 @@ type OAuthClientRegistration = {
112
197
  };
113
198
 
114
199
  type OAuthStatePayload = {
200
+ integrationKey?: "gmail" | "slack-personal";
201
+ connectAttemptId?: string;
202
+ externalContinuation?: ExternalActorContinuation;
203
+ returnUrl?: string;
115
204
  accountId: string;
116
205
  workspaceId: string;
117
206
  subjectId: string;
118
207
  ownership: ConnectionOwnership;
208
+ /** Signed proof that a live managed human authorized personal ownership. */
209
+ personalOwnerVerified: boolean;
119
210
  providerDomain: string;
120
211
  mcpUrl: string;
121
212
  resource: string;
@@ -126,6 +217,10 @@ type OAuthStatePayload = {
126
217
  tokenEndpoint: string;
127
218
  authorizationServer: string;
128
219
  issuer: string;
220
+ discoveryMode: OAuthDiscoveryMode;
221
+ discoveryMetadataSha256?: string;
222
+ protectedResourceMetadataUrl?: string;
223
+ authorizationServerMetadataUrl?: string;
129
224
  clientRegistrationMethod: OAuthClientRegistration["method"];
130
225
  tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
131
226
  resourceParameterSupported: boolean;
@@ -162,7 +257,8 @@ export type OAuthStartStage =
162
257
  | "mcp_challenge"
163
258
  | "protected_resource_metadata"
164
259
  | "authorization_server_metadata"
165
- | "client_registration";
260
+ | "client_registration"
261
+ | "state_persist";
166
262
 
167
263
  class OAuthStartStageError extends Error {
168
264
  constructor(
@@ -175,6 +271,13 @@ class OAuthStartStageError extends Error {
175
271
  }
176
272
  }
177
273
 
274
+ class OAuthMetadataUpstreamError extends Error {
275
+ constructor(readonly upstreamStatus: number) {
276
+ super(`OAuth metadata endpoint returned HTTP ${upstreamStatus}`);
277
+ this.name = "OAuthMetadataUpstreamError";
278
+ }
279
+ }
280
+
178
281
  class OAuthStartDeadline {
179
282
  readonly signal: AbortSignal;
180
283
  private readonly controller = new AbortController();
@@ -279,10 +382,90 @@ class OAuthCallbackDeadline {
279
382
  }
280
383
  }
281
384
 
385
+ /** Policy is the outermost lock. Receipt replay still runs normal authorization,
386
+ * but the DB calls authorizeAcquisition only for uncommitted progress. Provider
387
+ * requests must happen after this transaction, never inside either wrapper. */
388
+ export async function claimOAuthAcquisition(
389
+ db: Database,
390
+ scope: Parameters<typeof claimConnectOperation>[1],
391
+ input: Parameters<typeof claimConnectOperation>[2],
392
+ integrationKey: string | null,
393
+ acquiring = true,
394
+ ) {
395
+ if (!acquiring) return claimConnectOperation(db, scope, input);
396
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
397
+ claimConnectOperation(tx, scope, {
398
+ ...input,
399
+ authorizeAcquisition: async (locked, attempt) => {
400
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
401
+ await input.authorizeAcquisition?.(locked, attempt);
402
+ },
403
+ }),
404
+ );
405
+ }
406
+
407
+ type IntegrationSourceSelection = Record<string, unknown> & {
408
+ id: string;
409
+ syncEnabled: boolean;
410
+ readPolicy: "allow" | "ask" | "block";
411
+ };
412
+
413
+ /** Compare server-bound selections, not provider classification. Removing a
414
+ * source, disabling sync, or narrowing its read permission acquires no authority.
415
+ * Generation/timestamp bookkeeping does not turn an unchanged save into setup. */
416
+ export function integrationSourceSelectionRequiresAcquisition(
417
+ previous: readonly IntegrationSourceSelection[],
418
+ requested: readonly IntegrationSourceSelection[],
419
+ ): boolean {
420
+ const rank = { block: 0, ask: 1, allow: 2 };
421
+ const config = (source: IntegrationSourceSelection) => {
422
+ const {
423
+ selectedAt: _selectedAt,
424
+ configGeneration: _generation,
425
+ syncEnabled: _enabled,
426
+ readPolicy: _readPolicy,
427
+ ...binding
428
+ } = source;
429
+ return stableJson(binding);
430
+ };
431
+ return requested.some((source) => {
432
+ const existing = previous.find((candidate) => candidate.id === source.id);
433
+ return (
434
+ !existing ||
435
+ (!existing.syncEnabled && source.syncEnabled) ||
436
+ rank[source.readPolicy] > rank[existing.readPolicy] ||
437
+ config(existing) !== config(source)
438
+ );
439
+ });
440
+ }
441
+
442
+ export async function finishOAuthAcquisition(
443
+ db: Database,
444
+ scope: Parameters<typeof finishConnectOperation>[1],
445
+ input: Parameters<typeof finishConnectOperation>[2],
446
+ integrationKey: string | null,
447
+ ) {
448
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
449
+ finishConnectOperation(tx, scope, {
450
+ ...input,
451
+ authorizeAcquisition: async (locked, attempt) => {
452
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
453
+ await input.authorizeAcquisition?.(locked, attempt);
454
+ },
455
+ }),
456
+ );
457
+ }
458
+
282
459
  export async function startMcpOAuth(
283
460
  deps: OAuthClientDeps,
284
461
  context: OAuthStartContext,
285
462
  ): Promise<OAuthStartResponse> {
463
+ await withOrganizationIntegrationAcquisition(
464
+ deps.db,
465
+ context,
466
+ [context.integrationKey ?? "custom:mcp"],
467
+ async () => {},
468
+ );
286
469
  const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
287
470
  try {
288
471
  return await startMcpOAuthWithinDeadline(deps, context, deadline);
@@ -304,17 +487,51 @@ async function startMcpOAuthWithinDeadline(
304
487
  deadline: OAuthStartDeadline,
305
488
  ): Promise<OAuthStartResponse> {
306
489
  const { db, settings } = deps;
490
+ const externalContinuation = context.externalContinuation
491
+ ? ExternalActorContinuation.parse(context.externalContinuation)
492
+ : undefined;
493
+ const returnUrl =
494
+ context.payload.returnUrl !== undefined
495
+ ? exactExternalReturnUrl(context.payload.returnUrl)
496
+ : undefined;
497
+ if (
498
+ (externalContinuation && !returnUrl) ||
499
+ (returnUrl && !externalContinuation && !context.connectAttemptId)
500
+ )
501
+ throw new HTTPException(422, {
502
+ message: "external MCP OAuth requires verified actor authority and a host returnUrl",
503
+ });
504
+ if (
505
+ externalContinuation &&
506
+ (externalContinuation.actor.accountId !== context.accountId ||
507
+ externalContinuation.actor.effectiveSubjectId !== context.subjectId)
508
+ )
509
+ throw new HTTPException(403, { message: "external OAuth actor mismatch" });
307
510
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
308
- const officialGmailResource = mcpUrl === OFFICIAL_GMAIL_MCP_URL;
309
- const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
310
- const providerDomain = officialSlackResource
311
- ? "slack.com"
312
- : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
313
- const hostedSlackMcp = officialSlackResource || providerDomain === "slack.com";
314
- assertHostedSlackMcpOAuthStart(settings, context.payload, mcpUrl, hostedSlackMcp);
315
- const requestedOwnership: ConnectionOwnership = context.payload.ownership ?? "workspace";
316
- assertOfficialGmailPersonalOwnership(mcpUrl, requestedOwnership);
317
- const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
511
+ const urlProfile = builtInOAuthProfileFor({ mcpUrl });
512
+ const providerDomain =
513
+ urlProfile?.canonicalProviderDomain ??
514
+ canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
515
+ const builtInProfile = urlProfile ?? builtInOAuthProfileFor({ mcpUrl, providerDomain });
516
+ const profile =
517
+ builtInProfile ??
518
+ (await deadline.run("connection_lookup", async () => {
519
+ const raw = await getGlobalCatalogOAuthProfile(
520
+ db,
521
+ context.workspaceId,
522
+ catalogMcpUrlKey(mcpUrl),
523
+ );
524
+ return raw === null ? null : oauthProfileFromCatalog(mcpUrl, raw);
525
+ })) ??
526
+ DEFAULT_OAUTH_PROFILE;
527
+ assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
528
+ // Catalog defaults are setup preferences; an explicit ownership always wins.
529
+ const requestedOwnership: ConnectionOwnership =
530
+ context.payload.ownership ?? defaultOwnershipFor(profile);
531
+ assertProfileOwnership(profile, requestedOwnership);
532
+ const returnPath = safeReturnPath(
533
+ context.payload.returnPath ?? workspaceIntegrationsPath(context.workspaceId),
534
+ );
318
535
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
319
536
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
320
537
  const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
@@ -324,8 +541,8 @@ async function startMcpOAuthWithinDeadline(
324
541
  subjectId: context.subjectId,
325
542
  providerDomain,
326
543
  mcpUrl,
327
- hostedSlackMcp,
328
- exactMcpBinding: hostedSlackMcp || officialGmailResource,
544
+ connectionSelection: profile.connectionSelection,
545
+ exactMcpBinding: profile.exactMcpBinding,
329
546
  connectionId: context.payload.connectionId,
330
547
  requestedOwnership: context.payload.ownership,
331
548
  newConnectionOwnership: requestedOwnership,
@@ -337,30 +554,29 @@ async function startMcpOAuthWithinDeadline(
337
554
  const ownership = existing
338
555
  ? ownershipForConnection(existing.subjectId, context.subjectId)
339
556
  : requestedOwnership;
340
-
341
- const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
342
- if (hostedSlackMcp && !isLocalTestEnvironment(settings.environment)) {
343
- assertSlackAuthorizationServer(discovery.as);
344
- }
345
- const googleAuthorizationServer = hasGoogleAuthorizationServerIdentity(discovery.as);
346
- if (officialGmailResource || googleAuthorizationServer) {
347
- if (!officialGmailResource || providerDomain !== "gmailmcp.googleapis.com") {
348
- throw new HTTPException(422, {
349
- message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
350
- });
351
- }
352
- assertGoogleAuthorizationServer(discovery.as);
353
- }
354
- const resourceParameterSupported = !officialGmailResource;
355
- const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
557
+ assertProfileOwnership(profile, ownership);
558
+ // Only a managed human can own a personal Connection: personal-authority
559
+ // execution resolves through a delegation snapshot frozen on a human's causal
560
+ // turn, and migration 0256 can mint the `user` authority scope only for a
561
+ // subject that holds an active organization membership. An explicit personal
562
+ // choice is never silently downgraded to workspace ownership.
563
+ assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
564
+
565
+ const discovery = profile.providerOAuthDiscovery
566
+ ? await discoverProviderOAuth(mcpUrl, settings, deadline, profile)
567
+ : await discoverMcpOAuth(mcpUrl, settings, deadline);
568
+ assertDiscoveredAuthorizationServer(settings, discovery.as, profile, providerDomain);
569
+ const resourceParameterSupported =
570
+ discovery.mode === "rfc9728_protected_resource" && profile.sendResourceParameter;
571
+ const resource = discovery.resource;
356
572
  const verifier = randomPkceVerifier();
357
- // The Gmail PRM advertises broader grants, including full-mail access. The
358
- // reviewed connector never lets a caller widen the capability contract.
359
- const authorizeScopes = chooseMcpAuthorizeScopes({
360
- mcpUrl,
573
+ // A profile's exact scope override wins outright: the reviewed connector
574
+ // never lets a caller widen the capability contract.
575
+ const authorizeScopes = chooseProfileAuthorizeScopes(profile, {
361
576
  requested: context.payload.requestedScopes,
362
577
  challenged: discovery.challenge.scope,
363
578
  supported: discovery.prm.scopesSupported,
579
+ authorizationServerScopesSupported: stringArray(discovery.as.raw.scopes_supported),
364
580
  });
365
581
  const client = await deadline.run("client_registration", (signal) =>
366
582
  registerOAuthClient(
@@ -371,20 +587,50 @@ async function startMcpOAuthWithinDeadline(
371
587
  redirectUri,
372
588
  authorizeScopes,
373
589
  context.payload.oauthClient,
590
+ profile,
374
591
  signal,
592
+ context,
375
593
  ),
376
594
  );
595
+ if (
596
+ profile.confidentialClientRequired &&
597
+ (!isConfidentialOAuthClient(client) ||
598
+ !discovery.as.tokenEndpointAuthMethodsSupported.includes(client.tokenEndpointAuthMethod))
599
+ ) {
600
+ throw new OAuthStartStageError(
601
+ "client_registration",
602
+ "configuration_missing",
603
+ new HTTPException(503, {
604
+ message:
605
+ "Gmail OAuth requires a registered Google Web application client with a secret and supported token authentication",
606
+ }),
607
+ );
608
+ }
377
609
  const key = requireEnvironmentEncryption(settings);
378
- const state = createSignedState(requireIntegrationsStateSecret(settings), {
610
+ const fullState = createSignedState(requireIntegrationsStateSecret(settings), {
611
+ ...(context.integrationKey ? { integrationKey: context.integrationKey } : {}),
612
+ ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
379
613
  accountId: context.accountId,
380
614
  workspaceId: context.workspaceId,
381
615
  subjectId: context.subjectId,
382
616
  ownership,
617
+ // Signed record that a live principal was checked; the callback has no
618
+ // principal of its own and enforces exactly this decision.
619
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
620
+ ...(externalContinuation
621
+ ? {
622
+ encryptedExternalContinuation: encryptEnvironmentValue(
623
+ requireEnvironmentEncryption(settings),
624
+ JSON.stringify(externalContinuation),
625
+ ),
626
+ }
627
+ : {}),
628
+ ...(returnUrl ? { returnUrl } : {}),
383
629
  providerDomain,
384
630
  mcpUrl,
385
631
  resource,
386
- requestedScopes: officialGmailResource
387
- ? [...OFFICIAL_GMAIL_MCP_SCOPES]
632
+ requestedScopes: profile.requestedScopes
633
+ ? [...profile.requestedScopes]
388
634
  : uniqueStrings(context.payload.requestedScopes ?? []),
389
635
  authorizeScopes,
390
636
  encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
@@ -392,6 +638,14 @@ async function startMcpOAuthWithinDeadline(
392
638
  tokenEndpoint: discovery.as.tokenEndpoint,
393
639
  authorizationServer: client.authorizationServer,
394
640
  issuer: client.issuer,
641
+ discoveryMode: discovery.mode,
642
+ discoveryMetadataSha256: discovery.provenance.metadataSha256,
643
+ ...(discovery.provenance.protectedResourceMetadataUrl
644
+ ? {
645
+ protectedResourceMetadataUrl: discovery.provenance.protectedResourceMetadataUrl,
646
+ }
647
+ : {}),
648
+ authorizationServerMetadataUrl: discovery.provenance.authorizationServerMetadataUrl,
395
649
  clientRegistrationMethod: client.method,
396
650
  tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
397
651
  resourceParameterSupported,
@@ -403,6 +657,29 @@ async function startMcpOAuthWithinDeadline(
403
657
  returnPath,
404
658
  ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
405
659
  });
660
+ const expiresAt = new Date(Date.now() + oauthStateTtlMs);
661
+ let state = fullState;
662
+ if (settings.integrationsOauthShortStateEnabled) {
663
+ const stateId = randomUUID();
664
+ state = createSignedState(requireIntegrationsStateSecret(settings), {
665
+ kind: "mcp_oauth_reference",
666
+ id: stateId,
667
+ accountId: context.accountId,
668
+ workspaceId: context.workspaceId,
669
+ });
670
+ if (state.length > 512) {
671
+ throw new Error("MCP OAuth reference state exceeded its provider-safe length");
672
+ }
673
+ await deadline.run("state_persist", async () =>
674
+ storeIntegrationOAuthPendingState(db, {
675
+ id: stateId,
676
+ accountId: context.accountId,
677
+ workspaceId: context.workspaceId,
678
+ stateEncrypted: encryptEnvironmentValue(key, fullState),
679
+ expiresAt,
680
+ }),
681
+ );
682
+ }
406
683
  const authorizationUrl = buildAuthorizationUrl({
407
684
  endpoint: discovery.as.authorizationEndpoint,
408
685
  settings,
@@ -413,31 +690,84 @@ async function startMcpOAuthWithinDeadline(
413
690
  verifier,
414
691
  scopes: authorizeScopes,
415
692
  resourceParameterSupported,
693
+ ...(profile.extraAuthorizeParams ? { extraParams: profile.extraAuthorizeParams } : {}),
416
694
  });
417
695
  return OAuthStartResponse.parse({
418
696
  state,
419
697
  authorizationUrl,
420
- expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
698
+ expiresAt: expiresAt.toISOString(),
421
699
  });
422
700
  }
423
701
 
424
- export function assertOfficialGmailPersonalOwnership(
702
+ export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
703
+ return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
704
+ }
705
+
706
+ /** Start-time payload fences declared by the resolved profile. */
707
+ function assertOAuthStartProfile(
708
+ settings: Settings,
709
+ payload: OAuthStartRequest,
425
710
  mcpUrl: string,
426
- ownership: ConnectionOwnership,
711
+ profile: OAuthProviderProfile,
427
712
  ): void {
428
- if (mcpUrl === OFFICIAL_GMAIL_MCP_URL && ownership !== "personal") {
429
- throw new HTTPException(422, {
430
- message:
431
- "Gmail connections are personal only; each workspace member must connect their own mailbox",
432
- });
713
+ if (profile.rejectCallerOAuthClient && payload.oauthClient) {
714
+ throw new HTTPException(422, { message: profile.rejectCallerOAuthClient.message });
715
+ }
716
+ if (
717
+ profile.requireProviderDomain &&
718
+ payload.providerDomain &&
719
+ canonicalProviderDomain(payload.providerDomain) !== profile.requireProviderDomain.domain
720
+ ) {
721
+ throw new HTTPException(422, { message: profile.requireProviderDomain.message });
722
+ }
723
+ if (
724
+ profile.requireExactMcpUrl &&
725
+ !isLocalTestEnvironment(settings.environment) &&
726
+ mcpUrl !== profile.requireExactMcpUrl.url
727
+ ) {
728
+ throw new HTTPException(422, { message: profile.requireExactMcpUrl.message });
729
+ }
730
+ if (
731
+ profile.requireDeploymentClient &&
732
+ !deploymentManagedClientFor(settings, profile.requireDeploymentClient.key)
733
+ ) {
734
+ throw new HTTPException(503, { message: profile.requireDeploymentClient.message });
433
735
  }
434
736
  }
435
737
 
738
+ /**
739
+ * Post-discovery fences, in an order that preserves the historical error
740
+ * surface: the profile's provider identity, then its authorization-server
741
+ * origin pins, then the reserved-server guard for everything else.
742
+ */
743
+ function assertDiscoveredAuthorizationServer(
744
+ settings: Settings,
745
+ as: PinnableAuthorizationServer,
746
+ profile: OAuthProviderProfile,
747
+ providerDomain: string,
748
+ ): void {
749
+ if (
750
+ profile.postDiscoveryProviderDomain &&
751
+ providerDomain !== profile.postDiscoveryProviderDomain.domain
752
+ ) {
753
+ throw new HTTPException(422, { message: profile.postDiscoveryProviderDomain.message });
754
+ }
755
+ if (
756
+ profile.authorizationServer &&
757
+ !(profile.authorizationServer.skipInLocalTest && isLocalTestEnvironment(settings.environment))
758
+ ) {
759
+ assertAuthorizationServerPins(as, profile.authorizationServer);
760
+ }
761
+ assertAuthorizationServerNotReserved(as, profile);
762
+ }
763
+
436
764
  export async function completeMcpOAuthCallback(
437
765
  deps: OAuthClientDeps,
438
766
  input: {
439
767
  code?: string | undefined;
440
768
  state?: string | undefined;
769
+ /** The provider's `error` parameter, for example `access_denied` on Cancel. */
770
+ error?: string | undefined;
441
771
  requestUrl: string;
442
772
  },
443
773
  ): Promise<OAuthCallbackResult> {
@@ -456,12 +786,15 @@ async function completeMcpOAuthCallbackWithinDeadline(
456
786
  input: {
457
787
  code?: string | undefined;
458
788
  state?: string | undefined;
789
+ error?: string | undefined;
459
790
  requestUrl: string;
460
791
  },
461
792
  deadline: OAuthCallbackDeadline,
462
793
  ): Promise<OAuthCallbackResult> {
463
794
  const { db, settings, observability } = deps;
464
795
  let state: OAuthStatePayload | null = null;
796
+ let integrationKey: string | null = "custom:mcp";
797
+ let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
465
798
  if (!input.state) {
466
799
  const error = new OAuthCallbackStageError(
467
800
  "state_verify",
@@ -470,25 +803,94 @@ async function completeMcpOAuthCallbackWithinDeadline(
470
803
  );
471
804
  logOAuthCallbackFailure(observability, error, state);
472
805
  return {
473
- redirectTo: callbackReturnPath("/integrations", "error", {
806
+ redirectTo: callbackReturnPath(INTEGRATIONS_FALLBACK_PATH, "error", {
474
807
  stage: error.stage,
475
808
  reason: error.reason,
476
809
  }),
477
810
  };
478
811
  }
479
812
  try {
480
- state = readOAuthState(input.state, settings);
481
- // Fence state minted by an older deployment too: a rolling update must not
482
- // let a still-valid workspace-owned Gmail callback persist shared authority.
483
- assertOfficialGmailPersonalOwnership(state.mcpUrl, state.ownership);
484
- if (!input.code) {
485
- return {
486
- redirectTo: callbackReturnPath(state.returnPath, "error", {
487
- stage: "state_verify",
488
- reason: "missing_code",
489
- }),
813
+ state = await resolveMcpOAuthState(db, settings, input.state, deadline);
814
+ integrationKey = state.integrationKey ?? "custom:mcp";
815
+ if (state.connectAttemptId) {
816
+ const stored = await getConnectAttempt(db, state, state.connectAttemptId);
817
+ // Native Gmail setup uses its own provider id, but shares this callback.
818
+ // Bind that id to the exact reviewed Gmail destination before
819
+ // claiming an operation (including receipt replay), not to arbitrary MCP.
820
+ const gmailAttemptMatches =
821
+ stored.attempt.providerId === "gmail" &&
822
+ state.mcpUrl === OFFICIAL_GMAIL_MCP_URL &&
823
+ state.providerDomain === "gmailmcp.googleapis.com" &&
824
+ builtInOAuthProfileFor(state)?.key === "official-gmail" &&
825
+ personalOwnerStateAccepted(state);
826
+ if (
827
+ (!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) &&
828
+ !gmailAttemptMatches) ||
829
+ stored.attempt.ownership !== state.ownership ||
830
+ stored.returnUrl !== state.returnUrl
831
+ )
832
+ throw new HTTPException(403, { message: "OAuth attempt mismatch" });
833
+ connectOperation = {
834
+ attemptId: state.connectAttemptId,
835
+ operationId: `oauth:${state.nonce}`,
836
+ inputDigest: createHash("sha256").update(input.state).digest("hex"),
490
837
  };
838
+ integrationKey = integrationKeyForConnectProvider(stored.attempt.providerId);
839
+ const claim = await claimOAuthAcquisition(
840
+ db,
841
+ state,
842
+ {
843
+ ...connectOperation,
844
+ expectedRevision: stored.attempt.revision,
845
+ authorize: (tx, _attempt, origin) =>
846
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
847
+ },
848
+ integrationKey,
849
+ Boolean(input.code),
850
+ );
851
+ if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
491
852
  }
853
+ // The callback enforces the signed start-time principal decision.
854
+ // Workspace ownership requires no personal-owner claim.
855
+ if (!personalOwnerStateAccepted(state)) {
856
+ throw new OAuthCallbackStageError(
857
+ "state_verify",
858
+ "state_invalid",
859
+ new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }),
860
+ );
861
+ }
862
+ if (input.error || !input.code) {
863
+ // The provider sends `error` (for example `access_denied` when the user
864
+ // clicks Cancel) instead of a code. Report that as a refusal, not as an
865
+ // expired attempt, and never echo the provider's own text.
866
+ const cancelled = input.error === "access_denied";
867
+ if (connectOperation) {
868
+ await finishConnectOperation(db, state, {
869
+ ...connectOperation,
870
+ authorize: (tx, _attempt, origin) =>
871
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
872
+ commit: async (_tx, current) => ({
873
+ ...current,
874
+ revision: current.revision + 1,
875
+ state: cancelled ? "cancelled" : "failed",
876
+ nextAction: { type: "none" },
877
+ error: {
878
+ code: input.error ? "provider_denied" : "missing_code",
879
+ message: "Authorization was not completed. Start a new connection attempt.",
880
+ retryable: false,
881
+ },
882
+ }),
883
+ });
884
+ }
885
+ return callbackStateResult(
886
+ state,
887
+ "error",
888
+ input.error
889
+ ? { stage: "authorize", reason: cancelled ? "access_denied" : "provider_error" }
890
+ : { stage: "state_verify", reason: "missing_code" },
891
+ );
892
+ }
893
+ await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
492
894
  const consumed = await runCallbackDatabaseStage(
493
895
  deadline,
494
896
  "state_verify",
@@ -516,12 +918,21 @@ async function completeMcpOAuthCallbackWithinDeadline(
516
918
  ? error
517
919
  : new OAuthCallbackStageError("state_verify", "state_invalid", error);
518
920
  logOAuthCallbackFailure(observability, staged, state);
519
- return {
520
- redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
521
- stage: staged.stage,
522
- reason: staged.reason,
523
- }),
524
- };
921
+ if (!state) {
922
+ // The state itself could not be resolved, so it carries no return path.
923
+ // An authentic but aged state still names its workspace and "expired".
924
+ const failure = oauthStateFailureReturn(settings, input.state);
925
+ return {
926
+ redirectTo: callbackReturnPath(failure.returnPath, "error", {
927
+ stage: staged.stage,
928
+ reason: staged.reason === "state_invalid" ? failure.reason : staged.reason,
929
+ }),
930
+ };
931
+ }
932
+ return callbackStateResult(state, "error", {
933
+ stage: staged.stage,
934
+ reason: staged.reason,
935
+ });
525
936
  }
526
937
 
527
938
  const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
@@ -533,6 +944,16 @@ async function completeMcpOAuthCallbackWithinDeadline(
533
944
  const client = await runCallbackDatabaseStage(deadline, "client_lookup", db, (scopedDb) =>
534
945
  clientForState(scopedDb, settings, state),
535
946
  );
947
+ if (
948
+ builtInOAuthProfileFor(state)?.confidentialClientRequired &&
949
+ !isConfidentialOAuthClient(client)
950
+ ) {
951
+ throw new OAuthCallbackStageError(
952
+ "client_lookup",
953
+ "invalid_client",
954
+ new Error("Gmail OAuth client is unavailable"),
955
+ );
956
+ }
536
957
  const token = await deadline.run("token_exchange", (signal) =>
537
958
  exchangeAuthorizationCode(settings, {
538
959
  code: input.code!,
@@ -545,6 +966,27 @@ async function completeMcpOAuthCallbackWithinDeadline(
545
966
  signal,
546
967
  }),
547
968
  );
969
+ const profile = builtInOAuthProfileFor(state);
970
+ if (profile?.freshRefreshTokenRequired && !token.refreshToken) {
971
+ // Never splice a previous account's refresh token into a new grant.
972
+ // Google can omit refresh_token even after another consent prompt.
973
+ throw new OAuthCallbackStageError(
974
+ "token_exchange",
975
+ "offline_access_unavailable",
976
+ new Error("Google did not grant offline access. Start a new Gmail connection attempt."),
977
+ );
978
+ }
979
+ const scopes = grantedScopes(token.scopeText, state.authorizeScopes, profile);
980
+ if (
981
+ profile?.fullRequestedScopesRequired &&
982
+ profile.requestedScopes?.some((scope) => !scopes.includes(scope))
983
+ ) {
984
+ throw new OAuthCallbackStageError(
985
+ "token_exchange",
986
+ "insufficient_scope",
987
+ new Error("Google did not grant the reviewed Gmail permissions."),
988
+ );
989
+ }
548
990
  const verification = await verifyMcpToolsListNonFatal(
549
991
  observability,
550
992
  settings,
@@ -552,7 +994,6 @@ async function completeMcpOAuthCallbackWithinDeadline(
552
994
  token,
553
995
  deadline,
554
996
  );
555
- const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
556
997
  const credential = credentialBundle(token, state, client);
557
998
  const metadata = {
558
999
  resource: state.resource,
@@ -562,11 +1003,24 @@ async function completeMcpOAuthCallbackWithinDeadline(
562
1003
  tokenEndpoint: state.tokenEndpoint,
563
1004
  clientId: client.clientId,
564
1005
  clientRegistrationMethod: state.clientRegistrationMethod,
1006
+ oauthDiscovery: {
1007
+ mode: state.discoveryMode,
1008
+ resource: state.resource,
1009
+ issuer: state.issuer,
1010
+ ...(state.discoveryMetadataSha256 ? { metadataSha256: state.discoveryMetadataSha256 } : {}),
1011
+ ...(state.protectedResourceMetadataUrl
1012
+ ? { protectedResourceMetadataUrl: state.protectedResourceMetadataUrl }
1013
+ : {}),
1014
+ ...(state.authorizationServerMetadataUrl
1015
+ ? { authorizationServerMetadataUrl: state.authorizationServerMetadataUrl }
1016
+ : {}),
1017
+ },
565
1018
  mcpToolsVerification: verification.metadata,
1019
+ ...(verification.providerIdentity ?? {}),
566
1020
  ...(verification.tools ? { mcpTools: verification.tools } : {}),
567
1021
  };
568
1022
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
569
- const connection = await runCallbackDatabaseStage(deadline, "persist", db, async (scopedDb) => {
1023
+ const persist = async (scopedDb: Database) => {
570
1024
  await requireOAuthCallbackGrant(scopedDb, state!);
571
1025
  return state!.connectionId
572
1026
  ? await updateConnection(scopedDb, {
@@ -577,7 +1031,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
577
1031
  subjectId: ownerSubjectId,
578
1032
  providerDomain: state.providerDomain,
579
1033
  kind: "oauth2",
580
- status: "active",
1034
+ status: verification.connectionStatus ?? "active",
581
1035
  credentialEncrypted,
582
1036
  grantedScopes: scopes,
583
1037
  expiresAt: token.expiresAt,
@@ -590,13 +1044,55 @@ async function completeMcpOAuthCallbackWithinDeadline(
590
1044
  subjectId: ownerSubjectId,
591
1045
  providerDomain: state.providerDomain,
592
1046
  kind: "oauth2",
1047
+ status: verification.connectionStatus ?? "active",
593
1048
  credentialEncrypted,
594
1049
  grantedScopes: scopes,
595
1050
  expiresAt: token.expiresAt,
596
1051
  metadata,
597
1052
  createdBySubjectId: state.subjectId,
598
1053
  });
599
- });
1054
+ };
1055
+ if (connectOperation) {
1056
+ await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
1057
+ finishOAuthAcquisition(
1058
+ scopedDb,
1059
+ state!,
1060
+ {
1061
+ ...connectOperation!,
1062
+ authorize: (tx, _attempt, origin) =>
1063
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
1064
+ commit: async (tx, current) => {
1065
+ const connection = await persist(tx);
1066
+ if (!connection)
1067
+ throw new HTTPException(409, {
1068
+ message: "connection changed during OAuth reconnect",
1069
+ });
1070
+ return {
1071
+ ...current,
1072
+ revision: current.revision + 1,
1073
+ state: "complete",
1074
+ completionRequirement: "connection",
1075
+ credentialsCommitted: true,
1076
+ nextAction: { type: "none" },
1077
+ account: {
1078
+ id: connection.id,
1079
+ version: connection.version,
1080
+ providerId: current.providerId,
1081
+ label: state!.providerDomain,
1082
+ ownership: current.ownership,
1083
+ status: connection.status === "needs_reauth" ? "auth_needed" : "connected",
1084
+ },
1085
+ };
1086
+ },
1087
+ },
1088
+ integrationKey,
1089
+ ),
1090
+ );
1091
+ return { redirectTo: state.returnUrl!, exactReturn: true };
1092
+ }
1093
+ const connection = await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
1094
+ withOrganizationIntegrationAcquisition(scopedDb, state!, [integrationKey], persist),
1095
+ );
600
1096
  if (!connection) {
601
1097
  throw new HTTPException(409, {
602
1098
  message: "connection changed during OAuth reconnect; start again",
@@ -606,29 +1102,90 @@ async function completeMcpOAuthCallbackWithinDeadline(
606
1102
  // the enable connectionRef straight from the redirect, without a listConnections
607
1103
  // round-trip that could fail (transient, or a grant lacking connections:read)
608
1104
  // and leave the connection created but the capability un-enabled.
609
- return {
610
- redirectTo: callbackReturnPath(state.returnPath, "success", {
611
- connectionId: connection.id,
612
- providerDomain: connection.providerDomain,
613
- ownership: state.ownership,
614
- ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
615
- }),
616
- };
1105
+ return callbackStateResult(state, "success", {
1106
+ connectionId: connection.id,
1107
+ providerDomain: connection.providerDomain,
1108
+ ownership: state.ownership,
1109
+ ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
1110
+ });
617
1111
  } catch (error) {
618
1112
  const staged =
619
1113
  error instanceof OAuthCallbackStageError
620
1114
  ? error
621
1115
  : new OAuthCallbackStageError("persist", "persist_failed", error);
622
1116
  logOAuthCallbackFailure(observability, staged, state);
623
- return {
624
- redirectTo: callbackReturnPath(state.returnPath, "error", {
625
- stage: staged.stage,
626
- reason: staged.reason,
627
- }),
628
- };
1117
+ if (connectOperation && builtInOAuthProfileFor(state)?.key === "official-gmail") {
1118
+ // The external return URL stays exact. Its caller reads the durable
1119
+ // attempt result, which must not remain in flight after a rejected grant.
1120
+ try {
1121
+ await withDatabaseStatementTimeout(db, OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS, (tx) =>
1122
+ finishConnectOperation(tx, state!, {
1123
+ ...connectOperation!,
1124
+ authorize: (locked, _attempt, origin) =>
1125
+ requireConnectOwnerAuthority(locked, state!, "connections:write", origin),
1126
+ commit: async (_locked, current) => ({
1127
+ ...current,
1128
+ revision: current.revision + 1,
1129
+ state: "failed",
1130
+ nextAction: { type: "none" },
1131
+ error: {
1132
+ code: staged.reason,
1133
+ message:
1134
+ staged.reason === "offline_access_unavailable"
1135
+ ? "Google did not grant offline access. Your previous connection is unchanged. Start a new Gmail connection attempt."
1136
+ : staged.reason === "insufficient_scope"
1137
+ ? "Google did not grant all required Gmail permissions. Your previous connection is unchanged. Start a new connection attempt and approve the requested permissions."
1138
+ : "Gmail authorization could not be verified. Your previous connection is unchanged. Start a new connection attempt.",
1139
+ retryable: true,
1140
+ },
1141
+ }),
1142
+ }),
1143
+ );
1144
+ } catch (settlementError) {
1145
+ logOAuthCallbackFailure(
1146
+ observability,
1147
+ new OAuthCallbackStageError("persist", "persist_failed", settlementError),
1148
+ state,
1149
+ );
1150
+ }
1151
+ }
1152
+ return callbackStateResult(state, "error", {
1153
+ stage: staged.stage,
1154
+ reason: staged.reason,
1155
+ });
629
1156
  }
630
1157
  }
631
1158
 
1159
+ function exactExternalReturnUrl(raw: string): string {
1160
+ if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f]/.test(raw))
1161
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
1162
+ let url: URL;
1163
+ try {
1164
+ url = new URL(raw);
1165
+ } catch {
1166
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
1167
+ }
1168
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
1169
+ throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
1170
+ return raw;
1171
+ }
1172
+
1173
+ function callbackStateResult(
1174
+ state: OAuthStatePayload | null,
1175
+ status: Parameters<typeof callbackReturnPath>[1],
1176
+ details: Parameters<typeof callbackReturnPath>[2],
1177
+ ): OAuthCallbackResult {
1178
+ if ((state?.externalContinuation || state?.connectAttemptId) && state.returnUrl)
1179
+ return { redirectTo: state.returnUrl, exactReturn: true };
1180
+ return {
1181
+ redirectTo: callbackReturnPath(
1182
+ state?.returnPath ?? INTEGRATIONS_FALLBACK_PATH,
1183
+ status,
1184
+ details,
1185
+ ),
1186
+ };
1187
+ }
1188
+
632
1189
  export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string {
633
1190
  return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, "");
634
1191
  }
@@ -644,113 +1201,283 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
644
1201
  }
645
1202
 
646
1203
  async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
647
- const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
648
- if (
649
- !grant ||
650
- grant.accountId !== state.accountId ||
651
- !hasPermission(grant.permissions, "connections:write")
652
- ) {
653
- throw new HTTPException(403, {
654
- message: "OAuth subject no longer has permission to write this workspace connection",
655
- });
656
- }
1204
+ await requireConnectOwnerAuthority(db, state);
657
1205
  }
658
1206
 
659
- function assertHostedSlackMcpOAuthStart(
660
- settings: Settings,
661
- payload: OAuthStartRequest,
662
- mcpUrl: string,
663
- hostedSlackMcp: boolean,
664
- ): void {
665
- if (!hostedSlackMcp) return;
666
- if (payload.oauthClient) {
667
- throw new HTTPException(422, {
668
- message: "Slack OAuth client credentials are deployment-managed",
669
- });
670
- }
671
- if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
672
- throw new HTTPException(422, {
673
- message: "Slack provider identity does not match slack.com",
674
- });
675
- }
676
- if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
677
- throw new HTTPException(422, {
678
- message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
679
- });
680
- }
681
- if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
682
- throw new HTTPException(503, {
683
- message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
684
- });
1207
+ /** The hosted-Slack profile's origin pins, kept exported for its tests. */
1208
+ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
1209
+ const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer;
1210
+ if (pins) {
1211
+ assertAuthorizationServerPins(as, pins);
685
1212
  }
686
1213
  }
687
1214
 
688
- export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
689
- const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
690
- const endpointOrigins = [as.authorizationEndpoint, as.tokenEndpoint].map(
691
- (value) => new URL(value).origin,
692
- );
693
- if (
694
- issuerOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN && origin !== SLACK_MCP_ORIGIN) ||
695
- endpointOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN)
696
- ) {
697
- throw new HTTPException(422, {
698
- message: "Slack MCP authorization metadata did not remain bound to slack.com",
699
- });
1215
+ /** The official-Gmail profile's origin pins, kept exported for its tests. */
1216
+ export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
1217
+ const pins = builtInOAuthProfileByKey("official-gmail").authorizationServer;
1218
+ if (pins) {
1219
+ assertAuthorizationServerPins(as, pins);
700
1220
  }
701
1221
  }
702
1222
 
703
- function hasGoogleAuthorizationServerIdentity(as: AuthorizationServerMetadata): boolean {
704
- return [as.issuer, as.authorizationServer].some(
705
- (value) => new URL(value).origin === GOOGLE_OAUTH_ISSUER_ORIGIN,
706
- );
1223
+ /** Inspect without credentials, registration, or running tools. */
1224
+ export async function inspectMcpAuthentication(
1225
+ resource: string,
1226
+ settings: Settings,
1227
+ ): Promise<{
1228
+ kind: "oauth2" | "none" | "unknown";
1229
+ message?: string;
1230
+ }> {
1231
+ const deadline = new OAuthStartDeadline(12_000);
1232
+ try {
1233
+ const response = await deadline.run("mcp_challenge", (signal) =>
1234
+ fetchOAuth(resource, settings, {
1235
+ method: "POST",
1236
+ headers: {
1237
+ accept: "application/json, text/event-stream",
1238
+ "content-type": "application/json",
1239
+ },
1240
+ body: JSON.stringify({
1241
+ jsonrpc: "2.0",
1242
+ id: "auth-discovery",
1243
+ method: "initialize",
1244
+ params: {
1245
+ protocolVersion: "2025-03-26",
1246
+ capabilities: {},
1247
+ clientInfo: { name: "OpenGeni", version: "1.0" },
1248
+ },
1249
+ }),
1250
+ signal,
1251
+ }),
1252
+ );
1253
+ const challenge = parseMcpOAuthChallenge(response.headers.get("www-authenticate"));
1254
+ let initialized = false;
1255
+ if (response.ok) {
1256
+ initialized = await deadline.run("mcp_challenge", async () => {
1257
+ const reader = response.body?.getReader();
1258
+ if (!reader) return false;
1259
+ const decoder = new TextDecoder();
1260
+ let text = "";
1261
+ let bytes = 0;
1262
+ const sse = response.headers.get("content-type")?.includes("text/event-stream");
1263
+ try {
1264
+ while (true) {
1265
+ const chunk = await reader.read();
1266
+ if (chunk.done) return false;
1267
+ bytes += chunk.value.byteLength;
1268
+ if (bytes > 262144) return false;
1269
+ text += decoder.decode(chunk.value, { stream: true });
1270
+ const messages = sse
1271
+ ? text
1272
+ .split(/\r?\n/)
1273
+ .filter((line) => line.startsWith("data:"))
1274
+ .map((line) => line.slice(5).trim())
1275
+ : [text];
1276
+ for (const message of messages) {
1277
+ try {
1278
+ const value = JSON.parse(message);
1279
+ if (
1280
+ value.id === "auth-discovery" &&
1281
+ typeof value.result?.protocolVersion === "string" &&
1282
+ value.result?.serverInfo &&
1283
+ value.result?.capabilities
1284
+ )
1285
+ return true;
1286
+ } catch {
1287
+ /* Wait for a complete JSON message. */
1288
+ }
1289
+ }
1290
+ }
1291
+ } finally {
1292
+ await reader.cancel().catch(() => undefined);
1293
+ }
1294
+ });
1295
+ } else {
1296
+ await cancelResponseBody(response);
1297
+ }
1298
+ let metadataRead = false;
1299
+ let metadataAbsent = true;
1300
+ try {
1301
+ await resolveMcpOAuthDiscovery({
1302
+ resourceUrl: resource,
1303
+ challenge,
1304
+ fetchMetadata: async ({ kind, url }) => {
1305
+ try {
1306
+ const result = await deadline.run(
1307
+ kind === "protected_resource"
1308
+ ? "protected_resource_metadata"
1309
+ : "authorization_server_metadata",
1310
+ (signal) => fetchOAuthMetadata(url, settings, signal),
1311
+ );
1312
+ metadataRead = true;
1313
+ if (result.status !== "absent") metadataAbsent = false;
1314
+ return result;
1315
+ } catch (error) {
1316
+ metadataAbsent = false;
1317
+ throw error;
1318
+ }
1319
+ },
1320
+ validateEndpoint: (url, label) => oauthEndpointUrl(url, settings, label),
1321
+ canonicalizeResource: canonicalOAuthResource,
1322
+ });
1323
+ return { kind: "oauth2" };
1324
+ } catch (failure) {
1325
+ // Public initialization does not exclude optional or tool-level OAuth.
1326
+ // Only explicit absence of metadata permits the unauthenticated path.
1327
+ if (
1328
+ initialized &&
1329
+ !challenge.scheme &&
1330
+ !challenge.resourceMetadata &&
1331
+ metadataRead &&
1332
+ metadataAbsent
1333
+ )
1334
+ return { kind: "none" };
1335
+ return {
1336
+ kind: "unknown",
1337
+ message: mcpAuthInspectionFailureMessage(failure),
1338
+ };
1339
+ }
1340
+ } catch (failure) {
1341
+ return {
1342
+ kind: "unknown",
1343
+ message: mcpAuthInspectionFailureMessage(failure),
1344
+ };
1345
+ } finally {
1346
+ deadline.dispose();
1347
+ }
707
1348
  }
708
1349
 
709
- export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
710
- const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
711
- const authorizationOrigin = new URL(as.authorizationEndpoint).origin;
712
- const tokenOrigin = new URL(as.tokenEndpoint).origin;
713
- if (
714
- issuerOrigins.some((origin) => origin !== GOOGLE_OAUTH_ISSUER_ORIGIN) ||
715
- authorizationOrigin !== GOOGLE_OAUTH_ISSUER_ORIGIN ||
716
- tokenOrigin !== GOOGLE_TOKEN_ORIGIN
717
- ) {
718
- throw new HTTPException(422, {
719
- message: "Gmail MCP authorization metadata did not remain bound to Google",
720
- });
1350
+ function mcpAuthInspectionFailureMessage(failure: unknown): string {
1351
+ if (failure instanceof OAuthStartStageError) {
1352
+ if (failure.reason === "timeout") {
1353
+ return "Checking how to sign in timed out. Try again.";
1354
+ }
1355
+ if (failure.cause instanceof OAuthMetadataUpstreamError) {
1356
+ return `The provider returned HTTP ${failure.cause.upstreamStatus} while checking how to sign in. Retry or check the provider's setup instructions.`;
1357
+ }
721
1358
  }
1359
+ return "Could not determine how to sign in. Retry or check the provider's setup instructions.";
722
1360
  }
723
1361
 
724
1362
  async function discoverMcpOAuth(
725
1363
  resource: string,
726
1364
  settings: Settings,
727
1365
  deadline: OAuthStartDeadline,
1366
+ knownChallenge?: WwwAuthenticateChallenge,
728
1367
  ): Promise<{
729
1368
  challenge: WwwAuthenticateChallenge;
730
1369
  prm: ProtectedResourceMetadata;
731
1370
  as: AuthorizationServerMetadata;
1371
+ mode: McpOAuthDiscoveryMode;
1372
+ resource: string;
1373
+ provenance: {
1374
+ protectedResourceMetadataUrl: string | null;
1375
+ authorizationServerMetadataUrl: string;
1376
+ metadataSha256: string;
1377
+ };
732
1378
  }> {
733
- const challenge = await deadline.run("mcp_challenge", (signal) =>
734
- probeMcpChallenge(resource, settings, signal),
735
- );
736
- const prm = await deadline.run("protected_resource_metadata", (signal) =>
737
- discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata, signal),
738
- );
739
- const authorizationServer = prm.authorizationServers[0];
740
- if (!authorizationServer) {
741
- throw new HTTPException(422, {
742
- message: "MCP protected resource metadata did not advertise an authorization server",
1379
+ const challenge =
1380
+ knownChallenge ??
1381
+ (await deadline.run("mcp_challenge", (signal) =>
1382
+ probeMcpChallenge(resource, settings, signal),
1383
+ ));
1384
+ try {
1385
+ const discovery = await resolveMcpOAuthDiscovery({
1386
+ resourceUrl: resource,
1387
+ challenge,
1388
+ fetchMetadata: ({ kind, url }) =>
1389
+ deadline.run(
1390
+ kind === "protected_resource"
1391
+ ? "protected_resource_metadata"
1392
+ : "authorization_server_metadata",
1393
+ (signal) => fetchOAuthMetadata(url, settings, signal),
1394
+ ),
1395
+ validateEndpoint: (rawUrl, label) => oauthEndpointUrl(rawUrl, settings, label),
1396
+ canonicalizeResource: canonicalOAuthResource,
743
1397
  });
1398
+ return {
1399
+ challenge: discovery.challenge,
1400
+ prm: discovery.protectedResourceMetadata,
1401
+ as: discovery.authorizationServerMetadata,
1402
+ mode: discovery.mode,
1403
+ resource: discovery.resource,
1404
+ provenance: discovery.provenance,
1405
+ };
1406
+ } catch (error) {
1407
+ if (error instanceof OAuthStartStageError) throw error;
1408
+ if (error instanceof McpOAuthDiscoveryError) {
1409
+ throw new OAuthStartStageError(
1410
+ error.stage,
1411
+ error.classification,
1412
+ new HTTPException(422, { message: error.message }),
1413
+ );
1414
+ }
1415
+ throw error;
744
1416
  }
745
- const as = await deadline.run("authorization_server_metadata", (signal) =>
746
- discoverAuthorizationServerMetadata(authorizationServer, settings, signal),
1417
+ }
1418
+
1419
+ /** Exact built-in API bridge discovery never contacts its historical MCP identity. */
1420
+ async function discoverProviderOAuth(
1421
+ resource: string,
1422
+ settings: Settings,
1423
+ deadline: OAuthStartDeadline,
1424
+ profile: OAuthProviderProfile,
1425
+ ) {
1426
+ const pinned = profile.providerOAuthDiscovery;
1427
+ if (!pinned || resource !== profile.requireExactMcpUrl?.url)
1428
+ throw new HTTPException(422, { message: "invalid provider OAuth resource" });
1429
+ const fetched = await deadline.run("authorization_server_metadata", (signal) =>
1430
+ fetchOAuthMetadata(pinned.metadataUrl, settings, signal),
747
1431
  );
748
- if (!as.codeChallengeMethodsSupported.includes("S256")) {
749
- throw new HTTPException(422, {
750
- message: "authorization server does not support required PKCE S256",
751
- });
752
- }
753
- return { challenge, prm, as };
1432
+ if (fetched.status !== "present")
1433
+ throw new HTTPException(422, { message: "provider OAuth metadata is unavailable" });
1434
+ const raw = fetched.document;
1435
+ const issuer = normalizedIssuerKey(
1436
+ oauthEndpointUrl(requiredString(raw.issuer, "OAuth issuer"), settings, "OAuth issuer"),
1437
+ );
1438
+ if (issuer !== pinned.issuer)
1439
+ throw new HTTPException(422, { message: "provider OAuth issuer did not match its profile" });
1440
+ const as: AuthorizationServerMetadata = {
1441
+ issuer,
1442
+ authorizationServer: pinned.issuer,
1443
+ authorizationEndpoint: oauthEndpointUrl(
1444
+ requiredString(raw.authorization_endpoint, "OAuth authorization endpoint"),
1445
+ settings,
1446
+ "OAuth authorization endpoint",
1447
+ ),
1448
+ tokenEndpoint: oauthEndpointUrl(
1449
+ requiredString(raw.token_endpoint, "OAuth token endpoint"),
1450
+ settings,
1451
+ "OAuth token endpoint",
1452
+ ),
1453
+ clientIdMetadataDocumentSupported: false,
1454
+ tokenEndpointAuthMethodsSupported: stringArray(raw.token_endpoint_auth_methods_supported),
1455
+ codeChallengeMethodsSupported: stringArray(raw.code_challenge_methods_supported),
1456
+ raw,
1457
+ metadataUrl: fetched.url,
1458
+ };
1459
+ if (!as.codeChallengeMethodsSupported.includes("S256"))
1460
+ throw new HTTPException(422, { message: "provider OAuth requires PKCE S256" });
1461
+ return {
1462
+ challenge: { scheme: null, scope: [] } as WwwAuthenticateChallenge,
1463
+ prm: {
1464
+ resource,
1465
+ authorizationServers: [pinned.issuer],
1466
+ scopesSupported: [...(profile.requestedScopes ?? [])],
1467
+ raw: {},
1468
+ metadataUrl: "",
1469
+ } as ProtectedResourceMetadata,
1470
+ as,
1471
+ mode: "provider_oauth_metadata" as const,
1472
+ resource,
1473
+ provenance: {
1474
+ protectedResourceMetadataUrl: null,
1475
+ authorizationServerMetadataUrl: fetched.url,
1476
+ metadataSha256: createHash("sha256")
1477
+ .update(stableJson({ profile: profile.key, resource, metadataUrl: fetched.url, raw }))
1478
+ .digest("hex"),
1479
+ },
1480
+ };
754
1481
  }
755
1482
 
756
1483
  async function probeMcpChallenge(
@@ -765,115 +1492,45 @@ async function probeMcpChallenge(
765
1492
  });
766
1493
  try {
767
1494
  if (response.status !== 401) {
768
- return {};
1495
+ return { scheme: null, scope: [] };
769
1496
  }
770
- return parseWwwAuthenticate(response.headers.get("www-authenticate"));
1497
+ return parseMcpOAuthChallenge(response.headers.get("www-authenticate"));
771
1498
  } finally {
772
1499
  await cancelResponseBody(response);
773
1500
  }
774
1501
  }
775
1502
 
776
- async function discoverProtectedResourceMetadata(
777
- resource: string,
778
- settings: Settings,
779
- advertisedUrl?: string,
780
- signal?: AbortSignal,
781
- ): Promise<ProtectedResourceMetadata> {
782
- const candidates = uniqueStrings([
783
- ...(advertisedUrl ? [advertisedUrl] : []),
784
- ...wellKnownCandidates(resource, "oauth-protected-resource"),
785
- ]);
786
- for (const candidate of candidates) {
787
- const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
788
- if (error instanceof HTTPException) {
789
- throw error;
790
- }
791
- return null;
792
- });
793
- if (!payload) {
794
- continue;
795
- }
796
- const authorizationServers = stringArray(payload.authorization_servers);
797
- if (authorizationServers.length === 0) {
798
- continue;
799
- }
800
- return {
801
- authorizationServers,
802
- scopesSupported: stringArray(payload.scopes_supported),
803
- raw: payload,
804
- ...(stringValue(payload.resource) ? { resource: stringValue(payload.resource)! } : {}),
805
- };
806
- }
807
- throw new HTTPException(422, {
808
- message: "could not discover MCP protected resource metadata",
809
- });
810
- }
811
-
812
- async function discoverAuthorizationServerMetadata(
813
- authorizationServer: string,
1503
+ async function fetchOAuthMetadata(
1504
+ url: string,
814
1505
  settings: Settings,
815
1506
  signal: AbortSignal,
816
- ): Promise<AuthorizationServerMetadata> {
817
- const safeAuthorizationServer = oauthEndpointUrl(
818
- authorizationServer,
819
- settings,
820
- "OAuth authorization server",
821
- ).replace(/\/+$/, "");
822
- const candidates = uniqueStrings([
823
- // Prefer the RFC metadata locations before probing the issuer itself. Some
824
- // providers (including Linear) redirect their issuer root to a human docs
825
- // page; following that redirect can leave discovery waiting on an unrelated
826
- // streaming response even though the well-known metadata is immediately
827
- // available.
828
- ...wellKnownCandidates(safeAuthorizationServer, "oauth-authorization-server"),
829
- ...wellKnownCandidates(safeAuthorizationServer, "openid-configuration"),
830
- safeAuthorizationServer,
831
- ]);
832
- for (const candidate of candidates) {
833
- const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
834
- if (error instanceof HTTPException) {
835
- throw error;
836
- }
837
- return null;
838
- });
839
- if (!payload) {
840
- continue;
841
- }
842
- const authorizationEndpoint = stringValue(payload.authorization_endpoint);
843
- const tokenEndpoint = stringValue(payload.token_endpoint);
844
- if (!authorizationEndpoint || !tokenEndpoint) {
845
- continue;
846
- }
847
- const safeAuthorizationEndpoint = oauthEndpointUrl(
848
- authorizationEndpoint,
849
- settings,
850
- "OAuth authorization endpoint",
851
- );
852
- const safeTokenEndpoint = oauthEndpointUrl(tokenEndpoint, settings, "OAuth token endpoint");
853
- const registrationEndpoint = stringValue(payload.registration_endpoint);
854
- const issuer = oauthEndpointUrl(
855
- stringValue(payload.issuer) ?? safeAuthorizationServer,
856
- settings,
857
- "OAuth issuer",
858
- );
859
- const safeRegistrationEndpoint = registrationEndpoint
860
- ? oauthEndpointUrl(registrationEndpoint, settings, "OAuth registration endpoint")
861
- : undefined;
862
- return {
863
- issuer,
864
- authorizationServer: safeAuthorizationServer,
865
- authorizationEndpoint: safeAuthorizationEndpoint,
866
- tokenEndpoint: safeTokenEndpoint,
867
- clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
868
- tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
869
- codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
870
- raw: payload,
871
- ...(safeRegistrationEndpoint ? { registrationEndpoint: safeRegistrationEndpoint } : {}),
872
- };
873
- }
874
- throw new HTTPException(422, {
875
- message: "could not discover OAuth authorization server metadata",
1507
+ ): Promise<McpOAuthMetadataFetchResult> {
1508
+ const response = await fetchOAuth(url, settings, {
1509
+ headers: { accept: "application/json" },
1510
+ signal,
876
1511
  });
1512
+ if (response.status === 404 || response.status === 410) {
1513
+ await cancelResponseBody(response);
1514
+ return { status: "absent", url, httpStatus: response.status };
1515
+ }
1516
+ if (!response.ok) {
1517
+ await cancelResponseBody(response);
1518
+ throw new OAuthMetadataUpstreamError(response.status);
1519
+ }
1520
+ const payload = await readResponseJsonBounded<unknown>(
1521
+ response,
1522
+ OAUTH_MAX_RESPONSE_BYTES,
1523
+ "OAuth metadata response",
1524
+ { signal },
1525
+ );
1526
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1527
+ throw new Error("OAuth metadata response was not a JSON object");
1528
+ }
1529
+ return {
1530
+ status: "present",
1531
+ url,
1532
+ document: payload as Record<string, unknown>,
1533
+ };
877
1534
  }
878
1535
 
879
1536
  async function registerOAuthClient(
@@ -884,16 +1541,16 @@ async function registerOAuthClient(
884
1541
  redirectUri: string,
885
1542
  scopes: string[],
886
1543
  manual: OAuthStartRequest["oauthClient"],
1544
+ profile: OAuthProviderProfile,
887
1545
  signal: AbortSignal,
1546
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
888
1547
  ): Promise<OAuthClientRegistration> {
889
1548
  const operator = operatorClientForAs(settings, as);
890
1549
  if (operator) {
891
1550
  return operator;
892
1551
  }
893
- // Linear currently advertises CIMD but rejects its client metadata URL at
894
- // the authorization endpoint. Its documented interactive setup uses DCR,
895
- // so prefer the simultaneously advertised registration endpoint.
896
- if (prefersDynamicClientRegistration(as)) {
1552
+ const selfRegistration = preferredOAuthSelfRegistration(as, profile.clientSource);
1553
+ if (selfRegistration === "dcr") {
897
1554
  return await getOrCreateDynamicClientRegistration(
898
1555
  db,
899
1556
  settings,
@@ -901,9 +1558,10 @@ async function registerOAuthClient(
901
1558
  redirectUri,
902
1559
  scopes,
903
1560
  signal,
1561
+ acquisition,
904
1562
  );
905
1563
  }
906
- if (as.clientIdMetadataDocumentSupported) {
1564
+ if (selfRegistration === "cimd") {
907
1565
  return {
908
1566
  method: "cimd",
909
1567
  issuer: as.issuer,
@@ -925,13 +1583,33 @@ async function registerOAuthClient(
925
1583
  ),
926
1584
  };
927
1585
  }
928
- return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
1586
+ return await getOrCreateDynamicClientRegistration(
1587
+ db,
1588
+ settings,
1589
+ as,
1590
+ redirectUri,
1591
+ scopes,
1592
+ signal,
1593
+ acquisition,
1594
+ );
929
1595
  }
930
1596
 
931
- function prefersDynamicClientRegistration(as: AuthorizationServerMetadata): boolean {
932
- return Boolean(
933
- as.registrationEndpoint && normalizedIssuerKey(as.issuer) === "https://mcp.linear.app",
934
- );
1597
+ export function preferredOAuthSelfRegistration(
1598
+ as: Pick<
1599
+ AuthorizationServerMetadata,
1600
+ "clientIdMetadataDocumentSupported" | "registrationEndpoint"
1601
+ >,
1602
+ clientSource: OAuthProviderProfile["clientSource"],
1603
+ ): "cimd" | "dcr" | null {
1604
+ if (clientSource === "dcr") return "dcr";
1605
+ if (clientSource === "cimd" && as.clientIdMetadataDocumentSupported) return "cimd";
1606
+ // DCR produces an authorization-server-issued client id and is therefore the
1607
+ // interoperable default whenever the server advertises both registration
1608
+ // mechanisms. A reviewed provider profile may explicitly force CIMD for a
1609
+ // server whose registration endpoint is unsuitable.
1610
+ if (clientSource !== "cimd" && as.registrationEndpoint) return "dcr";
1611
+ if (as.clientIdMetadataDocumentSupported) return "cimd";
1612
+ return null;
935
1613
  }
936
1614
 
937
1615
  async function getOrCreateDynamicClientRegistration(
@@ -941,6 +1619,7 @@ async function getOrCreateDynamicClientRegistration(
941
1619
  redirectUri: string,
942
1620
  scopes: string[],
943
1621
  signal: AbortSignal,
1622
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
944
1623
  ): Promise<OAuthClientRegistration> {
945
1624
  const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
946
1625
  if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
@@ -961,6 +1640,12 @@ async function getOrCreateDynamicClientRegistration(
961
1640
  message: "manual OAuth client credentials are required for this authorization server",
962
1641
  });
963
1642
  }
1643
+ await withOrganizationIntegrationAcquisition(
1644
+ db,
1645
+ acquisition,
1646
+ [acquisition.integrationKey ?? "custom:mcp"],
1647
+ async () => {},
1648
+ );
964
1649
  const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
965
1650
  const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
966
1651
  const storeInput = {
@@ -972,34 +1657,43 @@ async function getOrCreateDynamicClientRegistration(
972
1657
  tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
973
1658
  metadata: registrationMetadata(as, redirectUri, scopes),
974
1659
  };
975
- if (storedClient) {
976
- const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
977
- ...storeInput,
978
- expectedClientId: storedClient.clientId,
979
- });
980
- if (replaced?.clientId === dcr.clientId) {
981
- return dcr;
982
- }
983
- return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
984
- }
985
- const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
986
- if (storedWinner.clientId === dcr.clientId) {
987
- return dcr;
988
- }
989
- const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
990
- if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
991
- return dcrRegistrationFromStored(winner);
992
- }
993
- if (winner) {
994
- const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
995
- ...storeInput,
996
- expectedClientId: winner.clientId,
997
- });
998
- if (replaced?.clientId === dcr.clientId) {
999
- return dcr;
1000
- }
1001
- }
1002
- return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
1660
+ // Registration has already happened outside this transaction. Fence only the
1661
+ // durable client credential; a restriction racing DCR must not persist it.
1662
+ return withOrganizationIntegrationAcquisition(
1663
+ db,
1664
+ acquisition,
1665
+ [acquisition.integrationKey ?? "custom:mcp"],
1666
+ async (tx) => {
1667
+ if (storedClient) {
1668
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1669
+ ...storeInput,
1670
+ expectedClientId: storedClient.clientId,
1671
+ });
1672
+ if (replaced?.clientId === dcr.clientId) {
1673
+ return dcr;
1674
+ }
1675
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1676
+ }
1677
+ const storedWinner = await storeIntegrationOAuthClient(tx, storeInput);
1678
+ if (storedWinner.clientId === dcr.clientId) {
1679
+ return dcr;
1680
+ }
1681
+ const winner = await loadIntegrationOAuthClient(tx, settings, as.issuer);
1682
+ if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
1683
+ return dcrRegistrationFromStored(winner);
1684
+ }
1685
+ if (winner) {
1686
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1687
+ ...storeInput,
1688
+ expectedClientId: winner.clientId,
1689
+ });
1690
+ if (replaced?.clientId === dcr.clientId) {
1691
+ return dcr;
1692
+ }
1693
+ }
1694
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1695
+ },
1696
+ );
1003
1697
  }
1004
1698
 
1005
1699
  function storedDcrClientSatisfiesPolicy(
@@ -1106,23 +1800,21 @@ function operatorClientEntryFor(
1106
1800
  candidates: string[],
1107
1801
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
1108
1802
  const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
1109
- if (
1110
- candidates.some((candidate) => {
1111
- try {
1112
- const origin = new URL(candidate).origin;
1113
- return origin === SLACK_OAUTH_ORIGIN || origin === SLACK_MCP_ORIGIN;
1114
- } catch {
1115
- return false;
1116
- }
1117
- }) &&
1118
- settings.slackClientId?.trim() &&
1119
- settings.slackClientSecret?.trim()
1120
- ) {
1121
- return {
1122
- clientId: settings.slackClientId.trim(),
1123
- clientSecret: settings.slackClientSecret.trim(),
1124
- tokenEndpointAuthMethod: "client_secret_post",
1125
- };
1803
+ const candidateOrigins = candidates.flatMap((candidate) => {
1804
+ try {
1805
+ return [new URL(candidate).origin];
1806
+ } catch {
1807
+ return [];
1808
+ }
1809
+ });
1810
+ for (const entry of DEPLOYMENT_MANAGED_CLIENTS) {
1811
+ if (!candidateOrigins.some((origin) => entry.issuerOrigins.includes(origin))) {
1812
+ continue;
1813
+ }
1814
+ const resolved = entry.resolve(settings);
1815
+ if (resolved) {
1816
+ return resolved;
1817
+ }
1126
1818
  }
1127
1819
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
1128
1820
  const exactKeys = uniqueStrings(
@@ -1142,6 +1834,33 @@ function operatorClientEntryFor(
1142
1834
  return null;
1143
1835
  }
1144
1836
 
1837
+ /** Secret-free readiness for the exact registered client used by Gmail setup. */
1838
+ export function gmailOAuthClientConfigured(settings: Settings): boolean {
1839
+ const entry = operatorClientEntryFor(settings, ["https://accounts.google.com"]);
1840
+ return Boolean(
1841
+ entry &&
1842
+ isConfidentialOAuthClient({
1843
+ ...entry,
1844
+ tokenEndpointAuthMethod: tokenAuthMethod(
1845
+ entry.tokenEndpointAuthMethod,
1846
+ Boolean(entry.clientSecret),
1847
+ ),
1848
+ }),
1849
+ );
1850
+ }
1851
+
1852
+ function isConfidentialOAuthClient(client: {
1853
+ clientId: string;
1854
+ clientSecret?: string | undefined;
1855
+ tokenEndpointAuthMethod: string;
1856
+ }): boolean {
1857
+ return Boolean(
1858
+ client.clientId.trim() &&
1859
+ client.clientSecret?.trim() &&
1860
+ ["client_secret_post", "client_secret_basic"].includes(client.tokenEndpointAuthMethod),
1861
+ );
1862
+ }
1863
+
1145
1864
  function normalizedIssuerKey(value: string): string {
1146
1865
  return value.replace(/\/+$/, "");
1147
1866
  }
@@ -1210,7 +1929,7 @@ async function existingOAuthConnectionForStart(
1210
1929
  subjectId: string;
1211
1930
  providerDomain: string;
1212
1931
  mcpUrl: string;
1213
- hostedSlackMcp: boolean;
1932
+ connectionSelection: OAuthProviderProfile["connectionSelection"];
1214
1933
  exactMcpBinding: boolean;
1215
1934
  connectionId?: string | undefined;
1216
1935
  requestedOwnership?: ConnectionOwnership | undefined;
@@ -1247,7 +1966,7 @@ async function existingOAuthConnectionForStart(
1247
1966
  connection.providerDomain === input.providerDomain &&
1248
1967
  (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl),
1249
1968
  );
1250
- if (input.hostedSlackMcp && input.newConnectionOwnership === "personal") {
1969
+ if (input.connectionSelection === "canonical_personal") {
1251
1970
  return selectCanonicalPersonalSlackConnection(matching);
1252
1971
  }
1253
1972
  return matching.find((connection) => connection.status === "active") ?? null;
@@ -1272,6 +1991,8 @@ export function buildAuthorizationUrl(input: {
1272
1991
  verifier: string;
1273
1992
  scopes: string[];
1274
1993
  resourceParameterSupported: boolean;
1994
+ /** Profile-declared extra authorize parameters, applied last. */
1995
+ extraParams?: Readonly<Record<string, string>> | undefined;
1275
1996
  }): string {
1276
1997
  const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
1277
1998
  const url = new URL(endpoint);
@@ -1280,23 +2001,63 @@ export function buildAuthorizationUrl(input: {
1280
2001
  url.searchParams.set("redirect_uri", input.redirectUri);
1281
2002
  url.searchParams.set("state", input.state);
1282
2003
  if (input.resourceParameterSupported) {
2004
+ // Suppressing RFC 8707 is a profile fact (Google's endpoints reject the
2005
+ // parameter); any provider-specific replacement parameters, such as
2006
+ // Google's offline-consent options, are that profile's
2007
+ // `extraAuthorizeParams` rather than behavior implied by the suppression.
1283
2008
  url.searchParams.set("resource", input.resource);
1284
- } else {
1285
- // Google's OAuth endpoints do not implement RFC 8707's `resource`
1286
- // parameter. Explicit offline consent is required to obtain the refresh
1287
- // token used by the durable connection broker.
1288
- url.searchParams.set("access_type", "offline");
1289
- url.searchParams.set("include_granted_scopes", "true");
1290
- url.searchParams.set("prompt", "consent");
1291
2009
  }
1292
2010
  url.searchParams.set("code_challenge_method", "S256");
1293
2011
  url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
1294
2012
  if (input.scopes.length > 0) {
1295
2013
  url.searchParams.set("scope", input.scopes.join(" "));
1296
2014
  }
2015
+ for (const [name, value] of Object.entries(input.extraParams ?? {})) {
2016
+ // Defense in depth behind the schema/curation validation: an extra
2017
+ // parameter can never displace a security parameter the client already
2018
+ // set (state, PKCE, scope, resource, redirect_uri, ...).
2019
+ if (!url.searchParams.has(name)) {
2020
+ url.searchParams.set(name, value);
2021
+ }
2022
+ }
1297
2023
  return url.toString();
1298
2024
  }
1299
2025
 
2026
+ async function resolveMcpOAuthState(
2027
+ db: Database,
2028
+ settings: Settings,
2029
+ state: string,
2030
+ deadline: OAuthCallbackDeadline,
2031
+ ): Promise<OAuthStatePayload> {
2032
+ const reference = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
2033
+ string,
2034
+ unknown
2035
+ > | null;
2036
+ if (reference?.kind !== "mcp_oauth_reference") {
2037
+ // In-flight grants from the previous release remain valid for their
2038
+ // normal ten-minute lifetime during a rolling deployment.
2039
+ return readOAuthState(state, settings);
2040
+ }
2041
+ const accountId = requiredString(reference.accountId, "state.accountId");
2042
+ const workspaceId = requiredString(reference.workspaceId, "state.workspaceId");
2043
+ const id = requiredString(reference.id, "state.id");
2044
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
2045
+ throw new HTTPException(400, { message: "invalid OAuth state reference" });
2046
+ }
2047
+ const encrypted = await runCallbackDatabaseStage(deadline, "state_verify", db, (scopedDb) =>
2048
+ loadIntegrationOAuthPendingState(scopedDb, { accountId, workspaceId, id }),
2049
+ );
2050
+ if (!encrypted) {
2051
+ throw new HTTPException(400, { message: "invalid or expired OAuth state" });
2052
+ }
2053
+ const fullState = decryptEnvironmentValue(requireEnvironmentEncryption(settings), encrypted);
2054
+ const resolved = readOAuthState(fullState, settings);
2055
+ if (resolved.accountId !== accountId || resolved.workspaceId !== workspaceId) {
2056
+ throw new HTTPException(400, { message: "OAuth state reference mismatch" });
2057
+ }
2058
+ return resolved;
2059
+ }
2060
+
1300
2061
  function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1301
2062
  const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
1302
2063
  string,
@@ -1305,19 +2066,76 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1305
2066
  if (!payload) {
1306
2067
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
1307
2068
  }
2069
+ if (
2070
+ payload.integrationKey !== undefined &&
2071
+ payload.integrationKey !== "gmail" &&
2072
+ payload.integrationKey !== "slack-personal"
2073
+ ) {
2074
+ throw new HTTPException(400, { message: "invalid OAuth integration identity" });
2075
+ }
1308
2076
  const nowSeconds = Math.floor(Date.now() / 1000);
1309
2077
  const iat = numberValue(payload.iat);
1310
2078
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
1311
2079
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
1312
2080
  }
1313
2081
  const resource = requiredString(payload.resource, "state.resource");
2082
+ const encodedDiscoveryMode = stringValue(payload.discoveryMode);
2083
+ const discoveryMode = discoveryModeValue(encodedDiscoveryMode);
2084
+ const discoveryMetadataSha256 = stringValue(payload.discoveryMetadataSha256);
2085
+ if (
2086
+ encodedDiscoveryMode &&
2087
+ (!discoveryMetadataSha256 || !/^[0-9a-f]{64}$/.test(discoveryMetadataSha256))
2088
+ ) {
2089
+ throw new HTTPException(400, { message: "invalid OAuth discovery state" });
2090
+ }
2091
+ const protectedResourceMetadataUrl = stringValue(payload.protectedResourceMetadataUrl);
2092
+ const authorizationServerMetadataUrl = stringValue(payload.authorizationServerMetadataUrl);
2093
+ if (
2094
+ encodedDiscoveryMode &&
2095
+ (!authorizationServerMetadataUrl ||
2096
+ (discoveryMode === "rfc9728_protected_resource" && !protectedResourceMetadataUrl) ||
2097
+ (discoveryMode !== "rfc9728_protected_resource" && protectedResourceMetadataUrl))
2098
+ ) {
2099
+ throw new HTTPException(400, { message: "invalid OAuth discovery provenance state" });
2100
+ }
2101
+ const resourceParameterSupported = payload.resourceParameterSupported !== false;
2102
+ if (discoveryMode === "legacy_2025_03_26_metadata" && resourceParameterSupported) {
2103
+ throw new HTTPException(400, { message: "invalid legacy OAuth resource parameter state" });
2104
+ }
2105
+ const ownership = connectionOwnership(payload.ownership);
2106
+ if (!ownership) {
2107
+ throw new HTTPException(400, {
2108
+ message: "missing or invalid OAuth ownership; restart connection setup",
2109
+ });
2110
+ }
1314
2111
  const parsed = {
1315
2112
  accountId: requiredString(payload.accountId, "state.accountId"),
1316
2113
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
1317
2114
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
1318
- // OAuth states minted before ownership was explicit were always personal.
1319
- // Preserve that meaning for in-flight reconnects during a rolling deploy.
1320
- ownership: connectionOwnership(payload.ownership) ?? "personal",
2115
+ ...(stringValue(payload.encryptedExternalContinuation)
2116
+ ? {
2117
+ externalContinuation: ExternalActorContinuation.parse(
2118
+ JSON.parse(
2119
+ decryptEnvironmentValue(
2120
+ requireEnvironmentEncryption(settings),
2121
+ requiredString(payload.encryptedExternalContinuation, "state.externalContinuation"),
2122
+ ),
2123
+ ),
2124
+ ),
2125
+ }
2126
+ : {}),
2127
+ ...(payload.returnUrl !== undefined
2128
+ ? { returnUrl: exactExternalReturnUrl(requiredString(payload.returnUrl, "state.returnUrl")) }
2129
+ : {}),
2130
+ ...(stringValue(payload.connectAttemptId)
2131
+ ? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") }
2132
+ : {}),
2133
+ ownership,
2134
+ // Absent on a legacy state, which therefore cannot land a personal owner.
2135
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
2136
+ ...(payload.integrationKey
2137
+ ? { integrationKey: payload.integrationKey as "gmail" | "slack-personal" }
2138
+ : {}),
1321
2139
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
1322
2140
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
1323
2141
  resource,
@@ -1343,11 +2161,31 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1343
2161
  settings,
1344
2162
  "OAuth issuer",
1345
2163
  ),
2164
+ discoveryMode,
2165
+ ...(discoveryMetadataSha256 ? { discoveryMetadataSha256 } : {}),
2166
+ ...(protectedResourceMetadataUrl
2167
+ ? {
2168
+ protectedResourceMetadataUrl: oauthEndpointUrl(
2169
+ protectedResourceMetadataUrl,
2170
+ settings,
2171
+ "OAuth protected resource metadata",
2172
+ ),
2173
+ }
2174
+ : {}),
2175
+ ...(authorizationServerMetadataUrl
2176
+ ? {
2177
+ authorizationServerMetadataUrl: oauthEndpointUrl(
2178
+ authorizationServerMetadataUrl,
2179
+ settings,
2180
+ "OAuth authorization server metadata",
2181
+ ),
2182
+ }
2183
+ : {}),
1346
2184
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
1347
2185
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
1348
2186
  // States minted before provider-specific compatibility was introduced used
1349
2187
  // the RFC 8707 resource parameter.
1350
- resourceParameterSupported: payload.resourceParameterSupported !== false,
2188
+ resourceParameterSupported,
1351
2189
  ...(stringValue(payload.encryptedClientSecret)
1352
2190
  ? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
1353
2191
  : {}),
@@ -1357,16 +2195,72 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1357
2195
  };
1358
2196
  const connectionId = stringValue(payload.connectionId);
1359
2197
  const connectionVersion = numberValue(payload.connectionVersion);
2198
+ assertProfileOwnership(builtInOAuthProfileFor(parsed), parsed.ownership);
1360
2199
  if (Boolean(connectionId) !== Boolean(connectionVersion)) {
1361
2200
  throw new HTTPException(400, { message: "invalid OAuth reconnect state" });
1362
2201
  }
2202
+ if (
2203
+ parsed.discoveryMode === "legacy_2025_03_26_metadata" &&
2204
+ (normalizedIssuerKey(parsed.authorizationServer) !== normalizedIssuerKey(parsed.issuer) ||
2205
+ new URL(parsed.resource).origin !== new URL(parsed.issuer).origin)
2206
+ ) {
2207
+ throw new HTTPException(400, { message: "invalid OAuth discovery binding state" });
2208
+ }
2209
+ if (parsed.discoveryMode === "provider_oauth_metadata") {
2210
+ const profile = builtInOAuthProfileFor(parsed);
2211
+ const discovery = profile?.providerOAuthDiscovery;
2212
+ if (
2213
+ !discovery ||
2214
+ parsed.mcpUrl !== profile?.requireExactMcpUrl?.url ||
2215
+ parsed.resource !== parsed.mcpUrl ||
2216
+ parsed.providerDomain !== profile?.postDiscoveryProviderDomain?.domain ||
2217
+ normalizedIssuerKey(parsed.issuer) !== discovery.issuer ||
2218
+ parsed.authorizationServer !== discovery.issuer ||
2219
+ parsed.authorizationServerMetadataUrl !== discovery.metadataUrl ||
2220
+ parsed.resourceParameterSupported
2221
+ ) {
2222
+ throw new HTTPException(400, { message: "invalid provider OAuth discovery binding state" });
2223
+ }
2224
+ assertDiscoveredAuthorizationServer(
2225
+ settings,
2226
+ { ...parsed, authorizationEndpoint: discovery.issuer },
2227
+ profile!,
2228
+ parsed.providerDomain,
2229
+ );
2230
+ }
1363
2231
  return {
1364
2232
  ...parsed,
2233
+ ...(parsed.discoveryMode === "provider_oauth_metadata"
2234
+ ? { issuer: normalizedIssuerKey(parsed.issuer) }
2235
+ : {}),
1365
2236
  ...(connectionId ? { connectionId } : {}),
1366
2237
  ...(connectionVersion !== undefined ? { connectionVersion } : {}),
1367
2238
  };
1368
2239
  }
1369
2240
 
2241
+ function discoveryModeValue(value: string | undefined): OAuthDiscoveryMode {
2242
+ if (!value) {
2243
+ // Every state minted before discovery modes existed used RFC 9728 PRM.
2244
+ return "rfc9728_protected_resource";
2245
+ }
2246
+ if (
2247
+ value === "rfc9728_protected_resource" ||
2248
+ value === "legacy_2025_03_26_metadata" ||
2249
+ value === "provider_oauth_metadata"
2250
+ ) {
2251
+ return value;
2252
+ }
2253
+ throw new HTTPException(400, { message: "invalid OAuth discovery mode state" });
2254
+ }
2255
+
2256
+ function assertProfileOwnership(
2257
+ profile: OAuthProviderProfile | null,
2258
+ ownership: ConnectionOwnership,
2259
+ ): void {
2260
+ if (profile?.requiredOwnership && ownership !== profile.requiredOwnership.ownership)
2261
+ throw new HTTPException(422, { message: profile.requiredOwnership.message });
2262
+ }
2263
+
1370
2264
  function connectionOwnership(value: unknown): ConnectionOwnership | undefined {
1371
2265
  return value === "workspace" || value === "personal" ? value : undefined;
1372
2266
  }
@@ -1562,6 +2456,9 @@ function logOAuthStartFailure(
1562
2456
  function oauthStartFailureReason(error: unknown): string {
1563
2457
  if (error instanceof RequestDeadlineError) return "timeout";
1564
2458
  if (error instanceof DestinationPolicyError) return error.reason;
2459
+ if (error instanceof OAuthMetadataUpstreamError) {
2460
+ return `upstream_http_${error.upstreamStatus}`;
2461
+ }
1565
2462
  if (error instanceof HTTPException) return `http_${error.status}`;
1566
2463
  if (error instanceof SyntaxError) return "invalid_response";
1567
2464
  return "request_failed";
@@ -1605,15 +2502,29 @@ function isDatabaseStatementTimeout(error: unknown): boolean {
1605
2502
 
1606
2503
  function oauthStartApiError(error: OAuthStartStageError): ApiHttpError {
1607
2504
  const timeout = error.reason === "timeout";
1608
- const status = timeout ? 408 : error.cause instanceof HTTPException ? error.cause.status : 422;
2505
+ const metadataUpstream = error.cause instanceof OAuthMetadataUpstreamError ? error.cause : null;
2506
+ const status = timeout
2507
+ ? 408
2508
+ : metadataUpstream
2509
+ ? 502
2510
+ : error.cause instanceof HTTPException
2511
+ ? error.cause.status
2512
+ : 422;
1609
2513
  return new ApiHttpError(status, {
1610
2514
  code: timeout || status >= 500 ? "upstream_unavailable" : "validation_failed",
1611
- retryable: timeout || status === 429 || status >= 500,
2515
+ retryable:
2516
+ timeout ||
2517
+ status === 429 ||
2518
+ (metadataUpstream
2519
+ ? metadataUpstream.upstreamStatus === 429 || metadataUpstream.upstreamStatus >= 500
2520
+ : status >= 500),
1612
2521
  message: timeout
1613
2522
  ? oauthStartTimeoutMessage(error.stage)
1614
- : error.cause instanceof HTTPException
1615
- ? error.cause.message
1616
- : `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
2523
+ : metadataUpstream
2524
+ ? `OAuth provider returned HTTP ${metadataUpstream.upstreamStatus} during ${oauthStartStageLabel(error.stage)}.`
2525
+ : error.cause instanceof HTTPException
2526
+ ? error.cause.message
2527
+ : `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
1617
2528
  details: {
1618
2529
  oauthStage: error.stage,
1619
2530
  oauthReason: error.reason,
@@ -1637,6 +2548,8 @@ function oauthStartStageLabel(stage: OAuthStartStage): string {
1637
2548
  return "authorization-server discovery";
1638
2549
  case "client_registration":
1639
2550
  return "OAuth client registration";
2551
+ case "state_persist":
2552
+ return "OAuth state persistence";
1640
2553
  }
1641
2554
  }
1642
2555
 
@@ -1762,11 +2675,44 @@ async function verifyMcpToolsListNonFatal(
1762
2675
  | { status: "ok"; checkedAt: string; toolCount: number }
1763
2676
  | { status: "failed"; checkedAt: string; reason: string };
1764
2677
  tools?: Array<{ name: string; description?: string }>;
2678
+ providerIdentity?: Record<string, string>;
2679
+ connectionStatus?: "needs_reauth";
1765
2680
  }> {
2681
+ let grantRejected = false;
1766
2682
  try {
1767
- const tools = await deadline.run("tools_list", (signal) =>
1768
- verifyMcpToolsList(settings, state.mcpUrl, token, signal),
1769
- );
2683
+ const profile = builtInOAuthProfileFor(state);
2684
+ const local = profile?.localToolVerification;
2685
+ let providerIdentity: Record<string, string> | undefined;
2686
+ const tools = await deadline.run("tools_list", async (signal) => {
2687
+ if (!local || state.mcpUrl !== profile?.requireExactMcpUrl?.url) {
2688
+ return verifyMcpToolsList(settings, state.mcpUrl, token, signal);
2689
+ }
2690
+ const response = await fetchOAuth(local.url, settings, {
2691
+ method: local.method ?? "POST",
2692
+ signal,
2693
+ headers: {
2694
+ authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
2695
+ },
2696
+ });
2697
+ if (!response.ok) {
2698
+ await cancelResponseBody(response);
2699
+ throw new Error("Connector token verification failed");
2700
+ }
2701
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
2702
+ response,
2703
+ OAUTH_MAX_RESPONSE_BYTES,
2704
+ "Connector token verification",
2705
+ { signal },
2706
+ );
2707
+ grantRejected = local.isRejectedGrant?.(payload) ?? false;
2708
+ providerIdentity = local.validateIdentity(payload);
2709
+ const verifiedTools = local.toolsForScopes(
2710
+ grantedScopes(token.scopeText, state.authorizeScopes, profile),
2711
+ );
2712
+ if (local.required && verifiedTools.length === 0)
2713
+ throw new Error("Connector verification did not report an authorized tool scope");
2714
+ return verifiedTools;
2715
+ });
1770
2716
  return {
1771
2717
  metadata: {
1772
2718
  status: "ok",
@@ -1774,6 +2720,7 @@ async function verifyMcpToolsListNonFatal(
1774
2720
  toolCount: tools.length,
1775
2721
  },
1776
2722
  tools,
2723
+ ...(providerIdentity ? { providerIdentity } : {}),
1777
2724
  };
1778
2725
  } catch (error) {
1779
2726
  const staged =
@@ -1783,6 +2730,7 @@ async function verifyMcpToolsListNonFatal(
1783
2730
  if (staged.reason === "timeout" && deadline.signal.aborted) {
1784
2731
  throw staged;
1785
2732
  }
2733
+ if (builtInOAuthProfileFor(state)?.localToolVerification?.required) throw staged;
1786
2734
  logOAuthVerificationWarning(observability, staged, state);
1787
2735
  return {
1788
2736
  metadata: {
@@ -1790,6 +2738,7 @@ async function verifyMcpToolsListNonFatal(
1790
2738
  checkedAt: new Date().toISOString(),
1791
2739
  reason: staged.reason,
1792
2740
  },
2741
+ ...(grantRejected ? { connectionStatus: "needs_reauth" as const } : {}),
1793
2742
  };
1794
2743
  }
1795
2744
  }
@@ -1836,7 +2785,7 @@ function callbackReturnPath(
1836
2785
  // Defense in depth: a `//host` pathname becomes a protocol-relative absolute
1837
2786
  // Location — an open redirect from the unauthenticated callback.
1838
2787
  if (url.pathname.startsWith("//")) {
1839
- const fallback = new URL("/integrations", "https://opengeni.local");
2788
+ const fallback = new URL(INTEGRATIONS_FALLBACK_PATH, "https://opengeni.local");
1840
2789
  fallback.search = url.search;
1841
2790
  return `${fallback.pathname}${fallback.search}`;
1842
2791
  }
@@ -1892,48 +2841,6 @@ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): st
1892
2841
  }
1893
2842
  }
1894
2843
 
1895
- function safeReturnPath(value: string): string {
1896
- if (!value.startsWith("/") || value.startsWith("//")) {
1897
- throw new HTTPException(400, {
1898
- message: "OAuth returnPath must be a relative path",
1899
- });
1900
- }
1901
- const parsed = new URL(value, "https://opengeni.local");
1902
- // `..` segments can normalize back into a `//host` prefix, which browsers
1903
- // resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
1904
- if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
1905
- throw new HTTPException(400, {
1906
- message: "OAuth returnPath must be a relative path",
1907
- });
1908
- }
1909
- return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1910
- }
1911
-
1912
- async function fetchJsonObject(
1913
- url: string,
1914
- settings: Settings,
1915
- signal?: AbortSignal,
1916
- ): Promise<Record<string, unknown>> {
1917
- const response = await fetchOAuth(url, settings, {
1918
- headers: { accept: "application/json" },
1919
- ...(signal ? { signal } : {}),
1920
- });
1921
- if (!response.ok) {
1922
- await cancelResponseBody(response);
1923
- throw new Error(`HTTP ${response.status}`);
1924
- }
1925
- const payload = await readResponseJsonBounded<unknown>(
1926
- response,
1927
- OAUTH_MAX_RESPONSE_BYTES,
1928
- "OAuth metadata response",
1929
- { ...(signal ? { signal } : {}) },
1930
- );
1931
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1932
- throw new Error("metadata response was not a JSON object");
1933
- }
1934
- return payload as Record<string, unknown>;
1935
- }
1936
-
1937
2844
  async function fetchOAuth(
1938
2845
  rawUrl: string,
1939
2846
  settings: Settings,
@@ -2006,41 +2913,6 @@ async function cancelResponseBody(response: Response): Promise<void> {
2006
2913
  await response.body?.cancel().catch(() => undefined);
2007
2914
  }
2008
2915
 
2009
- function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
2010
- if (!header) {
2011
- return {};
2012
- }
2013
- const bearerIndex = header.toLowerCase().indexOf("bearer");
2014
- if (bearerIndex < 0) {
2015
- return {};
2016
- }
2017
- const paramsText = header.slice(bearerIndex + "bearer".length);
2018
- const params: Record<string, string> = {};
2019
- const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
2020
- let match: RegExpExecArray | null;
2021
- while ((match = re.exec(paramsText)) !== null) {
2022
- const raw = match[2]!;
2023
- params[match[1]!.toLowerCase()] = raw.startsWith('"')
2024
- ? raw.slice(1, -1).replace(/\\"/g, '"')
2025
- : raw;
2026
- }
2027
- return {
2028
- ...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
2029
- ...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
2030
- ...(params.error ? { error: params.error } : {}),
2031
- };
2032
- }
2033
-
2034
- function wellKnownCandidates(rawUrl: string, name: string): string[] {
2035
- const url = new URL(rawUrl);
2036
- const path = url.pathname.replace(/^\/+|\/+$/g, "");
2037
- return uniqueStrings([
2038
- `${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
2039
- `${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
2040
- `${url.origin}/.well-known/${name}`,
2041
- ]);
2042
- }
2043
-
2044
2916
  function chooseAuthorizeScopes(
2045
2917
  requested: string[] | undefined,
2046
2918
  challenged: string[] | undefined,
@@ -2060,13 +2932,38 @@ export function chooseMcpAuthorizeScopes(input: {
2060
2932
  requested: string[] | undefined;
2061
2933
  challenged: string[] | undefined;
2062
2934
  supported: string[];
2935
+ authorizationServerScopesSupported?: string[];
2063
2936
  }): string[] {
2064
- return input.mcpUrl === OFFICIAL_GMAIL_MCP_URL
2065
- ? [...OFFICIAL_GMAIL_MCP_SCOPES]
2066
- : chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
2937
+ const profile = builtInOAuthProfileFor({ mcpUrl: input.mcpUrl }) ?? DEFAULT_OAUTH_PROFILE;
2938
+ return chooseProfileAuthorizeScopes(profile, input);
2067
2939
  }
2068
2940
 
2069
- function grantedScopes(scopeText: string | undefined, fallback: string[]): string[] {
2941
+ function chooseProfileAuthorizeScopes(
2942
+ profile: OAuthProviderProfile,
2943
+ input: {
2944
+ requested: string[] | undefined;
2945
+ challenged: string[] | undefined;
2946
+ supported: string[];
2947
+ authorizationServerScopesSupported?: string[];
2948
+ },
2949
+ ): string[] {
2950
+ // Reviewed profiles keep their exact scope contract. A generic long-running
2951
+ // connector also needs the AS-advertised refresh grant: the MCP resource's
2952
+ // challenge often advertises only its resource permission (e.g. mcp:use).
2953
+ if (profile.requestedScopes) return [...profile.requestedScopes];
2954
+ const scopes = chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
2955
+ return input.authorizationServerScopesSupported?.includes("offline_access")
2956
+ ? uniqueStrings([...scopes, "offline_access"])
2957
+ : scopes;
2958
+ }
2959
+
2960
+ function grantedScopes(
2961
+ scopeText: string | undefined,
2962
+ fallback: string[],
2963
+ profile?: OAuthProviderProfile | null,
2964
+ ): string[] {
2965
+ if (profile?.reportedScopesRequired && !scopeText) return [];
2966
+ if (profile?.normalizeGrantedScopes) return profile.normalizeGrantedScopes(scopeText ?? fallback);
2070
2967
  if (scopeText) {
2071
2968
  return uniqueStrings(scopeText.split(/\s+/).filter(Boolean));
2072
2969
  }