@opengeni/api-router 0.30.3 → 2.2.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-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.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 +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  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 +161 -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 +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -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 +353 -4
  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 = {
@@ -188,16 +207,58 @@ export type SlackMessageBlock =
188
207
  style?: "primary" | "danger";
189
208
  }>;
190
209
  }
210
+ | {
211
+ type: "context";
212
+ block_id?: string;
213
+ elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
214
+ }
215
+ | { type: "divider" };
216
+
217
+ export type SlackHomeBlock =
218
+ | {
219
+ type: "header";
220
+ block_id?: string;
221
+ text: { type: "plain_text"; text: string; emoji?: boolean };
222
+ }
223
+ | {
224
+ type: "section";
225
+ block_id?: string;
226
+ text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
227
+ accessory?: {
228
+ type: "button";
229
+ action_id: string;
230
+ text: { type: "plain_text"; text: string; emoji?: boolean };
231
+ url: string;
232
+ };
233
+ }
234
+ | {
235
+ type: "context";
236
+ block_id?: string;
237
+ elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
238
+ }
239
+ | {
240
+ type: "actions";
241
+ block_id: string;
242
+ elements: Array<{
243
+ type: "button";
244
+ action_id: string;
245
+ text: { type: "plain_text"; text: string; emoji?: boolean };
246
+ url: string;
247
+ style?: "primary" | "danger";
248
+ }>;
249
+ }
191
250
  | { type: "divider" };
192
251
 
193
252
  type SlackBotOperation =
194
253
  | "channels.list"
254
+ | "search.context"
195
255
  | "channel_history.read"
196
256
  | "thread_replies.read"
197
257
  | "users.list"
198
258
  | "files.list"
199
259
  | "file.info"
200
260
  | "file.content.read"
261
+ | "home.publish"
201
262
  | "message.post"
202
263
  | "message.update"
203
264
  | "message.delete";
@@ -210,6 +271,9 @@ type SlackBotContext = {
210
271
  scheduledTaskId?: string | null;
211
272
  };
212
273
 
274
+ type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>;
275
+ type SlackProviderAuthorization = () => Promise<boolean | void>;
276
+
213
277
  export type SlackReactionContextCheckpointBinding = {
214
278
  inboxId: string;
215
279
  accountId: string;
@@ -259,6 +323,35 @@ export class SlackBotProviderError extends Error {
259
323
  }
260
324
  }
261
325
 
326
+ export async function authorizeSlackSharedImageRead(
327
+ channel: {
328
+ isArchived: boolean;
329
+ isShared: boolean;
330
+ isExternallyShared: boolean;
331
+ isOrgShared: boolean;
332
+ isPendingExternallyShared: boolean;
333
+ isMpim: boolean;
334
+ },
335
+ authorizeSharedRead: (() => Promise<void>) | undefined,
336
+ ): Promise<void> {
337
+ if (channel.isArchived) {
338
+ throw new SlackBotProviderError("is_archived");
339
+ }
340
+ if (
341
+ !channel.isShared &&
342
+ !channel.isExternallyShared &&
343
+ !channel.isOrgShared &&
344
+ !channel.isPendingExternallyShared &&
345
+ !channel.isMpim
346
+ ) {
347
+ throw new SlackBotProviderError("slack_connect_unsupported");
348
+ }
349
+ if (!authorizeSharedRead) {
350
+ throw new SlackBotProviderError("slack_connect_unsupported");
351
+ }
352
+ await authorizeSharedRead();
353
+ }
354
+
262
355
  const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
263
356
  "account_inactive",
264
357
  "invalid_auth",
@@ -285,12 +378,16 @@ export class SlackBotCredentialVerificationError extends HTTPException {
285
378
 
286
379
  /**
287
380
  * 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.
381
+ * The OAuth exchange's app_id is the immutable app authority. Slack bot profile
382
+ * names are mutable presentation data and may lag manifest/App Home changes, so
383
+ * they must not decide whether a credential belongs to this installation.
291
384
  */
292
385
  export async function verifyOpenGeniSlackBotCredential(
293
386
  token: string,
387
+ expected: Readonly<{
388
+ appId: string;
389
+ displayName: OpenGeniSlackBotDisplayName;
390
+ }>,
294
391
  fetchImpl: FetchLike = fetch,
295
392
  now: Date = new Date(),
296
393
  ): Promise<VerifiedOpenGeniSlackBot> {
@@ -314,11 +411,11 @@ export async function verifyOpenGeniSlackBotCredential(
314
411
  );
315
412
  }
316
413
  const profile = slackRecord(user.profile);
317
- const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
318
- if (displayName !== "OpenGeni") {
414
+ const installedAppId = slackString(profile?.api_app_id);
415
+ if (installedAppId !== expected.appId) {
319
416
  throw new SlackBotCredentialVerificationError(
320
417
  "identity_mismatch",
321
- 'Slack bot display name must be exactly "OpenGeni"',
418
+ "Slack credential does not belong to the authorized Slack app",
322
419
  );
323
420
  }
324
421
 
@@ -331,7 +428,7 @@ export async function verifyOpenGeniSlackBotCredential(
331
428
  slackTeamName,
332
429
  botUserId,
333
430
  botId,
334
- botDisplayName: "OpenGeni",
431
+ botDisplayName: expected.displayName,
335
432
  verifiedAt: now.toISOString(),
336
433
  },
337
434
  };
@@ -427,6 +524,7 @@ export class OpenGeniSlackBotClient {
427
524
  private readonly metadata: OpenGeniSlackBotConnectionMetadata,
428
525
  private readonly context: SlackBotContext,
429
526
  private readonly fetchImpl: FetchLike = fetch,
527
+ private readonly authorizeProviderRequest?: SlackProviderAuthorization,
430
528
  ) {
431
529
  this.resolveCredential = buildConnectionTokenResolver(db, settings);
432
530
  }
@@ -453,6 +551,27 @@ export class OpenGeniSlackBotClient {
453
551
  return await this.requireMemberChannel(headers, channelId);
454
552
  }
455
553
 
554
+ /**
555
+ * Replace one Slack user's private App Home view. `views.publish` is a
556
+ * naturally convergent replace operation: Slack event retries may safely
557
+ * repeat the exact bounded view without creating duplicate provider state.
558
+ */
559
+ async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) {
560
+ const userId = requiredSlackString(input.userId, "user_id");
561
+ const blocks = validateSlackHomeBlocks(input.blocks);
562
+ const hash = input.hash ? requiredSlackString(input.hash, "hash") : null;
563
+ return await this.withAudit("home.publish", async (headers) => {
564
+ const payload = await this.call(headers, "views.publish", {
565
+ user_id: userId,
566
+ view: JSON.stringify({ type: "home", blocks }),
567
+ ...(hash ? { hash } : {}),
568
+ });
569
+ return {
570
+ viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"),
571
+ };
572
+ });
573
+ }
574
+
456
575
  async slackTaskPolicyFacts(channelId: string, userId: string) {
457
576
  return await this.withAudit("channel_history.read", async (headers) => {
458
577
  const conversation = await this.requireMemberChannel(headers, channelId);
@@ -649,11 +768,18 @@ export class OpenGeniSlackBotClient {
649
768
  async prepareReactionImageDownloads(input: {
650
769
  channelId: string;
651
770
  files: readonly { id: string; name: string; title: string }[];
771
+ authorizeSharedRead?: () => Promise<void>;
652
772
  }): Promise<PreparedSlackReactionImage[]> {
653
773
  const result = await this.withAudit("file.content.read", async (headers) => {
654
- await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
774
+ if (input.authorizeSharedRead) {
775
+ const channel = await this.requireMemberChannel(headers, input.channelId);
776
+ await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead);
777
+ } else {
778
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
779
+ }
655
780
  const prepared: PreparedSlackReactionImage[] = [];
656
781
  for (const candidate of input.files) {
782
+ await input.authorizeSharedRead?.();
657
783
  const payload = await this.call(headers, "files.info", { file: candidate.id });
658
784
  const fileRecord = slackRecord(payload.file);
659
785
  const file = projectFile(fileRecord);
@@ -688,6 +814,7 @@ export class OpenGeniSlackBotClient {
688
814
  /** Download and fully validate one previously authorized Slack image. */
689
815
  async downloadReactionImage(
690
816
  input: PreparedSlackReactionImage,
817
+ authorizeSharedRead?: () => Promise<void>,
691
818
  ): Promise<DownloadedSlackReactionImage> {
692
819
  return await this.withAudit("file.content.read", async () => {
693
820
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
@@ -700,7 +827,11 @@ export class OpenGeniSlackBotClient {
700
827
  ) {
701
828
  throw new SlackBotProviderError("invalid_file_size");
702
829
  }
703
- const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
830
+ const response = await this.fetchPrivateFile(
831
+ input.downloadUrl,
832
+ "file.content.read",
833
+ authorizeSharedRead,
834
+ );
704
835
  const responseContentType = normalizedContentType(response.headers.get("content-type"));
705
836
  if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
706
837
  await response.body?.cancel().catch(() => undefined);
@@ -748,6 +879,69 @@ export class OpenGeniSlackBotClient {
748
879
  });
749
880
  }
750
881
 
882
+ /**
883
+ * Workspace-wide public search through Slack's Real-time Search API
884
+ * (`assistant.search.context`) under the bot identity.
885
+ *
886
+ * The bot never carries private-search authority: `channel_types` is pinned
887
+ * to `public_channel` server-side regardless of caller input, so private
888
+ * channels, DMs, and MPIMs remain reachable only through a member's personal
889
+ * hosted-MCP grant. An install predating the search scopes fails closed with
890
+ * a reinstall hint instead of leaking Slack's `missing_scope` error.
891
+ */
892
+ async searchContext(input: {
893
+ query: string;
894
+ contentTypes?: readonly ("messages" | "files" | "channels")[];
895
+ includeBots?: boolean;
896
+ /** UNIX seconds bounds on message timestamps. */
897
+ before?: number;
898
+ after?: number;
899
+ sort?: "score" | "timestamp";
900
+ sortDir?: "asc" | "desc";
901
+ cursor?: string;
902
+ limit?: number;
903
+ }) {
904
+ const query = typeof input.query === "string" ? input.query.trim() : "";
905
+ if (!query || query.length > MAX_SEARCH_QUERY_CHARS) {
906
+ throw new SlackBotProviderError("invalid_query");
907
+ }
908
+ const contentTypes = input.contentTypes?.length
909
+ ? [...new Set(input.contentTypes)]
910
+ : ["messages"];
911
+ return await this.withAudit("search.context", async (headers) => {
912
+ // Inside the audit boundary so a fail-closed denial on a legacy install
913
+ // leaves the same `failed` evidence as a provider rejection.
914
+ if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) {
915
+ throw new SlackBotProviderError("slack_bot_search_scopes_missing");
916
+ }
917
+ const payload = await this.call(headers, "assistant.search.context", {
918
+ query,
919
+ channel_types: "public_channel",
920
+ content_types: contentTypes.join(","),
921
+ ...(input.includeBots ? { include_bots: "true" } : {}),
922
+ ...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}),
923
+ ...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}),
924
+ ...(input.sort ? { sort: input.sort } : {}),
925
+ ...(input.sortDir ? { sort_dir: input.sortDir } : {}),
926
+ ...(input.cursor ? { cursor: input.cursor } : {}),
927
+ limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)),
928
+ });
929
+ const results = slackRecord(payload.results);
930
+ return {
931
+ messages: slackArray(results?.messages)
932
+ .map(projectSearchMessage)
933
+ .filter((message): message is NonNullable<typeof message> => message !== null),
934
+ files: slackArray(results?.files)
935
+ .map(projectSearchFile)
936
+ .filter((file): file is NonNullable<typeof file> => file !== null),
937
+ channels: slackArray(results?.channels)
938
+ .map(projectSearchChannel)
939
+ .filter((channel): channel is NonNullable<typeof channel> => channel !== null),
940
+ nextCursor: responseCursor(payload),
941
+ };
942
+ });
943
+ }
944
+
751
945
  async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
752
946
  return await this.withAudit("files.list", async (headers) => {
753
947
  const requestedPage = this.fileListPage(input);
@@ -1211,10 +1405,7 @@ export class OpenGeniSlackBotClient {
1211
1405
  }
1212
1406
 
1213
1407
  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
- );
1408
+ const headers = await this.headersFor("message.delete");
1218
1409
  try {
1219
1410
  await this.call(headers, "chat.getPermalink", {
1220
1411
  channel: channelId,
@@ -1236,7 +1427,7 @@ export class OpenGeniSlackBotClient {
1236
1427
  text: string;
1237
1428
  }): Promise<{ timestamp: string }> {
1238
1429
  const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
1239
- const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
1430
+ const headers = await this.headersFor("message.post");
1240
1431
  const seenCursors = new Set<string>();
1241
1432
  let cursor: string | null = null;
1242
1433
  let matchedTimestamp: string | null = null;
@@ -1284,7 +1475,7 @@ export class OpenGeniSlackBotClient {
1284
1475
  return { timestamp: matchedTimestamp };
1285
1476
  }
1286
1477
 
1287
- private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
1478
+ private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) {
1288
1479
  const payload = await this.call(headers, "conversations.info", {
1289
1480
  channel: channelId,
1290
1481
  });
@@ -1299,7 +1490,7 @@ export class OpenGeniSlackBotClient {
1299
1490
  }
1300
1491
 
1301
1492
  private async requireActiveNonSharedMemberChannel(
1302
- headers: Record<string, string>,
1493
+ headers: SlackCallAuthority,
1303
1494
  channelId: string,
1304
1495
  ) {
1305
1496
  const projected = await this.requireMemberChannel(headers, channelId);
@@ -1313,7 +1504,7 @@ export class OpenGeniSlackBotClient {
1313
1504
  }
1314
1505
 
1315
1506
  private async requireFileForChannel(
1316
- headers: Record<string, string>,
1507
+ headers: SlackCallAuthority,
1317
1508
  operation: "file.info" | "file.content.read",
1318
1509
  input: { channelId: string; fileId: string; parentFileId?: string },
1319
1510
  ) {
@@ -1390,11 +1581,15 @@ export class OpenGeniSlackBotClient {
1390
1581
  }
1391
1582
 
1392
1583
  private async call(
1393
- headers: Record<string, string>,
1584
+ authority: SlackCallAuthority,
1394
1585
  method: string,
1395
1586
  params: Record<string, string>,
1396
1587
  ): Promise<SlackPayload> {
1397
1588
  try {
1589
+ const headers = await this.headersForDestination(
1590
+ authority.operation,
1591
+ `${SLACK_API_BASE}${method}`,
1592
+ );
1398
1593
  return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
1399
1594
  } catch (error) {
1400
1595
  if (slackCredentialRejected(error)) {
@@ -1410,11 +1605,10 @@ export class OpenGeniSlackBotClient {
1410
1605
 
1411
1606
  private async withAudit<T extends Record<string, unknown>>(
1412
1607
  operation: SlackBotOperation,
1413
- run: (headers: Record<string, string>) => Promise<T>,
1608
+ run: (authority: SlackCallAuthority) => Promise<T>,
1414
1609
  ): Promise<T & { receipt: SlackBotReceipt }> {
1415
1610
  try {
1416
- const headers = await this.headersFor(operation);
1417
- const result = await run(headers);
1611
+ const result = await run(await this.headersFor(operation));
1418
1612
  await this.recordAudit(operation, "succeeded");
1419
1613
  return { ...result, receipt: this.receipt(operation) };
1420
1614
  } catch (error) {
@@ -1423,11 +1617,8 @@ export class OpenGeniSlackBotClient {
1423
1617
  }
1424
1618
  }
1425
1619
 
1426
- private async headersFor(operation: SlackBotOperation): Promise<Record<string, string>> {
1427
- return await this.headersForDestination(
1428
- operation,
1429
- `${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
1430
- );
1620
+ private async headersFor(operation: SlackBotOperation): Promise<SlackCallAuthority> {
1621
+ return Object.freeze({ operation });
1431
1622
  }
1432
1623
 
1433
1624
  private async headersForDestination(
@@ -1450,14 +1641,40 @@ export class OpenGeniSlackBotClient {
1450
1641
  if (result.status !== "ok" || result.connectionId !== this.connection.id) {
1451
1642
  throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
1452
1643
  }
1644
+ const current = await requireOpenGeniSlackBotConnection(
1645
+ this.db,
1646
+ this.context.workspaceId,
1647
+ this.connection.id,
1648
+ );
1649
+ const currentMetadata = openGeniSlackBotMetadata(current.metadata);
1650
+ if (
1651
+ current.accountId !== this.context.accountId ||
1652
+ current.version !== this.connection.version ||
1653
+ result.connectionVersion !== this.connection.version ||
1654
+ !currentMetadata ||
1655
+ currentMetadata.slackTeamId !== this.metadata.slackTeamId ||
1656
+ currentMetadata.botId !== this.metadata.botId ||
1657
+ currentMetadata.botUserId !== this.metadata.botUserId
1658
+ ) {
1659
+ throw new Error("OpenGeni Slack bot connection authority changed");
1660
+ }
1661
+ // The adapter callback is a fallible preflight. The canonical credential
1662
+ // callback must remain the final await before the physical fetch.
1663
+ if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
1664
+ throw new Error("OpenGeni Slack bot provider request is no longer authorized");
1665
+ }
1666
+ if (result.authorizeProviderRequest && !(await result.authorizeProviderRequest())) {
1667
+ throw new Error("OpenGeni Slack bot provider request is no longer authorized");
1668
+ }
1453
1669
  return result.headers;
1454
1670
  }
1455
1671
 
1456
1672
  private async fetchPrivateFile(
1457
1673
  url: URL,
1458
1674
  operation: "file.info" | "file.content.read",
1675
+ authorizeBeforeFetch?: () => Promise<void>,
1459
1676
  ): Promise<Response> {
1460
- const response = await this.fetchPrivateFileOnce(url, operation);
1677
+ const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch);
1461
1678
  if (response.status < 300 || response.status >= 400) {
1462
1679
  return response;
1463
1680
  }
@@ -1478,18 +1695,20 @@ export class OpenGeniSlackBotClient {
1478
1695
  } catch {
1479
1696
  throw new SlackBotProviderError("invalid_file_redirect");
1480
1697
  }
1481
- return await this.fetchPrivateFileOnce(redirected, operation);
1698
+ return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch);
1482
1699
  }
1483
1700
 
1484
1701
  private async fetchPrivateFileOnce(
1485
1702
  url: URL,
1486
1703
  operation: "file.info" | "file.content.read",
1704
+ authorizeBeforeFetch?: () => Promise<void>,
1487
1705
  ): Promise<Response> {
1488
1706
  try {
1489
1707
  assertPrivateSlackFileUrl(url);
1490
1708
  } catch {
1491
1709
  throw new SlackBotProviderError("invalid_file_url");
1492
1710
  }
1711
+ await authorizeBeforeFetch?.();
1493
1712
  const headers = await this.headersForDestination(operation, url.toString());
1494
1713
  let response: Response;
1495
1714
  try {
@@ -1701,7 +1920,12 @@ export class OpenGeniSlackBotClient {
1701
1920
  }
1702
1921
 
1703
1922
  export function createOpenGeniSlackBotClient(
1704
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1923
+ deps: {
1924
+ db: Database;
1925
+ settings: Settings;
1926
+ slackFetch?: typeof fetch;
1927
+ authorizeProviderRequest?: SlackProviderAuthorization;
1928
+ },
1705
1929
  resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
1706
1930
  ): OpenGeniSlackBotClient {
1707
1931
  return new OpenGeniSlackBotClient(
@@ -1711,11 +1935,17 @@ export function createOpenGeniSlackBotClient(
1711
1935
  resolved.metadata,
1712
1936
  resolved.context,
1713
1937
  deps.slackFetch,
1938
+ deps.authorizeProviderRequest,
1714
1939
  );
1715
1940
  }
1716
1941
 
1717
1942
  export async function createOpenGeniSlackBotInteractionClient(
1718
- deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1943
+ deps: {
1944
+ db: Database;
1945
+ settings: Settings;
1946
+ slackFetch?: typeof fetch;
1947
+ authorizeProviderRequest?: SlackProviderAuthorization;
1948
+ },
1719
1949
  input: {
1720
1950
  accountId: string;
1721
1951
  workspaceId: string;
@@ -1747,6 +1977,7 @@ export async function createOpenGeniSlackBotInteractionClient(
1747
1977
  scheduledTaskId: null,
1748
1978
  },
1749
1979
  deps.slackFetch,
1980
+ deps.authorizeProviderRequest,
1750
1981
  );
1751
1982
  }
1752
1983
 
@@ -1770,7 +2001,7 @@ async function slackApiFetchWithHeaders(
1770
2001
  credentialHeaders: Record<string, string>,
1771
2002
  params: Record<string, string>,
1772
2003
  ): Promise<{ response: Response; payload: SlackPayload }> {
1773
- if (!/^[a-z]+\.[a-z]+$/i.test(method)) {
2004
+ if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) {
1774
2005
  throw new Error("invalid Slack API method");
1775
2006
  }
1776
2007
  const url = new URL(method, SLACK_API_BASE);
@@ -2576,6 +2807,74 @@ function embeddedSlackFileIds(content: string): Set<string> {
2576
2807
  );
2577
2808
  }
2578
2809
 
2810
+ /**
2811
+ * Bounded projections of `assistant.search.context` results. Content excerpts
2812
+ * are capped, Slack's rich `blocks` and context messages are deliberately
2813
+ * dropped, and permalinks are kept only when they are https URLs.
2814
+ */
2815
+ function projectSearchMessage(value: unknown) {
2816
+ const message = slackRecord(value);
2817
+ const channelId = slackString(message?.channel_id);
2818
+ const ts = slackString(message?.message_ts);
2819
+ if (!message || !channelId || !ts) return null;
2820
+ return {
2821
+ channelId,
2822
+ channelName: boundedSlackString(message.channel_name, 256),
2823
+ ts,
2824
+ authorUserId: boundedSlackString(message.author_user_id, 64),
2825
+ authorName: boundedSlackString(message.author_name, 256),
2826
+ isAuthorBot: message.is_author_bot === true,
2827
+ content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS),
2828
+ permalink: safeSlackPermalink(message.permalink),
2829
+ };
2830
+ }
2831
+
2832
+ function projectSearchFile(value: unknown) {
2833
+ const file = slackRecord(value);
2834
+ const id = slackString(file?.file_id);
2835
+ if (!file || !id) return null;
2836
+ return {
2837
+ id,
2838
+ title: boundedSlackString(file.title, 512),
2839
+ filetype: boundedSlackString(file.file_type, 128),
2840
+ authorUserId: boundedSlackString(file.author_user_id, 64),
2841
+ authorName: boundedSlackString(file.author_name, 256),
2842
+ dateCreated: slackUnixSeconds(file.date_created),
2843
+ dateUpdated: slackUnixSeconds(file.date_updated),
2844
+ content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS),
2845
+ permalink: safeSlackPermalink(file.permalink),
2846
+ };
2847
+ }
2848
+
2849
+ function projectSearchChannel(value: unknown) {
2850
+ const channel = slackRecord(value);
2851
+ const name = slackString(channel?.name);
2852
+ if (!channel || !name) return null;
2853
+ return {
2854
+ name: boundedSlackString(name, 256),
2855
+ topic: boundedSlackString(channel.topic, 512),
2856
+ purpose: boundedSlackString(channel.purpose, 512),
2857
+ creatorUserId: boundedSlackString(channel.creator_user_id, 64),
2858
+ dateCreated: slackUnixSeconds(channel.date_created),
2859
+ permalink: safeSlackPermalink(channel.permalink),
2860
+ };
2861
+ }
2862
+
2863
+ function slackUnixSeconds(value: unknown): number | null {
2864
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
2865
+ }
2866
+
2867
+ function safeSlackPermalink(value: unknown): string | null {
2868
+ // An over-long URL is dropped, never truncated into a still-parseable one.
2869
+ const raw = typeof value === "string" && value.length <= 1_024 ? value : "";
2870
+ if (!raw) return null;
2871
+ try {
2872
+ return new URL(raw).protocol === "https:" ? raw : null;
2873
+ } catch {
2874
+ return null;
2875
+ }
2876
+ }
2877
+
2579
2878
  function projectUser(value: unknown) {
2580
2879
  const user = slackRecord(value);
2581
2880
  const id = slackString(user?.id);
@@ -2701,30 +3000,6 @@ export function nextSlackFilesListPage(
2701
3000
  return nextPage;
2702
3001
  }
2703
3002
 
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
3003
  function validateSlackMessageBlocks(
2729
3004
  blocks: SlackMessageBlock[] | undefined,
2730
3005
  ): SlackMessageBlock[] | undefined {
@@ -2738,6 +3013,16 @@ function validateSlackMessageBlocks(
2738
3013
  return blocks;
2739
3014
  }
2740
3015
 
3016
+ function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] {
3017
+ if (blocks.length < 1 || blocks.length > 100) {
3018
+ throw new RangeError("Slack App Home blocks exceed the supported count");
3019
+ }
3020
+ if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) {
3021
+ throw new RangeError("Slack App Home blocks exceed the supported byte size");
3022
+ }
3023
+ return blocks;
3024
+ }
3025
+
2741
3026
  function boundedInt(value: number | undefined, max: number, fallback: number): number {
2742
3027
  return typeof value === "number" && Number.isInteger(value) && value > 0
2743
3028
  ? Math.min(value, max)