@opengeni/api-router 0.30.3 → 2.1.0-canary.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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-QKDFBBUE.js} +26385 -15573
  5. package/dist/chunk-QKDFBBUE.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +149 -9
  32. package/dist/integrations/slack-interactions.d.ts +4 -3
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +153 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +337 -57
  102. package/src/integrations/slack-interactions.ts +1265 -208
  103. package/src/integrations/slack-routing.ts +324 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +221 -3
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -5,8 +5,10 @@ import {
5
5
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
6
6
  OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
7
7
  evaluateOpenGeniSlackBotScopes,
8
+ hasOpenGeniSlackBotSearchScopes,
8
9
  type AccessGrant,
9
10
  type ConnectionMetadata,
11
+ type OpenGeniSlackBotDisplayName,
10
12
  type OpenGeniSlackBotConnectionMetadata,
11
13
  } from "@opengeni/contracts";
12
14
  import {
@@ -64,6 +66,12 @@ const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500;
64
66
  const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16;
65
67
  const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1;
66
68
  const MAX_USER_PAGE = 200;
69
+ /** Slack caps `assistant.search.context` at 20 results per request. */
70
+ const MAX_SEARCH_PAGE = 20;
71
+ /** Bounded projection cap for one search result's content excerpt. */
72
+ const MAX_SEARCH_CONTENT_CHARS = 2_000;
73
+ /** Matches the MCP input schema's cap; Slack signals its own via query_too_long. */
74
+ const MAX_SEARCH_QUERY_CHARS = 500;
67
75
  const MAX_FILE_PAGE = 200;
68
76
  const MAX_FILE_CURSOR_LENGTH = 1_024;
69
77
  const SLACK_FILE_CURSOR_VERSION = "files-v1";
@@ -106,6 +114,11 @@ export type DownloadedSlackReactionImage = Readonly<{
106
114
  bytes: Uint8Array;
107
115
  }>;
108
116
 
117
+ export type ExchangedOpenGeniSlackAuthorization = Readonly<{
118
+ accessToken: string;
119
+ appId: string;
120
+ }>;
121
+
109
122
  export async function exchangeOpenGeniSlackAuthorizationCode(
110
123
  input: {
111
124
  code: string;
@@ -114,7 +127,7 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
114
127
  redirectUri: string;
115
128
  },
116
129
  fetchImpl: FetchLike = fetch,
117
- ): Promise<string> {
130
+ ): Promise<ExchangedOpenGeniSlackAuthorization> {
118
131
  const body = new URLSearchParams({
119
132
  code: input.code,
120
133
  client_id: input.clientId,
@@ -158,7 +171,13 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
158
171
  message: "Slack installation did not return a bot token",
159
172
  });
160
173
  }
161
- return accessToken;
174
+ const appId = slackString(record.app_id);
175
+ if (!appId) {
176
+ throw new HTTPException(502, {
177
+ message: "Slack installation did not return an app identity",
178
+ });
179
+ }
180
+ return { accessToken, appId };
162
181
  }
163
182
 
164
183
  export type SlackBotReceipt = {
@@ -190,14 +209,51 @@ export type SlackMessageBlock =
190
209
  }
191
210
  | { type: "divider" };
192
211
 
212
+ export type SlackHomeBlock =
213
+ | {
214
+ type: "header";
215
+ block_id?: string;
216
+ text: { type: "plain_text"; text: string; emoji?: boolean };
217
+ }
218
+ | {
219
+ type: "section";
220
+ block_id?: string;
221
+ text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
222
+ accessory?: {
223
+ type: "button";
224
+ action_id: string;
225
+ text: { type: "plain_text"; text: string; emoji?: boolean };
226
+ url: string;
227
+ };
228
+ }
229
+ | {
230
+ type: "context";
231
+ block_id?: string;
232
+ elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
233
+ }
234
+ | {
235
+ type: "actions";
236
+ block_id: string;
237
+ elements: Array<{
238
+ type: "button";
239
+ action_id: string;
240
+ text: { type: "plain_text"; text: string; emoji?: boolean };
241
+ url: string;
242
+ style?: "primary" | "danger";
243
+ }>;
244
+ }
245
+ | { type: "divider" };
246
+
193
247
  type SlackBotOperation =
194
248
  | "channels.list"
249
+ | "search.context"
195
250
  | "channel_history.read"
196
251
  | "thread_replies.read"
197
252
  | "users.list"
198
253
  | "files.list"
199
254
  | "file.info"
200
255
  | "file.content.read"
256
+ | "home.publish"
201
257
  | "message.post"
202
258
  | "message.update"
203
259
  | "message.delete";
@@ -210,6 +266,9 @@ type SlackBotContext = {
210
266
  scheduledTaskId?: string | null;
211
267
  };
212
268
 
269
+ type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>;
270
+ type SlackProviderAuthorization = () => Promise<boolean | void>;
271
+
213
272
  export type SlackReactionContextCheckpointBinding = {
214
273
  inboxId: string;
215
274
  accountId: string;
@@ -259,6 +318,35 @@ export class SlackBotProviderError extends Error {
259
318
  }
260
319
  }
261
320
 
321
+ export async function authorizeSlackSharedImageRead(
322
+ channel: {
323
+ isArchived: boolean;
324
+ isShared: boolean;
325
+ isExternallyShared: boolean;
326
+ isOrgShared: boolean;
327
+ isPendingExternallyShared: boolean;
328
+ isMpim: boolean;
329
+ },
330
+ authorizeSharedRead: (() => Promise<void>) | undefined,
331
+ ): Promise<void> {
332
+ if (channel.isArchived) {
333
+ throw new SlackBotProviderError("is_archived");
334
+ }
335
+ if (
336
+ !channel.isShared &&
337
+ !channel.isExternallyShared &&
338
+ !channel.isOrgShared &&
339
+ !channel.isPendingExternallyShared &&
340
+ !channel.isMpim
341
+ ) {
342
+ throw new SlackBotProviderError("slack_connect_unsupported");
343
+ }
344
+ if (!authorizeSharedRead) {
345
+ throw new SlackBotProviderError("slack_connect_unsupported");
346
+ }
347
+ await authorizeSharedRead();
348
+ }
349
+
262
350
  const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
263
351
  "account_inactive",
264
352
  "invalid_auth",
@@ -285,12 +373,16 @@ export class SlackBotCredentialVerificationError extends HTTPException {
285
373
 
286
374
  /**
287
375
  * 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.
376
+ * The OAuth exchange's app_id is the immutable app authority. Slack bot profile
377
+ * names are mutable presentation data and may lag manifest/App Home changes, so
378
+ * they must not decide whether a credential belongs to this installation.
291
379
  */
292
380
  export async function verifyOpenGeniSlackBotCredential(
293
381
  token: string,
382
+ expected: Readonly<{
383
+ appId: string;
384
+ displayName: OpenGeniSlackBotDisplayName;
385
+ }>,
294
386
  fetchImpl: FetchLike = fetch,
295
387
  now: Date = new Date(),
296
388
  ): Promise<VerifiedOpenGeniSlackBot> {
@@ -314,11 +406,11 @@ export async function verifyOpenGeniSlackBotCredential(
314
406
  );
315
407
  }
316
408
  const profile = slackRecord(user.profile);
317
- const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
318
- if (displayName !== "OpenGeni") {
409
+ const installedAppId = slackString(profile?.api_app_id);
410
+ if (installedAppId !== expected.appId) {
319
411
  throw new SlackBotCredentialVerificationError(
320
412
  "identity_mismatch",
321
- 'Slack bot display name must be exactly "OpenGeni"',
413
+ "Slack credential does not belong to the authorized Slack app",
322
414
  );
323
415
  }
324
416
 
@@ -331,7 +423,7 @@ export async function verifyOpenGeniSlackBotCredential(
331
423
  slackTeamName,
332
424
  botUserId,
333
425
  botId,
334
- botDisplayName: "OpenGeni",
426
+ botDisplayName: expected.displayName,
335
427
  verifiedAt: now.toISOString(),
336
428
  },
337
429
  };
@@ -427,6 +519,7 @@ export class OpenGeniSlackBotClient {
427
519
  private readonly metadata: OpenGeniSlackBotConnectionMetadata,
428
520
  private readonly context: SlackBotContext,
429
521
  private readonly fetchImpl: FetchLike = fetch,
522
+ private readonly authorizeProviderRequest?: SlackProviderAuthorization,
430
523
  ) {
431
524
  this.resolveCredential = buildConnectionTokenResolver(db, settings);
432
525
  }
@@ -453,6 +546,27 @@ export class OpenGeniSlackBotClient {
453
546
  return await this.requireMemberChannel(headers, channelId);
454
547
  }
455
548
 
549
+ /**
550
+ * Replace one Slack user's private App Home view. `views.publish` is a
551
+ * naturally convergent replace operation: Slack event retries may safely
552
+ * repeat the exact bounded view without creating duplicate provider state.
553
+ */
554
+ async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) {
555
+ const userId = requiredSlackString(input.userId, "user_id");
556
+ const blocks = validateSlackHomeBlocks(input.blocks);
557
+ const hash = input.hash ? requiredSlackString(input.hash, "hash") : null;
558
+ return await this.withAudit("home.publish", async (headers) => {
559
+ const payload = await this.call(headers, "views.publish", {
560
+ user_id: userId,
561
+ view: JSON.stringify({ type: "home", blocks }),
562
+ ...(hash ? { hash } : {}),
563
+ });
564
+ return {
565
+ viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"),
566
+ };
567
+ });
568
+ }
569
+
456
570
  async slackTaskPolicyFacts(channelId: string, userId: string) {
457
571
  return await this.withAudit("channel_history.read", async (headers) => {
458
572
  const conversation = await this.requireMemberChannel(headers, channelId);
@@ -649,11 +763,18 @@ export class OpenGeniSlackBotClient {
649
763
  async prepareReactionImageDownloads(input: {
650
764
  channelId: string;
651
765
  files: readonly { id: string; name: string; title: string }[];
766
+ authorizeSharedRead?: () => Promise<void>;
652
767
  }): Promise<PreparedSlackReactionImage[]> {
653
768
  const result = await this.withAudit("file.content.read", async (headers) => {
654
- await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
769
+ if (input.authorizeSharedRead) {
770
+ const channel = await this.requireMemberChannel(headers, input.channelId);
771
+ await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead);
772
+ } else {
773
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
774
+ }
655
775
  const prepared: PreparedSlackReactionImage[] = [];
656
776
  for (const candidate of input.files) {
777
+ await input.authorizeSharedRead?.();
657
778
  const payload = await this.call(headers, "files.info", { file: candidate.id });
658
779
  const fileRecord = slackRecord(payload.file);
659
780
  const file = projectFile(fileRecord);
@@ -688,6 +809,7 @@ export class OpenGeniSlackBotClient {
688
809
  /** Download and fully validate one previously authorized Slack image. */
689
810
  async downloadReactionImage(
690
811
  input: PreparedSlackReactionImage,
812
+ authorizeSharedRead?: () => Promise<void>,
691
813
  ): Promise<DownloadedSlackReactionImage> {
692
814
  return await this.withAudit("file.content.read", async () => {
693
815
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
@@ -700,7 +822,11 @@ export class OpenGeniSlackBotClient {
700
822
  ) {
701
823
  throw new SlackBotProviderError("invalid_file_size");
702
824
  }
703
- const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
825
+ const response = await this.fetchPrivateFile(
826
+ input.downloadUrl,
827
+ "file.content.read",
828
+ authorizeSharedRead,
829
+ );
704
830
  const responseContentType = normalizedContentType(response.headers.get("content-type"));
705
831
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
706
832
  await response.body?.cancel().catch(() => undefined);
@@ -748,6 +874,69 @@ export class OpenGeniSlackBotClient {
748
874
  });
749
875
  }
750
876
 
877
+ /**
878
+ * Workspace-wide public search through Slack's Real-time Search API
879
+ * (`assistant.search.context`) under the bot identity.
880
+ *
881
+ * The bot never carries private-search authority: `channel_types` is pinned
882
+ * to `public_channel` server-side regardless of caller input, so private
883
+ * channels, DMs, and MPIMs remain reachable only through a member's personal
884
+ * hosted-MCP grant. An install predating the search scopes fails closed with
885
+ * a reinstall hint instead of leaking Slack's `missing_scope` error.
886
+ */
887
+ async searchContext(input: {
888
+ query: string;
889
+ contentTypes?: readonly ("messages" | "files" | "channels")[];
890
+ includeBots?: boolean;
891
+ /** UNIX seconds bounds on message timestamps. */
892
+ before?: number;
893
+ after?: number;
894
+ sort?: "score" | "timestamp";
895
+ sortDir?: "asc" | "desc";
896
+ cursor?: string;
897
+ limit?: number;
898
+ }) {
899
+ const query = typeof input.query === "string" ? input.query.trim() : "";
900
+ if (!query || query.length > MAX_SEARCH_QUERY_CHARS) {
901
+ throw new SlackBotProviderError("invalid_query");
902
+ }
903
+ const contentTypes = input.contentTypes?.length
904
+ ? [...new Set(input.contentTypes)]
905
+ : ["messages"];
906
+ return await this.withAudit("search.context", async (headers) => {
907
+ // Inside the audit boundary so a fail-closed denial on a legacy install
908
+ // leaves the same `failed` evidence as a provider rejection.
909
+ if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) {
910
+ throw new SlackBotProviderError("slack_bot_search_scopes_missing");
911
+ }
912
+ const payload = await this.call(headers, "assistant.search.context", {
913
+ query,
914
+ channel_types: "public_channel",
915
+ content_types: contentTypes.join(","),
916
+ ...(input.includeBots ? { include_bots: "true" } : {}),
917
+ ...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}),
918
+ ...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}),
919
+ ...(input.sort ? { sort: input.sort } : {}),
920
+ ...(input.sortDir ? { sort_dir: input.sortDir } : {}),
921
+ ...(input.cursor ? { cursor: input.cursor } : {}),
922
+ limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)),
923
+ });
924
+ const results = slackRecord(payload.results);
925
+ return {
926
+ messages: slackArray(results?.messages)
927
+ .map(projectSearchMessage)
928
+ .filter((message): message is NonNullable<typeof message> => message !== null),
929
+ files: slackArray(results?.files)
930
+ .map(projectSearchFile)
931
+ .filter((file): file is NonNullable<typeof file> => file !== null),
932
+ channels: slackArray(results?.channels)
933
+ .map(projectSearchChannel)
934
+ .filter((channel): channel is NonNullable<typeof channel> => channel !== null),
935
+ nextCursor: responseCursor(payload),
936
+ };
937
+ });
938
+ }
939
+
751
940
  async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
752
941
  return await this.withAudit("files.list", async (headers) => {
753
942
  const requestedPage = this.fileListPage(input);
@@ -1211,10 +1400,7 @@ export class OpenGeniSlackBotClient {
1211
1400
  }
1212
1401
 
1213
1402
  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
- );
1403
+ const headers = await this.headersFor("message.delete");
1218
1404
  try {
1219
1405
  await this.call(headers, "chat.getPermalink", {
1220
1406
  channel: channelId,
@@ -1236,7 +1422,7 @@ export class OpenGeniSlackBotClient {
1236
1422
  text: string;
1237
1423
  }): Promise<{ timestamp: string }> {
1238
1424
  const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
1239
- const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
1425
+ const headers = await this.headersFor("message.post");
1240
1426
  const seenCursors = new Set<string>();
1241
1427
  let cursor: string | null = null;
1242
1428
  let matchedTimestamp: string | null = null;
@@ -1284,7 +1470,7 @@ export class OpenGeniSlackBotClient {
1284
1470
  return { timestamp: matchedTimestamp };
1285
1471
  }
1286
1472
 
1287
- private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
1473
+ private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) {
1288
1474
  const payload = await this.call(headers, "conversations.info", {
1289
1475
  channel: channelId,
1290
1476
  });
@@ -1299,7 +1485,7 @@ export class OpenGeniSlackBotClient {
1299
1485
  }
1300
1486
 
1301
1487
  private async requireActiveNonSharedMemberChannel(
1302
- headers: Record<string, string>,
1488
+ headers: SlackCallAuthority,
1303
1489
  channelId: string,
1304
1490
  ) {
1305
1491
  const projected = await this.requireMemberChannel(headers, channelId);
@@ -1313,7 +1499,7 @@ export class OpenGeniSlackBotClient {
1313
1499
  }
1314
1500
 
1315
1501
  private async requireFileForChannel(
1316
- headers: Record<string, string>,
1502
+ headers: SlackCallAuthority,
1317
1503
  operation: "file.info" | "file.content.read",
1318
1504
  input: { channelId: string; fileId: string; parentFileId?: string },
1319
1505
  ) {
@@ -1390,11 +1576,15 @@ export class OpenGeniSlackBotClient {
1390
1576
  }
1391
1577
 
1392
1578
  private async call(
1393
- headers: Record<string, string>,
1579
+ authority: SlackCallAuthority,
1394
1580
  method: string,
1395
1581
  params: Record<string, string>,
1396
1582
  ): Promise<SlackPayload> {
1397
1583
  try {
1584
+ const headers = await this.headersForDestination(
1585
+ authority.operation,
1586
+ `${SLACK_API_BASE}${method}`,
1587
+ );
1398
1588
  return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
1399
1589
  } catch (error) {
1400
1590
  if (slackCredentialRejected(error)) {
@@ -1410,11 +1600,10 @@ export class OpenGeniSlackBotClient {
1410
1600
 
1411
1601
  private async withAudit<T extends Record<string, unknown>>(
1412
1602
  operation: SlackBotOperation,
1413
- run: (headers: Record<string, string>) => Promise<T>,
1603
+ run: (authority: SlackCallAuthority) => Promise<T>,
1414
1604
  ): Promise<T & { receipt: SlackBotReceipt }> {
1415
1605
  try {
1416
- const headers = await this.headersFor(operation);
1417
- const result = await run(headers);
1606
+ const result = await run(await this.headersFor(operation));
1418
1607
  await this.recordAudit(operation, "succeeded");
1419
1608
  return { ...result, receipt: this.receipt(operation) };
1420
1609
  } catch (error) {
@@ -1423,11 +1612,8 @@ export class OpenGeniSlackBotClient {
1423
1612
  }
1424
1613
  }
1425
1614
 
1426
- private async headersFor(operation: SlackBotOperation): Promise<Record<string, string>> {
1427
- return await this.headersForDestination(
1428
- operation,
1429
- `${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
1430
- );
1615
+ private async headersFor(operation: SlackBotOperation): Promise<SlackCallAuthority> {
1616
+ return Object.freeze({ operation });
1431
1617
  }
1432
1618
 
1433
1619
  private async headersForDestination(
@@ -1450,14 +1636,40 @@ export class OpenGeniSlackBotClient {
1450
1636
  if (result.status !== "ok" || result.connectionId !== this.connection.id) {
1451
1637
  throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
1452
1638
  }
1639
+ const current = await requireOpenGeniSlackBotConnection(
1640
+ this.db,
1641
+ this.context.workspaceId,
1642
+ this.connection.id,
1643
+ );
1644
+ const currentMetadata = openGeniSlackBotMetadata(current.metadata);
1645
+ if (
1646
+ current.accountId !== this.context.accountId ||
1647
+ current.version !== this.connection.version ||
1648
+ result.connectionVersion !== this.connection.version ||
1649
+ !currentMetadata ||
1650
+ currentMetadata.slackTeamId !== this.metadata.slackTeamId ||
1651
+ currentMetadata.botId !== this.metadata.botId ||
1652
+ currentMetadata.botUserId !== this.metadata.botUserId
1653
+ ) {
1654
+ throw new Error("OpenGeni Slack bot connection authority changed");
1655
+ }
1656
+ // The adapter callback is a fallible preflight. The canonical credential
1657
+ // callback must remain the final await before the physical fetch.
1658
+ if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
1659
+ throw new Error("OpenGeni Slack bot provider request is no longer authorized");
1660
+ }
1661
+ if (result.authorizeProviderRequest && !(await result.authorizeProviderRequest())) {
1662
+ throw new Error("OpenGeni Slack bot provider request is no longer authorized");
1663
+ }
1453
1664
  return result.headers;
1454
1665
  }
1455
1666
 
1456
1667
  private async fetchPrivateFile(
1457
1668
  url: URL,
1458
1669
  operation: "file.info" | "file.content.read",
1670
+ authorizeBeforeFetch?: () => Promise<void>,
1459
1671
  ): Promise<Response> {
1460
- const response = await this.fetchPrivateFileOnce(url, operation);
1672
+ const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch);
1461
1673
  if (response.status < 300 || response.status >= 400) {
1462
1674
  return response;
1463
1675
  }
@@ -1478,18 +1690,20 @@ export class OpenGeniSlackBotClient {
1478
1690
  } catch {
1479
1691
  throw new SlackBotProviderError("invalid_file_redirect");
1480
1692
  }
1481
- return await this.fetchPrivateFileOnce(redirected, operation);
1693
+ return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch);
1482
1694
  }
1483
1695
 
1484
1696
  private async fetchPrivateFileOnce(
1485
1697
  url: URL,
1486
1698
  operation: "file.info" | "file.content.read",
1699
+ authorizeBeforeFetch?: () => Promise<void>,
1487
1700
  ): Promise<Response> {
1488
1701
  try {
1489
1702
  assertPrivateSlackFileUrl(url);
1490
1703
  } catch {
1491
1704
  throw new SlackBotProviderError("invalid_file_url");
1492
1705
  }
1706
+ await authorizeBeforeFetch?.();
1493
1707
  const headers = await this.headersForDestination(operation, url.toString());
1494
1708
  let response: Response;
1495
1709
  try {
@@ -1701,7 +1915,12 @@ export class OpenGeniSlackBotClient {
1701
1915
  }
1702
1916
 
1703
1917
  export function createOpenGeniSlackBotClient(
1704
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1918
+ deps: {
1919
+ db: Database;
1920
+ settings: Settings;
1921
+ slackFetch?: typeof fetch;
1922
+ authorizeProviderRequest?: SlackProviderAuthorization;
1923
+ },
1705
1924
  resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
1706
1925
  ): OpenGeniSlackBotClient {
1707
1926
  return new OpenGeniSlackBotClient(
@@ -1711,11 +1930,17 @@ export function createOpenGeniSlackBotClient(
1711
1930
  resolved.metadata,
1712
1931
  resolved.context,
1713
1932
  deps.slackFetch,
1933
+ deps.authorizeProviderRequest,
1714
1934
  );
1715
1935
  }
1716
1936
 
1717
1937
  export async function createOpenGeniSlackBotInteractionClient(
1718
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1938
+ deps: {
1939
+ db: Database;
1940
+ settings: Settings;
1941
+ slackFetch?: typeof fetch;
1942
+ authorizeProviderRequest?: SlackProviderAuthorization;
1943
+ },
1719
1944
  input: {
1720
1945
  accountId: string;
1721
1946
  workspaceId: string;
@@ -1747,6 +1972,7 @@ export async function createOpenGeniSlackBotInteractionClient(
1747
1972
  scheduledTaskId: null,
1748
1973
  },
1749
1974
  deps.slackFetch,
1975
+ deps.authorizeProviderRequest,
1750
1976
  );
1751
1977
  }
1752
1978
 
@@ -1770,7 +1996,7 @@ async function slackApiFetchWithHeaders(
1770
1996
  credentialHeaders: Record<string, string>,
1771
1997
  params: Record<string, string>,
1772
1998
  ): Promise<{ response: Response; payload: SlackPayload }> {
1773
- if (!/^[a-z]+\.[a-z]+$/i.test(method)) {
1999
+ if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) {
1774
2000
  throw new Error("invalid Slack API method");
1775
2001
  }
1776
2002
  const url = new URL(method, SLACK_API_BASE);
@@ -2576,6 +2802,74 @@ function embeddedSlackFileIds(content: string): Set<string> {
2576
2802
  );
2577
2803
  }
2578
2804
 
2805
+ /**
2806
+ * Bounded projections of `assistant.search.context` results. Content excerpts
2807
+ * are capped, Slack's rich `blocks` and context messages are deliberately
2808
+ * dropped, and permalinks are kept only when they are https URLs.
2809
+ */
2810
+ function projectSearchMessage(value: unknown) {
2811
+ const message = slackRecord(value);
2812
+ const channelId = slackString(message?.channel_id);
2813
+ const ts = slackString(message?.message_ts);
2814
+ if (!message || !channelId || !ts) return null;
2815
+ return {
2816
+ channelId,
2817
+ channelName: boundedSlackString(message.channel_name, 256),
2818
+ ts,
2819
+ authorUserId: boundedSlackString(message.author_user_id, 64),
2820
+ authorName: boundedSlackString(message.author_name, 256),
2821
+ isAuthorBot: message.is_author_bot === true,
2822
+ content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS),
2823
+ permalink: safeSlackPermalink(message.permalink),
2824
+ };
2825
+ }
2826
+
2827
+ function projectSearchFile(value: unknown) {
2828
+ const file = slackRecord(value);
2829
+ const id = slackString(file?.file_id);
2830
+ if (!file || !id) return null;
2831
+ return {
2832
+ id,
2833
+ title: boundedSlackString(file.title, 512),
2834
+ filetype: boundedSlackString(file.file_type, 128),
2835
+ authorUserId: boundedSlackString(file.author_user_id, 64),
2836
+ authorName: boundedSlackString(file.author_name, 256),
2837
+ dateCreated: slackUnixSeconds(file.date_created),
2838
+ dateUpdated: slackUnixSeconds(file.date_updated),
2839
+ content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS),
2840
+ permalink: safeSlackPermalink(file.permalink),
2841
+ };
2842
+ }
2843
+
2844
+ function projectSearchChannel(value: unknown) {
2845
+ const channel = slackRecord(value);
2846
+ const name = slackString(channel?.name);
2847
+ if (!channel || !name) return null;
2848
+ return {
2849
+ name: boundedSlackString(name, 256),
2850
+ topic: boundedSlackString(channel.topic, 512),
2851
+ purpose: boundedSlackString(channel.purpose, 512),
2852
+ creatorUserId: boundedSlackString(channel.creator_user_id, 64),
2853
+ dateCreated: slackUnixSeconds(channel.date_created),
2854
+ permalink: safeSlackPermalink(channel.permalink),
2855
+ };
2856
+ }
2857
+
2858
+ function slackUnixSeconds(value: unknown): number | null {
2859
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
2860
+ }
2861
+
2862
+ function safeSlackPermalink(value: unknown): string | null {
2863
+ // An over-long URL is dropped, never truncated into a still-parseable one.
2864
+ const raw = typeof value === "string" && value.length <= 1_024 ? value : "";
2865
+ if (!raw) return null;
2866
+ try {
2867
+ return new URL(raw).protocol === "https:" ? raw : null;
2868
+ } catch {
2869
+ return null;
2870
+ }
2871
+ }
2872
+
2579
2873
  function projectUser(value: unknown) {
2580
2874
  const user = slackRecord(value);
2581
2875
  const id = slackString(user?.id);
@@ -2701,30 +2995,6 @@ export function nextSlackFilesListPage(
2701
2995
  return nextPage;
2702
2996
  }
2703
2997
 
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
- }
2726
- }
2727
-
2728
2998
  function validateSlackMessageBlocks(
2729
2999
  blocks: SlackMessageBlock[] | undefined,
2730
3000
  ): SlackMessageBlock[] | undefined {
@@ -2738,6 +3008,16 @@ function validateSlackMessageBlocks(
2738
3008
  return blocks;
2739
3009
  }
2740
3010
 
3011
+ function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] {
3012
+ if (blocks.length < 1 || blocks.length > 100) {
3013
+ throw new RangeError("Slack App Home blocks exceed the supported count");
3014
+ }
3015
+ if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) {
3016
+ throw new RangeError("Slack App Home blocks exceed the supported byte size");
3017
+ }
3018
+ return blocks;
3019
+ }
3020
+
2741
3021
  function boundedInt(value: number | undefined, max: number, fallback: number): number {
2742
3022
  return typeof value === "number" && Number.isInteger(value) && value > 0
2743
3023
  ? Math.min(value, max)