@opengeni/api-router 0.30.3 → 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 (434) 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-source-session.d.ts +3 -0
  35. package/dist/editable-artifact-websocket.d.ts +10 -18
  36. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  37. package/dist/fatal-process-boundary.d.ts +25 -0
  38. package/dist/github-access.d.ts +31 -2
  39. package/dist/http/acting-person.d.ts +24 -0
  40. package/dist/http/api-contract-compat.d.ts +19 -0
  41. package/dist/http/api-error.d.ts +27 -1
  42. package/dist/http/cors.d.ts +17 -0
  43. package/dist/http/deprecation.d.ts +42 -0
  44. package/dist/http/interaction-control-error.d.ts +13 -0
  45. package/dist/http/keyed-admission.d.ts +12 -0
  46. package/dist/http/local-browser-boundary.d.ts +72 -0
  47. package/dist/http/metrics-listener.d.ts +22 -0
  48. package/dist/http/path-identifier.d.ts +17 -0
  49. package/dist/http/registered-route-label.d.ts +16 -0
  50. package/dist/http/request-body.d.ts +27 -0
  51. package/dist/http/request-source.d.ts +52 -0
  52. package/dist/http/sse.d.ts +17 -6
  53. package/dist/http/unmatched-route.d.ts +18 -0
  54. package/dist/http/user-content.d.ts +36 -0
  55. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  56. package/dist/index.d.ts +10 -3
  57. package/dist/index.js +1091 -162
  58. package/dist/index.js.map +1 -1
  59. package/dist/integration-endpoint-test.d.ts +20 -0
  60. package/dist/integrations/api-integrations.d.ts +2 -2
  61. package/dist/integrations/atlassian.d.ts +29 -3
  62. package/dist/integrations/connect-authority.d.ts +1 -0
  63. package/dist/integrations/connect-callback-return.d.ts +6 -0
  64. package/dist/integrations/fiken.d.ts +16 -2
  65. package/dist/integrations/github-app-connect.d.ts +47 -0
  66. package/dist/integrations/github-installation-proof.d.ts +3 -0
  67. package/dist/integrations/github-lens-connect.d.ts +116 -0
  68. package/dist/integrations/github-skill-source.d.ts +1 -5
  69. package/dist/integrations/google-drive.d.ts +24 -0
  70. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  71. package/dist/integrations/marketplace-plugin.d.ts +2691 -0
  72. package/dist/integrations/oauth-client.d.ts +178 -18
  73. package/dist/integrations/oauth-profiles.d.ts +222 -0
  74. package/dist/integrations/oauth-return-path.d.ts +1 -0
  75. package/dist/integrations/personal-github-repositories.d.ts +84 -0
  76. package/dist/integrations/personal-github.d.ts +42 -0
  77. package/dist/integrations/pr-review-provider.d.ts +22 -0
  78. package/dist/integrations/provider-oauth.d.ts +19 -1
  79. package/dist/integrations/slack-app-home.d.ts +24 -0
  80. package/dist/integrations/slack-bot.d.ts +367 -12
  81. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  82. package/dist/integrations/slack-install.d.ts +20 -0
  83. package/dist/integrations/slack-interactions.d.ts +107 -9
  84. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  85. package/dist/integrations/slack-routing.d.ts +120 -0
  86. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  87. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  88. package/dist/integrations/social-oauth.d.ts +7 -0
  89. package/dist/interaction-agent-access.d.ts +15 -0
  90. package/dist/interaction-frame-proxy.d.ts +68 -0
  91. package/dist/interaction-metrics.d.ts +2 -0
  92. package/dist/mcp/action-catalog-types.d.ts +9 -0
  93. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  94. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  95. package/dist/mcp/child-read-evidence.d.ts +9 -0
  96. package/dist/mcp/command-read.d.ts +24 -0
  97. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  98. package/dist/mcp/contract-input.d.ts +11 -0
  99. package/dist/mcp/documents.d.ts +7 -5
  100. package/dist/mcp/files.d.ts +2 -2
  101. package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
  102. package/dist/mcp/knowledge-entries.d.ts +5 -0
  103. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  104. package/dist/mcp/receipts.d.ts +9 -0
  105. package/dist/mcp/request-abort.d.ts +19 -0
  106. package/dist/mcp/scheduled-task-input.d.ts +1189 -0
  107. package/dist/mcp/scheduled-task-view.d.ts +22 -4
  108. package/dist/mcp/server.d.ts +255 -3
  109. package/dist/mcp/session-event-view.d.ts +94 -0
  110. package/dist/mcp/session-view.d.ts +66 -2
  111. package/dist/mcp/session-wait.d.ts +174 -0
  112. package/dist/mcp-oauth.d.ts +52 -0
  113. package/dist/model-catalog.d.ts +11 -31
  114. package/dist/observability.d.ts +3 -1
  115. package/dist/organization-mcp.d.ts +52 -0
  116. package/dist/organization-recovery-notifications.d.ts +41 -0
  117. package/dist/routes/analytics-consent.d.ts +26 -0
  118. package/dist/routes/api-integrations.d.ts +25 -0
  119. package/dist/routes/api-keys.d.ts +17 -0
  120. package/dist/routes/artifact-catalog.d.ts +6 -0
  121. package/dist/routes/automations.d.ts +19 -0
  122. package/dist/routes/billing.d.ts +49 -0
  123. package/dist/routes/browser-sessions.d.ts +59 -2
  124. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  125. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  126. package/dist/routes/client-errors.d.ts +146 -0
  127. package/dist/routes/codex.d.ts +76 -2
  128. package/dist/routes/company-brain.d.ts +3 -0
  129. package/dist/routes/company-profile.d.ts +2 -1
  130. package/dist/routes/computer-sessions.d.ts +14 -0
  131. package/dist/routes/connect.d.ts +5 -0
  132. package/dist/routes/editable-artifacts.d.ts +2 -0
  133. package/dist/routes/external-identity-links.d.ts +6 -0
  134. package/dist/routes/feedback.d.ts +3 -0
  135. package/dist/routes/github-browser-pages.d.ts +12 -0
  136. package/dist/routes/github.d.ts +2 -0
  137. package/dist/routes/insights-response-cache.d.ts +10 -0
  138. package/dist/routes/insights-usage.d.ts +12 -0
  139. package/dist/routes/insights.d.ts +30 -1
  140. package/dist/routes/knowledge.d.ts +3 -0
  141. package/dist/routes/managed-auth-session-sets.d.ts +26 -0
  142. package/dist/routes/managed-onboarding.d.ts +29 -0
  143. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  144. package/dist/routes/model-connection-access.d.ts +3 -0
  145. package/dist/routes/organization-integration-policy.d.ts +3 -0
  146. package/dist/routes/organization-integrations.d.ts +6 -0
  147. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  148. package/dist/routes/organization-model-providers.d.ts +3 -0
  149. package/dist/routes/organization-recovery.d.ts +19 -0
  150. package/dist/routes/organization-service-accounts.d.ts +3 -0
  151. package/dist/routes/organization-sessions.d.ts +26 -0
  152. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  153. package/dist/routes/personal-github.d.ts +3 -0
  154. package/dist/routes/pr-review-github.d.ts +3 -0
  155. package/dist/routes/pr-review.d.ts +3 -0
  156. package/dist/routes/session-artifact-associations.d.ts +4 -0
  157. package/dist/routes/session-history-imports.d.ts +9 -0
  158. package/dist/routes/sessions.d.ts +44 -6
  159. package/dist/routes/skill-content.d.ts +4 -0
  160. package/dist/routes/skill-install-authority.d.ts +6 -0
  161. package/dist/routes/subscription-account-pools.d.ts +34 -0
  162. package/dist/routes/subscription-pool-access.d.ts +16 -0
  163. package/dist/routes/supergrok.d.ts +6 -1
  164. package/dist/routes/usage-allowances.d.ts +12 -0
  165. package/dist/routes/user-resource-authorities.d.ts +3 -0
  166. package/dist/routes/workspace-artifacts.d.ts +2 -1
  167. package/dist/routes/workspace-capture.d.ts +10 -2
  168. package/dist/routes/workspace-integrations.d.ts +35 -0
  169. package/dist/routes/workspace-learning.d.ts +3 -0
  170. package/dist/routes/workspace-model-providers.d.ts +4 -0
  171. package/dist/routes/workspaces.d.ts +29 -0
  172. package/dist/sandbox/auth-callout.d.ts +8 -0
  173. package/dist/sandbox/channel-a.d.ts +19 -1
  174. package/dist/sandbox/connection-authority.d.ts +3 -0
  175. package/dist/sandbox/enrollment.d.ts +9 -1
  176. package/dist/sandbox/machines.d.ts +5 -2
  177. package/dist/sandbox/metrics-ingestion.d.ts +28 -20
  178. package/dist/sandbox/rematerialize.d.ts +2 -0
  179. package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
  180. package/dist/sandbox/viewer.d.ts +26 -1
  181. package/dist/sandbox-file-artifacts.d.ts +50 -0
  182. package/dist/scheduled-task-deletion.d.ts +13 -0
  183. package/dist/session-create-observability.d.ts +5 -0
  184. package/dist/session-event-view-MPS2OZD3.js +292 -0
  185. package/dist/session-event-view-MPS2OZD3.js.map +1 -0
  186. package/dist/site-session-origin.d.ts +13 -0
  187. package/dist/site-uploads.d.ts +41 -0
  188. package/dist/slack-reaction-files.d.ts +1 -1
  189. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  190. package/dist/temporal-schedule-sync.d.ts +26 -0
  191. package/dist/transcription/providers/xai-subscription.d.ts +1 -0
  192. package/dist/transcription/service.d.ts +2 -1
  193. package/dist/work-discovery-observability.d.ts +33 -0
  194. package/dist/workspace-artifact-content.d.ts +28 -0
  195. package/dist/workspace-artifact-provenance.d.ts +7 -0
  196. package/dist/workspace-delete-observability.d.ts +10 -0
  197. package/dist/workspace-deletion.d.ts +6 -0
  198. package/dist/workspace-state-projection.d.ts +3 -5
  199. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  200. package/dist/workspace-tool-gateway.d.ts +123 -0
  201. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  202. package/package.json +23 -19
  203. package/src/access-grant-rls.ts +75 -0
  204. package/src/admission-trace.ts +14 -0
  205. package/src/api-websocket.ts +23 -0
  206. package/src/app.ts +1967 -252
  207. package/src/artifact-catalog-cursor.ts +60 -0
  208. package/src/auth/managed-auth-attempt-context.ts +70 -0
  209. package/src/auth/managed-auth-rate-limits.ts +252 -0
  210. package/src/auth/managed-auth-session-adapter.ts +222 -0
  211. package/src/auth/managed-auth.ts +536 -60
  212. package/src/auth/managed-email.ts +174 -0
  213. package/src/auth/managed-sign-in-notifications.ts +81 -0
  214. package/src/auth/new-signups-gate.ts +49 -0
  215. package/src/auth/organization-user-setup.ts +307 -0
  216. package/src/auth/signup-funnel-metrics.ts +164 -0
  217. package/src/browser-controller-authority.ts +1 -186
  218. package/src/browser-ephemeral.ts +39 -0
  219. package/src/browser-state-authority.ts +1 -236
  220. package/src/claude-subscription-account-conflicts.ts +12 -0
  221. package/src/claude-subscription-account-usage.ts +48 -0
  222. package/src/claude-subscription-oauth.ts +362 -0
  223. package/src/claude-subscription-profile.ts +73 -0
  224. package/src/claude-subscription-usage.ts +97 -0
  225. package/src/claude-workspace-connection.ts +82 -0
  226. package/src/codemode.ts +194 -30
  227. package/src/codex-realtime.ts +8 -2
  228. package/src/company-brain-okf.ts +340 -0
  229. package/src/connected-machine-computer-access.ts +33 -0
  230. package/src/connection-ownership.ts +199 -0
  231. package/src/controller-data-plane.ts +81 -0
  232. package/src/editable-artifact-native-kernel.ts +26 -15
  233. package/src/editable-artifact-office-import.ts +28 -3
  234. package/src/editable-artifact-production.ts +23 -7
  235. package/src/editable-artifact-source-session.ts +123 -0
  236. package/src/editable-artifact-websocket.ts +27 -21
  237. package/src/editable-artifact-workspace-files.ts +31 -6
  238. package/src/fatal-process-boundary.ts +246 -0
  239. package/src/gateway-realtime.ts +1 -1
  240. package/src/github-access.ts +147 -5
  241. package/src/http/acting-person.ts +71 -0
  242. package/src/http/api-contract-compat.ts +49 -0
  243. package/src/http/api-error.ts +115 -1
  244. package/src/http/auth.ts +48 -0
  245. package/src/http/cors.ts +67 -0
  246. package/src/http/deprecation.ts +118 -0
  247. package/src/http/interaction-control-error.ts +164 -0
  248. package/src/http/keyed-admission.ts +29 -0
  249. package/src/http/local-browser-boundary.ts +343 -0
  250. package/src/http/metrics-listener.ts +57 -0
  251. package/src/http/path-identifier.ts +54 -0
  252. package/src/http/registered-route-label.ts +34 -0
  253. package/src/http/request-body.ts +127 -0
  254. package/src/http/request-source.ts +178 -0
  255. package/src/http/sse.ts +486 -132
  256. package/src/http/unmatched-route.ts +60 -0
  257. package/src/http/user-content.ts +158 -0
  258. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  259. package/src/index.ts +157 -52
  260. package/src/integration-endpoint-test.ts +291 -0
  261. package/src/integrations/api-integrations.ts +98 -11
  262. package/src/integrations/atlassian.ts +474 -136
  263. package/src/integrations/connect-authority.ts +2 -0
  264. package/src/integrations/connect-callback-return.ts +86 -0
  265. package/src/integrations/fiken.ts +354 -64
  266. package/src/integrations/github-app-connect.ts +435 -0
  267. package/src/integrations/github-installation-proof.ts +60 -0
  268. package/src/integrations/github-lens-connect.ts +76 -0
  269. package/src/integrations/github-skill-source.ts +2 -142
  270. package/src/integrations/google-drive.ts +717 -225
  271. package/src/integrations/integration-commit-authority.ts +69 -0
  272. package/src/integrations/marketplace-plugin.ts +48 -0
  273. package/src/integrations/oauth-client.ts +1356 -459
  274. package/src/integrations/oauth-profiles.ts +537 -0
  275. package/src/integrations/oauth-return-path.ts +18 -0
  276. package/src/integrations/personal-github-repositories.ts +674 -0
  277. package/src/integrations/personal-github.ts +825 -0
  278. package/src/integrations/pr-review-provider.ts +246 -0
  279. package/src/integrations/provider-oauth.ts +250 -22
  280. package/src/integrations/slack-app-home.ts +300 -0
  281. package/src/integrations/slack-bot.ts +963 -239
  282. package/src/integrations/slack-file-upload-flow.ts +147 -0
  283. package/src/integrations/slack-install.ts +83 -0
  284. package/src/integrations/slack-interactions.ts +3167 -474
  285. package/src/integrations/slack-mrkdwn.ts +193 -0
  286. package/src/integrations/slack-routing.ts +337 -0
  287. package/src/integrations/slack-session-defaults.ts +224 -0
  288. package/src/integrations/slack-task-file-upload.ts +323 -0
  289. package/src/integrations/social-oauth.ts +213 -15
  290. package/src/interaction-agent-access.ts +48 -0
  291. package/src/interaction-frame-proxy.ts +409 -0
  292. package/src/interaction-metrics.ts +30 -0
  293. package/src/mcp/action-catalog-types.ts +9 -0
  294. package/src/mcp/action-catalog.gen.ts +6696 -0
  295. package/src/mcp/capability-account-readiness.ts +17 -0
  296. package/src/mcp/child-read-evidence.ts +19 -0
  297. package/src/mcp/command-read.ts +109 -0
  298. package/src/mcp/company-profile-agent-admin.ts +198 -0
  299. package/src/mcp/contract-input.ts +141 -0
  300. package/src/mcp/documents.ts +65 -377
  301. package/src/mcp/editable-artifacts.ts +12 -6
  302. package/src/mcp/files.ts +80 -43
  303. package/src/mcp/first-party-tool-permissions.ts +305 -0
  304. package/src/mcp/knowledge-entries.ts +322 -0
  305. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  306. package/src/mcp/receipts.ts +34 -0
  307. package/src/mcp/request-abort.ts +44 -0
  308. package/src/mcp/scheduled-task-input.ts +76 -0
  309. package/src/mcp/scheduled-task-view.ts +44 -3
  310. package/src/mcp/server.ts +3030 -1267
  311. package/src/mcp/session-event-view.ts +383 -0
  312. package/src/mcp/session-view.ts +84 -3
  313. package/src/mcp/session-wait.ts +665 -0
  314. package/src/mcp-oauth.ts +1123 -0
  315. package/src/model-catalog.ts +91 -333
  316. package/src/observability.ts +15 -1
  317. package/src/organization-mcp.ts +397 -0
  318. package/src/organization-recovery-notifications.ts +103 -0
  319. package/src/routes/analytics-consent.ts +123 -0
  320. package/src/routes/api-integrations.ts +364 -74
  321. package/src/routes/api-keys.ts +336 -10
  322. package/src/routes/artifact-catalog.ts +279 -0
  323. package/src/routes/automations.ts +768 -0
  324. package/src/routes/billing.ts +461 -46
  325. package/src/routes/browser-sessions.ts +780 -112
  326. package/src/routes/canonical-human-identities.ts +90 -14
  327. package/src/routes/capabilities.ts +107 -6
  328. package/src/routes/channels.ts +20 -3
  329. package/src/routes/claude-subscription-accounts.ts +334 -0
  330. package/src/routes/claude-subscription-oauth.ts +142 -0
  331. package/src/routes/client-errors.ts +362 -0
  332. package/src/routes/codex.ts +833 -116
  333. package/src/routes/company-brain.ts +322 -0
  334. package/src/routes/company-profile.ts +65 -1
  335. package/src/routes/computer-sessions.ts +557 -106
  336. package/src/routes/connect.ts +1938 -0
  337. package/src/routes/connections.ts +1039 -349
  338. package/src/routes/documents.ts +488 -287
  339. package/src/routes/editable-artifacts.ts +79 -9
  340. package/src/routes/enrollments.ts +135 -39
  341. package/src/routes/environments.ts +154 -58
  342. package/src/routes/external-identity-links.ts +201 -0
  343. package/src/routes/feedback.ts +113 -0
  344. package/src/routes/files.ts +267 -33
  345. package/src/routes/github-browser-pages.ts +162 -0
  346. package/src/routes/github.ts +446 -144
  347. package/src/routes/insights-response-cache.ts +83 -0
  348. package/src/routes/insights-usage.ts +345 -0
  349. package/src/routes/insights.ts +153 -19
  350. package/src/routes/install.ts +133 -38
  351. package/src/routes/integration-facets.ts +107 -33
  352. package/src/routes/interaction-resources.ts +30 -3
  353. package/src/routes/knowledge.ts +488 -0
  354. package/src/routes/machines.ts +313 -15
  355. package/src/routes/managed-auth-session-sets.ts +1218 -0
  356. package/src/routes/managed-onboarding.ts +337 -0
  357. package/src/routes/managed-sign-in-methods.ts +433 -0
  358. package/src/routes/memory-slack-publications.ts +3 -2
  359. package/src/routes/model-connection-access.ts +208 -0
  360. package/src/routes/organization-integration-policy.ts +116 -0
  361. package/src/routes/organization-integrations.ts +326 -0
  362. package/src/routes/organization-mcp-connections.ts +178 -0
  363. package/src/routes/organization-memberships.ts +1019 -27
  364. package/src/routes/organization-model-providers.ts +293 -0
  365. package/src/routes/organization-recovery.ts +325 -0
  366. package/src/routes/organization-service-accounts.ts +128 -0
  367. package/src/routes/organization-sessions.ts +350 -0
  368. package/src/routes/personal-github-git-broker.ts +785 -0
  369. package/src/routes/personal-github.ts +370 -0
  370. package/src/routes/plugins.ts +251 -22
  371. package/src/routes/pr-review-github.ts +881 -0
  372. package/src/routes/pr-review.ts +622 -0
  373. package/src/routes/preference-registry.ts +12 -93
  374. package/src/routes/rigs.ts +167 -50
  375. package/src/routes/scheduled-tasks.ts +346 -41
  376. package/src/routes/session-artifact-associations.ts +87 -0
  377. package/src/routes/session-history-imports.ts +280 -0
  378. package/src/routes/sessions.ts +2306 -264
  379. package/src/routes/skill-content.ts +297 -0
  380. package/src/routes/skill-install-authority.ts +64 -0
  381. package/src/routes/skills.ts +62 -9
  382. package/src/routes/social.ts +46 -18
  383. package/src/routes/subscription-account-pools.ts +436 -0
  384. package/src/routes/subscription-pool-access.ts +126 -0
  385. package/src/routes/supergrok.ts +219 -303
  386. package/src/routes/transcription-recordings.ts +38 -3
  387. package/src/routes/transcriptions.ts +4 -0
  388. package/src/routes/usage-allowances.ts +378 -0
  389. package/src/routes/user-resource-authorities.ts +138 -0
  390. package/src/routes/video-generation.ts +14 -10
  391. package/src/routes/workspace-artifacts.ts +275 -69
  392. package/src/routes/workspace-capture.ts +21 -0
  393. package/src/routes/workspace-integrations.ts +576 -0
  394. package/src/routes/workspace-learning.ts +159 -0
  395. package/src/routes/workspace-model-providers.ts +148 -0
  396. package/src/routes/workspace-state.ts +38 -25
  397. package/src/routes/workspaces.ts +1004 -127
  398. package/src/sandbox/auth-callout.ts +96 -19
  399. package/src/sandbox/channel-a.ts +323 -24
  400. package/src/sandbox/connection-authority.ts +3 -0
  401. package/src/sandbox/enrollment.ts +81 -6
  402. package/src/sandbox/machines.ts +218 -76
  403. package/src/sandbox/metrics-ingestion.ts +411 -62
  404. package/src/sandbox/rematerialize.ts +121 -19
  405. package/src/sandbox/session-attach-variable-sets.ts +131 -0
  406. package/src/sandbox/viewer.ts +280 -25
  407. package/src/sandbox-file-artifacts.ts +413 -0
  408. package/src/scheduled-task-deletion.ts +138 -0
  409. package/src/session-create-observability.ts +46 -0
  410. package/src/session-realtime-context.ts +6 -3
  411. package/src/site-session-origin.ts +48 -0
  412. package/src/site-uploads.ts +224 -0
  413. package/src/slack-reaction-files.ts +16 -5
  414. package/src/temporal-schedule-cleanup.ts +13 -0
  415. package/src/temporal-schedule-sync.ts +74 -0
  416. package/src/transcription/providers/codex-subscription.ts +2 -0
  417. package/src/transcription/providers/openai.ts +1 -0
  418. package/src/transcription/providers/xai-subscription.ts +49 -3
  419. package/src/transcription/segmenter.ts +8 -2
  420. package/src/transcription/service.ts +43 -6
  421. package/src/work-discovery-observability.ts +121 -0
  422. package/src/workspace-artifact-content.ts +144 -0
  423. package/src/workspace-artifact-provenance.ts +104 -0
  424. package/src/workspace-delete-observability.ts +75 -0
  425. package/src/workspace-deletion.ts +73 -0
  426. package/src/workspace-state-projection.ts +18 -148
  427. package/src/workspace-tool-gateway-observability.ts +100 -0
  428. package/src/workspace-tool-gateway.ts +870 -0
  429. package/src/workspace-webhook-dispatch.ts +190 -0
  430. package/src/xai-subscription-auth.ts +9 -2
  431. package/dist/chunk-WCXHI3FF.js +0 -49920
  432. package/dist/chunk-WCXHI3FF.js.map +0 -1
  433. package/dist/routes/packs.d.ts +0 -3
  434. package/src/routes/packs.ts +0 -729
@@ -1,4 +1,36 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import { verifyDirectModelAccess } from "@opengeni/core";
3
+ import {
4
+ assertClaudeWorkspaceCredential,
5
+ prepareClaudeWorkspaceCredential,
6
+ } from "../claude-workspace-connection";
7
+ import {
8
+ createConnectionIdempotently,
9
+ upsertWorkspaceProviderApiKeyConnection,
10
+ rotateWorkspaceProviderApiKeyConnection,
11
+ revokeWorkspaceProviderApiKeyConnections,
12
+ workspaceProviderApiKeyConnectionSpec,
13
+ type WorkspaceProviderApiKeyConnectionKind,
14
+ getConnectionCreationResult,
15
+ ConnectionCreateIdempotencyError,
16
+ } from "@opengeni/db";
17
+ import { sql } from "drizzle-orm";
18
+ import { assertOrganizationIntegrationAllowed } from "@opengeni/contracts";
19
+ import {
20
+ withOrganizationIntegrationAcquisition,
21
+ withOrganizationIntegrationPolicyFence,
22
+ } from "@opengeni/db/organization-integration-policy";
23
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
24
+ import {
25
+ startSlackBotInstall,
26
+ requireOpenGeniSlackOAuthSettings,
27
+ } from "../integrations/slack-install";
28
+ import {
29
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
30
+ WORKSPACE_OPENROUTER_CONNECTION_ROLE,
31
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
32
+ VERCEL_AI_GATEWAY_CONNECTION_ROLE,
33
+ } from "@opengeni/config";
2
34
  import {
3
35
  ATLASSIAN_PROVIDER_DOMAIN,
4
36
  AtlassianConnectionMetadata,
@@ -10,22 +42,24 @@ import {
10
42
  import {
11
43
  API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
12
44
  ApiIntegrationOAuthStartRequest,
45
+ DirectModelProviderMetadata,
13
46
  ConnectionResponse,
14
47
  CreateConnectionRequest,
15
- FIKEN_CREDENTIAL_LABEL,
16
- FIKEN_CREDENTIAL_ROLE,
17
- FIKEN_PROVIDER_DOMAIN,
18
48
  FikenInstallRequest,
19
49
  FikenOAuthStartRequest,
20
50
  FikenOAuthStartResponse,
21
51
  IntegrationClientMetadata,
22
52
  ListSlackInstallationBindingsResponse,
23
53
  ListConnectionsResponse,
54
+ OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
55
+ OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
24
56
  OpenGeniSlackBotInstallRequest,
25
- OpenGeniSlackBotInstallStart,
26
57
  OAuthStartRequest,
27
58
  OAuthStartResponse,
28
59
  UpdateConnectionRequest,
60
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
61
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
62
+ stableJson,
29
63
  } from "@opengeni/contracts";
30
64
  import {
31
65
  bindConnectorDocumentDestination,
@@ -40,24 +74,34 @@ import {
40
74
  GoogleDriveOAuthStartResponse,
41
75
  } from "@opengeni/contracts/google-drive";
42
76
  import {
43
- fikenConnectionMetadata,
77
+ PERSONAL_GITHUB_PROVIDER_DOMAIN,
78
+ PersonalGitHubDisconnectRequest,
79
+ hasReservedPersonalGitHubMetadata,
80
+ isPersonalGitHubConnection,
81
+ } from "@opengeni/contracts/personal-github";
82
+ import {
44
83
  hasPermission,
84
+ listOwnConnectionAccountsForGrant,
45
85
  hasReservedFikenMetadata,
46
86
  hasReservedOpenGeniSlackBotMetadata,
47
- isFikenConnection,
48
87
  isOpenGeniSlackBotConnection,
49
88
  openGeniSlackBotMetadata,
50
89
  requireAccessGrant,
51
90
  requireAccessGrantAuthorization,
52
91
  requireEnvironmentEncryption,
53
- resolveFikenDefaultCompanySlug,
92
+ externalContinuationCommitAuthorizer,
54
93
  } from "@opengeni/core";
55
94
  import {
56
95
  consumeIntegrationOAuthStateNonce,
57
- createConnection,
96
+ claimConnectOperation,
97
+ finishConnectOperation,
98
+ getConnectAttempt,
99
+ decryptEnvironmentValue,
100
+ withWorkspaceSubjectRls,
101
+ type Database,
58
102
  encryptEnvironmentValue,
103
+ brokeredCredentialBundleProblem,
59
104
  getConnectionMetadata,
60
- getWorkspaceGrant,
61
105
  listConnectionsMetadata,
62
106
  listSlackInstallationBindings,
63
107
  persistSlackBotInstallationWithSuccessAudit,
@@ -84,8 +128,10 @@ import {
84
128
  } from "../integrations/google-drive";
85
129
  import {
86
130
  completeApiIntegrationProviderOAuth,
131
+ isApiIntegrationProviderOAuthState,
87
132
  startApiIntegrationProviderOAuth,
88
133
  } from "../integrations/provider-oauth";
134
+ import { disconnectPersonalGitHub } from "../integrations/personal-github";
89
135
  import {
90
136
  browseAtlassianSources,
91
137
  completeAtlassianOAuthCallback,
@@ -97,9 +143,17 @@ import {
97
143
  import {
98
144
  completeMcpOAuthCallback,
99
145
  integrationBaseUrl,
146
+ oauthStateFailureReturn,
100
147
  startMcpOAuth,
101
148
  } from "../integrations/oauth-client";
149
+ import {
150
+ assertPersonalConnectionOwnerPrincipal,
151
+ isPersonalConnectionOwnerPrincipal,
152
+ requireLegacyOAuthActor,
153
+ requireProviderConsentInBrowser,
154
+ } from "../connection-ownership";
102
155
  import { canonicalProviderDomain } from "../integrations/provider-domain";
156
+ import { externalActorContinuationForAuthorization } from "@opengeni/core";
103
157
  import {
104
158
  exchangeOpenGeniSlackAuthorizationCode,
105
159
  SlackBotCredentialVerificationError,
@@ -107,19 +161,21 @@ import {
107
161
  } from "../integrations/slack-bot";
108
162
  import {
109
163
  completeFikenOAuthCallback,
110
- fikenCredentialBundle,
111
164
  startFikenOAuth,
112
- verifyFikenApiToken,
165
+ prepareFikenTokenInstall,
113
166
  } from "../integrations/fiken";
167
+ import { requireConnectOwnerAuthority } from "../integrations/connect-authority";
114
168
  import {
115
169
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
116
170
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
117
- OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
118
171
  } from "@opengeni/contracts";
119
- import { createSignedState, readSignedState } from "@opengeni/github";
172
+ import { readSignedState } from "@opengeni/github";
120
173
  import { oauthStateTtlMs, requireIntegrationsStateSecret } from "../integrations/oauth-client";
174
+ import { parseRequestJson } from "../http/request-body";
121
175
 
122
176
  type OpenGeniSlackInstallState = {
177
+ connectAttemptId?: string;
178
+ externalContinuation?: ExternalActorContinuation;
123
179
  accountId: string;
124
180
  workspaceId: string;
125
181
  subjectId: string;
@@ -149,6 +205,26 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
149
205
  );
150
206
  });
151
207
 
208
+ app.get("/v1/workspaces/:workspaceId/connections/accounts", async (c) => {
209
+ const workspaceId = c.req.param("workspaceId");
210
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
211
+ const includeInactive = c.req.query("includeInactive");
212
+ if (
213
+ includeInactive !== undefined &&
214
+ includeInactive !== "true" &&
215
+ includeInactive !== "false"
216
+ ) {
217
+ throw new HTTPException(400, { message: "includeInactive must be true or false" });
218
+ }
219
+ return c.json(
220
+ ListConnectionsResponse.parse({
221
+ connections: await listOwnConnectionAccountsForGrant(db, grant, {
222
+ includeInactive: includeInactive === "true",
223
+ }),
224
+ }),
225
+ );
226
+ });
227
+
152
228
  app.get("/v1/workspaces/:workspaceId/connections/slack-bot/bindings", async (c) => {
153
229
  const workspaceId = c.req.param("workspaceId");
154
230
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
@@ -162,79 +238,281 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
162
238
  );
163
239
  });
164
240
 
165
- app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
241
+ app.get("/v1/workspaces/:workspaceId/connections/operations/:operationId", async (c) => {
166
242
  const workspaceId = c.req.param("workspaceId");
167
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
168
- const payload = CreateConnectionRequest.parse(await c.req.json());
169
- assertNotReservedSlackBotMetadata(payload.metadata);
170
- assertNotReservedFikenMetadata(payload.metadata);
171
- assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
172
- const key = requireEnvironmentEncryption(settings);
173
- const subjectId = createConnectionSubjectId(payload, grant.subjectId);
174
- const providerDomain = canonicalProviderDomain(payload.providerDomain);
175
- assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
176
- assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
177
- assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
178
- const connection = await createConnection(db, {
243
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
244
+ const operationId = CreateConnectionRequest.shape.operationId
245
+ .unwrap()
246
+ .parse(c.req.param("operationId"));
247
+ const connection = await getConnectionCreationResult(db, {
179
248
  accountId: grant.accountId,
180
249
  workspaceId,
181
- subjectId,
182
- providerDomain,
183
- kind: payload.kind,
184
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
185
- grantedScopes: payload.grantedScopes,
186
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
187
- metadata: payload.metadata,
188
- createdBySubjectId: grant.subjectId,
250
+ subjectId: grant.subjectId,
251
+ operationId,
189
252
  });
190
- return c.json(ConnectionResponse.parse({ connection }), 201);
253
+ if (!connection) throw new HTTPException(404, { message: "Connection operation not found" });
254
+ return c.json(ConnectionResponse.parse({ connection }));
191
255
  });
192
256
 
193
- app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
257
+ app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
194
258
  const workspaceId = c.req.param("workspaceId");
195
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
196
- const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
197
- const slack = requireOpenGeniSlackOAuthSettings(settings);
198
- const existing = payload.connectionId
199
- ? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
200
- : null;
201
- if (payload.connectionId && !existing) {
202
- throw new HTTPException(404, { message: "connection not found" });
203
- }
204
- if (existing && !isOpenGeniSlackBotConnection(existing)) {
259
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
260
+ const grant = access.grant;
261
+ const beforeCommit = externalContinuationCommitAuthorizer(access);
262
+ const payload = await parseRequestJson(c, CreateConnectionRequest);
263
+ if (
264
+ payload.metadata?.directModelProvider !== undefined ||
265
+ String(payload.metadata?.credentialRole ?? "").startsWith("direct_")
266
+ ) {
267
+ const config = DirectModelProviderMetadata.safeParse(payload.metadata?.directModelProvider);
268
+ const domain = config.success
269
+ ? new URL(config.data.endpoint ?? "https://api.openai.com/v1").hostname
270
+ : null;
271
+ if (
272
+ !config.success ||
273
+ payload.subjectId !== null ||
274
+ payload.kind !== "api_key" ||
275
+ canonicalProviderDomain(payload.providerDomain) !== domain ||
276
+ payload.metadata?.credentialRole !== `direct_${config.data.provider}` ||
277
+ typeof payload.credential.apiKey !== "string" ||
278
+ !payload.credential.apiKey.trim() ||
279
+ !payload.operationId ||
280
+ payload.expiresAt
281
+ ) {
282
+ throw new HTTPException(422, {
283
+ message:
284
+ "Invalid OpenAI or Azure OpenAI connection: provide a key, model, and official provider endpoint",
285
+ });
286
+ }
287
+ payload.metadata = { ...payload.metadata, directModelProvider: config.data };
288
+ if (payload.verifyModelAccess) {
289
+ await verifyDirectModelAccess(
290
+ config.data,
291
+ payload.credential.apiKey as string,
292
+ deps.directModelFetch,
293
+ );
294
+ }
295
+ } else if (payload.verifyModelAccess) {
205
296
  throw new HTTPException(422, {
206
- message: "connectionId is not an OpenGeni Slack bot connection",
297
+ message: "Model checks require an OpenAI or Azure OpenAI connection",
207
298
  });
208
299
  }
209
- const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
210
- const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
211
- const returnPath = `/workspaces/${workspaceId}/capabilities`;
212
- const state = createSignedState(requireIntegrationsStateSecret(settings), {
213
- accountId: grant.accountId,
214
- workspaceId,
215
- subjectId: grant.subjectId,
216
- returnPath,
217
- ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
300
+ const directModelKey = payload.metadata?.directModelProvider !== undefined;
301
+ // All writes in this closure must use the caller-owned scoped transaction.
302
+ // eslint-disable-next-line no-shadow
303
+ const persist = async (db: Database) => {
304
+ assertNotReservedSlackBotMetadata(payload.metadata);
305
+ assertNotReservedFikenMetadata(payload.metadata);
306
+ assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
307
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
308
+ const key = requireEnvironmentEncryption(settings);
309
+ const subjectId = createConnectionSubjectId(payload, grant.subjectId);
310
+ if (subjectId !== null) {
311
+ assertPersonalConnectionOwnerPrincipal(access);
312
+ }
313
+ const providerDomain = canonicalProviderDomain(payload.providerDomain);
314
+ payload.credential = prepareClaudeWorkspaceCredential(
315
+ settings,
316
+ workspaceId,
317
+ payload.metadata,
318
+ payload.credential,
319
+ );
320
+ assertClaudeWorkspaceCredential(settings, {
321
+ subjectId,
322
+ providerDomain,
323
+ kind: payload.kind,
324
+ metadata: payload.metadata,
325
+ credential: payload.credential,
326
+ });
327
+ assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
328
+ assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
329
+ assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
330
+ assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
331
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
332
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
333
+ subjectId,
334
+ providerDomain,
335
+ kind: payload.kind,
336
+ metadata: payload.metadata,
337
+ });
338
+ // Validated model keys store `{ apiKey }` for model execution. Generic
339
+ // integration credentials still require an explicit broker destination.
340
+ if (!workspaceProviderKind && !directModelKey)
341
+ assertBrokeredApiKeyCredential(payload.kind, payload.credential);
342
+ const connection = workspaceProviderKind
343
+ ? await (async () => {
344
+ const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
345
+ if (!payload.operationId) {
346
+ throw new HTTPException(400, {
347
+ message: `connecting ${provider.label} requires an operationId`,
348
+ });
349
+ }
350
+ const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
351
+ const metadata = workspaceProviderCredentialMetadata(
352
+ workspaceProviderKind,
353
+ payload.metadata,
354
+ );
355
+ const input = {
356
+ accountId: grant.accountId,
357
+ workspaceId,
358
+ operationId: payload.operationId,
359
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
360
+ action: "create",
361
+ providerDomain,
362
+ kind: payload.kind,
363
+ subjectId,
364
+ credential: payload.credential,
365
+ grantedScopes: payload.grantedScopes,
366
+ expiresAt: expiresAt?.toISOString() ?? null,
367
+ metadata,
368
+ }),
369
+ credentialEncrypted,
370
+ grantedScopes: payload.grantedScopes,
371
+ expiresAt,
372
+ metadata,
373
+ updatedBySubjectId: grant.subjectId,
374
+ };
375
+ const created = await upsertWorkspaceProviderApiKeyConnection(
376
+ db,
377
+ workspaceProviderKind,
378
+ input,
379
+ );
380
+ if (!created || created.status === "revoked") {
381
+ throw new HTTPException(409, {
382
+ message: `${provider.label} is already connected; reload before replacing its key`,
383
+ });
384
+ }
385
+ return created;
386
+ })()
387
+ : await createConnectionIdempotently(db, {
388
+ accountId: grant.accountId,
389
+ workspaceId,
390
+ subjectId,
391
+ providerDomain,
392
+ kind: payload.kind,
393
+ credentialEncrypted,
394
+ grantedScopes: payload.grantedScopes,
395
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
396
+ metadata: payload.metadata,
397
+ createdBySubjectId: grant.subjectId,
398
+ ...(payload.operationId
399
+ ? {
400
+ operation: {
401
+ id: payload.operationId,
402
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
403
+ action: "create",
404
+ providerDomain,
405
+ kind: payload.kind,
406
+ subjectId,
407
+ credential: payload.credential,
408
+ grantedScopes: payload.grantedScopes,
409
+ expiresAt: payload.expiresAt
410
+ ? new Date(payload.expiresAt).toISOString()
411
+ : null,
412
+ metadata: payload.metadata,
413
+ }),
414
+ },
415
+ }
416
+ : {}),
417
+ }).catch((error: unknown) => {
418
+ if (error instanceof ConnectionCreateIdempotencyError) {
419
+ throw new HTTPException(409, { message: error.message });
420
+ }
421
+ throw error;
422
+ });
423
+ return c.json(ConnectionResponse.parse({ connection }), 201);
424
+ };
425
+ const commit = (database: Database) =>
426
+ beforeCommit
427
+ ? withWorkspaceSubjectRls(database, workspaceId, grant.subjectId, async (tx) => {
428
+ await beforeCommit(tx);
429
+ return persist(tx);
430
+ })
431
+ : persist(database);
432
+ // Only the canonical workspace model-key lane is outside integration policy.
433
+ // Generic credentials have no validated protocol/curated target in this contract.
434
+ const modelKey = workspaceProviderApiKeyConnectionKind({
435
+ subjectId: createConnectionSubjectId(payload, grant.subjectId),
436
+ providerDomain: canonicalProviderDomain(payload.providerDomain),
437
+ kind: payload.kind,
438
+ metadata: payload.metadata,
218
439
  });
219
- const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
220
- authorizationUrl.searchParams.set("client_id", slack.clientId);
221
- authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
222
- authorizationUrl.searchParams.set("redirect_uri", redirectUri);
223
- authorizationUrl.searchParams.set("state", state);
440
+ return modelKey || directModelKey
441
+ ? commit(db)
442
+ : withOrganizationIntegrationAcquisition(
443
+ db,
444
+ { accountId: grant.accountId, workspaceId },
445
+ [null],
446
+ commit,
447
+ );
448
+ });
449
+
450
+ app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
451
+ const workspaceId = c.req.param("workspaceId");
452
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
453
+ requireLegacyOAuthActor(access);
454
+ const grant = access.grant;
455
+ const payload = await parseRequestJson(c, OpenGeniSlackBotInstallRequest);
224
456
  return c.json(
225
- OpenGeniSlackBotInstallStart.parse({
226
- authorizationUrl: authorizationUrl.toString(),
227
- expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
228
- }),
457
+ await withOrganizationIntegrationAcquisition(
458
+ db,
459
+ { accountId: grant.accountId, workspaceId },
460
+ ["slack-bot"],
461
+ (tx) =>
462
+ startSlackBotInstall(
463
+ { ...deps, db: tx },
464
+ {
465
+ accountId: grant.accountId,
466
+ workspaceId,
467
+ subjectId: grant.subjectId,
468
+ requestUrl: c.req.url,
469
+ ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
470
+ },
471
+ ),
472
+ ),
229
473
  );
230
474
  });
231
475
 
232
476
  app.get("/v1/integrations/slack/callback", async (c) => {
233
477
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
234
478
  let state: OpenGeniSlackInstallState | null = null;
479
+ let exactReturnUrl: string | undefined;
480
+ let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
235
481
  let stage: SlackBotInstallCallbackFailureStage = "permission_check";
236
482
  try {
237
483
  state = readOpenGeniSlackInstallState(c.req.query("state"), settings);
484
+ if (state.connectAttemptId) {
485
+ const stored = await getConnectAttempt(db, state, state.connectAttemptId);
486
+ if (stored.attempt.providerId !== "slack-bot" || stored.attempt.ownership !== "workspace")
487
+ throw new HTTPException(403, { message: "Slack attempt mismatch" });
488
+ exactReturnUrl = stored.returnUrl;
489
+ operation = {
490
+ attemptId: state.connectAttemptId,
491
+ operationId: `oauth:${state.nonce}`,
492
+ inputDigest: createHash("sha256").update(c.req.query("state")!).digest("hex"),
493
+ };
494
+ const callbackState = state;
495
+ const callbackOperation = operation;
496
+ const claim = await withOrganizationIntegrationPolicyFence(
497
+ db,
498
+ callbackState,
499
+ (policyTx, policy) =>
500
+ claimConnectOperation(policyTx, callbackState, {
501
+ ...callbackOperation,
502
+ expectedRevision: stored.attempt.revision,
503
+ authorize: (tx, _attempt, origin) =>
504
+ requireConnectOwnerAuthority(tx, callbackState, "connections:write", origin),
505
+ authorizeAcquisition: async () => {
506
+ // Provider denial/cancellation acquires nothing; completed exact
507
+ // receipts replay before this callback is invoked.
508
+ if (!c.req.query("error") && c.req.query("code"))
509
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
510
+ },
511
+ }),
512
+ );
513
+ if (claim.status === "replayed")
514
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
515
+ }
238
516
  await requireSlackInstallCallbackGrant(db, state);
239
517
  stage = "nonce_consume";
240
518
  const consumed = await consumeIntegrationOAuthStateNonce(db, {
@@ -252,6 +530,25 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
252
530
  "Slack installation state has already been used",
253
531
  );
254
532
  }
533
+ if (operation && (c.req.query("error") || !c.req.query("code"))) {
534
+ await finishConnectOperation(db, state, {
535
+ ...operation,
536
+ authorize: (tx, _attempt, origin) =>
537
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
538
+ commit: async (_tx, current) => ({
539
+ ...current,
540
+ revision: current.revision + 1,
541
+ state: c.req.query("error") === "access_denied" ? "cancelled" : "failed",
542
+ nextAction: { type: "none" },
543
+ error: {
544
+ code: c.req.query("error") ? "provider_denied" : "missing_code",
545
+ message: "Authorization was not completed. Start a new connection attempt.",
546
+ retryable: false,
547
+ },
548
+ }),
549
+ });
550
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
551
+ }
255
552
  if (c.req.query("error")) {
256
553
  stage = "provider_denial";
257
554
  throw new SlackInstallCallbackError(
@@ -269,9 +566,10 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
269
566
  "Slack installation callback is missing code",
270
567
  );
271
568
  }
569
+ await withOrganizationIntegrationAcquisition(db, state, ["slack-bot"], async () => {});
272
570
  const slack = requireOpenGeniSlackOAuthSettings(settings);
273
571
  const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
274
- const token = await exchangeOpenGeniSlackAuthorizationCode(
572
+ const authorization = await exchangeOpenGeniSlackAuthorizationCode(
275
573
  {
276
574
  code,
277
575
  clientId: slack.clientId,
@@ -281,21 +579,72 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
281
579
  deps.slackFetch ?? fetch,
282
580
  );
283
581
  stage = "credential_verification";
284
- const verified = await verifyOpenGeniSlackBotCredential(token, deps.slackFetch ?? fetch);
582
+ const verified = await verifyOpenGeniSlackBotCredential(
583
+ authorization.accessToken,
584
+ {
585
+ appId: authorization.appId,
586
+ displayName: settings.slackBotDisplayName,
587
+ },
588
+ deps.slackFetch ?? fetch,
589
+ new Date(),
590
+ );
285
591
  stage = "permission_recheck";
286
592
  await requireSlackInstallCallbackGrant(db, state);
287
593
  stage = "persistence";
288
- const connection = await persistOpenGeniSlackBotConnection({
289
- deps,
290
- state,
291
- token,
292
- verified,
293
- });
594
+ const acceptedState = state;
595
+ const persist = (tx: Database) =>
596
+ persistOpenGeniSlackBotConnection({
597
+ deps: { ...deps, db: tx },
598
+ state: acceptedState,
599
+ token: authorization.accessToken,
600
+ verified,
601
+ });
602
+ if (operation) {
603
+ const acceptedOperation = operation;
604
+ await withOrganizationIntegrationPolicyFence(db, acceptedState, (policyTx, policy) =>
605
+ finishConnectOperation(policyTx, acceptedState, {
606
+ ...acceptedOperation,
607
+ authorize: (tx, _attempt, origin) =>
608
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
609
+ commit: async (tx, current) => {
610
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
611
+ const connection = await persist(tx);
612
+ return {
613
+ ...current,
614
+ revision: current.revision + 1,
615
+ state: "complete",
616
+ credentialsCommitted: true,
617
+ nextAction: { type: "none" },
618
+ account: {
619
+ id: connection.id,
620
+ version: connection.version,
621
+ providerId: "slack-bot",
622
+ label: "Slack workspace bot",
623
+ ownership: "workspace",
624
+ status: "connected",
625
+ },
626
+ };
627
+ },
628
+ }),
629
+ );
630
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
631
+ }
632
+ const connection = await withOrganizationIntegrationAcquisition(
633
+ db,
634
+ acceptedState,
635
+ ["slack-bot"],
636
+ async (tx) => {
637
+ await requireSlackInstallCallbackGrant(tx, acceptedState);
638
+ return persist(tx);
639
+ },
640
+ );
294
641
  return c.redirect(
295
642
  slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
296
643
  302,
297
644
  );
298
645
  } catch (error) {
646
+ if (exactReturnUrl)
647
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
299
648
  if (state) {
300
649
  const failure = slackInstallCallbackFailure(stage, error);
301
650
  try {
@@ -316,7 +665,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
316
665
  }
317
666
  const reason = slackInstallErrorReason(error);
318
667
  return c.redirect(
319
- slackInstallReturnUrl(baseUrl, state?.returnPath ?? "/integrations", "error", reason),
668
+ slackInstallReturnUrl(
669
+ baseUrl,
670
+ state?.returnPath ?? oauthStateFailureReturn(settings, c.req.query("state")).returnPath,
671
+ "error",
672
+ reason,
673
+ ),
320
674
  302,
321
675
  );
322
676
  }
@@ -328,80 +682,32 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
328
682
  app.post("/v1/workspaces/:workspaceId/connections/fiken/install", async (c) => {
329
683
  assertIntegrationsEnabled();
330
684
  const workspaceId = c.req.param("workspaceId");
331
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
332
- const payload = FikenInstallRequest.parse(await c.req.json());
333
- const key = requireEnvironmentEncryption(settings);
334
- const existing = payload.connectionId
335
- ? await getConnectionMetadata(db, workspaceId, payload.connectionId, null)
336
- : null;
337
- if (payload.connectionId && !existing) {
338
- throw new HTTPException(404, { message: "connection not found" });
339
- }
340
- if (existing && !isFikenConnection(existing)) {
341
- throw new HTTPException(422, { message: "connectionId is not a Fiken connection" });
342
- }
343
- const verified = await verifyFikenApiToken(payload.apiToken, deps.fikenFetch ?? fetch);
344
- const previousDefault = existing
345
- ? (fikenConnectionMetadata(existing.metadata)?.defaultCompanySlug ?? null)
346
- : null;
347
- const defaultCompanySlug = resolveFikenDefaultCompanySlug({
348
- requested: payload.defaultCompanySlug ?? null,
349
- previous: previousDefault,
350
- companies: verified.companies,
351
- });
352
- if (payload.defaultCompanySlug && defaultCompanySlug !== payload.defaultCompanySlug) {
353
- throw new HTTPException(422, {
354
- message: `defaultCompanySlug is not among the companies this token can access: ${verified.companies
355
- .map((company) => company.slug)
356
- .join(", ")}`,
357
- });
358
- }
359
- const metadata = {
360
- credentialRole: FIKEN_CREDENTIAL_ROLE,
361
- credentialLabel: FIKEN_CREDENTIAL_LABEL,
362
- companies: verified.companies,
363
- defaultCompanySlug,
364
- verifiedAt: new Date().toISOString(),
365
- };
366
- const credentialEncrypted = encryptCredentialBundle(
367
- key,
368
- fikenCredentialBundle(payload.apiToken),
369
- );
370
- if (existing) {
371
- // Rewrites the whole credential identity: a token pasted over an OAuth
372
- // row must also flip kind and clear the OAuth expiry, or the broker
373
- // keeps treating the api_key bundle as a refreshable oauth2 credential.
374
- const updated = await updateConnection(db, {
375
- workspaceId,
376
- connectionId: existing.id,
377
- visibleToSubjectId: null,
378
- expectedVersion: existing.version,
379
- kind: "api_key",
380
- status: "active",
381
- credentialEncrypted,
382
- grantedScopes: [],
383
- expiresAt: null,
384
- metadata,
385
- updatedBySubjectId: grant.subjectId,
386
- });
387
- if (!updated) {
388
- throw new HTTPException(409, { message: "the Fiken connection changed; retry" });
389
- }
390
- return c.json(ConnectionResponse.parse({ connection: updated }));
391
- }
392
- const connection = await createConnection(db, {
393
- accountId: grant.accountId,
685
+ const authorization = await requireAccessGrantAuthorization(
686
+ c,
687
+ deps,
394
688
  workspaceId,
395
- subjectId: null,
396
- providerDomain: FIKEN_PROVIDER_DOMAIN,
397
- kind: "api_key",
398
- credentialEncrypted,
399
- grantedScopes: [],
400
- expiresAt: null,
401
- metadata,
402
- createdBySubjectId: grant.subjectId,
403
- });
404
- return c.json(ConnectionResponse.parse({ connection }), 201);
689
+ "connections:write",
690
+ );
691
+ const grant = authorization.grant;
692
+ const payload = await parseRequestJson(c, FikenInstallRequest);
693
+ const persist = await prepareFikenTokenInstall(deps, grant, payload);
694
+ const continuation = externalActorContinuationForAuthorization(authorization);
695
+ const connection = await withOrganizationIntegrationAcquisition(
696
+ db,
697
+ { accountId: grant.accountId, workspaceId },
698
+ ["fiken"],
699
+ async (tx) => {
700
+ await requireConnectOwnerAuthority(tx, {
701
+ accountId: grant.accountId,
702
+ workspaceId,
703
+ subjectId: grant.subjectId,
704
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
705
+ ...(continuation ? { externalContinuation: continuation } : {}),
706
+ });
707
+ return persist(tx);
708
+ },
709
+ );
710
+ return c.json(ConnectionResponse.parse({ connection }), payload.connectionId ? 200 : 201);
405
711
  });
406
712
 
407
713
  // Fiken OAuth (registered app) start. Both Fiken lanes produce the same
@@ -409,8 +715,10 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
409
715
  app.post("/v1/workspaces/:workspaceId/connections/fiken/oauth/start", async (c) => {
410
716
  assertIntegrationsEnabled();
411
717
  const workspaceId = c.req.param("workspaceId");
412
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
413
- const payload = FikenOAuthStartRequest.parse(await c.req.json());
718
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
719
+ requireLegacyOAuthActor(access);
720
+ const grant = access.grant;
721
+ const payload = await parseRequestJson(c, FikenOAuthStartRequest);
414
722
  requireEnvironmentEncryption(settings);
415
723
  return c.json(
416
724
  FikenOAuthStartResponse.parse(
@@ -433,13 +741,20 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
433
741
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
434
742
  requestUrl: c.req.url,
435
743
  });
744
+ if (result.exactReturn)
745
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
436
746
  return c.redirect(result.redirectTo, 302);
437
747
  });
438
748
 
439
749
  app.post("/v1/workspaces/:workspaceId/connections/google-drive/install", async (c) => {
440
750
  assertIntegrationsEnabled();
441
751
  const workspaceId = c.req.param("workspaceId");
442
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
752
+ // The Drive connector writes only a personal Connection, so only a managed
753
+ // human may start it.
754
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
755
+ assertPersonalConnectionOwnerPrincipal(access, "Google Drive");
756
+ requireLegacyOAuthActor(access);
757
+ const grant = access.grant;
443
758
  const parsed = GoogleDriveOAuthStartRequest.safeParse(await c.req.json());
444
759
  if (!parsed.success) {
445
760
  throw new HTTPException(400, { message: "invalid Google Drive install request" });
@@ -460,7 +775,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
460
775
  app.post("/v1/workspaces/:workspaceId/connections/atlassian/install", async (c) => {
461
776
  assertIntegrationsEnabled();
462
777
  const workspaceId = c.req.param("workspaceId");
463
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
778
+ // The Atlassian connector writes only a personal Connection, so only a
779
+ // managed human may start it.
780
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
781
+ assertPersonalConnectionOwnerPrincipal(access, "Atlassian");
782
+ requireLegacyOAuthActor(access);
783
+ const grant = access.grant;
464
784
  const parsed = AtlassianOAuthStartRequest.safeParse(await c.req.json());
465
785
  if (!parsed.success) {
466
786
  throw new HTTPException(400, { message: "invalid Atlassian install request" });
@@ -486,6 +806,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
486
806
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
487
807
  requestUrl: c.req.url,
488
808
  });
809
+ if (result.exactReturn)
810
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
489
811
  return c.redirect(result.redirectTo, 302);
490
812
  });
491
813
 
@@ -561,6 +883,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
561
883
  requestUrl: c.req.url,
562
884
  };
563
885
  const result = await completeGoogleDriveOAuthCallback(deps, input);
886
+ if (result.exactReturn)
887
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
564
888
  return c.redirect(result.redirectTo, 302);
565
889
  });
566
890
 
@@ -659,150 +983,351 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
659
983
  "connections:write",
660
984
  );
661
985
  const { grant } = authorization;
662
- const payload = UpdateConnectionRequest.parse(await c.req.json());
663
- assertNotReservedSlackBotMetadata(payload.metadata);
664
- assertNotReservedFikenMetadata(payload.metadata);
665
- assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
666
- const existing = await getConnectionMetadata(
986
+ const payload = await parseRequestJson(c, UpdateConnectionRequest);
987
+ // Policy first, then the existing tenancy prefix and exact credential row.
988
+ // The locked live identity makes unchanged/reducing updates safe against a
989
+ // concurrent replacement; all final writes below use this same transaction.
990
+ return withOrganizationIntegrationPolicyFence(
667
991
  db,
668
- workspaceId,
669
- c.req.param("connectionId"),
670
- grant.subjectId,
671
- );
672
- if (existing && isOpenGeniSlackBotConnection(existing)) {
673
- const destination = ConnectorDocumentDestinationSelection.safeParse(
674
- payload.metadata?.documentDestination,
675
- );
676
- const destinationOnlyUpdate =
677
- payload.metadata !== undefined &&
678
- Object.keys(payload.metadata).length === 1 &&
679
- payload.providerDomain === undefined &&
680
- payload.subjectId === undefined &&
681
- payload.kind === undefined &&
682
- payload.status === undefined &&
683
- payload.credential === undefined &&
684
- payload.grantedScopes === undefined &&
685
- payload.expiresAt === undefined;
686
- if (!destination.success || !destinationOnlyUpdate) {
687
- throw new HTTPException(422, {
688
- message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
689
- });
690
- }
691
- const destinationSelection = destination.data;
692
- if (
693
- destinationSelection.authorityKind === "organization" &&
694
- authorization.accountGrant?.permissions.includes("account:admin") !== true
695
- ) {
696
- throw new HTTPException(403, { message: "missing permission: account:admin" });
697
- }
698
- if (
699
- destinationSelection.authorityKind === "workspace" &&
700
- !hasPermission(grant.permissions, "workspace:admin")
701
- ) {
702
- throw new HTTPException(403, { message: "missing permission: workspace:admin" });
703
- }
704
- if (
705
- destinationSelection.authorityKind === "personal" &&
706
- (!authorization.contextIntegrity ||
707
- authorization.authenticatedSubjectId !== grant.subjectId)
708
- ) {
709
- throw new HTTPException(403, {
710
- message: "personal destination requires the exact actor",
711
- });
712
- }
713
- const metadata = {
714
- ...existing.metadata,
715
- documentDestination: bindConnectorDocumentDestination(destinationSelection, {
716
- accountId: grant.accountId,
717
- workspaceId,
718
- initiatingSubjectId: grant.subjectId,
992
+ { accountId: grant.accountId, workspaceId },
993
+ (policyTx, policy) =>
994
+ // eslint-disable-next-line no-shadow -- every existing final writer must use this exact scoped transaction
995
+ withWorkspaceSubjectRls(policyTx, workspaceId, grant.subjectId, async (db) => {
996
+ const initial = await getConnectionMetadata(
997
+ db,
998
+ workspaceId,
999
+ c.req.param("connectionId"),
1000
+ grant.subjectId,
1001
+ );
1002
+ if (
1003
+ initial?.metadata.directModelProvider !== undefined ||
1004
+ payload.metadata?.directModelProvider !== undefined ||
1005
+ String(payload.metadata?.credentialRole ?? "").startsWith("direct_")
1006
+ ) {
1007
+ throw new HTTPException(422, {
1008
+ message:
1009
+ "Disconnect and reconnect OpenAI or Azure OpenAI to replace its key or deployment",
1010
+ });
1011
+ }
1012
+ // Model-key rotation owns an advisory -> credential-row prefix. Do
1013
+ // not take its row first or change that excluded lane's lock order.
1014
+ if (!initial || !workspaceProviderApiKeyConnectionKind(initial)) {
1015
+ await db.execute(
1016
+ sql`select id from connections where workspace_id = ${workspaceId}::uuid and id = ${c.req.param("connectionId")}::uuid and (subject_id is null or subject_id = ${grant.subjectId}) for update`,
1017
+ );
1018
+ }
1019
+ assertNotReservedSlackBotMetadata(payload.metadata);
1020
+ assertNotReservedFikenMetadata(payload.metadata);
1021
+ assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
1022
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
1023
+ const existing = await getConnectionMetadata(
1024
+ db,
1025
+ workspaceId,
1026
+ c.req.param("connectionId"),
1027
+ grant.subjectId,
1028
+ );
1029
+ if (existing && isOpenGeniSlackBotConnection(existing)) {
1030
+ const destination = ConnectorDocumentDestinationSelection.safeParse(
1031
+ payload.metadata?.documentDestination,
1032
+ );
1033
+ const destinationOnlyUpdate =
1034
+ payload.metadata !== undefined &&
1035
+ Object.keys(payload.metadata).length === 1 &&
1036
+ payload.providerDomain === undefined &&
1037
+ payload.subjectId === undefined &&
1038
+ payload.kind === undefined &&
1039
+ payload.status === undefined &&
1040
+ payload.credential === undefined &&
1041
+ payload.grantedScopes === undefined &&
1042
+ payload.expiresAt === undefined;
1043
+ if (!destination.success || !destinationOnlyUpdate) {
1044
+ throw new HTTPException(422, {
1045
+ message:
1046
+ "use the dedicated Opengeni Slack bot reinstall flow to update this connection",
1047
+ });
1048
+ }
1049
+ const destinationSelection = destination.data;
1050
+ if (
1051
+ destinationSelection.authorityKind === "organization" &&
1052
+ authorization.accountGrant?.permissions.includes("account:admin") !== true
1053
+ ) {
1054
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
1055
+ }
1056
+ if (
1057
+ destinationSelection.authorityKind === "workspace" &&
1058
+ !hasPermission(grant.permissions, "workspace:admin")
1059
+ ) {
1060
+ throw new HTTPException(403, { message: "missing permission: workspace:admin" });
1061
+ }
1062
+ if (
1063
+ destinationSelection.authorityKind === "personal" &&
1064
+ (!authorization.contextIntegrity ||
1065
+ authorization.authenticatedSubjectId !== grant.subjectId)
1066
+ ) {
1067
+ throw new HTTPException(403, {
1068
+ message: "personal destination requires the exact actor",
1069
+ });
1070
+ }
1071
+ const metadata = {
1072
+ ...existing.metadata,
1073
+ documentDestination: bindConnectorDocumentDestination(destinationSelection, {
1074
+ accountId: grant.accountId,
1075
+ workspaceId,
1076
+ initiatingSubjectId: grant.subjectId,
1077
+ }),
1078
+ };
1079
+ if (
1080
+ stableJson(metadata.documentDestination) !==
1081
+ stableJson(existing.metadata.documentDestination)
1082
+ ) {
1083
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
1084
+ }
1085
+ const connection = await updateSlackBotDocumentDestination(db, {
1086
+ accountId: grant.accountId,
1087
+ workspaceId,
1088
+ connectionId: existing.id,
1089
+ visibleToSubjectId: grant.subjectId,
1090
+ expectedVersion: existing.version,
1091
+ metadata,
1092
+ updatedBySubjectId: grant.subjectId,
1093
+ });
1094
+ if (!connection) {
1095
+ throw new HTTPException(409, {
1096
+ message: "Slack bot connection changed; reload before saving its destination",
1097
+ });
1098
+ }
1099
+ return c.json(ConnectionResponse.parse({ connection }));
1100
+ }
1101
+ if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
1102
+ throw new HTTPException(422, {
1103
+ message: "use the dedicated Google Drive reconnect or source-selection flow",
1104
+ });
1105
+ }
1106
+ if (existing?.metadata.credentialRole === API_INTEGRATION_OAUTH_CREDENTIAL_ROLE) {
1107
+ throw new HTTPException(422, {
1108
+ message:
1109
+ "use the dedicated Integration provider OAuth flow to update this connection",
1110
+ });
1111
+ }
1112
+ if (existing && AtlassianConnectionMetadata.safeParse(existing.metadata).success) {
1113
+ throw new HTTPException(422, {
1114
+ message: "use the dedicated Atlassian reconnect or source-selection flow",
1115
+ });
1116
+ }
1117
+ if (existing && isPersonalGitHubConnection(existing)) {
1118
+ throw new HTTPException(422, {
1119
+ message: "use the dedicated personal GitHub reconnect flow to update this connection",
1120
+ });
1121
+ }
1122
+ if (existing) {
1123
+ const providerDomain = canonicalProviderDomain(
1124
+ payload.providerDomain ?? existing.providerDomain,
1125
+ );
1126
+ const kind = payload.kind ?? existing.kind;
1127
+ const subjectId =
1128
+ payload.subjectId === undefined
1129
+ ? existing.subjectId
1130
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1131
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
1132
+ subjectId,
1133
+ providerDomain,
1134
+ kind,
1135
+ metadata: payload.metadata ?? existing.metadata,
1136
+ });
1137
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
1138
+ throw new HTTPException(422, {
1139
+ message:
1140
+ targetWorkspaceProviderKind === "vercel_gateway"
1141
+ ? "use the Vercel AI Gateway connect flow to create this connection"
1142
+ : "use the OpenRouter connect flow to create this connection",
1143
+ });
1144
+ }
1145
+ assertNotDirectPersonalSlackOAuth(providerDomain, kind);
1146
+ assertNotDirectGoogleDriveOAuth(
1147
+ providerDomain,
1148
+ kind,
1149
+ payload.metadata ?? existing.metadata,
1150
+ );
1151
+ assertNotDirectAtlassianOAuth(
1152
+ providerDomain,
1153
+ kind,
1154
+ payload.metadata ?? existing.metadata,
1155
+ );
1156
+ assertNotDirectPersonalGitHubOAuth(
1157
+ providerDomain,
1158
+ kind,
1159
+ payload.metadata ?? existing.metadata,
1160
+ );
1161
+ }
1162
+ // Status is not a free-form field: revocation goes through DELETE, and the
1163
+ // broker owns needs_reauth/error. Reactivating a connection is only
1164
+ // meaningful together with a fresh credential bundle — otherwise a PATCH
1165
+ // could clear the broker's re-auth signal while stale tokens stay in place.
1166
+ if (payload.status !== undefined) {
1167
+ if (payload.status !== "active") {
1168
+ throw new HTTPException(400, {
1169
+ message: 'status can only be set to "active"; use DELETE to revoke',
1170
+ });
1171
+ }
1172
+ if (payload.credential === undefined) {
1173
+ throw new HTTPException(400, {
1174
+ message: "reactivating a connection requires a new credential",
1175
+ });
1176
+ }
1177
+ }
1178
+ const existingWorkspaceProviderKind = existing
1179
+ ? workspaceProviderApiKeyConnectionKind(existing)
1180
+ : null;
1181
+ if (existing && existingWorkspaceProviderKind) {
1182
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
1183
+ const providerDomain = canonicalProviderDomain(
1184
+ payload.providerDomain ?? existing.providerDomain,
1185
+ );
1186
+ const subjectId =
1187
+ payload.subjectId === undefined
1188
+ ? existing.subjectId
1189
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1190
+ const kind = payload.kind ?? existing.kind;
1191
+ if (
1192
+ subjectId !== null ||
1193
+ providerDomain !== provider.providerDomain ||
1194
+ kind !== "api_key" ||
1195
+ (payload.metadata?.credentialRole !== undefined &&
1196
+ payload.metadata.credentialRole !== provider.credentialRole)
1197
+ ) {
1198
+ throw new HTTPException(422, {
1199
+ message: `${provider.label} connection identity cannot be changed`,
1200
+ });
1201
+ }
1202
+ if (payload.credential === undefined) {
1203
+ throw new HTTPException(400, {
1204
+ message: `updating a ${provider.label} connection requires a new credential`,
1205
+ });
1206
+ }
1207
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
1208
+ throw new HTTPException(400, {
1209
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
1210
+ });
1211
+ }
1212
+ payload.credential = prepareClaudeWorkspaceCredential(
1213
+ settings,
1214
+ workspaceId,
1215
+ existing.metadata,
1216
+ payload.credential,
1217
+ );
1218
+ assertClaudeWorkspaceCredential(settings, {
1219
+ subjectId,
1220
+ providerDomain,
1221
+ kind,
1222
+ metadata: existing.metadata,
1223
+ credential: payload.credential,
1224
+ });
1225
+ const key = requireEnvironmentEncryption(settings);
1226
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
1227
+ const expiresAt =
1228
+ payload.expiresAt !== undefined
1229
+ ? payload.expiresAt
1230
+ ? new Date(payload.expiresAt)
1231
+ : null
1232
+ : existing.expiresAt
1233
+ ? new Date(existing.expiresAt)
1234
+ : null;
1235
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
1236
+ ...existing.metadata,
1237
+ ...(payload.metadata ?? {}),
1238
+ });
1239
+ const input = {
1240
+ accountId: grant.accountId,
1241
+ workspaceId,
1242
+ connectionId: existing.id,
1243
+ expectedVersion: payload.expectedVersion,
1244
+ operationId: payload.operationId,
1245
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
1246
+ action: "rotate",
1247
+ connectionId: existing.id,
1248
+ expectedVersion: payload.expectedVersion,
1249
+ credential: payload.credential,
1250
+ grantedScopes,
1251
+ expiresAt: expiresAt?.toISOString() ?? null,
1252
+ metadata,
1253
+ }),
1254
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
1255
+ grantedScopes,
1256
+ expiresAt,
1257
+ metadata,
1258
+ updatedBySubjectId: grant.subjectId,
1259
+ };
1260
+ const connection = await rotateWorkspaceProviderApiKeyConnection(
1261
+ db,
1262
+ existingWorkspaceProviderKind,
1263
+ input,
1264
+ );
1265
+ if (!connection) {
1266
+ throw new HTTPException(409, {
1267
+ message: `${provider.label} connection changed; reload before replacing its key`,
1268
+ });
1269
+ }
1270
+ return c.json(ConnectionResponse.parse({ connection }));
1271
+ }
1272
+ const key =
1273
+ payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
1274
+ const subjectId =
1275
+ payload.subjectId === undefined
1276
+ ? undefined
1277
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1278
+ if (
1279
+ existing &&
1280
+ (payload.credential !== undefined ||
1281
+ (payload.providerDomain !== undefined &&
1282
+ canonicalProviderDomain(payload.providerDomain) !== existing.providerDomain) ||
1283
+ (payload.kind !== undefined && payload.kind !== existing.kind) ||
1284
+ (subjectId !== undefined && subjectId !== existing.subjectId) ||
1285
+ payload.grantedScopes?.some((scope) => !existing.grantedScopes.includes(scope)) ||
1286
+ (payload.metadata !== undefined &&
1287
+ ["mcpUrl", "resource", "credentialRole"].some(
1288
+ (field) =>
1289
+ stableJson(payload.metadata?.[field] ?? null) !==
1290
+ stableJson(existing.metadata[field] ?? null),
1291
+ )) ||
1292
+ (payload.expiresAt !== undefined &&
1293
+ existing.expiresAt !== null &&
1294
+ (payload.expiresAt === null ||
1295
+ Date.parse(payload.expiresAt) > Date.parse(existing.expiresAt))) ||
1296
+ (payload.status === "active" && existing.status !== "active"))
1297
+ ) {
1298
+ assertOrganizationIntegrationAllowed(policy, null);
1299
+ }
1300
+ if (payload.credential !== undefined) {
1301
+ assertBrokeredApiKeyCredential(payload.kind ?? existing?.kind, payload.credential);
1302
+ }
1303
+ const connection = await updateConnection(db, {
1304
+ workspaceId,
1305
+ connectionId: c.req.param("connectionId"),
1306
+ visibleToSubjectId: grant.subjectId,
1307
+ updatedBySubjectId: grant.subjectId,
1308
+ ...(payload.providerDomain !== undefined
1309
+ ? { providerDomain: canonicalProviderDomain(payload.providerDomain) }
1310
+ : {}),
1311
+ ...(subjectId !== undefined ? { subjectId } : {}),
1312
+ ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
1313
+ ...(payload.status !== undefined ? { status: payload.status } : {}),
1314
+ ...(payload.credential !== undefined && key
1315
+ ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) }
1316
+ : {}),
1317
+ ...(payload.grantedScopes !== undefined
1318
+ ? { grantedScopes: payload.grantedScopes }
1319
+ : {}),
1320
+ ...(payload.expiresAt !== undefined
1321
+ ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null }
1322
+ : {}),
1323
+ ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
1324
+ });
1325
+ if (!connection) {
1326
+ throw new HTTPException(404, { message: "connection not found" });
1327
+ }
1328
+ return c.json(ConnectionResponse.parse({ connection }));
719
1329
  }),
720
- };
721
- const connection = await updateSlackBotDocumentDestination(db, {
722
- accountId: grant.accountId,
723
- workspaceId,
724
- connectionId: existing.id,
725
- visibleToSubjectId: grant.subjectId,
726
- expectedVersion: existing.version,
727
- metadata,
728
- updatedBySubjectId: grant.subjectId,
729
- });
730
- if (!connection) {
731
- throw new HTTPException(409, {
732
- message: "Slack bot connection changed; reload before saving its destination",
733
- });
734
- }
735
- return c.json(ConnectionResponse.parse({ connection }));
736
- }
737
- if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
738
- throw new HTTPException(422, {
739
- message: "use the dedicated Google Drive reconnect or source-selection flow",
740
- });
741
- }
742
- if (existing?.metadata.credentialRole === API_INTEGRATION_OAUTH_CREDENTIAL_ROLE) {
743
- throw new HTTPException(422, {
744
- message: "use the dedicated Integration provider OAuth flow to update this connection",
745
- });
746
- }
747
- if (existing && AtlassianConnectionMetadata.safeParse(existing.metadata).success) {
748
- throw new HTTPException(422, {
749
- message: "use the dedicated Atlassian reconnect or source-selection flow",
750
- });
751
- }
752
- if (existing) {
753
- const providerDomain = canonicalProviderDomain(
754
- payload.providerDomain ?? existing.providerDomain,
755
- );
756
- const kind = payload.kind ?? existing.kind;
757
- assertNotDirectPersonalSlackOAuth(providerDomain, kind);
758
- assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
759
- assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
760
- }
761
- // Status is not a free-form field: revocation goes through DELETE, and the
762
- // broker owns needs_reauth/error. Reactivating a connection is only
763
- // meaningful together with a fresh credential bundle — otherwise a PATCH
764
- // could clear the broker's re-auth signal while stale tokens stay in place.
765
- if (payload.status !== undefined) {
766
- if (payload.status !== "active") {
767
- throw new HTTPException(400, {
768
- message: 'status can only be set to "active"; use DELETE to revoke',
769
- });
770
- }
771
- if (payload.credential === undefined) {
772
- throw new HTTPException(400, {
773
- message: "reactivating a connection requires a new credential",
774
- });
775
- }
776
- }
777
- const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
778
- const subjectId =
779
- payload.subjectId === undefined
780
- ? undefined
781
- : writableSubjectId(payload.subjectId, grant.subjectId);
782
- const connection = await updateConnection(db, {
783
- workspaceId,
784
- connectionId: c.req.param("connectionId"),
785
- visibleToSubjectId: grant.subjectId,
786
- updatedBySubjectId: grant.subjectId,
787
- ...(payload.providerDomain !== undefined
788
- ? { providerDomain: canonicalProviderDomain(payload.providerDomain) }
789
- : {}),
790
- ...(subjectId !== undefined ? { subjectId } : {}),
791
- ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
792
- ...(payload.status !== undefined ? { status: payload.status } : {}),
793
- ...(payload.credential !== undefined && key
794
- ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) }
795
- : {}),
796
- ...(payload.grantedScopes !== undefined ? { grantedScopes: payload.grantedScopes } : {}),
797
- ...(payload.expiresAt !== undefined
798
- ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null }
799
- : {}),
800
- ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
801
- });
802
- if (!connection) {
803
- throw new HTTPException(404, { message: "connection not found" });
804
- }
805
- return c.json(ConnectionResponse.parse({ connection }));
1330
+ );
806
1331
  });
807
1332
 
808
1333
  app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
@@ -813,6 +1338,26 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
813
1338
  if (!existing) {
814
1339
  throw new HTTPException(404, { message: "connection not found" });
815
1340
  }
1341
+ const expectedVersion = c.req.query("expectedVersion");
1342
+ if (expectedVersion !== undefined) {
1343
+ if (!/^[1-9]\d*$/.test(expectedVersion) || !Number.isSafeInteger(Number(expectedVersion))) {
1344
+ throw new HTTPException(400, { message: "invalid expected connection version" });
1345
+ }
1346
+ if (Number(expectedVersion) !== existing.version) {
1347
+ throw new HTTPException(409, { message: "connection changed; refresh before disconnect" });
1348
+ }
1349
+ }
1350
+ const isPersonalGitHub =
1351
+ existing.subjectId === grant.subjectId && isPersonalGitHubConnection(existing);
1352
+ if (isPersonalGitHub) {
1353
+ const access = await requireAccessGrantAuthorization(
1354
+ c,
1355
+ deps,
1356
+ workspaceId,
1357
+ "connections:write",
1358
+ );
1359
+ assertPersonalConnectionOwnerPrincipal(access, "My GitHub account");
1360
+ }
816
1361
  const isGoogleDrive =
817
1362
  existing.subjectId === grant.subjectId &&
818
1363
  existing.providerDomain === GOOGLE_DRIVE_PROVIDER_DOMAIN &&
@@ -823,6 +1368,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
823
1368
  existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
824
1369
  existing.kind === "oauth2" &&
825
1370
  AtlassianConnectionMetadata.safeParse(existing.metadata).success;
1371
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
826
1372
  const disconnectPayload = await c.req.json().catch(() => null);
827
1373
  const googleDriveDisconnect = isGoogleDrive
828
1374
  ? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
@@ -843,7 +1389,23 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
843
1389
  atlassianDisconnect.error.issues[0]?.message ?? "invalid Atlassian disconnect request",
844
1390
  });
845
1391
  }
846
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian) {
1392
+ const personalGitHubDisconnect = isPersonalGitHub
1393
+ ? PersonalGitHubDisconnectRequest.safeParse(disconnectPayload)
1394
+ : null;
1395
+ if (personalGitHubDisconnect && !personalGitHubDisconnect.success) {
1396
+ throw new HTTPException(400, {
1397
+ message:
1398
+ personalGitHubDisconnect.error.issues[0]?.message ??
1399
+ "invalid personal GitHub disconnect request",
1400
+ });
1401
+ }
1402
+ if (
1403
+ existing.status === "revoked" &&
1404
+ !isGoogleDrive &&
1405
+ !isAtlassian &&
1406
+ !isPersonalGitHub &&
1407
+ !workspaceProviderKind
1408
+ ) {
847
1409
  return c.json(ConnectionResponse.parse({ connection: existing }));
848
1410
  }
849
1411
  const connection = isGoogleDrive
@@ -860,24 +1422,39 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
860
1422
  connection: existing,
861
1423
  payload: atlassianDisconnect!.data,
862
1424
  })
863
- : isOpenGeniSlackBotConnection(existing)
864
- ? await revokeConnectionWithSlackBotSuccessAudit(db, {
865
- accountId: grant.accountId,
1425
+ : isPersonalGitHub
1426
+ ? await disconnectPersonalGitHub(deps, {
866
1427
  workspaceId,
867
1428
  subjectId: grant.subjectId,
868
1429
  connectionId,
869
- expectedVersion: existing.version,
870
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
871
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
872
- slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
1430
+ payload: personalGitHubDisconnect!.data,
873
1431
  })
874
- : await revokeConnection(
875
- db,
876
- workspaceId,
877
- connectionId,
878
- grant.subjectId,
879
- existing.version,
880
- );
1432
+ : isOpenGeniSlackBotConnection(existing)
1433
+ ? await revokeConnectionWithSlackBotSuccessAudit(db, {
1434
+ accountId: grant.accountId,
1435
+ workspaceId,
1436
+ subjectId: grant.subjectId,
1437
+ connectionId,
1438
+ expectedVersion: existing.version,
1439
+ credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
1440
+ credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
1441
+ slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
1442
+ })
1443
+ : workspaceProviderKind
1444
+ ? await revokeWorkspaceProviderApiKeyConnections(db, workspaceProviderKind, {
1445
+ accountId: grant.accountId,
1446
+ workspaceId,
1447
+ connectionId,
1448
+ expectedVersion: existing.version,
1449
+ updatedBySubjectId: grant.subjectId,
1450
+ })
1451
+ : await revokeConnection(
1452
+ db,
1453
+ workspaceId,
1454
+ connectionId,
1455
+ grant.subjectId,
1456
+ existing.version,
1457
+ );
881
1458
  if (!connection) {
882
1459
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
883
1460
  }
@@ -887,7 +1464,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
887
1464
  app.post("/v1/workspaces/:workspaceId/connections/oauth/start", async (c) => {
888
1465
  assertIntegrationsEnabled();
889
1466
  const workspaceId = c.req.param("workspaceId");
890
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1467
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
1468
+ requireProviderConsentInBrowser(access);
1469
+ const grant = access.grant;
891
1470
  const parsed = OAuthStartRequest.safeParse(await c.req.json());
892
1471
  if (!parsed.success) {
893
1472
  throw new HTTPException(400, {
@@ -898,9 +1477,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
898
1477
  const result = await startMcpOAuth(
899
1478
  { db, settings, observability, oauthStartDeadlineMs: deps.oauthStartDeadlineMs },
900
1479
  {
1480
+ ...(externalActorContinuationForAuthorization(access)
1481
+ ? { externalContinuation: externalActorContinuationForAuthorization(access)! }
1482
+ : {}),
901
1483
  accountId: grant.accountId,
902
1484
  workspaceId,
903
1485
  subjectId: grant.subjectId,
1486
+ personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(access),
904
1487
  requestUrl: c.req.url,
905
1488
  payload,
906
1489
  },
@@ -911,7 +1494,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
911
1494
  app.post("/v1/workspaces/:workspaceId/integrations/oauth/start", async (c) => {
912
1495
  assertIntegrationsEnabled();
913
1496
  const workspaceId = c.req.param("workspaceId");
914
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1497
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
1498
+ requireProviderConsentInBrowser(access);
1499
+ const grant = access.grant;
915
1500
  const parsed = ApiIntegrationOAuthStartRequest.safeParse(await c.req.json());
916
1501
  if (!parsed.success) {
917
1502
  throw new HTTPException(400, {
@@ -921,9 +1506,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
921
1506
  return c.json(
922
1507
  OAuthStartResponse.parse(
923
1508
  await startApiIntegrationProviderOAuth(deps, {
1509
+ ...(externalActorContinuationForAuthorization(access)
1510
+ ? { externalContinuation: externalActorContinuationForAuthorization(access)! }
1511
+ : {}),
924
1512
  accountId: grant.accountId,
925
1513
  workspaceId,
926
1514
  subjectId: grant.subjectId,
1515
+ personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(access),
927
1516
  requestUrl: c.req.url,
928
1517
  payload: parsed.data,
929
1518
  }),
@@ -933,15 +1522,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
933
1522
 
934
1523
  app.get("/v1/integrations/oauth/callback", async (c) => {
935
1524
  assertIntegrationsEnabled();
936
- const result = await completeMcpOAuthCallback(
937
- { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
938
- {
939
- code: c.req.query("code"),
940
- state: c.req.query("state"),
941
- requestUrl: c.req.url,
942
- },
943
- );
944
- return c.redirect(result.redirectTo, 302);
1525
+ const input = {
1526
+ ...(c.req.query("code") ? { code: c.req.query("code") } : {}),
1527
+ ...(c.req.query("state") ? { state: c.req.query("state") } : {}),
1528
+ ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
1529
+ requestUrl: c.req.url,
1530
+ };
1531
+ const result = isApiIntegrationProviderOAuthState(input.state, deps.settings)
1532
+ ? await completeApiIntegrationProviderOAuth(deps, input)
1533
+ : await completeMcpOAuthCallback(
1534
+ { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
1535
+ input,
1536
+ );
1537
+ if ("exactReturn" in result && result.exactReturn)
1538
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
1539
+ const redirectTo = settings.webBaseUrl
1540
+ ? new URL(result.redirectTo, settings.webBaseUrl).toString()
1541
+ : result.redirectTo;
1542
+ return c.redirect(redirectTo, 302);
945
1543
  });
946
1544
 
947
1545
  app.get("/v1/integrations/provider-oauth/callback", async (c) => {
@@ -952,6 +1550,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
952
1550
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
953
1551
  requestUrl: c.req.url,
954
1552
  });
1553
+ if (result.exactReturn)
1554
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
955
1555
  return c.redirect(result.redirectTo, 302);
956
1556
  });
957
1557
 
@@ -1000,7 +1600,7 @@ async function persistOpenGeniSlackBotConnection(input: {
1000
1600
  throw new SlackInstallCallbackError(
1001
1601
  422,
1002
1602
  "connection_conflict",
1003
- "connectionId is not an OpenGeni Slack bot connection",
1603
+ "connectionId is not an Opengeni Slack bot connection",
1004
1604
  "principal_validation",
1005
1605
  );
1006
1606
  }
@@ -1068,21 +1668,6 @@ async function persistOpenGeniSlackBotConnection(input: {
1068
1668
  }
1069
1669
  }
1070
1670
 
1071
- function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]): {
1072
- clientId: string;
1073
- clientSecret: string;
1074
- } {
1075
- const clientId = settings.slackClientId?.trim();
1076
- const clientSecret = settings.slackClientSecret?.trim();
1077
- if (!clientId || !clientSecret) {
1078
- throw new HTTPException(503, {
1079
- message:
1080
- "OpenGeni Slack installation requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
1081
- });
1082
- }
1083
- return { clientId, clientSecret };
1084
- }
1085
-
1086
1671
  function readOpenGeniSlackInstallState(
1087
1672
  rawState: string | undefined,
1088
1673
  settings: ApiRouteDeps["settings"],
@@ -1097,6 +1682,11 @@ function readOpenGeniSlackInstallState(
1097
1682
  if (!payload) {
1098
1683
  throw new HTTPException(400, { message: "invalid or expired Slack installation state" });
1099
1684
  }
1685
+ if (
1686
+ (payload.kind !== undefined && payload.kind !== "slack_bot_install") ||
1687
+ (payload.connectAttemptId && payload.kind !== "slack_bot_install")
1688
+ )
1689
+ throw new HTTPException(400, { message: "invalid Slack installation state kind" });
1100
1690
  const requiredString = (value: unknown, label: string): string => {
1101
1691
  if (typeof value !== "string" || value.length === 0) {
1102
1692
  throw new HTTPException(400, { message: `invalid Slack installation ${label}` });
@@ -1131,6 +1721,21 @@ function readOpenGeniSlackInstallState(
1131
1721
  workspaceId,
1132
1722
  subjectId,
1133
1723
  returnPath,
1724
+ ...(typeof payload.connectAttemptId === "string"
1725
+ ? { connectAttemptId: payload.connectAttemptId }
1726
+ : {}),
1727
+ ...(typeof payload.encryptedExternalContinuation === "string"
1728
+ ? {
1729
+ externalContinuation: ExternalActorContinuation.parse(
1730
+ JSON.parse(
1731
+ decryptEnvironmentValue(
1732
+ requireEnvironmentEncryption(settings),
1733
+ payload.encryptedExternalContinuation,
1734
+ ),
1735
+ ),
1736
+ ),
1737
+ }
1738
+ : {}),
1134
1739
  ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
1135
1740
  nonce: requiredString(payload.nonce, "nonce"),
1136
1741
  iat:
@@ -1182,6 +1787,9 @@ function slackInstallCallbackFailure(
1182
1787
  if (error instanceof SlackBotLifecycleSuccessAuditError) {
1183
1788
  return { stage: "persistence", reason: "success_audit_failed" };
1184
1789
  }
1790
+ if (stage === "permission_check" && error instanceof HTTPException && error.status === 403) {
1791
+ return { stage, reason: "permission_lost" };
1792
+ }
1185
1793
  if (stage === "code_exchange") {
1186
1794
  return { stage, reason: "exchange_failed" };
1187
1795
  }
@@ -1205,18 +1813,7 @@ async function requireSlackInstallCallbackGrant(
1205
1813
  db: ApiRouteDeps["db"],
1206
1814
  state: OpenGeniSlackInstallState,
1207
1815
  ): Promise<void> {
1208
- const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
1209
- if (
1210
- !grant ||
1211
- grant.accountId !== state.accountId ||
1212
- !hasPermission(grant.permissions, "connections:write")
1213
- ) {
1214
- throw new SlackInstallCallbackError(
1215
- 403,
1216
- "permission_lost",
1217
- "Slack installation subject no longer has permission for this workspace",
1218
- );
1219
- }
1816
+ await requireConnectOwnerAuthority(db, state, "connections:write");
1220
1817
  }
1221
1818
 
1222
1819
  function assertNotDirectPersonalSlackOAuth(providerDomain: string, kind: string): void {
@@ -1257,10 +1854,78 @@ function assertNotDirectAtlassianOAuth(
1257
1854
  }
1258
1855
  }
1259
1856
 
1857
+ function assertNotDirectPersonalGitHubOAuth(
1858
+ providerDomain: string,
1859
+ kind: string,
1860
+ metadata: Record<string, unknown> | undefined,
1861
+ ): void {
1862
+ if (
1863
+ (providerDomain === PERSONAL_GITHUB_PROVIDER_DOMAIN && kind === "oauth2") ||
1864
+ hasReservedPersonalGitHubMetadata(metadata)
1865
+ ) {
1866
+ throw new HTTPException(422, {
1867
+ message: "personal GitHub credentials must use the dedicated OAuth connection flow",
1868
+ });
1869
+ }
1870
+ }
1871
+
1872
+ function assertNotReservedPersonalGitHubMetadata(
1873
+ metadata: Record<string, unknown> | undefined,
1874
+ ): void {
1875
+ if (hasReservedPersonalGitHubMetadata(metadata)) {
1876
+ throw new HTTPException(422, {
1877
+ message: "personal GitHub metadata is reserved for the dedicated OAuth connection flow",
1878
+ });
1879
+ }
1880
+ }
1881
+
1882
+ function workspaceProviderApiKeyConnectionKind(input: {
1883
+ subjectId?: string | null;
1884
+ providerDomain: string;
1885
+ kind: string;
1886
+ metadata?: Record<string, unknown>;
1887
+ }): WorkspaceProviderApiKeyConnectionKind | null {
1888
+ if (input.subjectId != null || input.kind !== "api_key") return null;
1889
+ const providerDomain = input.providerDomain.toLowerCase();
1890
+ if (
1891
+ providerDomain === "api.anthropic.com" &&
1892
+ (input.metadata?.credentialRole === "anthropic" ||
1893
+ input.metadata?.credentialRole === "claude_subscription")
1894
+ )
1895
+ return input.metadata.credentialRole;
1896
+ if (
1897
+ providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
1898
+ input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
1899
+ ) {
1900
+ return "vercel_gateway";
1901
+ }
1902
+ if (
1903
+ providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
1904
+ input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
1905
+ ) {
1906
+ return "openrouter";
1907
+ }
1908
+ return null;
1909
+ }
1910
+
1911
+ /**
1912
+ * A brokered api_key Connection is usable only through `headers` or
1913
+ * `placements`; anything else was previously accepted and then failed every
1914
+ * tool call with a misleading "connect an account" auth-needed notice.
1915
+ */
1916
+ function assertBrokeredApiKeyCredential(
1917
+ kind: string | undefined,
1918
+ credential: Record<string, unknown>,
1919
+ ): void {
1920
+ if (kind !== "api_key") return;
1921
+ const problem = brokeredCredentialBundleProblem(credential);
1922
+ if (problem) throw new HTTPException(422, { message: problem });
1923
+ }
1924
+
1260
1925
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1261
1926
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1262
1927
  throw new HTTPException(422, {
1263
- message: "OpenGeni Slack bot metadata is reserved for the dedicated connection flow",
1928
+ message: "Opengeni Slack bot metadata is reserved for the dedicated connection flow",
1264
1929
  });
1265
1930
  }
1266
1931
  }
@@ -1316,6 +1981,31 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
1316
1981
  return encryptEnvironmentValue(key, JSON.stringify(credential));
1317
1982
  }
1318
1983
 
1984
+ function workspaceProviderCredentialMetadata(
1985
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1986
+ metadata: Record<string, unknown> | undefined,
1987
+ ): Record<string, unknown> {
1988
+ const {
1989
+ [OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
1990
+ [OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
1991
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
1992
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
1993
+ ...effectiveMetadata
1994
+ } = metadata ?? {};
1995
+ for (const role of ["anthropic", "claude_subscription"]) {
1996
+ delete effectiveMetadata[`${role}CredentialOperationId`];
1997
+ delete effectiveMetadata[`${role}CredentialOperationDigest`];
1998
+ }
1999
+ return {
2000
+ ...effectiveMetadata,
2001
+ credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
2002
+ };
2003
+ }
2004
+
2005
+ function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
2006
+ return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
2007
+ }
2008
+
1319
2009
  function slackBotCredentialBundle(token: string): Record<string, unknown> {
1320
2010
  const headerName = ["author", "ization"].join("");
1321
2011
  const scheme = ["Bear", "er"].join("");