@opengeni/api-router 0.30.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (436) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/api-websocket.d.ts +18 -0
  4. package/dist/app.d.ts +50 -2
  5. package/dist/app.js +12 -1
  6. package/dist/artifact-catalog-cursor.d.ts +27 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
  10. package/dist/auth/managed-auth.d.ts +102 -3
  11. package/dist/auth/managed-email.d.ts +29 -0
  12. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  13. package/dist/auth/new-signups-gate.d.ts +23 -0
  14. package/dist/auth/organization-user-setup.d.ts +80 -0
  15. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-state-authority.d.ts +1 -27
  19. package/dist/chunk-6TZ5LG3O.js +123948 -0
  20. package/dist/chunk-6TZ5LG3O.js.map +1 -0
  21. package/dist/chunk-OGQDXVXY.js +18 -0
  22. package/dist/chunk-OGQDXVXY.js.map +1 -0
  23. package/dist/claude-subscription-account-conflicts.d.ts +1 -0
  24. package/dist/claude-subscription-account-usage.d.ts +39 -0
  25. package/dist/claude-subscription-oauth.d.ts +25 -0
  26. package/dist/claude-subscription-profile.d.ts +9 -0
  27. package/dist/claude-subscription-usage.d.ts +44 -0
  28. package/dist/claude-workspace-connection.d.ts +15 -0
  29. package/dist/codemode.d.ts +32 -1
  30. package/dist/company-brain-okf.d.ts +24 -0
  31. package/dist/connected-machine-computer-access.d.ts +11 -0
  32. package/dist/connection-ownership.d.ts +54 -0
  33. package/dist/controller-data-plane.d.ts +32 -0
  34. package/dist/editable-artifact-office-import.d.ts +7 -4
  35. package/dist/editable-artifact-source-session.d.ts +3 -0
  36. package/dist/editable-artifact-websocket.d.ts +10 -18
  37. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  38. package/dist/fatal-process-boundary.d.ts +25 -0
  39. package/dist/github-access.d.ts +31 -2
  40. package/dist/http/acting-person.d.ts +24 -0
  41. package/dist/http/api-contract-compat.d.ts +19 -0
  42. package/dist/http/api-error.d.ts +27 -1
  43. package/dist/http/cors.d.ts +17 -0
  44. package/dist/http/deprecation.d.ts +42 -0
  45. package/dist/http/interaction-control-error.d.ts +13 -0
  46. package/dist/http/keyed-admission.d.ts +12 -0
  47. package/dist/http/local-browser-boundary.d.ts +72 -0
  48. package/dist/http/metrics-listener.d.ts +22 -0
  49. package/dist/http/path-identifier.d.ts +17 -0
  50. package/dist/http/registered-route-label.d.ts +16 -0
  51. package/dist/http/request-body.d.ts +27 -0
  52. package/dist/http/request-source.d.ts +52 -0
  53. package/dist/http/sse.d.ts +17 -6
  54. package/dist/http/unmatched-route.d.ts +18 -0
  55. package/dist/http/user-content.d.ts +36 -0
  56. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  57. package/dist/index.d.ts +10 -3
  58. package/dist/index.js +1129 -166
  59. package/dist/index.js.map +1 -1
  60. package/dist/integration-endpoint-test.d.ts +20 -0
  61. package/dist/integrations/api-integrations.d.ts +2 -2
  62. package/dist/integrations/atlassian.d.ts +29 -3
  63. package/dist/integrations/connect-authority.d.ts +1 -0
  64. package/dist/integrations/connect-callback-return.d.ts +6 -0
  65. package/dist/integrations/fiken.d.ts +16 -2
  66. package/dist/integrations/github-app-connect.d.ts +47 -0
  67. package/dist/integrations/github-installation-proof.d.ts +3 -0
  68. package/dist/integrations/github-lens-connect.d.ts +116 -0
  69. package/dist/integrations/github-skill-source.d.ts +1 -5
  70. package/dist/integrations/google-drive.d.ts +25 -0
  71. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  72. package/dist/integrations/marketplace-plugin.d.ts +2691 -0
  73. package/dist/integrations/oauth-client.d.ts +178 -18
  74. package/dist/integrations/oauth-profiles.d.ts +222 -0
  75. package/dist/integrations/oauth-return-path.d.ts +1 -0
  76. package/dist/integrations/personal-github-repositories.d.ts +84 -0
  77. package/dist/integrations/personal-github.d.ts +42 -0
  78. package/dist/integrations/pr-review-provider.d.ts +22 -0
  79. package/dist/integrations/provider-oauth.d.ts +19 -1
  80. package/dist/integrations/slack-app-home.d.ts +24 -0
  81. package/dist/integrations/slack-bot.d.ts +367 -12
  82. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  83. package/dist/integrations/slack-install.d.ts +20 -0
  84. package/dist/integrations/slack-interactions.d.ts +113 -10
  85. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  86. package/dist/integrations/slack-routing.d.ts +120 -0
  87. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  88. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  89. package/dist/integrations/social-oauth.d.ts +7 -0
  90. package/dist/interaction-agent-access.d.ts +15 -0
  91. package/dist/interaction-frame-proxy.d.ts +68 -0
  92. package/dist/interaction-metrics.d.ts +2 -0
  93. package/dist/mcp/action-catalog-types.d.ts +9 -0
  94. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  95. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  96. package/dist/mcp/child-read-evidence.d.ts +9 -0
  97. package/dist/mcp/command-read.d.ts +24 -0
  98. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  99. package/dist/mcp/contract-input.d.ts +11 -0
  100. package/dist/mcp/documents.d.ts +7 -5
  101. package/dist/mcp/files.d.ts +2 -2
  102. package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
  103. package/dist/mcp/knowledge-entries.d.ts +5 -0
  104. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  105. package/dist/mcp/receipts.d.ts +9 -0
  106. package/dist/mcp/request-abort.d.ts +19 -0
  107. package/dist/mcp/scheduled-task-input.d.ts +1189 -0
  108. package/dist/mcp/scheduled-task-view.d.ts +22 -4
  109. package/dist/mcp/server.d.ts +255 -3
  110. package/dist/mcp/session-event-view.d.ts +94 -0
  111. package/dist/mcp/session-view.d.ts +66 -2
  112. package/dist/mcp/session-wait.d.ts +174 -0
  113. package/dist/mcp-oauth.d.ts +52 -0
  114. package/dist/model-catalog.d.ts +11 -31
  115. package/dist/observability.d.ts +3 -1
  116. package/dist/organization-mcp.d.ts +52 -0
  117. package/dist/organization-recovery-notifications.d.ts +41 -0
  118. package/dist/routes/analytics-consent.d.ts +26 -0
  119. package/dist/routes/api-integrations.d.ts +25 -0
  120. package/dist/routes/api-keys.d.ts +17 -0
  121. package/dist/routes/artifact-catalog.d.ts +6 -0
  122. package/dist/routes/automations.d.ts +19 -0
  123. package/dist/routes/billing.d.ts +49 -0
  124. package/dist/routes/browser-sessions.d.ts +71 -3
  125. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  126. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  127. package/dist/routes/client-errors.d.ts +146 -0
  128. package/dist/routes/codex.d.ts +76 -2
  129. package/dist/routes/company-brain.d.ts +3 -0
  130. package/dist/routes/company-profile.d.ts +2 -1
  131. package/dist/routes/computer-sessions.d.ts +14 -0
  132. package/dist/routes/connect.d.ts +5 -0
  133. package/dist/routes/editable-artifacts.d.ts +2 -0
  134. package/dist/routes/external-identity-links.d.ts +6 -0
  135. package/dist/routes/feedback.d.ts +3 -0
  136. package/dist/routes/files.d.ts +4 -0
  137. package/dist/routes/github-browser-pages.d.ts +12 -0
  138. package/dist/routes/github.d.ts +2 -0
  139. package/dist/routes/insights-response-cache.d.ts +10 -0
  140. package/dist/routes/insights-usage.d.ts +12 -0
  141. package/dist/routes/insights.d.ts +30 -1
  142. package/dist/routes/knowledge.d.ts +3 -0
  143. package/dist/routes/managed-auth-session-sets.d.ts +26 -0
  144. package/dist/routes/managed-onboarding.d.ts +29 -0
  145. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  146. package/dist/routes/model-connection-access.d.ts +3 -0
  147. package/dist/routes/organization-integration-policy.d.ts +3 -0
  148. package/dist/routes/organization-integrations.d.ts +6 -0
  149. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  150. package/dist/routes/organization-model-providers.d.ts +3 -0
  151. package/dist/routes/organization-recovery.d.ts +19 -0
  152. package/dist/routes/organization-service-accounts.d.ts +3 -0
  153. package/dist/routes/organization-sessions.d.ts +26 -0
  154. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  155. package/dist/routes/personal-github.d.ts +3 -0
  156. package/dist/routes/pr-review-github.d.ts +3 -0
  157. package/dist/routes/pr-review.d.ts +3 -0
  158. package/dist/routes/session-artifact-associations.d.ts +4 -0
  159. package/dist/routes/session-history-imports.d.ts +9 -0
  160. package/dist/routes/sessions.d.ts +44 -6
  161. package/dist/routes/skill-content.d.ts +4 -0
  162. package/dist/routes/skill-install-authority.d.ts +6 -0
  163. package/dist/routes/subscription-account-pools.d.ts +34 -0
  164. package/dist/routes/subscription-pool-access.d.ts +16 -0
  165. package/dist/routes/supergrok.d.ts +6 -1
  166. package/dist/routes/usage-allowances.d.ts +12 -0
  167. package/dist/routes/user-resource-authorities.d.ts +3 -0
  168. package/dist/routes/workspace-artifacts.d.ts +2 -1
  169. package/dist/routes/workspace-capture.d.ts +10 -2
  170. package/dist/routes/workspace-integrations.d.ts +35 -0
  171. package/dist/routes/workspace-learning.d.ts +3 -0
  172. package/dist/routes/workspace-model-providers.d.ts +4 -0
  173. package/dist/routes/workspaces.d.ts +29 -0
  174. package/dist/sandbox/auth-callout.d.ts +8 -0
  175. package/dist/sandbox/channel-a.d.ts +19 -1
  176. package/dist/sandbox/connection-authority.d.ts +3 -0
  177. package/dist/sandbox/enrollment.d.ts +9 -1
  178. package/dist/sandbox/machines.d.ts +5 -2
  179. package/dist/sandbox/metrics-ingestion.d.ts +28 -20
  180. package/dist/sandbox/rematerialize.d.ts +2 -0
  181. package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
  182. package/dist/sandbox/viewer.d.ts +26 -1
  183. package/dist/sandbox-file-artifacts.d.ts +50 -0
  184. package/dist/scheduled-task-deletion.d.ts +13 -0
  185. package/dist/session-create-observability.d.ts +5 -0
  186. package/dist/session-event-view-MPS2OZD3.js +292 -0
  187. package/dist/session-event-view-MPS2OZD3.js.map +1 -0
  188. package/dist/site-session-origin.d.ts +13 -0
  189. package/dist/site-uploads.d.ts +41 -0
  190. package/dist/slack-reaction-files.d.ts +1 -1
  191. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  192. package/dist/temporal-schedule-sync.d.ts +26 -0
  193. package/dist/transcription/providers/xai-subscription.d.ts +1 -0
  194. package/dist/transcription/service.d.ts +2 -1
  195. package/dist/work-discovery-observability.d.ts +33 -0
  196. package/dist/workspace-artifact-content.d.ts +28 -0
  197. package/dist/workspace-artifact-provenance.d.ts +7 -0
  198. package/dist/workspace-delete-observability.d.ts +10 -0
  199. package/dist/workspace-deletion.d.ts +6 -0
  200. package/dist/workspace-state-projection.d.ts +3 -5
  201. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  202. package/dist/workspace-tool-gateway.d.ts +123 -0
  203. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  204. package/package.json +23 -19
  205. package/src/access-grant-rls.ts +75 -0
  206. package/src/admission-trace.ts +14 -0
  207. package/src/api-websocket.ts +23 -0
  208. package/src/app.ts +1974 -256
  209. package/src/artifact-catalog-cursor.ts +60 -0
  210. package/src/auth/managed-auth-attempt-context.ts +70 -0
  211. package/src/auth/managed-auth-rate-limits.ts +252 -0
  212. package/src/auth/managed-auth-session-adapter.ts +222 -0
  213. package/src/auth/managed-auth.ts +536 -60
  214. package/src/auth/managed-email.ts +174 -0
  215. package/src/auth/managed-sign-in-notifications.ts +81 -0
  216. package/src/auth/new-signups-gate.ts +49 -0
  217. package/src/auth/organization-user-setup.ts +307 -0
  218. package/src/auth/signup-funnel-metrics.ts +164 -0
  219. package/src/browser-controller-authority.ts +1 -186
  220. package/src/browser-ephemeral.ts +39 -0
  221. package/src/browser-state-authority.ts +1 -236
  222. package/src/claude-subscription-account-conflicts.ts +12 -0
  223. package/src/claude-subscription-account-usage.ts +48 -0
  224. package/src/claude-subscription-oauth.ts +362 -0
  225. package/src/claude-subscription-profile.ts +73 -0
  226. package/src/claude-subscription-usage.ts +97 -0
  227. package/src/claude-workspace-connection.ts +82 -0
  228. package/src/codemode.ts +194 -30
  229. package/src/codex-realtime.ts +8 -2
  230. package/src/company-brain-okf.ts +340 -0
  231. package/src/connected-machine-computer-access.ts +33 -0
  232. package/src/connection-ownership.ts +199 -0
  233. package/src/controller-data-plane.ts +81 -0
  234. package/src/editable-artifact-native-kernel.ts +26 -15
  235. package/src/editable-artifact-office-import.ts +77 -9
  236. package/src/editable-artifact-production.ts +23 -7
  237. package/src/editable-artifact-source-session.ts +123 -0
  238. package/src/editable-artifact-websocket.ts +27 -21
  239. package/src/editable-artifact-workspace-files.ts +31 -6
  240. package/src/fatal-process-boundary.ts +246 -0
  241. package/src/gateway-realtime.ts +1 -1
  242. package/src/github-access.ts +147 -5
  243. package/src/http/acting-person.ts +71 -0
  244. package/src/http/api-contract-compat.ts +49 -0
  245. package/src/http/api-error.ts +115 -1
  246. package/src/http/auth.ts +48 -0
  247. package/src/http/cors.ts +67 -0
  248. package/src/http/deprecation.ts +118 -0
  249. package/src/http/interaction-control-error.ts +164 -0
  250. package/src/http/keyed-admission.ts +29 -0
  251. package/src/http/local-browser-boundary.ts +343 -0
  252. package/src/http/metrics-listener.ts +57 -0
  253. package/src/http/path-identifier.ts +54 -0
  254. package/src/http/registered-route-label.ts +34 -0
  255. package/src/http/request-body.ts +127 -0
  256. package/src/http/request-source.ts +178 -0
  257. package/src/http/sse.ts +486 -132
  258. package/src/http/unmatched-route.ts +60 -0
  259. package/src/http/user-content.ts +158 -0
  260. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  261. package/src/index.ts +157 -52
  262. package/src/integration-endpoint-test.ts +291 -0
  263. package/src/integrations/api-integrations.ts +98 -11
  264. package/src/integrations/atlassian.ts +474 -136
  265. package/src/integrations/connect-authority.ts +2 -0
  266. package/src/integrations/connect-callback-return.ts +86 -0
  267. package/src/integrations/fiken.ts +354 -64
  268. package/src/integrations/github-app-connect.ts +435 -0
  269. package/src/integrations/github-installation-proof.ts +60 -0
  270. package/src/integrations/github-lens-connect.ts +76 -0
  271. package/src/integrations/github-skill-source.ts +2 -142
  272. package/src/integrations/google-drive.ts +820 -222
  273. package/src/integrations/integration-commit-authority.ts +69 -0
  274. package/src/integrations/marketplace-plugin.ts +48 -0
  275. package/src/integrations/oauth-client.ts +1356 -459
  276. package/src/integrations/oauth-profiles.ts +537 -0
  277. package/src/integrations/oauth-return-path.ts +18 -0
  278. package/src/integrations/personal-github-repositories.ts +674 -0
  279. package/src/integrations/personal-github.ts +825 -0
  280. package/src/integrations/pr-review-provider.ts +246 -0
  281. package/src/integrations/provider-oauth.ts +250 -22
  282. package/src/integrations/slack-app-home.ts +300 -0
  283. package/src/integrations/slack-bot.ts +963 -239
  284. package/src/integrations/slack-file-upload-flow.ts +147 -0
  285. package/src/integrations/slack-install.ts +83 -0
  286. package/src/integrations/slack-interactions.ts +3170 -474
  287. package/src/integrations/slack-mrkdwn.ts +193 -0
  288. package/src/integrations/slack-routing.ts +337 -0
  289. package/src/integrations/slack-session-defaults.ts +224 -0
  290. package/src/integrations/slack-task-file-upload.ts +323 -0
  291. package/src/integrations/social-oauth.ts +213 -15
  292. package/src/interaction-agent-access.ts +48 -0
  293. package/src/interaction-frame-proxy.ts +409 -0
  294. package/src/interaction-metrics.ts +30 -0
  295. package/src/mcp/action-catalog-types.ts +9 -0
  296. package/src/mcp/action-catalog.gen.ts +6696 -0
  297. package/src/mcp/capability-account-readiness.ts +17 -0
  298. package/src/mcp/child-read-evidence.ts +19 -0
  299. package/src/mcp/command-read.ts +109 -0
  300. package/src/mcp/company-profile-agent-admin.ts +198 -0
  301. package/src/mcp/contract-input.ts +141 -0
  302. package/src/mcp/documents.ts +65 -377
  303. package/src/mcp/editable-artifacts.ts +12 -6
  304. package/src/mcp/files.ts +80 -38
  305. package/src/mcp/first-party-tool-permissions.ts +305 -0
  306. package/src/mcp/knowledge-entries.ts +322 -0
  307. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  308. package/src/mcp/receipts.ts +34 -0
  309. package/src/mcp/request-abort.ts +44 -0
  310. package/src/mcp/scheduled-task-input.ts +76 -0
  311. package/src/mcp/scheduled-task-view.ts +44 -3
  312. package/src/mcp/server.ts +3030 -1267
  313. package/src/mcp/session-event-view.ts +383 -0
  314. package/src/mcp/session-view.ts +84 -3
  315. package/src/mcp/session-wait.ts +665 -0
  316. package/src/mcp-oauth.ts +1123 -0
  317. package/src/model-catalog.ts +91 -333
  318. package/src/observability.ts +15 -1
  319. package/src/organization-mcp.ts +397 -0
  320. package/src/organization-recovery-notifications.ts +103 -0
  321. package/src/routes/analytics-consent.ts +123 -0
  322. package/src/routes/api-integrations.ts +364 -74
  323. package/src/routes/api-keys.ts +336 -10
  324. package/src/routes/artifact-catalog.ts +279 -0
  325. package/src/routes/automations.ts +768 -0
  326. package/src/routes/billing.ts +461 -46
  327. package/src/routes/browser-sessions.ts +839 -131
  328. package/src/routes/canonical-human-identities.ts +90 -14
  329. package/src/routes/capabilities.ts +107 -6
  330. package/src/routes/channels.ts +20 -3
  331. package/src/routes/claude-subscription-accounts.ts +334 -0
  332. package/src/routes/claude-subscription-oauth.ts +142 -0
  333. package/src/routes/client-errors.ts +362 -0
  334. package/src/routes/codex.ts +833 -116
  335. package/src/routes/company-brain.ts +322 -0
  336. package/src/routes/company-profile.ts +65 -1
  337. package/src/routes/computer-sessions.ts +557 -106
  338. package/src/routes/connect.ts +1938 -0
  339. package/src/routes/connections.ts +1040 -349
  340. package/src/routes/documents.ts +488 -287
  341. package/src/routes/editable-artifacts.ts +79 -9
  342. package/src/routes/enrollments.ts +135 -39
  343. package/src/routes/environments.ts +154 -58
  344. package/src/routes/external-identity-links.ts +201 -0
  345. package/src/routes/feedback.ts +113 -0
  346. package/src/routes/files.ts +337 -39
  347. package/src/routes/github-browser-pages.ts +162 -0
  348. package/src/routes/github.ts +446 -144
  349. package/src/routes/insights-response-cache.ts +83 -0
  350. package/src/routes/insights-usage.ts +345 -0
  351. package/src/routes/insights.ts +153 -19
  352. package/src/routes/install.ts +133 -38
  353. package/src/routes/integration-facets.ts +107 -33
  354. package/src/routes/interaction-resources.ts +30 -3
  355. package/src/routes/knowledge.ts +488 -0
  356. package/src/routes/machines.ts +313 -15
  357. package/src/routes/managed-auth-session-sets.ts +1218 -0
  358. package/src/routes/managed-onboarding.ts +337 -0
  359. package/src/routes/managed-sign-in-methods.ts +433 -0
  360. package/src/routes/memory-slack-publications.ts +3 -2
  361. package/src/routes/model-connection-access.ts +208 -0
  362. package/src/routes/organization-integration-policy.ts +116 -0
  363. package/src/routes/organization-integrations.ts +326 -0
  364. package/src/routes/organization-mcp-connections.ts +178 -0
  365. package/src/routes/organization-memberships.ts +1019 -27
  366. package/src/routes/organization-model-providers.ts +293 -0
  367. package/src/routes/organization-recovery.ts +325 -0
  368. package/src/routes/organization-service-accounts.ts +128 -0
  369. package/src/routes/organization-sessions.ts +350 -0
  370. package/src/routes/personal-github-git-broker.ts +785 -0
  371. package/src/routes/personal-github.ts +370 -0
  372. package/src/routes/plugins.ts +251 -22
  373. package/src/routes/pr-review-github.ts +881 -0
  374. package/src/routes/pr-review.ts +622 -0
  375. package/src/routes/preference-registry.ts +12 -93
  376. package/src/routes/rigs.ts +167 -50
  377. package/src/routes/scheduled-tasks.ts +346 -41
  378. package/src/routes/session-artifact-associations.ts +87 -0
  379. package/src/routes/session-history-imports.ts +280 -0
  380. package/src/routes/sessions.ts +2306 -264
  381. package/src/routes/skill-content.ts +297 -0
  382. package/src/routes/skill-install-authority.ts +64 -0
  383. package/src/routes/skills.ts +62 -9
  384. package/src/routes/social.ts +46 -18
  385. package/src/routes/subscription-account-pools.ts +436 -0
  386. package/src/routes/subscription-pool-access.ts +126 -0
  387. package/src/routes/supergrok.ts +219 -303
  388. package/src/routes/transcription-recordings.ts +38 -3
  389. package/src/routes/transcriptions.ts +4 -0
  390. package/src/routes/usage-allowances.ts +378 -0
  391. package/src/routes/user-resource-authorities.ts +138 -0
  392. package/src/routes/video-generation.ts +14 -10
  393. package/src/routes/workspace-artifacts.ts +275 -69
  394. package/src/routes/workspace-capture.ts +21 -0
  395. package/src/routes/workspace-integrations.ts +576 -0
  396. package/src/routes/workspace-learning.ts +159 -0
  397. package/src/routes/workspace-model-providers.ts +148 -0
  398. package/src/routes/workspace-state.ts +38 -25
  399. package/src/routes/workspaces.ts +1004 -127
  400. package/src/sandbox/auth-callout.ts +96 -19
  401. package/src/sandbox/channel-a.ts +323 -24
  402. package/src/sandbox/connection-authority.ts +3 -0
  403. package/src/sandbox/enrollment.ts +81 -6
  404. package/src/sandbox/machines.ts +218 -76
  405. package/src/sandbox/metrics-ingestion.ts +411 -62
  406. package/src/sandbox/rematerialize.ts +121 -19
  407. package/src/sandbox/session-attach-variable-sets.ts +131 -0
  408. package/src/sandbox/viewer.ts +280 -25
  409. package/src/sandbox-file-artifacts.ts +413 -0
  410. package/src/scheduled-task-deletion.ts +138 -0
  411. package/src/session-create-observability.ts +46 -0
  412. package/src/session-realtime-context.ts +6 -3
  413. package/src/site-session-origin.ts +48 -0
  414. package/src/site-uploads.ts +224 -0
  415. package/src/slack-reaction-files.ts +16 -5
  416. package/src/temporal-schedule-cleanup.ts +13 -0
  417. package/src/temporal-schedule-sync.ts +74 -0
  418. package/src/transcription/providers/codex-subscription.ts +2 -0
  419. package/src/transcription/providers/openai.ts +1 -0
  420. package/src/transcription/providers/xai-subscription.ts +49 -3
  421. package/src/transcription/segmenter.ts +8 -2
  422. package/src/transcription/service.ts +43 -6
  423. package/src/work-discovery-observability.ts +121 -0
  424. package/src/workspace-artifact-content.ts +144 -0
  425. package/src/workspace-artifact-provenance.ts +104 -0
  426. package/src/workspace-delete-observability.ts +75 -0
  427. package/src/workspace-deletion.ts +73 -0
  428. package/src/workspace-state-projection.ts +18 -148
  429. package/src/workspace-tool-gateway-observability.ts +100 -0
  430. package/src/workspace-tool-gateway.ts +870 -0
  431. package/src/workspace-webhook-dispatch.ts +190 -0
  432. package/src/xai-subscription-auth.ts +9 -2
  433. package/dist/chunk-2PQMSRCB.js +0 -49759
  434. package/dist/chunk-2PQMSRCB.js.map +0 -1
  435. package/dist/routes/packs.d.ts +0 -3
  436. package/src/routes/packs.ts +0 -729
@@ -1,12 +1,17 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
3
3
  import {
4
4
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
5
5
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
6
6
  OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
7
+ OPENGENI_SLACK_FILE_UPLOAD_REQUIRED_SCOPE,
7
8
  evaluateOpenGeniSlackBotScopes,
9
+ hasOpenGeniSlackFileUploadScope,
10
+ hasOpenGeniSlackBotSearchScopes,
11
+ hasOpenGeniSlackReactionScope,
8
12
  type AccessGrant,
9
13
  type ConnectionMetadata,
14
+ type OpenGeniSlackBotDisplayName,
10
15
  type OpenGeniSlackBotConnectionMetadata,
11
16
  } from "@opengeni/contracts";
12
17
  import {
@@ -18,14 +23,20 @@ import {
18
23
  } from "@opengeni/core";
19
24
  import {
20
25
  buildConnectionTokenResolver,
26
+ buildSlackApiRateLimiter,
21
27
  claimSlackBotDeleteOperation,
22
28
  claimSlackBotPostOperation,
23
29
  claimSlackBotUpdateOperation,
24
30
  completeSlackBotDeleteOperation,
25
31
  completeSlackBotPostOperation,
26
32
  completeSlackBotUpdateOperation,
33
+ getScheduledTask,
27
34
  getSession,
35
+ getSlackBotPostOperation,
28
36
  listConnectionsMetadata,
37
+ prepareScheduledSlackBotMessage,
38
+ readScheduledSlackBotMessage,
39
+ ScheduledSlackBotMessageRefusedError,
29
40
  markSlackBotDeleteOperationProviderStarted,
30
41
  markSlackBotPostOperationProviderStarted,
31
42
  recordAuditEvent,
@@ -41,11 +52,14 @@ import {
41
52
  type FetchLike,
42
53
  } from "@opengeni/network";
43
54
  import { HTTPException } from "hono/http-exception";
55
+ import sharp from "sharp";
44
56
 
45
57
  const SLACK_API_BASE = "https://slack.com/api/";
46
58
  const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
47
59
  const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
48
60
  export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
61
+ // Base64 plus MCP metadata must stay below the runtime's 1 MiB model-result cap.
62
+ export const SLACK_MCP_IMAGE_MAX_BYTES = 640 * 1024;
49
63
  const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
50
64
  const SLACK_TIMEOUT_MS = 10_000;
51
65
  const MAX_CHANNEL_PAGE = 200;
@@ -64,6 +78,12 @@ const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500;
64
78
  const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16;
65
79
  const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1;
66
80
  const MAX_USER_PAGE = 200;
81
+ /** Slack caps `assistant.search.context` at 20 results per request. */
82
+ const MAX_SEARCH_PAGE = 20;
83
+ /** Bounded projection cap for one search result's content excerpt. */
84
+ const MAX_SEARCH_CONTENT_CHARS = 2_000;
85
+ /** Matches the MCP input schema's cap; Slack signals its own via query_too_long. */
86
+ const MAX_SEARCH_QUERY_CHARS = 500;
67
87
  const MAX_FILE_PAGE = 200;
68
88
  const MAX_FILE_CURSOR_LENGTH = 1_024;
69
89
  const SLACK_FILE_CURSOR_VERSION = "files-v1";
@@ -106,6 +126,11 @@ export type DownloadedSlackReactionImage = Readonly<{
106
126
  bytes: Uint8Array;
107
127
  }>;
108
128
 
129
+ export type ExchangedOpenGeniSlackAuthorization = Readonly<{
130
+ accessToken: string;
131
+ appId: string;
132
+ }>;
133
+
109
134
  export async function exchangeOpenGeniSlackAuthorizationCode(
110
135
  input: {
111
136
  code: string;
@@ -114,7 +139,7 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
114
139
  redirectUri: string;
115
140
  },
116
141
  fetchImpl: FetchLike = fetch,
117
- ): Promise<string> {
142
+ ): Promise<ExchangedOpenGeniSlackAuthorization> {
118
143
  const body = new URLSearchParams({
119
144
  code: input.code,
120
145
  client_id: input.clientId,
@@ -158,7 +183,13 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
158
183
  message: "Slack installation did not return a bot token",
159
184
  });
160
185
  }
161
- return accessToken;
186
+ const appId = slackString(record.app_id);
187
+ if (!appId) {
188
+ throw new HTTPException(502, {
189
+ message: "Slack installation did not return an app identity",
190
+ });
191
+ }
192
+ return { accessToken, appId };
162
193
  }
163
194
 
164
195
  export type SlackBotReceipt = {
@@ -188,16 +219,59 @@ export type SlackMessageBlock =
188
219
  style?: "primary" | "danger";
189
220
  }>;
190
221
  }
222
+ | {
223
+ type: "context";
224
+ block_id?: string;
225
+ elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
226
+ }
227
+ | { type: "divider" };
228
+
229
+ export type SlackHomeBlock =
230
+ | {
231
+ type: "header";
232
+ block_id?: string;
233
+ text: { type: "plain_text"; text: string; emoji?: boolean };
234
+ }
235
+ | {
236
+ type: "section";
237
+ block_id?: string;
238
+ text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
239
+ accessory?: {
240
+ type: "button";
241
+ action_id: string;
242
+ text: { type: "plain_text"; text: string; emoji?: boolean };
243
+ url: string;
244
+ };
245
+ }
246
+ | {
247
+ type: "context";
248
+ block_id?: string;
249
+ elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
250
+ }
251
+ | {
252
+ type: "actions";
253
+ block_id: string;
254
+ elements: Array<{
255
+ type: "button";
256
+ action_id: string;
257
+ text: { type: "plain_text"; text: string; emoji?: boolean };
258
+ url: string;
259
+ style?: "primary" | "danger";
260
+ }>;
261
+ }
191
262
  | { type: "divider" };
192
263
 
193
264
  type SlackBotOperation =
194
265
  | "channels.list"
266
+ | "search.context"
195
267
  | "channel_history.read"
196
268
  | "thread_replies.read"
197
269
  | "users.list"
198
270
  | "files.list"
199
271
  | "file.info"
200
272
  | "file.content.read"
273
+ | "file.upload"
274
+ | "home.publish"
201
275
  | "message.post"
202
276
  | "message.update"
203
277
  | "message.delete";
@@ -210,6 +284,9 @@ type SlackBotContext = {
210
284
  scheduledTaskId?: string | null;
211
285
  };
212
286
 
287
+ type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>;
288
+ type SlackProviderAuthorization = () => Promise<boolean | void>;
289
+
213
290
  export type SlackReactionContextCheckpointBinding = {
214
291
  inboxId: string;
215
292
  accountId: string;
@@ -259,6 +336,95 @@ export class SlackBotProviderError extends Error {
259
336
  }
260
337
  }
261
338
 
339
+ export function slackHistoryRateLimited(error: unknown): error is SlackBotProviderError {
340
+ return (
341
+ error instanceof SlackBotProviderError &&
342
+ (error.code === "http_429" || error.code === "rate_limited" || error.code === "ratelimited")
343
+ );
344
+ }
345
+
346
+ /**
347
+ * The post or update ledger already binds this operation id to different
348
+ * request bytes. Raised from the durable claim, before any Slack write, so a
349
+ * caller that knows an earlier release wrote other bytes for the same operation
350
+ * may retry once with those bytes.
351
+ */
352
+ export class SlackBotOperationConflictError extends Error {
353
+ constructor(kind: "post" | "update" | "delete") {
354
+ super(`operationId is already bound to a different Slack ${kind} request`);
355
+ this.name = "SlackBotOperationConflictError";
356
+ }
357
+ }
358
+
359
+ /** Only Slack's one-time HTTPS upload endpoint may receive retained bytes. */
360
+ export function slackFileUploadUrl(value: string): URL {
361
+ let url: URL;
362
+ try {
363
+ url = new URL(value);
364
+ } catch {
365
+ throw new SlackBotProviderError("invalid_upload_url");
366
+ }
367
+ if (
368
+ value.length > 4096 ||
369
+ url.protocol !== "https:" ||
370
+ url.hostname !== "files.slack.com" ||
371
+ url.port !== "" ||
372
+ url.username ||
373
+ url.password ||
374
+ url.hash ||
375
+ !url.pathname.startsWith("/upload/v1/")
376
+ ) {
377
+ throw new SlackBotProviderError("invalid_upload_url");
378
+ }
379
+ return url;
380
+ }
381
+
382
+ /** A channel listing alone is not proof of delivery to the requested thread. */
383
+ export function slackFileSharedToThread(
384
+ value: unknown,
385
+ input: { fileId: string; channelId: string; threadTimestamp: string; botUserId: string },
386
+ ): boolean {
387
+ const file = slackRecord(value);
388
+ if (file?.id !== input.fileId || file.user !== input.botUserId) return false;
389
+ const shares = slackRecord(file.shares);
390
+ return ["public", "private"].some((kind) => {
391
+ const entries = slackRecord(shares?.[kind])?.[input.channelId];
392
+ return (
393
+ Array.isArray(entries) &&
394
+ entries.some((entry) => slackRecord(entry)?.thread_ts === input.threadTimestamp)
395
+ );
396
+ });
397
+ }
398
+
399
+ export async function authorizeSlackSharedImageRead(
400
+ channel: {
401
+ isArchived: boolean;
402
+ isShared: boolean;
403
+ isExternallyShared: boolean;
404
+ isOrgShared: boolean;
405
+ isPendingExternallyShared: boolean;
406
+ isMpim: boolean;
407
+ },
408
+ authorizeSharedRead: (() => Promise<void>) | undefined,
409
+ ): Promise<void> {
410
+ if (channel.isArchived) {
411
+ throw new SlackBotProviderError("is_archived");
412
+ }
413
+ if (
414
+ !channel.isShared &&
415
+ !channel.isExternallyShared &&
416
+ !channel.isOrgShared &&
417
+ !channel.isPendingExternallyShared &&
418
+ !channel.isMpim
419
+ ) {
420
+ throw new SlackBotProviderError("slack_connect_unsupported");
421
+ }
422
+ if (!authorizeSharedRead) {
423
+ throw new SlackBotProviderError("slack_connect_unsupported");
424
+ }
425
+ await authorizeSharedRead();
426
+ }
427
+
262
428
  const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
263
429
  "account_inactive",
264
430
  "invalid_auth",
@@ -285,12 +451,16 @@ export class SlackBotCredentialVerificationError extends HTTPException {
285
451
 
286
452
  /**
287
453
  * Validates a write-only xoxb credential before it can enter encrypted storage.
288
- * Slack does not expose the app's display_information name to this scope set;
289
- * users.info provides the authoritative installed bot display name, while the
290
- * documented manifest fixes the app name itself to the same exact value.
454
+ * The OAuth exchange's app_id is the immutable app authority. Slack bot profile
455
+ * names are mutable presentation data and may lag manifest/App Home changes, so
456
+ * they must not decide whether a credential belongs to this installation.
291
457
  */
292
458
  export async function verifyOpenGeniSlackBotCredential(
293
459
  token: string,
460
+ expected: Readonly<{
461
+ appId: string;
462
+ displayName: OpenGeniSlackBotDisplayName;
463
+ }>,
294
464
  fetchImpl: FetchLike = fetch,
295
465
  now: Date = new Date(),
296
466
  ): Promise<VerifiedOpenGeniSlackBot> {
@@ -314,11 +484,11 @@ export async function verifyOpenGeniSlackBotCredential(
314
484
  );
315
485
  }
316
486
  const profile = slackRecord(user.profile);
317
- const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
318
- if (displayName !== "OpenGeni") {
487
+ const installedAppId = slackString(profile?.api_app_id);
488
+ if (installedAppId !== expected.appId) {
319
489
  throw new SlackBotCredentialVerificationError(
320
490
  "identity_mismatch",
321
- 'Slack bot display name must be exactly "OpenGeni"',
491
+ "Slack credential does not belong to the authorized Slack app",
322
492
  );
323
493
  }
324
494
 
@@ -331,7 +501,7 @@ export async function verifyOpenGeniSlackBotCredential(
331
501
  slackTeamName,
332
502
  botUserId,
333
503
  botId,
334
- botDisplayName: "OpenGeni",
504
+ botDisplayName: expected.displayName,
335
505
  verifiedAt: now.toISOString(),
336
506
  },
337
507
  };
@@ -355,17 +525,17 @@ export async function resolveSlackBotConnectionForTool(input: {
355
525
  }
356
526
  const boundConnectionId = scheduledSlackBotConnectionId(session?.metadata);
357
527
  if (boundConnectionId && (!session || !isTrustedScheduledSlackBotSession(session))) {
358
- throw new Error("OpenGeni Slack bot routing metadata is not scheduler-authorized");
528
+ throw new Error("Opengeni Slack bot routing metadata is not scheduler-authorized");
359
529
  }
360
530
  if (
361
531
  boundConnectionId &&
362
532
  input.requestedConnectionId &&
363
533
  input.requestedConnectionId !== boundConnectionId
364
534
  ) {
365
- throw new Error("this scheduled session is bound to a different OpenGeni Slack bot connection");
535
+ throw new Error("this scheduled session is bound to a different Opengeni Slack bot connection");
366
536
  }
367
537
  if (!boundConnectionId && !input.grant.permissions.includes("connections:read")) {
368
- throw new Error("connections:read is required to select an OpenGeni Slack bot connection");
538
+ throw new Error("connections:read is required to select an Opengeni Slack bot connection");
369
539
  }
370
540
  let connectionId = boundConnectionId ?? input.requestedConnectionId;
371
541
  if (!connectionId) {
@@ -375,7 +545,7 @@ export async function resolveSlackBotConnectionForTool(input: {
375
545
  (connection) => connection.status === "active" && isOpenGeniSlackBotConnection(connection),
376
546
  );
377
547
  if (activeConnections.length === 0) {
378
- throw new Error("no active OpenGeni Slack bot connection is installed in this workspace");
548
+ throw new Error("no active Opengeni Slack bot connection is installed in this workspace");
379
549
  }
380
550
  if (activeConnections.length > 1) {
381
551
  const principals = new Set(
@@ -386,7 +556,7 @@ export async function resolveSlackBotConnectionForTool(input: {
386
556
  );
387
557
  if (principals.size > 1) {
388
558
  throw new Error(
389
- "connectionId is required because this workspace has multiple active OpenGeni Slack bot connections",
559
+ "connectionId is required because this workspace has multiple active Opengeni Slack bot connections",
390
560
  );
391
561
  }
392
562
  }
@@ -399,7 +569,7 @@ export async function resolveSlackBotConnectionForTool(input: {
399
569
  );
400
570
  const metadata = openGeniSlackBotMetadata(connection.metadata);
401
571
  if (!metadata) {
402
- throw new Error("OpenGeni Slack bot connection metadata is invalid");
572
+ throw new Error("Opengeni Slack bot connection metadata is invalid");
403
573
  }
404
574
  return {
405
575
  connection,
@@ -417,8 +587,209 @@ export async function resolveSlackBotConnectionForTool(input: {
417
587
  };
418
588
  }
419
589
 
590
+ /**
591
+ * The one destination a scheduled run may post to as the OpenGeni bot: the
592
+ * channel a person chose on the task, through the bot connection frozen on the
593
+ * run's session. The agent never supplies a channel. Every call re-reads the
594
+ * task, so a person clearing or changing the channel takes effect at once.
595
+ */
596
+ export async function resolveScheduledSlackBotPostTarget(input: {
597
+ db: Database;
598
+ grant: AccessGrant;
599
+ sessionId: string | null;
600
+ }): Promise<
601
+ Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>> & {
602
+ scheduledTaskId: string;
603
+ channelId: string;
604
+ }
605
+ > {
606
+ const refuse = (reason: string): never => {
607
+ throw new Error(`Posting to the task's Slack channel is unavailable: ${reason}`);
608
+ };
609
+ if (!input.sessionId) refuse("this is not a scheduled task run");
610
+ const session = await getSession(input.db, input.grant.workspaceId, input.sessionId!);
611
+ if (!session || !isTrustedScheduledSlackBotSession(session)) {
612
+ refuse("this is not a scheduled task run with an Opengeni Slack bot");
613
+ }
614
+ const connectionId = scheduledSlackBotConnectionId(session!.metadata)!;
615
+ const scheduledTaskId = String(session!.metadata.scheduledTaskId);
616
+ const task = await getScheduledTask(input.db, input.grant.workspaceId, scheduledTaskId);
617
+ if (!task) refuse("the scheduled task was deleted");
618
+ if (task!.runMode === "existing_session") refuse("the task continues an existing chat");
619
+ if (task!.agentConfig.slackBotConnectionId !== connectionId) {
620
+ refuse("the task no longer uses this Opengeni Slack bot");
621
+ }
622
+ const channelId = task!.agentConfig.slackBotChannelId;
623
+ if (!channelId) refuse("no one has chosen a Slack channel for this task");
624
+ const resolved = await resolveSlackBotConnectionForTool({
625
+ db: input.db,
626
+ grant: input.grant,
627
+ sessionId: input.sessionId,
628
+ requestedConnectionId: connectionId,
629
+ });
630
+ return { ...resolved, scheduledTaskId, channelId: channelId! };
631
+ }
632
+
633
+ /**
634
+ * Save one message for the task's channel without sending it. The returned id
635
+ * is server-owned and becomes the Slack post operation id, so retrying the
636
+ * send can never post the same message twice.
637
+ */
638
+ export async function prepareScheduledSlackBotPost(input: {
639
+ db: Database;
640
+ grant: AccessGrant;
641
+ sessionId: string | null;
642
+ text: string;
643
+ threadTimestamp?: string | undefined;
644
+ }) {
645
+ const target = await resolveScheduledSlackBotPostTarget(input);
646
+ const message = await prepareScheduledSlackBotMessage(input.db, {
647
+ accountId: input.grant.accountId,
648
+ workspaceId: input.grant.workspaceId,
649
+ sessionId: input.sessionId!,
650
+ scheduledTaskId: target.scheduledTaskId,
651
+ connectionId: target.connection.id,
652
+ connectionVersion: target.connection.version,
653
+ channelId: target.channelId,
654
+ threadTimestamp: input.threadTimestamp ?? null,
655
+ text: input.text,
656
+ }).catch((error: unknown) => {
657
+ if (error instanceof ScheduledSlackBotMessageRefusedError) {
658
+ throw new Error(`Posting to the task's Slack channel is unavailable: ${error.message}`);
659
+ }
660
+ throw error;
661
+ });
662
+ return {
663
+ messageId: message.id,
664
+ identity: "workspace_bot" as const,
665
+ channelId: message.channelId,
666
+ threadTimestamp: message.threadTimestamp,
667
+ text: message.text,
668
+ sent: false,
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Send a message this run prepared, exactly as saved. The destination must
674
+ * still be the task's channel and the bot connection unchanged; otherwise the
675
+ * send is refused rather than redirected.
676
+ */
677
+ export async function sendScheduledSlackBotPost(input: {
678
+ db: Database;
679
+ settings: Settings;
680
+ grant: AccessGrant;
681
+ sessionId: string | null;
682
+ messageId: string;
683
+ slackFetch?: typeof fetch;
684
+ authorizeProviderRequest?: SlackProviderAuthorization;
685
+ }) {
686
+ const target = await resolveScheduledSlackBotPostTarget(input);
687
+ const message = await readScheduledSlackBotMessage(input.db, {
688
+ accountId: input.grant.accountId,
689
+ workspaceId: input.grant.workspaceId,
690
+ sessionId: input.sessionId!,
691
+ id: input.messageId,
692
+ });
693
+ if (!message) {
694
+ throw new Error("This prepared Slack message does not exist in this chat");
695
+ }
696
+ const channelChanged =
697
+ message.channelId !== target.channelId || message.scheduledTaskId !== target.scheduledTaskId;
698
+ const botChanged =
699
+ message.connectionId !== target.connection.id ||
700
+ message.connectionVersion !== target.connection.version;
701
+ const client = createOpenGeniSlackBotClient(
702
+ {
703
+ db: input.db,
704
+ settings: input.settings,
705
+ ...(input.slackFetch ? { slackFetch: input.slackFetch } : {}),
706
+ ...(input.authorizeProviderRequest
707
+ ? { authorizeProviderRequest: input.authorizeProviderRequest }
708
+ : {}),
709
+ },
710
+ target,
711
+ );
712
+ const post = () =>
713
+ client.postMessage({
714
+ operationId: message.id,
715
+ channelId: message.channelId,
716
+ ...(message.threadTimestamp ? { threadTimestamp: message.threadTimestamp } : {}),
717
+ text: message.text,
718
+ requireActiveNonSharedChannel: true,
719
+ });
720
+ if (!channelChanged && !botChanged) return await post();
721
+ // The destination moved after this message was prepared, so it is never sent
722
+ // now. Say truthfully whether an earlier send already reached Slack, so the
723
+ // agent does not post the same content again believing nothing was sent.
724
+ const earlier = await getSlackBotPostOperation(
725
+ input.db,
726
+ input.grant.workspaceId,
727
+ message.connectionId,
728
+ message.id,
729
+ );
730
+ if (earlier?.status === "completed" && message.connectionId === target.connection.id) {
731
+ // Replays the recorded result from the post ledger; no Slack call is made.
732
+ return await post();
733
+ }
734
+ const reason = channelChanged
735
+ ? "The task's Slack channel changed after this message was prepared"
736
+ : "The Opengeni Slack bot changed after this message was prepared";
737
+ if (earlier?.status === "completed") {
738
+ throw new Error(`${reason}. It had already been posted, so it was not sent again.`);
739
+ }
740
+ if (earlier && earlier.status !== "pending") {
741
+ throw new Error(
742
+ `${reason}. An earlier send was interrupted, so it may already have been posted to the previous channel; it was not sent again.`,
743
+ );
744
+ }
745
+ throw new Error(`${reason}, so it was not sent. Prepare a new message for the current channel.`);
746
+ }
747
+
748
+ /**
749
+ * Check, with the bot's token, that a person's chosen task channel is one the
750
+ * bot is a member of, active, and not shared with another organization.
751
+ */
752
+ export async function verifyScheduledTaskSlackChannel(
753
+ deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
754
+ input: {
755
+ accountId: string;
756
+ workspaceId: string;
757
+ subjectId: string;
758
+ connectionId: string;
759
+ channelId: string;
760
+ },
761
+ ): Promise<void> {
762
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
763
+ accountId: input.accountId,
764
+ workspaceId: input.workspaceId,
765
+ connectionId: input.connectionId,
766
+ subjectId: input.subjectId,
767
+ });
768
+ let channel: Awaited<ReturnType<OpenGeniSlackBotClient["verifyChannelAccess"]>>;
769
+ try {
770
+ channel = await client.verifyChannelAccess(input.channelId);
771
+ } catch (error) {
772
+ throw new HTTPException(422, {
773
+ message: `The Opengeni bot cannot post in that Slack channel. Invite it to the channel first. (${safeFailureCode(error)})`,
774
+ });
775
+ }
776
+ if (
777
+ channel.isDirectMessage ||
778
+ channel.isArchived ||
779
+ channel.isShared ||
780
+ channel.isExternallyShared ||
781
+ channel.isOrgShared
782
+ ) {
783
+ throw new HTTPException(422, {
784
+ message:
785
+ "Scheduled posts need an active Slack channel that is not shared with another organization",
786
+ });
787
+ }
788
+ }
789
+
420
790
  export class OpenGeniSlackBotClient {
421
791
  private readonly resolveCredential: ReturnType<typeof buildConnectionTokenResolver>;
792
+ private readonly rateLimit: ReturnType<typeof buildSlackApiRateLimiter>;
422
793
 
423
794
  constructor(
424
795
  private readonly db: Database,
@@ -427,8 +798,20 @@ export class OpenGeniSlackBotClient {
427
798
  private readonly metadata: OpenGeniSlackBotConnectionMetadata,
428
799
  private readonly context: SlackBotContext,
429
800
  private readonly fetchImpl: FetchLike = fetch,
801
+ private readonly authorizeProviderRequest?: SlackProviderAuthorization,
430
802
  ) {
431
803
  this.resolveCredential = buildConnectionTokenResolver(db, settings);
804
+ this.rateLimit = buildSlackApiRateLimiter(db, settings);
805
+ }
806
+
807
+ private historyPageLimit(approvedLimit: number): number {
808
+ return this.settings.slackAccessMode === "full" ? approvedLimit : 15;
809
+ }
810
+
811
+ private postReconciliationPageLimit(): number {
812
+ // Ambiguous writes stay unknown when the one bounded pilot page cannot
813
+ // prove the provider identity. Never crawl or resend an unknown message.
814
+ return this.settings.slackAccessMode === "full" ? MAX_SLACK_POST_RECONCILIATION_PAGES : 1;
432
815
  }
433
816
 
434
817
  async listChannels(input: { limit?: number; cursor?: string } = {}) {
@@ -453,6 +836,159 @@ export class OpenGeniSlackBotClient {
453
836
  return await this.requireMemberChannel(headers, channelId);
454
837
  }
455
838
 
839
+ /** Server-only upload preparation; the temporary URL never enters a tool result. */
840
+ async allocateFileUpload(input: {
841
+ channelId: string;
842
+ privateRecipientSlackUserId?: string;
843
+ filename: string;
844
+ sizeBytes: number;
845
+ }) {
846
+ this.requireFileUploadScope();
847
+ return this.withAudit("file.upload", async (authority) => {
848
+ await this.requireActiveNonSharedMemberChannel(
849
+ authority,
850
+ input.channelId,
851
+ input.privateRecipientSlackUserId,
852
+ );
853
+ const payload = await this.call(authority, "files.getUploadURLExternal", {
854
+ filename: input.filename,
855
+ length: String(input.sizeBytes),
856
+ });
857
+ const fileId = slackString(payload.file_id);
858
+ const uploadUrl = slackString(payload.upload_url);
859
+ if (!fileId || !/^F[A-Z0-9]{1,63}$/.test(fileId) || !uploadUrl) {
860
+ throw new SlackBotProviderError("invalid_upload_response");
861
+ }
862
+ return { fileId, uploadUrl: slackFileUploadUrl(uploadUrl) };
863
+ });
864
+ }
865
+
866
+ async transferFileUpload(input: {
867
+ channelId: string;
868
+ privateRecipientSlackUserId?: string;
869
+ uploadUrl: URL;
870
+ bytes: Uint8Array;
871
+ }) {
872
+ this.requireFileUploadScope();
873
+ const uploadUrl = slackFileUploadUrl(input.uploadUrl.toString());
874
+ return this.withAudit("file.upload", async (authority) => {
875
+ await this.requireActiveNonSharedMemberChannel(
876
+ authority,
877
+ input.channelId,
878
+ input.privateRecipientSlackUserId,
879
+ );
880
+ // Reauthorize this exact destination and live attempt immediately before
881
+ // I/O, but do NOT forward the bot token to Slack's temporary upload URL.
882
+ await this.headersForDestination("file.upload", uploadUrl.toString());
883
+ let response: Response;
884
+ try {
885
+ response = await this.fetchImpl(uploadUrl, {
886
+ method: "POST",
887
+ headers: { "content-type": "application/octet-stream" },
888
+ body: Buffer.from(input.bytes),
889
+ redirect: "error",
890
+ signal: AbortSignal.timeout(30_000),
891
+ });
892
+ } catch {
893
+ throw new SlackBotProviderError("upload_transport_error");
894
+ }
895
+ await response.body?.cancel().catch(() => undefined);
896
+ if (!response.ok) {
897
+ throw new SlackBotProviderError(
898
+ `upload_http_${response.status}`,
899
+ slackRetryAfterMs(response),
900
+ );
901
+ }
902
+ return { uploaded: true };
903
+ });
904
+ }
905
+
906
+ async completeFileUpload(input: {
907
+ channelId: string;
908
+ privateRecipientSlackUserId?: string;
909
+ threadTimestamp: string;
910
+ fileId: string;
911
+ title: string;
912
+ }) {
913
+ this.requireFileUploadScope();
914
+ return this.withAudit("file.upload", async (authority) => {
915
+ await this.requireActiveNonSharedMemberChannel(
916
+ authority,
917
+ input.channelId,
918
+ input.privateRecipientSlackUserId,
919
+ );
920
+ const payload = await this.call(authority, "files.completeUploadExternal", {
921
+ files: JSON.stringify([{ id: input.fileId, title: input.title }]),
922
+ channel_id: input.channelId,
923
+ thread_ts: input.threadTimestamp,
924
+ });
925
+ if (
926
+ !Array.isArray(payload.files) ||
927
+ !payload.files.some((file) => slackRecord(file)?.id === input.fileId)
928
+ ) {
929
+ throw new SlackBotProviderError("invalid_upload_completion_response");
930
+ }
931
+ return { fileId: input.fileId };
932
+ });
933
+ }
934
+
935
+ async reconcileFileUpload(input: {
936
+ channelId: string;
937
+ privateRecipientSlackUserId?: string;
938
+ threadTimestamp: string;
939
+ fileId: string;
940
+ }) {
941
+ this.requireFileUploadScope();
942
+ return this.withAudit("file.upload", async (authority) => {
943
+ await this.requireActiveNonSharedMemberChannel(
944
+ authority,
945
+ input.channelId,
946
+ input.privateRecipientSlackUserId,
947
+ );
948
+ let payload: SlackPayload;
949
+ try {
950
+ payload = await this.call(authority, "files.info", { file: input.fileId });
951
+ } catch (error) {
952
+ if (error instanceof SlackBotProviderError && error.code === "file_not_found")
953
+ return { shared: false };
954
+ throw error;
955
+ }
956
+ return {
957
+ shared: slackFileSharedToThread(payload.file, {
958
+ ...input,
959
+ botUserId: this.metadata.botUserId,
960
+ }),
961
+ };
962
+ });
963
+ }
964
+
965
+ private requireFileUploadScope() {
966
+ if (!hasOpenGeniSlackFileUploadScope(this.connection.grantedScopes)) {
967
+ throw new SlackBotProviderError("slack_bot_file_upload_scope_missing");
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Replace one Slack user's private App Home view. `views.publish` is a
973
+ * naturally convergent replace operation: Slack event retries may safely
974
+ * repeat the exact bounded view without creating duplicate provider state.
975
+ */
976
+ async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) {
977
+ const userId = requiredSlackString(input.userId, "user_id");
978
+ const blocks = validateSlackHomeBlocks(input.blocks);
979
+ const hash = input.hash ? requiredSlackString(input.hash, "hash") : null;
980
+ return await this.withAudit("home.publish", async (headers) => {
981
+ const payload = await this.call(headers, "views.publish", {
982
+ user_id: userId,
983
+ view: JSON.stringify({ type: "home", blocks }),
984
+ ...(hash ? { hash } : {}),
985
+ });
986
+ return {
987
+ viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"),
988
+ };
989
+ });
990
+ }
991
+
456
992
  async slackTaskPolicyFacts(channelId: string, userId: string) {
457
993
  return await this.withAudit("channel_history.read", async (headers) => {
458
994
  const conversation = await this.requireMemberChannel(headers, channelId);
@@ -485,7 +1021,13 @@ export class OpenGeniSlackBotClient {
485
1021
  await input.authorizeRead?.();
486
1022
  const payload = await this.call(headers, "conversations.history", {
487
1023
  channel: input.channelId,
488
- limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
1024
+ limit: String(
1025
+ boundedInt(
1026
+ input.limit,
1027
+ this.historyPageLimit(MAX_HISTORY_PAGE),
1028
+ this.historyPageLimit(50),
1029
+ ),
1030
+ ),
489
1031
  ...(input.cursor ? { cursor: input.cursor } : {}),
490
1032
  ...(input.latest ? { latest: input.latest } : {}),
491
1033
  ...(input.latest && input.inclusive ? { inclusive: "true" } : {}),
@@ -514,7 +1056,13 @@ export class OpenGeniSlackBotClient {
514
1056
  const payload = await this.call(headers, "conversations.replies", {
515
1057
  channel: input.channelId,
516
1058
  ts: input.threadTimestamp,
517
- limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
1059
+ limit: String(
1060
+ boundedInt(
1061
+ input.limit,
1062
+ this.historyPageLimit(MAX_THREAD_PAGE),
1063
+ this.historyPageLimit(50),
1064
+ ),
1065
+ ),
518
1066
  ...(input.cursor ? { cursor: input.cursor } : {}),
519
1067
  ...(input.oldest ? { oldest: input.oldest } : {}),
520
1068
  ...(input.latest ? { latest: input.latest } : {}),
@@ -534,7 +1082,6 @@ export class OpenGeniSlackBotClient {
534
1082
  messageTimestamp: string;
535
1083
  checkpoint: unknown | null;
536
1084
  checkpointBinding: SlackReactionContextCheckpointBinding;
537
- saveCheckpoint: (checkpoint: SlackReactionContextCheckpoint) => Promise<void>;
538
1085
  }) {
539
1086
  return await this.withAudit("thread_replies.read", async (headers) => {
540
1087
  const checkpointKey = environmentsEncryptionKeyBytes(this.settings);
@@ -547,84 +1094,61 @@ export class OpenGeniSlackBotClient {
547
1094
  input.channelId,
548
1095
  input.messageTimestamp,
549
1096
  );
550
- const restored = input.checkpoint
551
- ? parseSlackReactionContextCheckpoint(
552
- input.checkpoint,
553
- input.checkpointBinding,
554
- checkpointKey,
555
- )
556
- : null;
1097
+ // Validate historical inbox checkpoints, but never use retained context as
1098
+ // current provider truth or continue the old full-thread scan.
1099
+ if (input.checkpoint) {
1100
+ parseSlackReactionContextCheckpoint(
1101
+ input.checkpoint,
1102
+ input.checkpointBinding,
1103
+ checkpointKey,
1104
+ );
1105
+ }
557
1106
  const info = await this.requireMemberChannel(headers, input.channelId);
558
1107
  if (info.isShared || info.isExternallyShared || info.isOrgShared) {
559
1108
  throw new SlackBotProviderError("slack_connect_unsupported");
560
1109
  }
561
- const messages: ReturnType<typeof projectMessage>[] = restored
562
- ? restored.state.messages.map(projectSlackReactionCheckpointMessage)
563
- : [];
564
- const seenMessageTimestamps = new Set(restored?.state.seenMessageTimestamps ?? []);
565
- const seenCursors = new Set(restored?.state.seenCursors ?? []);
566
- let cursor: string | null = restored?.state.nextCursor ?? null;
567
- let nextCursor: string | null = cursor;
568
- let threadTimestamp: string | null = restored?.state.threadTimestamp ?? null;
569
- let reactedMessage: ReturnType<typeof projectMessage> | null = null;
570
- const checkpointCreatedAtMs = restored?.state.createdAtMs ?? Date.now();
571
- const firstPage = restored?.state.pageCount ?? 0;
572
-
573
- for (let page = firstPage; page < MAX_REACTION_CONTEXT_PAGES; page += 1) {
1110
+ if (!hasOpenGeniSlackReactionScope(this.connection.grantedScopes)) {
1111
+ throw new SlackBotProviderError("slack_bot_reaction_scope_missing");
1112
+ }
1113
+ // This method is used only for a validated reaction summon. reactions.get
1114
+ // supplies that exact reacted message even when it is far into a thread;
1115
+ // it is not an alternate transport for arbitrary conversation history.
1116
+ const reacted = await this.call(headers, "reactions.get", {
1117
+ channel: input.channelId,
1118
+ timestamp: input.messageTimestamp,
1119
+ full: "true",
1120
+ });
1121
+ if (reacted.type !== "message" || reacted.channel !== input.channelId) {
1122
+ throw new SlackBotProviderError("message_not_found");
1123
+ }
1124
+ const reactedMessage = projectMessage(reacted.message);
1125
+ if (reactedMessage.timestamp !== input.messageTimestamp) {
1126
+ throw new SlackBotProviderError("message_not_found");
1127
+ }
1128
+ const threadTimestamp = reactedMessage.threadTimestamp || reactedMessage.timestamp;
1129
+ let messages = [reactedMessage];
1130
+ let nextCursor: string | null = null;
1131
+ let contextUnavailable = false;
1132
+ try {
574
1133
  const payload = await this.call(headers, "conversations.replies", {
575
1134
  channel: input.channelId,
576
- // Slack accepts either the parent timestamp or a message timestamp from
577
- // inside the thread and returns the containing thread.
578
- ts: input.messageTimestamp,
1135
+ ts: threadTimestamp,
1136
+ latest: reactedMessage.timestamp,
1137
+ inclusive: "true",
579
1138
  limit: String(MAX_REACTION_CONTEXT_MESSAGES),
580
- ...(cursor ? { cursor } : {}),
581
1139
  });
582
1140
  const pageMessages = slackArray(payload.messages)
583
1141
  .map(projectMessage)
584
1142
  .filter((message) => message.timestamp.length > 0);
585
- const first = pageMessages[0];
586
- threadTimestamp ??= first?.threadTimestamp || first?.timestamp || null;
587
- for (const message of pageMessages) {
588
- if (seenMessageTimestamps.has(message.timestamp)) continue;
589
- seenMessageTimestamps.add(message.timestamp);
590
- messages.push(message);
591
- }
592
- reactedMessage =
593
- reactedMessage ??
594
- pageMessages.find((message) => message.timestamp === input.messageTimestamp) ??
595
- null;
1143
+ // Fresh exact message content wins over any duplicate context entry.
1144
+ messages = [
1145
+ ...pageMessages.filter((message) => message.timestamp !== reactedMessage.timestamp),
1146
+ reactedMessage,
1147
+ ];
596
1148
  nextCursor = responseCursor(payload);
597
- if (reactedMessage || !nextCursor) break;
598
- if (seenCursors.has(nextCursor)) {
599
- throw new SlackBotProviderError("reaction_pagination_invalid");
600
- }
601
- seenCursors.add(nextCursor);
602
- const pageCount = page + 1;
603
- if (pageCount >= MAX_REACTION_CONTEXT_PAGES) {
604
- throw new SlackBotProviderError("reaction_pagination_exhausted");
605
- }
606
- const retainedMessages = selectSlackReactionCheckpointMessages(messages);
607
- messages.splice(0, messages.length, ...retainedMessages);
608
- await input.saveCheckpoint(
609
- createSlackReactionContextCheckpoint(
610
- input.checkpointBinding,
611
- {
612
- createdAtMs: checkpointCreatedAtMs,
613
- pageCount,
614
- nextCursor,
615
- seenCursors: [...seenCursors],
616
- seenMessageTimestamps: [...seenMessageTimestamps],
617
- threadTimestamp,
618
- messages: retainedMessages.map(slackReactionCheckpointMessage),
619
- },
620
- checkpointKey,
621
- ),
622
- );
623
- cursor = nextCursor;
624
- }
625
-
626
- if (!reactedMessage || !threadTimestamp) {
627
- throw new SlackBotProviderError("message_not_found");
1149
+ } catch (error) {
1150
+ if (!slackHistoryRateLimited(error)) throw error;
1151
+ contextUnavailable = true;
628
1152
  }
629
1153
  const boundedMessages = selectSlackReactionContextMessages(
630
1154
  messages,
@@ -635,7 +1159,9 @@ export class OpenGeniSlackBotClient {
635
1159
  threadTimestamp,
636
1160
  reactedMessage,
637
1161
  messages: boundedMessages,
638
- truncated: nextCursor !== null || seenMessageTimestamps.size > boundedMessages.length,
1162
+ truncated:
1163
+ contextUnavailable || nextCursor !== null || messages.length > boundedMessages.length,
1164
+ ...(contextUnavailable ? { contextUnavailable: true } : {}),
639
1165
  };
640
1166
  });
641
1167
  }
@@ -649,11 +1175,18 @@ export class OpenGeniSlackBotClient {
649
1175
  async prepareReactionImageDownloads(input: {
650
1176
  channelId: string;
651
1177
  files: readonly { id: string; name: string; title: string }[];
1178
+ authorizeSharedRead?: () => Promise<void>;
652
1179
  }): Promise<PreparedSlackReactionImage[]> {
653
1180
  const result = await this.withAudit("file.content.read", async (headers) => {
654
- await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
1181
+ if (input.authorizeSharedRead) {
1182
+ const channel = await this.requireMemberChannel(headers, input.channelId);
1183
+ await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead);
1184
+ } else {
1185
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
1186
+ }
655
1187
  const prepared: PreparedSlackReactionImage[] = [];
656
1188
  for (const candidate of input.files) {
1189
+ await input.authorizeSharedRead?.();
657
1190
  const payload = await this.call(headers, "files.info", { file: candidate.id });
658
1191
  const fileRecord = slackRecord(payload.file);
659
1192
  const file = projectFile(fileRecord);
@@ -688,6 +1221,7 @@ export class OpenGeniSlackBotClient {
688
1221
  /** Download and fully validate one previously authorized Slack image. */
689
1222
  async downloadReactionImage(
690
1223
  input: PreparedSlackReactionImage,
1224
+ authorizeSharedRead?: () => Promise<void>,
691
1225
  ): Promise<DownloadedSlackReactionImage> {
692
1226
  return await this.withAudit("file.content.read", async () => {
693
1227
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
@@ -700,7 +1234,11 @@ export class OpenGeniSlackBotClient {
700
1234
  ) {
701
1235
  throw new SlackBotProviderError("invalid_file_size");
702
1236
  }
703
- const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
1237
+ const response = await this.fetchPrivateFile(
1238
+ input.downloadUrl,
1239
+ "file.content.read",
1240
+ authorizeSharedRead,
1241
+ );
704
1242
  const responseContentType = normalizedContentType(response.headers.get("content-type"));
705
1243
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
706
1244
  await response.body?.cancel().catch(() => undefined);
@@ -748,6 +1286,79 @@ export class OpenGeniSlackBotClient {
748
1286
  });
749
1287
  }
750
1288
 
1289
+ /**
1290
+ * Public search in a trusted Slack interaction through Real-time Search
1291
+ * (`assistant.search.context`) under the bot identity. Unlisted deployments
1292
+ * cannot use this API; approved deployments also need Slack's action token.
1293
+ *
1294
+ * The bot never carries private-search authority: `channel_types` is pinned
1295
+ * to `public_channel` server-side regardless of caller input, so private
1296
+ * channels, DMs, and MPIMs remain reachable only through a member's personal
1297
+ * hosted-MCP grant. An install predating the search scopes fails closed with
1298
+ * a reinstall hint instead of leaking Slack's `missing_scope` error.
1299
+ */
1300
+ async searchContext(input: {
1301
+ query: string;
1302
+ /** Server-owned Slack interaction token, never an agent tool argument. */
1303
+ actionToken?: string;
1304
+ contentTypes?: readonly ("messages" | "files" | "channels")[];
1305
+ includeBots?: boolean;
1306
+ /** UNIX seconds bounds on message timestamps. */
1307
+ before?: number;
1308
+ after?: number;
1309
+ sort?: "score" | "timestamp";
1310
+ sortDir?: "asc" | "desc";
1311
+ cursor?: string;
1312
+ limit?: number;
1313
+ }) {
1314
+ const query = typeof input.query === "string" ? input.query.trim() : "";
1315
+ if (!query || query.length > MAX_SEARCH_QUERY_CHARS) {
1316
+ throw new SlackBotProviderError("invalid_query");
1317
+ }
1318
+ const contentTypes = input.contentTypes?.length
1319
+ ? [...new Set(input.contentTypes)]
1320
+ : ["messages"];
1321
+ return await this.withAudit("search.context", async (headers) => {
1322
+ if (this.settings.slackAccessMode !== "full") {
1323
+ throw new SlackBotProviderError("slack_bot_search_unavailable");
1324
+ }
1325
+ if (!input.actionToken) {
1326
+ throw new SlackBotProviderError("slack_bot_search_action_required");
1327
+ }
1328
+ // Inside the audit boundary so a fail-closed denial on a legacy install
1329
+ // leaves the same `failed` evidence as a provider rejection.
1330
+ if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) {
1331
+ throw new SlackBotProviderError("slack_bot_search_scopes_missing");
1332
+ }
1333
+ const payload = await this.call(headers, "assistant.search.context", {
1334
+ query,
1335
+ action_token: input.actionToken,
1336
+ channel_types: "public_channel",
1337
+ content_types: contentTypes.join(","),
1338
+ ...(input.includeBots ? { include_bots: "true" } : {}),
1339
+ ...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}),
1340
+ ...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}),
1341
+ ...(input.sort ? { sort: input.sort } : {}),
1342
+ ...(input.sortDir ? { sort_dir: input.sortDir } : {}),
1343
+ ...(input.cursor ? { cursor: input.cursor } : {}),
1344
+ limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)),
1345
+ });
1346
+ const results = slackRecord(payload.results);
1347
+ return {
1348
+ messages: slackArray(results?.messages)
1349
+ .map(projectSearchMessage)
1350
+ .filter((message): message is NonNullable<typeof message> => message !== null),
1351
+ files: slackArray(results?.files)
1352
+ .map(projectSearchFile)
1353
+ .filter((file): file is NonNullable<typeof file> => file !== null),
1354
+ channels: slackArray(results?.channels)
1355
+ .map(projectSearchChannel)
1356
+ .filter((channel): channel is NonNullable<typeof channel> => channel !== null),
1357
+ nextCursor: responseCursor(payload),
1358
+ };
1359
+ });
1360
+ }
1361
+
751
1362
  async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
752
1363
  return await this.withAudit("files.list", async (headers) => {
753
1364
  const requestedPage = this.fileListPage(input);
@@ -797,6 +1408,35 @@ export class OpenGeniSlackBotClient {
797
1408
  "file.content.read",
798
1409
  input,
799
1410
  );
1411
+ if (SLACK_REACTION_IMAGE_MIME_TYPES.has(normalizedContentType(file.mimetype))) {
1412
+ if (input.offset !== undefined && input.offset !== 0) {
1413
+ throw new SlackBotProviderError("invalid_file_offset");
1414
+ }
1415
+ // A context file is read only on explicit request. Keep the same
1416
+ // non-shared channel boundary and byte validation as invocation images.
1417
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
1418
+ if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
1419
+ throw new SlackBotProviderError("file_not_shared_to_channel");
1420
+ }
1421
+ if (file.size !== null && (file.size < 1 || file.size > SLACK_REACTION_IMAGE_MAX_BYTES)) {
1422
+ throw new SlackBotProviderError("invalid_file_size");
1423
+ }
1424
+ if (file.size !== null && file.size > SLACK_MCP_IMAGE_MAX_BYTES) {
1425
+ throw new SlackBotProviderError("image_result_too_large");
1426
+ }
1427
+ const image = await this.downloadReactionImage({
1428
+ fileId: file.id,
1429
+ filename: file.name || file.title || file.id,
1430
+ declaredMimeType: normalizedContentType(file.mimetype),
1431
+ declaredSizeBytes: file.size,
1432
+ downloadUrl: privateSlackFileUrl(fileRecord),
1433
+ });
1434
+ if (image.bytes.byteLength > SLACK_MCP_IMAGE_MAX_BYTES) {
1435
+ throw new SlackBotProviderError("image_result_too_large");
1436
+ }
1437
+ await validateSlackMcpImage(image.bytes);
1438
+ return { channel: info, file, image };
1439
+ }
800
1440
  const embeddedTranscript = embeddedHuddleTranscription(fileRecord, parentFileRecord);
801
1441
  const { contentType, content } =
802
1442
  embeddedTranscript ?? (await this.readPrivateFileText(fileRecord, "file.content.read"));
@@ -857,6 +1497,7 @@ export class OpenGeniSlackBotClient {
857
1497
  const targetKind = input.userId ? "user" : "channel";
858
1498
  const targetId = input.userId ?? input.channelId!;
859
1499
  const blocks = validateSlackMessageBlocks(input.blocks);
1500
+ const wireBlocks = slackMessageWireBlocks(blocks);
860
1501
  const requestDigest = this.postRequestDigest({
861
1502
  operationId: input.operationId,
862
1503
  targetKind,
@@ -877,10 +1518,10 @@ export class OpenGeniSlackBotClient {
877
1518
  claimLeaseMs: SLACK_POST_CLAIM_LEASE_MS,
878
1519
  });
879
1520
  if (claim.kind === "connection_not_found") {
880
- throw new Error("OpenGeni Slack bot connection no longer exists");
1521
+ throw new Error("Opengeni Slack bot connection no longer exists");
881
1522
  }
882
1523
  if (claim.kind === "conflict") {
883
- throw new Error("operationId is already bound to a different Slack post request");
1524
+ throw new SlackBotOperationConflictError("post");
884
1525
  }
885
1526
  if (claim.kind === "in_progress") {
886
1527
  throw new Error("Slack post operation is already in progress; retry the same operationId");
@@ -938,7 +1579,7 @@ export class OpenGeniSlackBotClient {
938
1579
  const posted = await this.call(headers, "chat.postMessage", {
939
1580
  channel: channelId,
940
1581
  text: input.text,
941
- ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
1582
+ ...(wireBlocks ? { blocks: JSON.stringify(wireBlocks) } : {}),
942
1583
  client_msg_id: input.operationId,
943
1584
  unfurl_links: "false",
944
1585
  unfurl_media: "false",
@@ -1003,6 +1644,7 @@ export class OpenGeniSlackBotClient {
1003
1644
  let providerCallStarted = false;
1004
1645
  const blocks = validateSlackMessageBlocks(input.blocks);
1005
1646
  try {
1647
+ const wireBlocks = slackMessageWireBlocks(blocks);
1006
1648
  const headers = await this.headersFor(operation);
1007
1649
  await this.requireMemberChannel(headers, input.channelId);
1008
1650
  const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) });
@@ -1018,10 +1660,10 @@ export class OpenGeniSlackBotClient {
1018
1660
  claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS,
1019
1661
  });
1020
1662
  if (claim.kind === "connection_not_found") {
1021
- throw new Error("OpenGeni Slack bot connection no longer exists");
1663
+ throw new Error("Opengeni Slack bot connection no longer exists");
1022
1664
  }
1023
1665
  if (claim.kind === "conflict") {
1024
- throw new Error("operationId is already bound to a different Slack update request");
1666
+ throw new SlackBotOperationConflictError("update");
1025
1667
  }
1026
1668
  if (claim.kind === "in_progress") {
1027
1669
  throw new Error(
@@ -1041,7 +1683,7 @@ export class OpenGeniSlackBotClient {
1041
1683
  channel: input.channelId,
1042
1684
  ts: input.timestamp,
1043
1685
  text: input.text,
1044
- ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
1686
+ ...(wireBlocks ? { blocks: JSON.stringify(wireBlocks) } : {}),
1045
1687
  });
1046
1688
  const slackChannelId = requiredSlackString(updated.channel, "channel");
1047
1689
  const slackMessageTimestamp = requiredSlackString(updated.ts, "ts");
@@ -1112,10 +1754,10 @@ export class OpenGeniSlackBotClient {
1112
1754
  claimLeaseMs: SLACK_DELETE_CLAIM_LEASE_MS,
1113
1755
  });
1114
1756
  if (claim.kind === "connection_not_found") {
1115
- throw new Error("OpenGeni Slack bot connection no longer exists");
1757
+ throw new Error("Opengeni Slack bot connection no longer exists");
1116
1758
  }
1117
1759
  if (claim.kind === "conflict") {
1118
- throw new Error("operationId is already bound to a different Slack delete request");
1760
+ throw new SlackBotOperationConflictError("delete");
1119
1761
  }
1120
1762
  if (claim.kind === "in_progress") {
1121
1763
  throw new Error(
@@ -1211,10 +1853,7 @@ export class OpenGeniSlackBotClient {
1211
1853
  }
1212
1854
 
1213
1855
  private async slackMessageExists(channelId: string, timestamp: string): Promise<boolean> {
1214
- const headers = await this.headersForDestination(
1215
- "message.delete",
1216
- `${SLACK_API_BASE}chat.getPermalink`,
1217
- );
1856
+ const headers = await this.headersFor("message.delete");
1218
1857
  try {
1219
1858
  await this.call(headers, "chat.getPermalink", {
1220
1859
  channel: channelId,
@@ -1236,15 +1875,17 @@ export class OpenGeniSlackBotClient {
1236
1875
  text: string;
1237
1876
  }): Promise<{ timestamp: string }> {
1238
1877
  const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
1239
- const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
1878
+ const headers = await this.headersFor("message.post");
1240
1879
  const seenCursors = new Set<string>();
1241
1880
  let cursor: string | null = null;
1242
1881
  let matchedTimestamp: string | null = null;
1243
1882
  let exhausted = false;
1244
- for (let page = 0; page < MAX_SLACK_POST_RECONCILIATION_PAGES; page += 1) {
1883
+ for (let page = 0; page < this.postReconciliationPageLimit(); page += 1) {
1245
1884
  const payload = await this.call(headers, method, {
1246
1885
  channel: input.channelId,
1247
- limit: String(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
1886
+ limit: String(
1887
+ this.historyPageLimit(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
1888
+ ),
1248
1889
  ...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}),
1249
1890
  ...(cursor ? { cursor } : {}),
1250
1891
  });
@@ -1284,7 +1925,7 @@ export class OpenGeniSlackBotClient {
1284
1925
  return { timestamp: matchedTimestamp };
1285
1926
  }
1286
1927
 
1287
- private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
1928
+ private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) {
1288
1929
  const payload = await this.call(headers, "conversations.info", {
1289
1930
  channel: channelId,
1290
1931
  });
@@ -1299,8 +1940,9 @@ export class OpenGeniSlackBotClient {
1299
1940
  }
1300
1941
 
1301
1942
  private async requireActiveNonSharedMemberChannel(
1302
- headers: Record<string, string>,
1943
+ headers: SlackCallAuthority,
1303
1944
  channelId: string,
1945
+ privateRecipientSlackUserId?: string,
1304
1946
  ) {
1305
1947
  const projected = await this.requireMemberChannel(headers, channelId);
1306
1948
  if (projected.isArchived) {
@@ -1309,11 +1951,14 @@ export class OpenGeniSlackBotClient {
1309
1951
  if (projected.isShared || projected.isExternallyShared || projected.isOrgShared) {
1310
1952
  throw new SlackBotProviderError("slack_connect_unsupported");
1311
1953
  }
1954
+ if (privateRecipientSlackUserId) {
1955
+ assertSlackPrivateTaskRecipient(projected, privateRecipientSlackUserId);
1956
+ }
1312
1957
  return projected;
1313
1958
  }
1314
1959
 
1315
1960
  private async requireFileForChannel(
1316
- headers: Record<string, string>,
1961
+ headers: SlackCallAuthority,
1317
1962
  operation: "file.info" | "file.content.read",
1318
1963
  input: { channelId: string; fileId: string; parentFileId?: string },
1319
1964
  ) {
@@ -1390,13 +2035,32 @@ export class OpenGeniSlackBotClient {
1390
2035
  }
1391
2036
 
1392
2037
  private async call(
1393
- headers: Record<string, string>,
2038
+ authority: SlackCallAuthority,
1394
2039
  method: string,
1395
2040
  params: Record<string, string>,
1396
2041
  ): Promise<SlackPayload> {
2042
+ let providerRequestStarted = false;
1397
2043
  try {
2044
+ const destination = `${SLACK_API_BASE}${method}`;
2045
+ // Refused or stale authority cannot consume another installation's quota.
2046
+ await this.headersForDestination(authority.operation, destination, "preflight");
2047
+ const retryAfterSeconds = await this.rateLimit(this.metadata.slackTeamId, method);
2048
+ if (retryAfterSeconds > 0) {
2049
+ throw new SlackBotProviderError("rate_limited", retryAfterSeconds * 1_000);
2050
+ }
2051
+ // Quota reservation is an await: recheck live authority after it, as the
2052
+ // final await before each physical provider request.
2053
+ const headers = await this.headersForDestination(authority.operation, destination);
2054
+ providerRequestStarted = true;
1398
2055
  return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
1399
2056
  } catch (error) {
2057
+ if (providerRequestStarted && slackHistoryRateLimited(error)) {
2058
+ await this.rateLimit(
2059
+ this.metadata.slackTeamId,
2060
+ method,
2061
+ Math.max(1, Math.ceil((error.retryAfterMs ?? 60_000) / 1_000)),
2062
+ );
2063
+ }
1400
2064
  if (slackCredentialRejected(error)) {
1401
2065
  await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
1402
2066
  id: this.connection.id,
@@ -1410,11 +2074,10 @@ export class OpenGeniSlackBotClient {
1410
2074
 
1411
2075
  private async withAudit<T extends Record<string, unknown>>(
1412
2076
  operation: SlackBotOperation,
1413
- run: (headers: Record<string, string>) => Promise<T>,
2077
+ run: (authority: SlackCallAuthority) => Promise<T>,
1414
2078
  ): Promise<T & { receipt: SlackBotReceipt }> {
1415
2079
  try {
1416
- const headers = await this.headersFor(operation);
1417
- const result = await run(headers);
2080
+ const result = await run(await this.headersFor(operation));
1418
2081
  await this.recordAudit(operation, "succeeded");
1419
2082
  return { ...result, receipt: this.receipt(operation) };
1420
2083
  } catch (error) {
@@ -1423,16 +2086,14 @@ export class OpenGeniSlackBotClient {
1423
2086
  }
1424
2087
  }
1425
2088
 
1426
- private async headersFor(operation: SlackBotOperation): Promise<Record<string, string>> {
1427
- return await this.headersForDestination(
1428
- operation,
1429
- `${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
1430
- );
2089
+ private async headersFor(operation: SlackBotOperation): Promise<SlackCallAuthority> {
2090
+ return Object.freeze({ operation });
1431
2091
  }
1432
2092
 
1433
2093
  private async headersForDestination(
1434
2094
  operation: SlackBotOperation,
1435
2095
  destinationUrl: string,
2096
+ credentialResolutionMode: "execution" | "preflight" = "execution",
1436
2097
  ): Promise<Record<string, string>> {
1437
2098
  const result = await this.resolveCredential({
1438
2099
  workspaceId: this.context.workspaceId,
@@ -1442,13 +2103,49 @@ export class OpenGeniSlackBotClient {
1442
2103
  connectionId: this.connection.id,
1443
2104
  providerDomain: "slack.com",
1444
2105
  kind: "app_install",
1445
- scopes: [...OPENGENI_SLACK_BOT_REQUIRED_SCOPES],
2106
+ scopes: [
2107
+ ...OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
2108
+ ...(operation === "file.upload" ? [OPENGENI_SLACK_FILE_UPLOAD_REQUIRED_SCOPE] : []),
2109
+ ],
1446
2110
  subjectScope: "workspace",
1447
2111
  },
1448
2112
  destinationUrl,
2113
+ credentialResolutionMode,
1449
2114
  });
1450
2115
  if (result.status !== "ok" || result.connectionId !== this.connection.id) {
1451
- throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
2116
+ throw new Error("Opengeni Slack bot connection needs to be reinstalled");
2117
+ }
2118
+ const current = await requireOpenGeniSlackBotConnection(
2119
+ this.db,
2120
+ this.context.workspaceId,
2121
+ this.connection.id,
2122
+ );
2123
+ const currentMetadata = openGeniSlackBotMetadata(current.metadata);
2124
+ if (operation === "file.upload" && !hasOpenGeniSlackFileUploadScope(current.grantedScopes)) {
2125
+ throw new SlackBotProviderError("slack_bot_file_upload_scope_missing");
2126
+ }
2127
+ if (
2128
+ current.accountId !== this.context.accountId ||
2129
+ current.version !== this.connection.version ||
2130
+ result.connectionVersion !== this.connection.version ||
2131
+ !currentMetadata ||
2132
+ currentMetadata.slackTeamId !== this.metadata.slackTeamId ||
2133
+ currentMetadata.botId !== this.metadata.botId ||
2134
+ currentMetadata.botUserId !== this.metadata.botUserId
2135
+ ) {
2136
+ throw new Error("Opengeni Slack bot connection authority changed");
2137
+ }
2138
+ // The adapter callback is a fallible preflight. The canonical credential
2139
+ // callback must remain the final await before the physical fetch.
2140
+ if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
2141
+ throw new Error("Opengeni Slack bot provider request is no longer authorized");
2142
+ }
2143
+ if (
2144
+ credentialResolutionMode === "execution" &&
2145
+ result.authorizeProviderRequest &&
2146
+ !(await result.authorizeProviderRequest())
2147
+ ) {
2148
+ throw new Error("Opengeni Slack bot provider request is no longer authorized");
1452
2149
  }
1453
2150
  return result.headers;
1454
2151
  }
@@ -1456,8 +2153,9 @@ export class OpenGeniSlackBotClient {
1456
2153
  private async fetchPrivateFile(
1457
2154
  url: URL,
1458
2155
  operation: "file.info" | "file.content.read",
2156
+ authorizeBeforeFetch?: () => Promise<void>,
1459
2157
  ): Promise<Response> {
1460
- const response = await this.fetchPrivateFileOnce(url, operation);
2158
+ const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch);
1461
2159
  if (response.status < 300 || response.status >= 400) {
1462
2160
  return response;
1463
2161
  }
@@ -1478,18 +2176,20 @@ export class OpenGeniSlackBotClient {
1478
2176
  } catch {
1479
2177
  throw new SlackBotProviderError("invalid_file_redirect");
1480
2178
  }
1481
- return await this.fetchPrivateFileOnce(redirected, operation);
2179
+ return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch);
1482
2180
  }
1483
2181
 
1484
2182
  private async fetchPrivateFileOnce(
1485
2183
  url: URL,
1486
2184
  operation: "file.info" | "file.content.read",
2185
+ authorizeBeforeFetch?: () => Promise<void>,
1487
2186
  ): Promise<Response> {
1488
2187
  try {
1489
2188
  assertPrivateSlackFileUrl(url);
1490
2189
  } catch {
1491
2190
  throw new SlackBotProviderError("invalid_file_url");
1492
2191
  }
2192
+ await authorizeBeforeFetch?.();
1493
2193
  const headers = await this.headersForDestination(operation, url.toString());
1494
2194
  let response: Response;
1495
2195
  try {
@@ -1497,7 +2197,8 @@ export class OpenGeniSlackBotClient {
1497
2197
  method: "GET",
1498
2198
  headers: {
1499
2199
  ...headers,
1500
- accept: "text/*, application/json, application/xml, application/xhtml+xml",
2200
+ accept:
2201
+ "image/png, image/jpeg, image/webp, text/*, application/json, application/xml, application/xhtml+xml",
1501
2202
  },
1502
2203
  redirect: "manual",
1503
2204
  signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
@@ -1700,8 +2401,22 @@ export class OpenGeniSlackBotClient {
1700
2401
  }
1701
2402
  }
1702
2403
 
2404
+ /** Full decode guards the model image block against header-only or corrupt files. */
2405
+ export async function validateSlackMcpImage(bytes: Uint8Array): Promise<void> {
2406
+ try {
2407
+ await sharp(bytes, { limitInputPixels: 16_000_000, failOn: "error" }).stats();
2408
+ } catch {
2409
+ throw new SlackBotProviderError("invalid_file_content");
2410
+ }
2411
+ }
2412
+
1703
2413
  export function createOpenGeniSlackBotClient(
1704
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
2414
+ deps: {
2415
+ db: Database;
2416
+ settings: Settings;
2417
+ slackFetch?: typeof fetch;
2418
+ authorizeProviderRequest?: SlackProviderAuthorization;
2419
+ },
1705
2420
  resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
1706
2421
  ): OpenGeniSlackBotClient {
1707
2422
  return new OpenGeniSlackBotClient(
@@ -1711,11 +2426,17 @@ export function createOpenGeniSlackBotClient(
1711
2426
  resolved.metadata,
1712
2427
  resolved.context,
1713
2428
  deps.slackFetch,
2429
+ deps.authorizeProviderRequest,
1714
2430
  );
1715
2431
  }
1716
2432
 
1717
2433
  export async function createOpenGeniSlackBotInteractionClient(
1718
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
2434
+ deps: {
2435
+ db: Database;
2436
+ settings: Settings;
2437
+ slackFetch?: typeof fetch;
2438
+ authorizeProviderRequest?: SlackProviderAuthorization;
2439
+ },
1719
2440
  input: {
1720
2441
  accountId: string;
1721
2442
  workspaceId: string;
@@ -1730,10 +2451,10 @@ export async function createOpenGeniSlackBotInteractionClient(
1730
2451
  input.connectionId,
1731
2452
  );
1732
2453
  if (connection.accountId !== input.accountId) {
1733
- throw new Error("OpenGeni Slack bot connection tenant mismatch");
2454
+ throw new Error("Opengeni Slack bot connection tenant mismatch");
1734
2455
  }
1735
2456
  const metadata = openGeniSlackBotMetadata(connection.metadata);
1736
- if (!metadata) throw new Error("OpenGeni Slack bot connection metadata is invalid");
2457
+ if (!metadata) throw new Error("Opengeni Slack bot connection metadata is invalid");
1737
2458
  return new OpenGeniSlackBotClient(
1738
2459
  deps.db,
1739
2460
  deps.settings,
@@ -1747,6 +2468,7 @@ export async function createOpenGeniSlackBotInteractionClient(
1747
2468
  scheduledTaskId: null,
1748
2469
  },
1749
2470
  deps.slackFetch,
2471
+ deps.authorizeProviderRequest,
1750
2472
  );
1751
2473
  }
1752
2474
 
@@ -1770,7 +2492,7 @@ async function slackApiFetchWithHeaders(
1770
2492
  credentialHeaders: Record<string, string>,
1771
2493
  params: Record<string, string>,
1772
2494
  ): Promise<{ response: Response; payload: SlackPayload }> {
1773
- if (!/^[a-z]+\.[a-z]+$/i.test(method)) {
2495
+ if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) {
1774
2496
  throw new Error("invalid Slack API method");
1775
2497
  }
1776
2498
  const url = new URL(method, SLACK_API_BASE);
@@ -1785,6 +2507,7 @@ async function slackApiFetchWithHeaders(
1785
2507
  "content-type": "application/x-www-form-urlencoded",
1786
2508
  },
1787
2509
  body,
2510
+ redirect: "error",
1788
2511
  signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
1789
2512
  });
1790
2513
  } catch {
@@ -1829,7 +2552,7 @@ function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
1829
2552
  ];
1830
2553
  throw new SlackBotCredentialVerificationError(
1831
2554
  "scope_mismatch",
1832
- `Slack bot scopes do not satisfy the OpenGeni manifest (${facts.join("; ")})`,
2555
+ `Slack bot scopes do not satisfy the Opengeni manifest (${facts.join("; ")})`,
1833
2556
  );
1834
2557
  }
1835
2558
  }
@@ -1862,6 +2585,7 @@ function projectChannel(value: unknown) {
1862
2585
  isMember: channel.is_member === true,
1863
2586
  isDirectMessage: channel.is_im === true,
1864
2587
  isMpim: channel.is_mpim === true,
2588
+ userId: nullableBoundedSlackString(channel.user, 128),
1865
2589
  isArchived: channel.is_archived === true,
1866
2590
  isShared: channel.is_shared === true,
1867
2591
  isExternallyShared: channel.is_ext_shared === true,
@@ -1879,6 +2603,16 @@ function projectChannel(value: unknown) {
1879
2603
  };
1880
2604
  }
1881
2605
 
2606
+ /** A private route needs live provider proof of the exact linked requester's bot IM. */
2607
+ export function assertSlackPrivateTaskRecipient(
2608
+ channel: { isDirectMessage: boolean; isMpim: boolean; userId: string | null },
2609
+ slackUserId: string,
2610
+ ): void {
2611
+ if (!channel.isDirectMessage || channel.isMpim || channel.userId !== slackUserId) {
2612
+ throw new SlackBotProviderError("private_task_recipient_changed");
2613
+ }
2614
+ }
2615
+
1882
2616
  function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) {
1883
2617
  const user = slackRecord(value);
1884
2618
  const id = slackString(user?.id);
@@ -1952,39 +2686,6 @@ function assertSlackReactionCheckpointBinding(
1952
2686
  }
1953
2687
  }
1954
2688
 
1955
- function createSlackReactionContextCheckpoint(
1956
- binding: SlackReactionContextCheckpointBinding,
1957
- state: SlackReactionContextCheckpointUnsigned["state"],
1958
- key: Uint8Array,
1959
- ): SlackReactionContextCheckpoint {
1960
- const unsigned: SlackReactionContextCheckpointUnsigned = {
1961
- version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION,
1962
- binding: { ...binding },
1963
- state: {
1964
- createdAtMs: state.createdAtMs,
1965
- pageCount: state.pageCount,
1966
- nextCursor: state.nextCursor,
1967
- seenCursors: [...state.seenCursors],
1968
- seenMessageTimestamps: [...state.seenMessageTimestamps],
1969
- threadTimestamp: state.threadTimestamp,
1970
- messages: state.messages.map((message) => ({
1971
- ...message,
1972
- files: message.files.map((file) => ({ ...file })),
1973
- })),
1974
- },
1975
- };
1976
- const checkpoint: SlackReactionContextCheckpoint = {
1977
- ...unsigned,
1978
- signature: slackReactionContextCheckpointSignature(unsigned, key),
1979
- };
1980
- if (
1981
- Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES
1982
- ) {
1983
- throw new SlackBotProviderError("reaction_checkpoint_too_large");
1984
- }
1985
- return checkpoint;
1986
- }
1987
-
1988
2689
  function parseSlackReactionContextCheckpoint(
1989
2690
  value: unknown,
1990
2691
  expectedBinding: SlackReactionContextCheckpointBinding,
@@ -2203,64 +2904,6 @@ function parseSlackReactionCheckpointMessage(value: unknown): SlackReactionCheck
2203
2904
  };
2204
2905
  }
2205
2906
 
2206
- function slackReactionCheckpointMessage(
2207
- message: ReturnType<typeof projectMessage>,
2208
- ): SlackReactionCheckpointMessage {
2209
- const files: SlackReactionCheckpointMessage["files"] = [];
2210
- let fileLabelChars = 0;
2211
- for (const file of message.files) {
2212
- const label = file.title || file.name || file.id;
2213
- if (!label) continue;
2214
- const addedChars = label.length + (files.length > 0 ? 2 : 0);
2215
- if (
2216
- files.length >= MAX_REACTION_CONTEXT_CHECKPOINT_FILES ||
2217
- fileLabelChars + addedChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS
2218
- ) {
2219
- break;
2220
- }
2221
- files.push({ id: file.id, label });
2222
- fileLabelChars += addedChars;
2223
- }
2224
- return {
2225
- timestamp: message.timestamp,
2226
- userId: message.userId,
2227
- botId: message.botId,
2228
- threadTimestamp: message.threadTimestamp,
2229
- text: message.text,
2230
- files,
2231
- };
2232
- }
2233
-
2234
- function projectSlackReactionCheckpointMessage(
2235
- message: SlackReactionCheckpointMessage,
2236
- ): ReturnType<typeof projectMessage> {
2237
- return {
2238
- timestamp: message.timestamp,
2239
- userId: message.userId,
2240
- botId: message.botId,
2241
- threadTimestamp: message.threadTimestamp,
2242
- text: message.text,
2243
- files: message.files.map((file) => ({
2244
- id: file.id,
2245
- name: "",
2246
- title: file.label,
2247
- mimetype: "",
2248
- filetype: "",
2249
- mode: "",
2250
- size: null,
2251
- originatingHuddleId: "",
2252
- huddleTranscriptFileId: "",
2253
- })),
2254
- };
2255
- }
2256
-
2257
- function selectSlackReactionCheckpointMessages(
2258
- messages: ReturnType<typeof projectMessage>[],
2259
- ): ReturnType<typeof projectMessage>[] {
2260
- if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return [...messages];
2261
- return [messages[0]!, ...messages.slice(-(MAX_REACTION_CONTEXT_MESSAGES - 1))];
2262
- }
2263
-
2264
2907
  function hasExactSlackCheckpointKeys(value: Record<string, unknown>, expected: string[]): boolean {
2265
2908
  return Object.keys(value).sort().join(",") === [...expected].sort().join(",");
2266
2909
  }
@@ -2576,6 +3219,74 @@ function embeddedSlackFileIds(content: string): Set<string> {
2576
3219
  );
2577
3220
  }
2578
3221
 
3222
+ /**
3223
+ * Bounded projections of `assistant.search.context` results. Content excerpts
3224
+ * are capped, Slack's rich `blocks` and context messages are deliberately
3225
+ * dropped, and permalinks are kept only when they are https URLs.
3226
+ */
3227
+ function projectSearchMessage(value: unknown) {
3228
+ const message = slackRecord(value);
3229
+ const channelId = slackString(message?.channel_id);
3230
+ const ts = slackString(message?.message_ts);
3231
+ if (!message || !channelId || !ts) return null;
3232
+ return {
3233
+ channelId,
3234
+ channelName: boundedSlackString(message.channel_name, 256),
3235
+ ts,
3236
+ authorUserId: boundedSlackString(message.author_user_id, 64),
3237
+ authorName: boundedSlackString(message.author_name, 256),
3238
+ isAuthorBot: message.is_author_bot === true,
3239
+ content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS),
3240
+ permalink: safeSlackPermalink(message.permalink),
3241
+ };
3242
+ }
3243
+
3244
+ function projectSearchFile(value: unknown) {
3245
+ const file = slackRecord(value);
3246
+ const id = slackString(file?.file_id);
3247
+ if (!file || !id) return null;
3248
+ return {
3249
+ id,
3250
+ title: boundedSlackString(file.title, 512),
3251
+ filetype: boundedSlackString(file.file_type, 128),
3252
+ authorUserId: boundedSlackString(file.author_user_id, 64),
3253
+ authorName: boundedSlackString(file.author_name, 256),
3254
+ dateCreated: slackUnixSeconds(file.date_created),
3255
+ dateUpdated: slackUnixSeconds(file.date_updated),
3256
+ content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS),
3257
+ permalink: safeSlackPermalink(file.permalink),
3258
+ };
3259
+ }
3260
+
3261
+ function projectSearchChannel(value: unknown) {
3262
+ const channel = slackRecord(value);
3263
+ const name = slackString(channel?.name);
3264
+ if (!channel || !name) return null;
3265
+ return {
3266
+ name: boundedSlackString(name, 256),
3267
+ topic: boundedSlackString(channel.topic, 512),
3268
+ purpose: boundedSlackString(channel.purpose, 512),
3269
+ creatorUserId: boundedSlackString(channel.creator_user_id, 64),
3270
+ dateCreated: slackUnixSeconds(channel.date_created),
3271
+ permalink: safeSlackPermalink(channel.permalink),
3272
+ };
3273
+ }
3274
+
3275
+ function slackUnixSeconds(value: unknown): number | null {
3276
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
3277
+ }
3278
+
3279
+ function safeSlackPermalink(value: unknown): string | null {
3280
+ // An over-long URL is dropped, never truncated into a still-parseable one.
3281
+ const raw = typeof value === "string" && value.length <= 1_024 ? value : "";
3282
+ if (!raw) return null;
3283
+ try {
3284
+ return new URL(raw).protocol === "https:" ? raw : null;
3285
+ } catch {
3286
+ return null;
3287
+ }
3288
+ }
3289
+
2579
3290
  function projectUser(value: unknown) {
2580
3291
  const user = slackRecord(value);
2581
3292
  const id = slackString(user?.id);
@@ -2701,28 +3412,31 @@ export function nextSlackFilesListPage(
2701
3412
  return nextPage;
2702
3413
  }
2703
3414
 
2704
- function slackMethodForOperation(operation: SlackBotOperation): string {
2705
- switch (operation) {
2706
- case "channels.list":
2707
- return "conversations.list";
2708
- case "channel_history.read":
2709
- return "conversations.history";
2710
- case "thread_replies.read":
2711
- return "conversations.replies";
2712
- case "users.list":
2713
- return "users.list";
2714
- case "files.list":
2715
- return "files.list";
2716
- case "file.info":
2717
- case "file.content.read":
2718
- return "files.info";
2719
- case "message.post":
2720
- return "chat.postMessage";
2721
- case "message.update":
2722
- return "chat.update";
2723
- case "message.delete":
2724
- return "chat.delete";
2725
- }
3415
+ /**
3416
+ * Slack action IDs are unique within a block. Keep the logical card untouched
3417
+ * for the durable request digest, and partition repeated actions only on the
3418
+ * provider wire. In particular, a legacy invalid_blocks receipt can retry with
3419
+ * its original operation ID and digest; completed/uncertain posts keep their
3420
+ * existing replay/reconciliation path. Values and action IDs remain exact.
3421
+ */
3422
+ export function slackMessageWireBlocks(
3423
+ blocks: SlackMessageBlock[] | undefined,
3424
+ ): SlackMessageBlock[] | undefined {
3425
+ if (!blocks) return undefined;
3426
+ const wire = blocks.flatMap((block, index): SlackMessageBlock[] => {
3427
+ if (
3428
+ block.type !== "actions" ||
3429
+ new Set(block.elements.map((e) => e.action_id)).size === block.elements.length
3430
+ )
3431
+ return [block];
3432
+ const id = createHash("sha256").update(`${index}:${block.block_id}`).digest("hex").slice(0, 24);
3433
+ return block.elements.map((element, position) => ({
3434
+ type: "actions",
3435
+ block_id: `opengeni_wire_${id}_${position}`,
3436
+ elements: [element],
3437
+ }));
3438
+ });
3439
+ return validateSlackMessageBlocks(wire);
2726
3440
  }
2727
3441
 
2728
3442
  function validateSlackMessageBlocks(
@@ -2738,6 +3452,16 @@ function validateSlackMessageBlocks(
2738
3452
  return blocks;
2739
3453
  }
2740
3454
 
3455
+ function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] {
3456
+ if (blocks.length < 1 || blocks.length > 100) {
3457
+ throw new RangeError("Slack App Home blocks exceed the supported count");
3458
+ }
3459
+ if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) {
3460
+ throw new RangeError("Slack App Home blocks exceed the supported byte size");
3461
+ }
3462
+ return blocks;
3463
+ }
3464
+
2741
3465
  function boundedInt(value: number | undefined, max: number, fallback: number): number {
2742
3466
  return typeof value === "number" && Number.isInteger(value) && value > 0
2743
3467
  ? Math.min(value, max)