@opengeni/api-router 0.26.1 → 0.30.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 (87) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +19 -1
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
  8. package/dist/chunk-2PQMSRCB.js.map +1 -0
  9. package/dist/controller-data-plane.d.ts +12 -0
  10. package/dist/http/auth.d.ts +1 -1
  11. package/dist/http/sse.d.ts +18 -1
  12. package/dist/index.js +1 -1
  13. package/dist/integrations/api-integrations.d.ts +2 -2
  14. package/dist/integrations/fiken.d.ts +233 -0
  15. package/dist/integrations/google-drive.d.ts +6 -6
  16. package/dist/integrations/provider-oauth.d.ts +0 -2
  17. package/dist/integrations/slack-bot.d.ts +171 -1
  18. package/dist/integrations/slack-interactions.d.ts +17 -5
  19. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  20. package/dist/interaction-metrics.d.ts +11 -0
  21. package/dist/mcp/server.d.ts +3 -2
  22. package/dist/model-catalog.d.ts +1 -0
  23. package/dist/routes/canonical-human-identities.d.ts +3 -0
  24. package/dist/routes/channels.d.ts +3 -0
  25. package/dist/routes/integration-facets.d.ts +3 -0
  26. package/dist/routes/interaction-resources.d.ts +5 -0
  27. package/dist/routes/organization-memberships.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +2 -1
  29. package/dist/routes/slack-task-policy.d.ts +3 -0
  30. package/dist/routes/supergrok.d.ts +3 -0
  31. package/dist/sandbox/channel-a.d.ts +8 -1
  32. package/dist/sandbox/machines.d.ts +2 -2
  33. package/dist/sandbox/viewer.d.ts +3 -1
  34. package/dist/slack-reaction-files.d.ts +27 -0
  35. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  36. package/dist/xai-realtime.d.ts +26 -0
  37. package/dist/xai-subscription-auth.d.ts +23 -0
  38. package/package.json +17 -16
  39. package/src/app.ts +352 -49
  40. package/src/auth/canonical-human-session-admission.ts +116 -0
  41. package/src/auth/managed-auth.ts +101 -0
  42. package/src/browser-auth-broker.ts +155 -0
  43. package/src/browser-controller-authority.ts +50 -1
  44. package/src/browser-network-route.ts +34 -0
  45. package/src/controller-data-plane.ts +35 -0
  46. package/src/http/auth.ts +27 -9
  47. package/src/http/sse.ts +229 -5
  48. package/src/integrations/api-integrations.ts +57 -42
  49. package/src/integrations/fiken.ts +1230 -0
  50. package/src/integrations/google-drive.ts +31 -26
  51. package/src/integrations/provider-oauth.ts +70 -91
  52. package/src/integrations/slack-bot.ts +638 -9
  53. package/src/integrations/slack-interactions.ts +1583 -73
  54. package/src/interaction-holder-heartbeat.ts +95 -0
  55. package/src/interaction-metrics.ts +159 -0
  56. package/src/mcp/documents.ts +115 -6
  57. package/src/mcp/server.ts +865 -242
  58. package/src/model-catalog.ts +19 -6
  59. package/src/routes/api-integrations.ts +32 -29
  60. package/src/routes/browser-identities.ts +24 -0
  61. package/src/routes/browser-sessions.ts +2122 -183
  62. package/src/routes/canonical-human-identities.ts +156 -0
  63. package/src/routes/channels.ts +90 -0
  64. package/src/routes/computer-sessions.ts +441 -86
  65. package/src/routes/connections.ts +141 -4
  66. package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
  67. package/src/routes/interaction-resources.ts +595 -0
  68. package/src/routes/organization-memberships.ts +53 -0
  69. package/src/routes/plugins.ts +2 -1
  70. package/src/routes/sessions.ts +419 -43
  71. package/src/routes/skills.ts +92 -10
  72. package/src/routes/slack-task-policy.ts +115 -0
  73. package/src/routes/supergrok.ts +717 -0
  74. package/src/routes/transcription-recordings.ts +9 -2
  75. package/src/routes/transcriptions.ts +2 -1
  76. package/src/routes/video-generation.ts +34 -4
  77. package/src/routes/workspaces.ts +22 -4
  78. package/src/sandbox/channel-a.ts +29 -2
  79. package/src/sandbox/machines.ts +5 -5
  80. package/src/sandbox/viewer.ts +10 -3
  81. package/src/slack-reaction-files.ts +181 -0
  82. package/src/transcription/providers/xai-subscription.ts +110 -0
  83. package/src/transcription/service.ts +17 -2
  84. package/src/xai-realtime.ts +216 -0
  85. package/src/xai-subscription-auth.ts +132 -0
  86. package/dist/chunk-JIKNR5YL.js.map +0 -1
  87. package/dist/routes/integration-features.d.ts +0 -3
@@ -20,14 +20,18 @@ import {
20
20
  buildConnectionTokenResolver,
21
21
  claimSlackBotDeleteOperation,
22
22
  claimSlackBotPostOperation,
23
+ claimSlackBotUpdateOperation,
23
24
  completeSlackBotDeleteOperation,
24
25
  completeSlackBotPostOperation,
26
+ completeSlackBotUpdateOperation,
25
27
  getSession,
26
28
  listConnectionsMetadata,
27
29
  markSlackBotDeleteOperationProviderStarted,
30
+ markSlackBotPostOperationProviderStarted,
28
31
  recordAuditEvent,
29
32
  releaseSlackBotDeleteOperationClaim,
30
33
  releaseSlackBotPostOperationClaim,
34
+ releaseSlackBotUpdateOperationClaim,
31
35
  setConnectionStatus,
32
36
  type Database,
33
37
  } from "@opengeni/db";
@@ -41,6 +45,7 @@ import { HTTPException } from "hono/http-exception";
41
45
  const SLACK_API_BASE = "https://slack.com/api/";
42
46
  const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
43
47
  const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
48
+ export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
44
49
  const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
45
50
  const SLACK_TIMEOUT_MS = 10_000;
46
51
  const MAX_CHANNEL_PAGE = 200;
@@ -64,7 +69,13 @@ const MAX_FILE_CURSOR_LENGTH = 1_024;
64
69
  const SLACK_FILE_CURSOR_VERSION = "files-v1";
65
70
  const MAX_PROJECTED_TEXT = 4_000;
66
71
  const SLACK_POST_CLAIM_LEASE_MS = 30_000;
72
+ const MAX_SLACK_POST_RECONCILIATION_PAGES = 8;
73
+ const SLACK_UPDATE_CLAIM_LEASE_MS = 30_000;
67
74
  const SLACK_DELETE_CLAIM_LEASE_MS = 30_000;
75
+ const MAX_SLACK_BLOCKS = 50;
76
+ const MAX_SLACK_BLOCK_BYTES = 32 * 1024;
77
+ const SLACK_PRIVATE_FILE_HOSTS = new Set(["files.slack.com", "slack.com"]);
78
+ const SLACK_REACTION_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
68
79
 
69
80
  type SlackPayload = Record<string, unknown> & { ok?: unknown; error?: unknown };
70
81
 
@@ -80,6 +91,21 @@ export type VerifiedOpenGeniSlackBot = {
80
91
  metadata: OpenGeniSlackBotConnectionMetadata;
81
92
  };
82
93
 
94
+ export type PreparedSlackReactionImage = Readonly<{
95
+ fileId: string;
96
+ filename: string;
97
+ declaredMimeType: string;
98
+ declaredSizeBytes: number | null;
99
+ downloadUrl: URL | null;
100
+ }>;
101
+
102
+ export type DownloadedSlackReactionImage = Readonly<{
103
+ fileId: string;
104
+ filename: string;
105
+ contentType: "image/png" | "image/jpeg" | "image/webp";
106
+ bytes: Uint8Array;
107
+ }>;
108
+
83
109
  export async function exchangeOpenGeniSlackAuthorizationCode(
84
110
  input: {
85
111
  code: string;
@@ -145,6 +171,25 @@ export type SlackBotReceipt = {
145
171
  clientMessageId?: string;
146
172
  };
147
173
 
174
+ export type SlackMessageBlock =
175
+ | {
176
+ type: "section";
177
+ block_id?: string;
178
+ text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
179
+ }
180
+ | {
181
+ type: "actions";
182
+ block_id: string;
183
+ elements: Array<{
184
+ type: "button";
185
+ action_id: string;
186
+ value: string;
187
+ text: { type: "plain_text"; text: string; emoji?: boolean };
188
+ style?: "primary" | "danger";
189
+ }>;
190
+ }
191
+ | { type: "divider" };
192
+
148
193
  type SlackBotOperation =
149
194
  | "channels.list"
150
195
  | "channel_history.read"
@@ -154,6 +199,7 @@ type SlackBotOperation =
154
199
  | "file.info"
155
200
  | "file.content.read"
156
201
  | "message.post"
202
+ | "message.update"
157
203
  | "message.delete";
158
204
 
159
205
  type SlackBotContext = {
@@ -407,15 +453,36 @@ export class OpenGeniSlackBotClient {
407
453
  return await this.requireMemberChannel(headers, channelId);
408
454
  }
409
455
 
456
+ async slackTaskPolicyFacts(channelId: string, userId: string) {
457
+ return await this.withAudit("channel_history.read", async (headers) => {
458
+ const conversation = await this.requireMemberChannel(headers, channelId);
459
+ const governed =
460
+ conversation.isShared ||
461
+ conversation.isExternallyShared ||
462
+ conversation.isOrgShared ||
463
+ conversation.isPendingExternallyShared ||
464
+ conversation.isMpim;
465
+ if (!governed) return { conversation, initiator: null };
466
+ const payload = await this.call(headers, "users.info", { user: userId });
467
+ const initiator = projectSlackTaskPolicyUser(payload.user, this.metadata.slackTeamId);
468
+ if (!initiator || initiator.id !== userId) {
469
+ throw new SlackBotProviderError("slack_task_initiator_unavailable");
470
+ }
471
+ return { conversation, initiator };
472
+ });
473
+ }
474
+
410
475
  async channelHistory(input: {
411
476
  channelId: string;
412
477
  limit?: number;
413
478
  cursor?: string;
414
479
  latest?: string;
415
480
  inclusive?: boolean;
481
+ authorizeRead?: () => Promise<void>;
416
482
  }) {
417
483
  return await this.withAudit("channel_history.read", async (headers) => {
418
484
  const info = await this.requireMemberChannel(headers, input.channelId);
485
+ await input.authorizeRead?.();
419
486
  const payload = await this.call(headers, "conversations.history", {
420
487
  channel: input.channelId,
421
488
  limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
@@ -436,14 +503,22 @@ export class OpenGeniSlackBotClient {
436
503
  threadTimestamp: string;
437
504
  limit?: number;
438
505
  cursor?: string;
506
+ oldest?: string;
507
+ latest?: string;
508
+ inclusive?: boolean;
509
+ authorizeRead?: () => Promise<void>;
439
510
  }) {
440
511
  return await this.withAudit("thread_replies.read", async (headers) => {
441
512
  const info = await this.requireMemberChannel(headers, input.channelId);
513
+ await input.authorizeRead?.();
442
514
  const payload = await this.call(headers, "conversations.replies", {
443
515
  channel: input.channelId,
444
516
  ts: input.threadTimestamp,
445
517
  limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
446
518
  ...(input.cursor ? { cursor: input.cursor } : {}),
519
+ ...(input.oldest ? { oldest: input.oldest } : {}),
520
+ ...(input.latest ? { latest: input.latest } : {}),
521
+ ...((input.oldest || input.latest) && input.inclusive ? { inclusive: "true" } : {}),
447
522
  });
448
523
  return {
449
524
  channel: info,
@@ -565,6 +640,99 @@ export class OpenGeniSlackBotClient {
565
640
  });
566
641
  }
567
642
 
643
+ /**
644
+ * Re-fetch and authorize the exact reacted-message files before any byte or
645
+ * workspace-storage mutation. The returned private URLs are process-local
646
+ * capabilities only and must never be persisted, logged, or projected into
647
+ * session input.
648
+ */
649
+ async prepareReactionImageDownloads(input: {
650
+ channelId: string;
651
+ files: readonly { id: string; name: string; title: string }[];
652
+ }): Promise<PreparedSlackReactionImage[]> {
653
+ const result = await this.withAudit("file.content.read", async (headers) => {
654
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
655
+ const prepared: PreparedSlackReactionImage[] = [];
656
+ for (const candidate of input.files) {
657
+ const payload = await this.call(headers, "files.info", { file: candidate.id });
658
+ const fileRecord = slackRecord(payload.file);
659
+ const file = projectFile(fileRecord);
660
+ if (!fileRecord || !file || file.id !== candidate.id) {
661
+ throw new SlackBotProviderError("file_not_found");
662
+ }
663
+ if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
664
+ throw new SlackBotProviderError("file_not_shared_to_channel");
665
+ }
666
+ let downloadUrl: URL | null = null;
667
+ try {
668
+ downloadUrl = privateSlackFileUrl(fileRecord);
669
+ } catch {
670
+ // A malformed provider URL is a per-file omission, not permission to
671
+ // send a credential to a different destination.
672
+ }
673
+ prepared.push(
674
+ Object.freeze({
675
+ fileId: file.id,
676
+ filename: file.name || file.title || candidate.name || candidate.title || file.id,
677
+ declaredMimeType: normalizedContentType(file.mimetype),
678
+ declaredSizeBytes: file.size,
679
+ downloadUrl,
680
+ }),
681
+ );
682
+ }
683
+ return { files: prepared };
684
+ });
685
+ return result.files;
686
+ }
687
+
688
+ /** Download and fully validate one previously authorized Slack image. */
689
+ async downloadReactionImage(
690
+ input: PreparedSlackReactionImage,
691
+ ): Promise<DownloadedSlackReactionImage> {
692
+ return await this.withAudit("file.content.read", async () => {
693
+ if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
694
+ throw new SlackBotProviderError("unsupported_file_type");
695
+ }
696
+ if (!input.downloadUrl) throw new SlackBotProviderError("file_content_unavailable");
697
+ if (
698
+ input.declaredSizeBytes !== null &&
699
+ (input.declaredSizeBytes < 1 || input.declaredSizeBytes > SLACK_REACTION_IMAGE_MAX_BYTES)
700
+ ) {
701
+ throw new SlackBotProviderError("invalid_file_size");
702
+ }
703
+ const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
704
+ const responseContentType = normalizedContentType(response.headers.get("content-type"));
705
+ if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
706
+ await response.body?.cancel().catch(() => undefined);
707
+ throw new SlackBotProviderError("unsupported_file_type");
708
+ }
709
+ let bytes: Uint8Array;
710
+ try {
711
+ bytes = await readResponseBodyBounded(
712
+ response,
713
+ SLACK_REACTION_IMAGE_MAX_BYTES,
714
+ "Slack image content",
715
+ );
716
+ } catch {
717
+ throw new SlackBotProviderError("invalid_file_content");
718
+ }
719
+ const sniffed = sniffSlackReactionImageMime(bytes);
720
+ if (!sniffed) throw new SlackBotProviderError("invalid_file_content");
721
+ if (input.declaredMimeType !== sniffed || responseContentType !== sniffed) {
722
+ throw new SlackBotProviderError("file_content_type_mismatch");
723
+ }
724
+ if (input.declaredSizeBytes !== null && input.declaredSizeBytes !== bytes.byteLength) {
725
+ throw new SlackBotProviderError("file_size_mismatch");
726
+ }
727
+ return Object.freeze({
728
+ fileId: input.fileId,
729
+ filename: input.filename,
730
+ contentType: sniffed,
731
+ bytes,
732
+ });
733
+ });
734
+ }
735
+
568
736
  async listUsers(input: { limit?: number; cursor?: string } = {}) {
569
737
  return await this.withAudit("users.list", async (headers) => {
570
738
  const payload = await this.call(headers, "users.list", {
@@ -653,18 +821,25 @@ export class OpenGeniSlackBotClient {
653
821
  });
654
822
  }
655
823
 
824
+ /**
825
+ * Internal server-owned delivery only. Generic model-facing MCP callers do
826
+ * not have a trustworthy durable logical-delivery identity and must never
827
+ * reach this method with a caller-generated operation ID.
828
+ */
656
829
  async postMessage(input: {
657
830
  operationId: string;
658
831
  channelId?: string;
659
832
  userId?: string;
660
833
  threadTimestamp?: string;
661
834
  text: string;
835
+ blocks?: SlackMessageBlock[];
662
836
  requireActiveNonSharedChannel?: boolean;
663
837
  }) {
664
838
  const operation = "message.post" as const;
665
839
  const claimHolderId = crypto.randomUUID();
666
840
  let claimAcquired = false;
667
841
  let providerCallStarted = false;
842
+ let outcomeUnknown = false;
668
843
  try {
669
844
  const headers = await this.headersFor(operation);
670
845
  let channelId = input.channelId;
@@ -681,12 +856,14 @@ export class OpenGeniSlackBotClient {
681
856
  }
682
857
  const targetKind = input.userId ? "user" : "channel";
683
858
  const targetId = input.userId ?? input.channelId!;
859
+ const blocks = validateSlackMessageBlocks(input.blocks);
684
860
  const requestDigest = this.postRequestDigest({
685
861
  operationId: input.operationId,
686
862
  targetKind,
687
863
  targetId,
688
864
  ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
689
865
  text: input.text,
866
+ ...(blocks ? { blocks } : {}),
690
867
  });
691
868
  const claim = await claimSlackBotPostOperation(this.db, {
692
869
  accountId: this.context.accountId,
@@ -712,17 +889,59 @@ export class OpenGeniSlackBotClient {
712
889
  return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp);
713
890
  }
714
891
  claimAcquired = true;
892
+ outcomeUnknown = claim.kind === "reconcile";
893
+ if (claim.kind === "reconcile") {
894
+ const reconciled = await this.reconcilePostMessage({
895
+ operationId: input.operationId,
896
+ channelId,
897
+ ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
898
+ text: input.text,
899
+ });
900
+ const completed = await completeSlackBotPostOperation(this.db, {
901
+ accountId: this.context.accountId,
902
+ workspaceId: this.context.workspaceId,
903
+ connectionId: this.connection.id,
904
+ operationId: input.operationId,
905
+ claimHolderId,
906
+ slackChannelId: channelId,
907
+ slackMessageTimestamp: reconciled.timestamp,
908
+ subjectId: this.context.subjectId,
909
+ auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
910
+ });
911
+ if (completed.kind !== "completed") {
912
+ throw new Error("Slack post reconciliation lost its durable operation claim");
913
+ }
914
+ claimAcquired = false;
915
+ return this.completedPostResult(
916
+ completed.operation,
917
+ input.operationId,
918
+ input.threadTimestamp,
919
+ );
920
+ }
715
921
  if (input.requireActiveNonSharedChannel) {
716
922
  if (input.userId || !input.channelId) {
717
923
  throw new Error("active non-shared channel validation requires channelId");
718
924
  }
719
925
  await this.requireActiveNonSharedMemberChannel(headers, channelId);
720
926
  }
927
+ const providerStarted = await markSlackBotPostOperationProviderStarted(this.db, {
928
+ accountId: this.context.accountId,
929
+ workspaceId: this.context.workspaceId,
930
+ connectionId: this.connection.id,
931
+ operationId: input.operationId,
932
+ claimHolderId,
933
+ });
934
+ if (!providerStarted) {
935
+ throw new Error("Slack post operation lost its durable claim before provider call");
936
+ }
721
937
  providerCallStarted = true;
722
938
  const posted = await this.call(headers, "chat.postMessage", {
723
939
  channel: channelId,
724
940
  text: input.text,
941
+ ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
725
942
  client_msg_id: input.operationId,
943
+ unfurl_links: "false",
944
+ unfurl_media: "false",
726
945
  ...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}),
727
946
  });
728
947
  const slackChannelId = requiredSlackString(posted.channel, "channel");
@@ -749,8 +968,108 @@ export class OpenGeniSlackBotClient {
749
968
  );
750
969
  } catch (error) {
751
970
  const failureCode = safeFailureCode(error);
971
+ const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error);
752
972
  if (claimAcquired) {
753
973
  await releaseSlackBotPostOperationClaim(this.db, {
974
+ accountId: this.context.accountId,
975
+ workspaceId: this.context.workspaceId,
976
+ connectionId: this.connection.id,
977
+ operationId: input.operationId,
978
+ claimHolderId,
979
+ outcomeUnknown: outcomeUnknown || ambiguous,
980
+ failureCode,
981
+ }).catch(() => undefined);
982
+ }
983
+ await this.recordAudit(
984
+ operation,
985
+ outcomeUnknown || ambiguous ? "ambiguous" : "failed",
986
+ failureCode,
987
+ input.operationId,
988
+ );
989
+ throw error;
990
+ }
991
+ }
992
+
993
+ async updateMessage(input: {
994
+ operationId: string;
995
+ channelId: string;
996
+ timestamp: string;
997
+ text: string;
998
+ blocks?: SlackMessageBlock[];
999
+ }): Promise<{ channelId: string; timestamp: string; receipt: SlackBotReceipt }> {
1000
+ const operation = "message.update" as const;
1001
+ const claimHolderId = crypto.randomUUID();
1002
+ let claimAcquired = false;
1003
+ let providerCallStarted = false;
1004
+ const blocks = validateSlackMessageBlocks(input.blocks);
1005
+ try {
1006
+ const headers = await this.headersFor(operation);
1007
+ await this.requireMemberChannel(headers, input.channelId);
1008
+ const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) });
1009
+ const claim = await claimSlackBotUpdateOperation(this.db, {
1010
+ accountId: this.context.accountId,
1011
+ workspaceId: this.context.workspaceId,
1012
+ connectionId: this.connection.id,
1013
+ operationId: input.operationId,
1014
+ slackChannelId: input.channelId,
1015
+ slackMessageTimestamp: input.timestamp,
1016
+ requestDigest,
1017
+ claimHolderId,
1018
+ claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS,
1019
+ });
1020
+ if (claim.kind === "connection_not_found") {
1021
+ throw new Error("OpenGeni Slack bot connection no longer exists");
1022
+ }
1023
+ if (claim.kind === "conflict") {
1024
+ throw new Error("operationId is already bound to a different Slack update request");
1025
+ }
1026
+ if (claim.kind === "in_progress") {
1027
+ throw new Error(
1028
+ "Slack update operation is already in progress; retry the same operationId",
1029
+ );
1030
+ }
1031
+ if (claim.kind === "completed") {
1032
+ return {
1033
+ channelId: claim.operation.slackChannelId,
1034
+ timestamp: claim.operation.slackMessageTimestamp,
1035
+ receipt: this.receipt(operation, input.operationId),
1036
+ };
1037
+ }
1038
+ claimAcquired = true;
1039
+ providerCallStarted = true;
1040
+ const updated = await this.call(headers, "chat.update", {
1041
+ channel: input.channelId,
1042
+ ts: input.timestamp,
1043
+ text: input.text,
1044
+ ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
1045
+ });
1046
+ const slackChannelId = requiredSlackString(updated.channel, "channel");
1047
+ const slackMessageTimestamp = requiredSlackString(updated.ts, "ts");
1048
+ if (slackChannelId !== input.channelId || slackMessageTimestamp !== input.timestamp) {
1049
+ throw new SlackBotProviderError("message_update_identity_mismatch");
1050
+ }
1051
+ const completed = await completeSlackBotUpdateOperation(this.db, {
1052
+ accountId: this.context.accountId,
1053
+ workspaceId: this.context.workspaceId,
1054
+ connectionId: this.connection.id,
1055
+ operationId: input.operationId,
1056
+ claimHolderId,
1057
+ subjectId: this.context.subjectId,
1058
+ auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
1059
+ });
1060
+ if (completed !== "completed") {
1061
+ throw new Error("Slack update completion lost its durable operation claim");
1062
+ }
1063
+ claimAcquired = false;
1064
+ return {
1065
+ channelId: slackChannelId,
1066
+ timestamp: slackMessageTimestamp,
1067
+ receipt: this.receipt(operation, input.operationId),
1068
+ };
1069
+ } catch (error) {
1070
+ const failureCode = safeFailureCode(error);
1071
+ if (claimAcquired) {
1072
+ await releaseSlackBotUpdateOperationClaim(this.db, {
754
1073
  accountId: this.context.accountId,
755
1074
  workspaceId: this.context.workspaceId,
756
1075
  connectionId: this.connection.id,
@@ -910,6 +1229,61 @@ export class OpenGeniSlackBotClient {
910
1229
  }
911
1230
  }
912
1231
 
1232
+ private async reconcilePostMessage(input: {
1233
+ operationId: string;
1234
+ channelId: string;
1235
+ threadTimestamp?: string;
1236
+ text: string;
1237
+ }): Promise<{ timestamp: string }> {
1238
+ const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
1239
+ const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
1240
+ const seenCursors = new Set<string>();
1241
+ let cursor: string | null = null;
1242
+ let matchedTimestamp: string | null = null;
1243
+ let exhausted = false;
1244
+ for (let page = 0; page < MAX_SLACK_POST_RECONCILIATION_PAGES; page += 1) {
1245
+ const payload = await this.call(headers, method, {
1246
+ channel: input.channelId,
1247
+ limit: String(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
1248
+ ...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}),
1249
+ ...(cursor ? { cursor } : {}),
1250
+ });
1251
+ for (const value of slackArray(payload.messages)) {
1252
+ const message = slackRecord(value);
1253
+ if (!message || slackString(message.client_msg_id) !== input.operationId) continue;
1254
+ const timestamp = requiredSlackString(message.ts, "message.ts");
1255
+ const threadTimestamp = slackString(message.thread_ts);
1256
+ const threadMatches = input.threadTimestamp
1257
+ ? threadTimestamp === input.threadTimestamp
1258
+ : !threadTimestamp || threadTimestamp === timestamp;
1259
+ if (slackString(message.text) !== input.text || !threadMatches) {
1260
+ throw new SlackBotProviderError("post_reconciliation_mismatch");
1261
+ }
1262
+ if (matchedTimestamp && matchedTimestamp !== timestamp) {
1263
+ throw new SlackBotProviderError("post_reconciliation_duplicate");
1264
+ }
1265
+ matchedTimestamp = timestamp;
1266
+ }
1267
+ const nextCursor = responseCursor(payload);
1268
+ if (!nextCursor) {
1269
+ exhausted = true;
1270
+ break;
1271
+ }
1272
+ if (seenCursors.has(nextCursor)) {
1273
+ throw new SlackBotProviderError("post_reconciliation_invalid_cursor");
1274
+ }
1275
+ seenCursors.add(nextCursor);
1276
+ cursor = nextCursor;
1277
+ }
1278
+ if (!exhausted) {
1279
+ throw new SlackBotProviderError("post_reconciliation_truncated");
1280
+ }
1281
+ if (!matchedTimestamp) {
1282
+ throw new SlackBotProviderError("post_outcome_unknown");
1283
+ }
1284
+ return { timestamp: matchedTimestamp };
1285
+ }
1286
+
913
1287
  private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
914
1288
  const payload = await this.call(headers, "conversations.info", {
915
1289
  channel: channelId,
@@ -1093,13 +1467,17 @@ export class OpenGeniSlackBotClient {
1093
1467
  let redirected: URL;
1094
1468
  try {
1095
1469
  redirected = new URL(location, url);
1096
- assertPrivateSlackFileUrl(redirected);
1097
1470
  } catch {
1098
1471
  throw new SlackBotProviderError("invalid_file_redirect");
1099
1472
  }
1100
- if (isSlackInteractiveFileRedirect(redirected)) {
1473
+ if (isSlackOwnedInteractiveFileRedirect(redirected)) {
1101
1474
  throw new SlackBotProviderError("file_requires_user_access");
1102
1475
  }
1476
+ try {
1477
+ assertPrivateSlackFileUrl(redirected);
1478
+ } catch {
1479
+ throw new SlackBotProviderError("invalid_file_redirect");
1480
+ }
1103
1481
  return await this.fetchPrivateFileOnce(redirected, operation);
1104
1482
  }
1105
1483
 
@@ -1107,6 +1485,11 @@ export class OpenGeniSlackBotClient {
1107
1485
  url: URL,
1108
1486
  operation: "file.info" | "file.content.read",
1109
1487
  ): Promise<Response> {
1488
+ try {
1489
+ assertPrivateSlackFileUrl(url);
1490
+ } catch {
1491
+ throw new SlackBotProviderError("invalid_file_url");
1492
+ }
1110
1493
  const headers = await this.headersForDestination(operation, url.toString());
1111
1494
  let response: Response;
1112
1495
  try {
@@ -1184,6 +1567,7 @@ export class OpenGeniSlackBotClient {
1184
1567
  targetId: string;
1185
1568
  threadTimestamp?: string;
1186
1569
  text: string;
1570
+ blocks?: SlackMessageBlock[];
1187
1571
  }): string {
1188
1572
  const key = environmentsEncryptionKeyBytes(this.settings);
1189
1573
  if (!key) throw new Error("connection encryption is not configured");
@@ -1196,6 +1580,30 @@ export class OpenGeniSlackBotClient {
1196
1580
  targetId: input.targetId,
1197
1581
  threadTimestamp: input.threadTimestamp ?? null,
1198
1582
  text: input.text,
1583
+ blocks: input.blocks ?? null,
1584
+ }),
1585
+ )
1586
+ .digest("hex");
1587
+ }
1588
+
1589
+ private updateRequestDigest(input: {
1590
+ operationId: string;
1591
+ channelId: string;
1592
+ timestamp: string;
1593
+ text: string;
1594
+ blocks?: SlackMessageBlock[];
1595
+ }): string {
1596
+ const key = environmentsEncryptionKeyBytes(this.settings);
1597
+ if (!key) throw new Error("connection encryption is not configured");
1598
+ return createHmac("sha256", key)
1599
+ .update(
1600
+ JSON.stringify({
1601
+ operationId: input.operationId,
1602
+ connectionId: this.connection.id,
1603
+ channelId: input.channelId,
1604
+ timestamp: input.timestamp,
1605
+ text: input.text,
1606
+ blocks: input.blocks ?? null,
1199
1607
  }),
1200
1608
  )
1201
1609
  .digest("hex");
@@ -1453,10 +1861,15 @@ function projectChannel(value: unknown) {
1453
1861
  isPrivate: channel.is_private === true,
1454
1862
  isMember: channel.is_member === true,
1455
1863
  isDirectMessage: channel.is_im === true,
1864
+ isMpim: channel.is_mpim === true,
1456
1865
  isArchived: channel.is_archived === true,
1457
1866
  isShared: channel.is_shared === true,
1458
1867
  isExternallyShared: channel.is_ext_shared === true,
1459
1868
  isOrgShared: channel.is_org_shared === true,
1869
+ isPendingExternallyShared: channel.is_pending_ext_shared === true,
1870
+ contextTeamId: nullableBoundedSlackString(channel.context_team_id, 128),
1871
+ connectedTeamIds: slackStringArrayOrNull(channel.connected_team_ids, 128),
1872
+ sharedTeamIds: slackStringArrayOrNull(channel.shared_team_ids, 128),
1460
1873
  topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
1461
1874
  purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
1462
1875
  numMembers:
@@ -1466,6 +1879,45 @@ function projectChannel(value: unknown) {
1466
1879
  };
1467
1880
  }
1468
1881
 
1882
+ function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) {
1883
+ const user = slackRecord(value);
1884
+ const id = slackString(user?.id);
1885
+ const teamId = nullableBoundedSlackString(user?.team_id ?? user?.team, 128);
1886
+ if (!user || !id) return null;
1887
+ const guestFactsPresent =
1888
+ typeof user.is_restricted === "boolean" && typeof user.is_ultra_restricted === "boolean";
1889
+ const explicitExternal = typeof user.is_external === "boolean" ? user.is_external : null;
1890
+ return {
1891
+ id,
1892
+ teamId,
1893
+ isGuest: guestFactsPresent
1894
+ ? user.is_restricted === true || user.is_ultra_restricted === true
1895
+ : null,
1896
+ isExternal:
1897
+ explicitExternal !== null
1898
+ ? explicitExternal
1899
+ : teamId === null
1900
+ ? null
1901
+ : teamId !== installationTeamId,
1902
+ };
1903
+ }
1904
+
1905
+ function nullableBoundedSlackString(value: unknown, maxLength: number): string | null {
1906
+ const bounded = boundedSlackString(value, maxLength);
1907
+ return bounded || null;
1908
+ }
1909
+
1910
+ function slackStringArrayOrNull(value: unknown, maxLength: number): string[] | null {
1911
+ if (!Array.isArray(value)) return null;
1912
+ const result: string[] = [];
1913
+ for (const item of value) {
1914
+ const bounded = boundedSlackString(item, maxLength);
1915
+ if (!bounded) return null;
1916
+ result.push(bounded);
1917
+ }
1918
+ return [...new Set(result)].sort();
1919
+ }
1920
+
1469
1921
  function projectMessage(value: unknown) {
1470
1922
  const message = slackRecord(value) ?? {};
1471
1923
  return {
@@ -1913,8 +2365,8 @@ function privateSlackFileUrl(file: Record<string, unknown>): URL | null {
1913
2365
 
1914
2366
  function assertPrivateSlackFileUrl(url: URL): void {
1915
2367
  const hostname = url.hostname.toLowerCase();
1916
- if (url.protocol !== "https:" || (hostname !== "slack.com" && !hostname.endsWith(".slack.com"))) {
1917
- throw new Error("Slack file URL must use HTTPS on slack.com");
2368
+ if (url.protocol !== "https:" || !SLACK_PRIVATE_FILE_HOSTS.has(hostname) || url.port) {
2369
+ throw new Error("Slack file URL must use an allowed HTTPS Slack host");
1918
2370
  }
1919
2371
  if (url.username || url.password) {
1920
2372
  throw new Error("Slack file URL must not contain credentials");
@@ -1925,10 +2377,172 @@ function isSlackInteractiveFileRedirect(url: URL): boolean {
1925
2377
  return url.pathname === "/" && url.searchParams.has("redir");
1926
2378
  }
1927
2379
 
2380
+ function isSlackOwnedInteractiveFileRedirect(url: URL): boolean {
2381
+ const hostname = url.hostname.toLowerCase();
2382
+ return (
2383
+ url.protocol === "https:" &&
2384
+ !url.port &&
2385
+ !url.username &&
2386
+ !url.password &&
2387
+ (hostname === "slack.com" || hostname.endsWith(".slack.com")) &&
2388
+ isSlackInteractiveFileRedirect(url)
2389
+ );
2390
+ }
2391
+
1928
2392
  function normalizedContentType(value: string | null): string {
1929
2393
  return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase();
1930
2394
  }
1931
2395
 
2396
+ export function sniffSlackReactionImageMime(
2397
+ bytes: Uint8Array,
2398
+ ): "image/png" | "image/jpeg" | "image/webp" | null {
2399
+ if (bytes.byteLength < 12 || hasMarkupPrefix(bytes)) return null;
2400
+ if (isCompletePng(bytes)) return "image/png";
2401
+ if (isCompleteJpeg(bytes)) return "image/jpeg";
2402
+ if (isCompleteWebp(bytes)) return "image/webp";
2403
+ return null;
2404
+ }
2405
+
2406
+ function hasMarkupPrefix(bytes: Uint8Array): boolean {
2407
+ const prefix = new TextDecoder("utf-8", { fatal: false })
2408
+ .decode(bytes.subarray(0, Math.min(bytes.byteLength, 256)))
2409
+ .replace(/^\uFEFF/u, "")
2410
+ .trimStart()
2411
+ .toLowerCase();
2412
+ return (
2413
+ prefix.startsWith("<svg") ||
2414
+ prefix.startsWith("<html") ||
2415
+ prefix.startsWith("<!doctype") ||
2416
+ prefix.startsWith("<?xml")
2417
+ );
2418
+ }
2419
+
2420
+ function isCompletePng(bytes: Uint8Array): boolean {
2421
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
2422
+ if (!signature.every((value, index) => bytes[index] === value)) return false;
2423
+ let offset = 8;
2424
+ let sawHeader = false;
2425
+ while (offset + 12 <= bytes.byteLength) {
2426
+ const length = readUint32Be(bytes, offset);
2427
+ const typeOffset = offset + 4;
2428
+ const dataOffset = offset + 8;
2429
+ const next = dataOffset + length + 4;
2430
+ if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
2431
+ const type = String.fromCharCode(
2432
+ bytes[typeOffset]!,
2433
+ bytes[typeOffset + 1]!,
2434
+ bytes[typeOffset + 2]!,
2435
+ bytes[typeOffset + 3]!,
2436
+ );
2437
+ if (!sawHeader) {
2438
+ if (type !== "IHDR" || length !== 13) return false;
2439
+ sawHeader = true;
2440
+ }
2441
+ if (type === "IEND") return length === 0 && next === bytes.byteLength;
2442
+ offset = next;
2443
+ }
2444
+ return false;
2445
+ }
2446
+
2447
+ function isCompleteJpeg(bytes: Uint8Array): boolean {
2448
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
2449
+ let offset = 2;
2450
+ let sawFrame = false;
2451
+ let inScan = false;
2452
+ while (offset < bytes.byteLength) {
2453
+ if (inScan) {
2454
+ if (bytes[offset] !== 0xff) {
2455
+ offset += 1;
2456
+ continue;
2457
+ }
2458
+ while (bytes[offset] === 0xff) offset += 1;
2459
+ const marker = bytes[offset];
2460
+ if (marker === undefined) return false;
2461
+ if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
2462
+ offset += 1;
2463
+ continue;
2464
+ }
2465
+ if (marker === 0xd9) return sawFrame && offset + 1 === bytes.byteLength;
2466
+ inScan = false;
2467
+ offset -= 1;
2468
+ continue;
2469
+ }
2470
+ if (bytes[offset] !== 0xff) return false;
2471
+ while (bytes[offset] === 0xff) offset += 1;
2472
+ const marker = bytes[offset++];
2473
+ if (marker === undefined || marker === 0x00) return false;
2474
+ if (marker === 0xd9) return sawFrame && offset === bytes.byteLength;
2475
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
2476
+ if (offset + 2 > bytes.byteLength) return false;
2477
+ const length = (bytes[offset]! << 8) | bytes[offset + 1]!;
2478
+ if (length < 2 || offset + length > bytes.byteLength) return false;
2479
+ if (
2480
+ marker === 0xc0 ||
2481
+ marker === 0xc1 ||
2482
+ marker === 0xc2 ||
2483
+ marker === 0xc3 ||
2484
+ marker === 0xc5 ||
2485
+ marker === 0xc6 ||
2486
+ marker === 0xc7 ||
2487
+ marker === 0xc9 ||
2488
+ marker === 0xca ||
2489
+ marker === 0xcb ||
2490
+ marker === 0xcd ||
2491
+ marker === 0xce ||
2492
+ marker === 0xcf
2493
+ ) {
2494
+ sawFrame = true;
2495
+ }
2496
+ if (marker === 0xda) inScan = true;
2497
+ offset += length;
2498
+ }
2499
+ return false;
2500
+ }
2501
+
2502
+ function isCompleteWebp(bytes: Uint8Array): boolean {
2503
+ if (
2504
+ ascii(bytes, 0, 4) !== "RIFF" ||
2505
+ ascii(bytes, 8, 12) !== "WEBP" ||
2506
+ readUint32Le(bytes, 4) + 8 !== bytes.byteLength
2507
+ ) {
2508
+ return false;
2509
+ }
2510
+ let offset = 12;
2511
+ let sawImageChunk = false;
2512
+ while (offset + 8 <= bytes.byteLength) {
2513
+ const type = ascii(bytes, offset, offset + 4);
2514
+ const length = readUint32Le(bytes, offset + 4);
2515
+ const paddedLength = length + (length % 2);
2516
+ const next = offset + 8 + paddedLength;
2517
+ if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
2518
+ if (type === "VP8 " || type === "VP8L" || type === "VP8X") sawImageChunk = true;
2519
+ offset = next;
2520
+ }
2521
+ return sawImageChunk && offset === bytes.byteLength;
2522
+ }
2523
+
2524
+ function readUint32Be(bytes: Uint8Array, offset: number): number {
2525
+ return (
2526
+ bytes[offset]! * 0x1_000000 +
2527
+ bytes[offset + 1]! * 0x1_0000 +
2528
+ bytes[offset + 2]! * 0x100 +
2529
+ bytes[offset + 3]!
2530
+ );
2531
+ }
2532
+
2533
+ function readUint32Le(bytes: Uint8Array, offset: number): number {
2534
+ return (
2535
+ bytes[offset]! +
2536
+ bytes[offset + 1]! * 0x100 +
2537
+ bytes[offset + 2]! * 0x1_0000 +
2538
+ bytes[offset + 3]! * 0x1_000000
2539
+ );
2540
+ }
2541
+
2542
+ function ascii(bytes: Uint8Array, start: number, end: number): string {
2543
+ return String.fromCharCode(...bytes.subarray(start, end));
2544
+ }
2545
+
1932
2546
  function isSupportedSlackTextContentType(value: string): boolean {
1933
2547
  return (
1934
2548
  value.startsWith("text/") ||
@@ -2104,11 +2718,26 @@ function slackMethodForOperation(operation: SlackBotOperation): string {
2104
2718
  return "files.info";
2105
2719
  case "message.post":
2106
2720
  return "chat.postMessage";
2721
+ case "message.update":
2722
+ return "chat.update";
2107
2723
  case "message.delete":
2108
2724
  return "chat.delete";
2109
2725
  }
2110
2726
  }
2111
2727
 
2728
+ function validateSlackMessageBlocks(
2729
+ blocks: SlackMessageBlock[] | undefined,
2730
+ ): SlackMessageBlock[] | undefined {
2731
+ if (blocks === undefined) return undefined;
2732
+ if (blocks.length < 1 || blocks.length > MAX_SLACK_BLOCKS) {
2733
+ throw new RangeError("Slack message blocks exceed the supported count");
2734
+ }
2735
+ if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > MAX_SLACK_BLOCK_BYTES) {
2736
+ throw new RangeError("Slack message blocks exceed the supported byte size");
2737
+ }
2738
+ return blocks;
2739
+ }
2740
+
2112
2741
  function boundedInt(value: number | undefined, max: number, fallback: number): number {
2113
2742
  return typeof value === "number" && Number.isInteger(value) && value > 0
2114
2743
  ? Math.min(value, max)
@@ -2160,9 +2789,9 @@ function safeFailureCode(error: unknown): string {
2160
2789
 
2161
2790
  function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
2162
2791
  if (!(error instanceof SlackBotProviderError)) return true;
2163
- return (
2164
- error.code === "transport_error" ||
2165
- error.code === "invalid_response" ||
2166
- error.code.startsWith("http_")
2167
- );
2792
+ if (error.code === "transport_error" || error.code === "invalid_response") return true;
2793
+ const httpStatus = /^http_(\d{3})$/u.exec(error.code)?.[1];
2794
+ if (!httpStatus) return false;
2795
+ const status = Number(httpStatus);
2796
+ return status === 408 || status >= 500;
2168
2797
  }