@opengeni/api-router 0.15.1 → 0.15.5

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.
@@ -1,5 +1,11 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
- import type { AccessGrant, HumanInputQuestion, SessionEvent } from "@opengeni/contracts";
2
+ import {
3
+ DEFAULT_FIRST_PARTY_MCP_TOOLS,
4
+ type AccessGrant,
5
+ type FirstPartyMcpToolName,
6
+ type HumanInputQuestion,
7
+ type SessionEvent,
8
+ } from "@opengeni/contracts";
3
9
  import {
4
10
  acceptSessionHumanInputResponse,
5
11
  advanceSlackInteractionDelivery,
@@ -8,6 +14,7 @@ import {
8
14
  claimSlackInteractionProgressDelivery,
9
15
  claimSlackInteractionInbox,
10
16
  closeSlackInteractionDelivery,
17
+ deferSlackInteractionDelivery,
11
18
  deleteSlackBotUserLink,
12
19
  enqueueSlackInteractionInbox,
13
20
  getOrCreateSlackInteraction,
@@ -39,7 +46,7 @@ import {
39
46
  import { publishDurableSessionEvents } from "@opengeni/events";
40
47
  import type { Context, Hono } from "hono";
41
48
  import { HTTPException } from "hono/http-exception";
42
- import { createOpenGeniSlackBotInteractionClient } from "./slack-bot";
49
+ import { createOpenGeniSlackBotInteractionClient, SlackBotProviderError } from "./slack-bot";
43
50
 
44
51
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
45
52
  export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
@@ -55,8 +62,11 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
55
62
  const MAX_SLACK_TEXT_CHARS = 3_500;
56
63
  const MAX_SLACK_INPUT_CHARS = 8_000;
57
64
  const MAX_PROGRESS_MESSAGES = 3;
65
+ const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
58
66
  const INBOX_LEASE_MS = 30_000;
59
67
  const DELIVERY_LEASE_MS = 30_000;
68
+ const MAX_DELIVERY_ATTEMPTS = 8;
69
+ const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
60
70
  export const SLACK_TASK_INSTRUCTIONS = [
61
71
  "This turn originated from Slack. Slack message and thread context is task-local only.",
62
72
  "Do not write Slack context to Documents, Knowledge, Memory, preferences, Workspace Charter, instructions, or policy unless a separate explicit authorized user action requests it.",
@@ -64,6 +74,23 @@ export const SLACK_TASK_INSTRUCTIONS = [
64
74
  "Keep user-visible output concise, bounded, and safe to send back to Slack.",
65
75
  ].join(" ");
66
76
 
77
+ /**
78
+ * Slack-originated tasks may retrieve the workspace bot's bounded read surface
79
+ * on demand. Connector tools are explicit-only, so freeze that narrow context
80
+ * selection at session creation while keeping Slack mutations out of the model
81
+ * surface; interaction delivery remains owned by the durable delivery pump.
82
+ */
83
+ export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
84
+ ...DEFAULT_FIRST_PARTY_MCP_TOOLS,
85
+ "slack_bot_list_channels",
86
+ "slack_bot_channel_history",
87
+ "slack_bot_thread_replies",
88
+ "slack_bot_list_users",
89
+ "slack_bot_list_files",
90
+ "slack_bot_file_info",
91
+ "slack_bot_file_content",
92
+ ] satisfies readonly FirstPartyMcpToolName[];
93
+
67
94
  export type NormalizedSlackInteraction = {
68
95
  providerEventId: string;
69
96
  providerMessageId: string;
@@ -181,6 +208,29 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
181
208
  throw new HTTPException(403, {
182
209
  message: "Slack installation unavailable",
183
210
  });
211
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
212
+ accountId: installation.accountId,
213
+ workspaceId: installation.workspaceId,
214
+ connectionId: installation.connectionId,
215
+ subjectId: "service:slack-interaction",
216
+ });
217
+ try {
218
+ await client.verifyChannelAccess(entry.slackChannelId);
219
+ } catch (error) {
220
+ if (error instanceof SlackBotProviderError && error.code === "not_in_channel") {
221
+ return c.text(
222
+ "OpenGeni is not a member of this channel. Add @OpenGeni, then run /opengeni again.",
223
+ 200,
224
+ );
225
+ }
226
+ // Slash commands are not replayed through the Events API. A transient
227
+ // membership preflight failure must therefore fall through to the
228
+ // durable inbox, whose normal claim/backoff path retries the same task.
229
+ // Permanent provider/local failures remain an honest request failure.
230
+ if (!(error instanceof SlackBotProviderError) || permanentSlackDeliveryError(error)) {
231
+ throw error;
232
+ }
233
+ }
184
234
  await enqueueNormalizedSlackInteraction(deps, installation, entry);
185
235
  return c.text("OpenGeni accepted this task and will reply in a thread.", 200);
186
236
  });
@@ -231,18 +281,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
231
281
 
232
282
  app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
233
283
  const workspaceId = c.req.param("workspaceId");
234
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
284
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
235
285
  const body = record(await c.req.json().catch(() => null));
236
- const connectionId = boundedString(body?.connectionId, 64);
237
- const slackTeamId = boundedString(body?.slackTeamId, 64);
238
- const slackUserId = boundedString(body?.slackUserId, 64);
239
- if (!connectionId || !slackTeamId || !slackUserId) {
286
+ const linkToken = boundedString(body?.linkToken, 2_048);
287
+ const signingSecret = deps.settings.slackSigningSecret;
288
+ const link =
289
+ linkToken && signingSecret ? verifySlackUserLinkToken(signingSecret, linkToken) : null;
290
+ if (!link || link.workspaceId !== workspaceId) {
240
291
  throw new HTTPException(400, {
241
- message: "invalid Slack identity link request",
292
+ message: "invalid or expired Slack identity link",
242
293
  });
243
294
  }
244
- const route = await resolveSlackInstallationRoute(deps.db, slackTeamId);
245
- if (!route || route.workspaceId !== workspaceId || route.connectionId !== connectionId) {
295
+ const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
296
+ if (!route || route.workspaceId !== workspaceId || route.connectionId !== link.connectionId) {
246
297
  throw new HTTPException(404, {
247
298
  message: "Slack installation not found",
248
299
  });
@@ -251,9 +302,9 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
251
302
  await saveSlackBotUserLink(deps.db, {
252
303
  accountId: grant.accountId,
253
304
  workspaceId,
254
- connectionId,
255
- slackTeamId,
256
- slackUserId,
305
+ connectionId: link.connectionId,
306
+ slackTeamId: link.slackTeamId,
307
+ slackUserId: link.slackUserId,
257
308
  subjectId: grant.subjectId,
258
309
  linkedBySubjectId: grant.subjectId,
259
310
  }),
@@ -293,7 +344,11 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
293
344
  });
294
345
  } catch (error) {
295
346
  const code = safeErrorCode(error);
296
- if (entry.attemptCount >= 5 || permanentSlackInteractionError(error)) {
347
+ if (
348
+ entry.attemptCount >= 5 ||
349
+ permanentSlackInteractionError(error) ||
350
+ permanentSlackDeliveryError(error)
351
+ ) {
297
352
  await settleSlackInteractionInbox(deps.db, {
298
353
  entry,
299
354
  claimHolderId: holder,
@@ -305,6 +360,7 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
305
360
  entry,
306
361
  claimHolderId: holder,
307
362
  errorCode: code,
363
+ retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, entry.attemptCount)),
308
364
  });
309
365
  }
310
366
  }
@@ -320,11 +376,29 @@ export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<bo
320
376
  if (!interaction) return false;
321
377
  try {
322
378
  await deliverSlackSessionEvents(deps, interaction, deliveryHolder);
323
- } catch {
324
- await releaseSlackInteractionDelivery(deps.db, {
325
- ...interaction,
326
- claimHolderId: deliveryHolder,
327
- }).catch(() => undefined);
379
+ } catch (error) {
380
+ const errorCode = slackDeliveryErrorCode(error);
381
+ if (
382
+ interaction.deliveryAttemptCount >= MAX_DELIVERY_ATTEMPTS ||
383
+ permanentSlackDeliveryError(error)
384
+ ) {
385
+ await closeSlackInteractionDelivery(deps.db, {
386
+ ...interaction,
387
+ claimHolderId: deliveryHolder,
388
+ sequence: interaction.lastDeliveredSessionEventSequence,
389
+ state: "failed",
390
+ errorCode,
391
+ }).catch(() => undefined);
392
+ } else {
393
+ await deferSlackInteractionDelivery(deps.db, {
394
+ ...interaction,
395
+ claimHolderId: deliveryHolder,
396
+ retryAt: new Date(
397
+ Date.now() + slackDeliveryRetryMs(error, interaction.deliveryAttemptCount),
398
+ ),
399
+ errorCode,
400
+ }).catch(() => undefined);
401
+ }
328
402
  }
329
403
  return true;
330
404
  }
@@ -383,8 +457,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
383
457
  if (!link) {
384
458
  await client.postMessage({
385
459
  operationId: deterministicUuid(`slack-link:${entry.id}`),
386
- channelId: entry.slackChannelId,
387
- ...(entry.slackThreadTs ? { threadTimestamp: entry.slackThreadTs } : {}),
460
+ userId: entry.slackUserId,
388
461
  text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
389
462
  });
390
463
  return;
@@ -423,6 +496,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
423
496
  requestedSessionId: interaction.sessionReservationId,
424
497
  initialMessage: entry.text,
425
498
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
499
+ firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
426
500
  idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
427
501
  clientEventId: `slack:${entry.providerEventId}`,
428
502
  });
@@ -777,15 +851,82 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
777
851
 
778
852
  function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
779
853
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
780
- if (!base) return "OpenGeni Settings → Integrations → Slack";
781
- const url = new URL("/settings/integrations/slack", base);
782
- url.searchParams.set("workspaceId", entry.workspaceId);
783
- url.searchParams.set("connectionId", entry.connectionId);
784
- url.searchParams.set("teamId", entry.slackTeamId);
785
- url.searchParams.set("userId", entry.slackUserId);
854
+ const signingSecret = deps.settings.slackSigningSecret;
855
+ if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
856
+ const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
857
+ url.searchParams.set("slack_link", createSlackUserLinkToken(signingSecret, entry));
786
858
  return url.toString();
787
859
  }
788
860
 
861
+ type SlackUserLinkToken = {
862
+ workspaceId: string;
863
+ connectionId: string;
864
+ slackTeamId: string;
865
+ slackUserId: string;
866
+ expiresAt: number;
867
+ };
868
+
869
+ export function createSlackUserLinkToken(
870
+ signingSecret: string,
871
+ entry: Pick<
872
+ SlackInteractionInboxEntry,
873
+ "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
874
+ >,
875
+ nowMs = Date.now(),
876
+ ) {
877
+ const payload = Buffer.from(
878
+ JSON.stringify({
879
+ workspaceId: entry.workspaceId,
880
+ connectionId: entry.connectionId,
881
+ slackTeamId: entry.slackTeamId,
882
+ slackUserId: entry.slackUserId,
883
+ expiresAt: nowMs + SLACK_USER_LINK_TTL_MS,
884
+ } satisfies SlackUserLinkToken),
885
+ "utf8",
886
+ ).toString("base64url");
887
+ const signature = createHmac("sha256", signingSecret).update(payload).digest("base64url");
888
+ return `${payload}.${signature}`;
889
+ }
890
+
891
+ export function verifySlackUserLinkToken(
892
+ signingSecret: string,
893
+ token: string,
894
+ nowMs = Date.now(),
895
+ ): SlackUserLinkToken | null {
896
+ const [payload, signature, extra] = token.split(".");
897
+ if (!payload || !signature || extra || payload.length > 1_500 || signature.length > 128)
898
+ return null;
899
+ const expected = createHmac("sha256", signingSecret).update(payload).digest("base64url");
900
+ const actualBytes = Buffer.from(signature, "utf8");
901
+ const expectedBytes = Buffer.from(expected, "utf8");
902
+ if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
903
+ return null;
904
+ }
905
+ try {
906
+ const value = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
907
+ const workspaceId = boundedString(value?.workspaceId, 64);
908
+ const connectionId = boundedString(value?.connectionId, 64);
909
+ const slackTeamId = boundedString(value?.slackTeamId, 64);
910
+ const slackUserId = boundedString(value?.slackUserId, 64);
911
+ const expiresAt = value?.expiresAt;
912
+ if (
913
+ !workspaceId ||
914
+ !connectionId ||
915
+ !slackTeamId ||
916
+ !slackUserId ||
917
+ typeof expiresAt !== "number" ||
918
+ !Number.isSafeInteger(expiresAt) ||
919
+ expiresAt < nowMs ||
920
+ expiresAt > nowMs + SLACK_USER_LINK_TTL_MS
921
+ ) {
922
+ return null;
923
+ }
924
+ return { workspaceId, connectionId, slackTeamId, slackUserId, expiresAt };
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
789
930
  function slackRouteKey(channelId: string, threadTs: string) {
790
931
  return `${channelId}:${threadTs}`;
791
932
  }
@@ -840,6 +981,7 @@ function safePayloadText(payload: unknown, field: string) {
840
981
  }
841
982
 
842
983
  function safeErrorCode(error: unknown) {
984
+ if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
843
985
  const raw = error instanceof Error ? error.name : "slack_interaction_error";
844
986
  return (
845
987
  raw
@@ -854,3 +996,38 @@ class SlackInteractionPermanentError extends Error {}
854
996
  function permanentSlackInteractionError(error: unknown) {
855
997
  return error instanceof SlackInteractionPermanentError || error instanceof HTTPException;
856
998
  }
999
+
1000
+ const PERMANENT_SLACK_DELIVERY_CODES = new Set([
1001
+ "account_inactive",
1002
+ "cannot_reply_to_message",
1003
+ "channel_not_found",
1004
+ "invalid_auth",
1005
+ "invalid_ts",
1006
+ "is_archived",
1007
+ "message_not_found",
1008
+ "not_authed",
1009
+ "not_in_channel",
1010
+ "token_expired",
1011
+ "token_revoked",
1012
+ ]);
1013
+
1014
+ function permanentSlackDeliveryError(error: unknown) {
1015
+ if (!(error instanceof SlackBotProviderError)) return false;
1016
+ if (PERMANENT_SLACK_DELIVERY_CODES.has(error.code)) return true;
1017
+ const status = /^http_(\d{3})$/.exec(error.code)?.[1];
1018
+ return status
1019
+ ? Number(status) >= 400 && Number(status) < 500 && status !== "408" && status !== "429"
1020
+ : false;
1021
+ }
1022
+
1023
+ function slackDeliveryRetryMs(error: unknown, attemptCount: number) {
1024
+ if (error instanceof SlackBotProviderError && error.retryAfterMs) {
1025
+ return error.retryAfterMs;
1026
+ }
1027
+ return Math.min(1_000 * 2 ** Math.max(0, attemptCount - 1), MAX_DELIVERY_RETRY_MS);
1028
+ }
1029
+
1030
+ function slackDeliveryErrorCode(error: unknown) {
1031
+ if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
1032
+ return safeErrorCode(error);
1033
+ }