@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
@@ -7,14 +7,20 @@ import {
7
7
  ListSlackUserLinkAccessRequestsResponse,
8
8
  PrepareSlackUserLinkAccessRequest,
9
9
  evaluateSlackTaskPolicy,
10
+ resolveWorkspaceSlackOrchestrationNoticeSettings,
10
11
  resolveWorkspaceSlackReactionSummonSettings,
12
+ SlackChannelRouteListResponse,
11
13
  SlackReactionChannelListResponse,
14
+ UpdateSlackChannelRoutesRequest,
12
15
  SlackUserLinkAccessMutationRequest,
13
16
  SlackUserLinkAccessRequest,
14
17
  type AccessGrant,
15
18
  type FileResourceRef,
16
19
  type FirstPartyMcpToolName,
17
20
  type HumanInputQuestion,
21
+ type ResolvedWorkspaceSlackOrchestrationNoticeSettings,
22
+ type ChildRequiresActionPayload,
23
+ type SessionAuthorizationListScope,
18
24
  type SessionEvent,
19
25
  type WorkspaceSlackReactionSummonSettings,
20
26
  workspaceSlackReactionChannelAllowed,
@@ -31,13 +37,16 @@ import {
31
37
  advanceSlackInteractionDelivery,
32
38
  bindSlackInteractionSession,
33
39
  cancelSlackUserLinkAccessRequest,
40
+ claimSlackAppHomeRefresh,
34
41
  claimSlackInteractionDelivery,
35
42
  claimSlackInteractionProgressDelivery,
36
43
  claimSlackInteractionInbox,
37
44
  closeSlackInteractionDelivery,
38
45
  completeSlackUserLinkAccessIfGranted,
46
+ decodeSessionListCursor,
39
47
  deferSlackInteractionDelivery,
40
48
  deleteSlackBotUserLink,
49
+ enqueueSlackAppHomeRefresh,
41
50
  enqueueSlackInteractionInbox,
42
51
  getConnectionMetadata,
43
52
  getOrCreateSlackInteraction,
@@ -45,36 +54,61 @@ import {
45
54
  getSession,
46
55
  getSessionEvent,
47
56
  getSessionHumanInputRequest,
57
+ childRequiresActionResolutionExists,
58
+ getSessionSystemUpdateById,
48
59
  getSlackBotPostOperation,
49
60
  getSlackBotUserLink,
50
61
  getSlackInteractionActionHandle,
51
62
  getSlackInteractionByClientEventId,
52
63
  getSlackInteractionById,
53
- getSlackInteractionByRoute,
64
+ getSlackInteractionByConnectionRoute,
65
+ probeSlackActionHandleTenancy,
66
+ openSlackRoutePrompt,
67
+ getSlackRoutePromptByOption,
68
+ settleSlackRoutePrompt,
69
+ answerSlackRoutePrompt,
70
+ type SlackRoutableWorkspace,
71
+ getSlackChannelRoute,
72
+ listSlackChannelRoutes,
73
+ upsertSlackChannelRoute,
74
+ deleteSlackChannelRoute,
75
+ getSlackUserDmRoute,
76
+ listNamedSubjectSlackRoutableWorkspaces,
54
77
  getActiveSlackTaskPolicy,
55
78
  getSlackSharedTaskOrigin,
56
79
  getSessionEventByClientEventId,
57
80
  getWorkspace,
58
81
  getWorkspaceGrant,
82
+ namedSubjectPersonalWorkspaceId,
83
+ resolveSlackTargetAuthority,
59
84
  listSlackInteractionProgressDeliveryEvidence,
60
85
  listSessionEventPage,
61
86
  listSessionHumanInputRequests,
62
87
  listPendingSlackUserLinkAccessRequests,
88
+ listSlackInstallationBindings,
89
+ slackUserLinkAccessRequestTeam,
63
90
  rekeySlackInteractionRoute,
64
91
  reopenSlackInteractionDelivery,
65
92
  reserveSlackInteractionActionHandles,
93
+ renewSlackAppHomeRefreshClaim,
66
94
  releaseSlackInteractionDelivery,
95
+ releaseSlackAppHomeRefresh,
67
96
  releaseSlackInteractionInbox,
68
97
  requestSlackUserLinkWorkspaceAccess,
69
98
  resolveSlackInstallationRoute,
99
+ resolveSlackInteractionFirstTaskHint,
70
100
  saveSlackInteractionInboxReactionCheckpoint,
71
101
  saveSlackSharedTaskOrigin,
72
102
  settleSlackInteractionInbox,
103
+ settleSlackAppHomeRefresh,
73
104
  settleSlackInteractionActionHandles,
105
+ listSessionsForSubject,
106
+ SessionListAccessError,
74
107
  denySlackUserLinkAccessRequest,
75
108
  prepareSlackUserLinkAccessRequest,
76
109
  SlackUserLinkAccessPersistenceError,
77
110
  type SlackInstallationRoute,
111
+ type SlackAppHomeRefresh,
78
112
  type SlackInteraction,
79
113
  type SlackInteractionActionHandle,
80
114
  type SlackInteractionActionKind,
@@ -88,6 +122,7 @@ import {
88
122
  hasPermission,
89
123
  requireAccessContext,
90
124
  requireAccessGrant,
125
+ requireSessionAuthorizationListScope,
91
126
  type ApiRouteDeps,
92
127
  } from "@opengeni/core";
93
128
  import { publishDurableSessionEvents } from "@opengeni/events";
@@ -100,6 +135,20 @@ import {
100
135
  type SlackMessageBlock,
101
136
  SlackBotProviderError,
102
137
  } from "./slack-bot";
138
+ import {
139
+ isSlackDirectMessageConversation,
140
+ resolveSlackWorkspaceRoute,
141
+ slackRoutedRequestText,
142
+ type SlackRouteResolution,
143
+ type SlackRouteTenancy,
144
+ } from "./slack-routing";
145
+ import {
146
+ buildSlackAppHomeAccessBlocks,
147
+ buildSlackAppHomeBlocks,
148
+ escapeSlackMrkdwn,
149
+ isSlackAppHomeLinkAction,
150
+ slackAppHomeOpenedEvent,
151
+ } from "./slack-app-home";
103
152
  import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
104
153
 
105
154
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
@@ -112,6 +161,13 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
112
161
  "turn.failed",
113
162
  "turn.cancelled",
114
163
  "session.status.changed",
164
+ // Orchestration surfacing. Both are filtered again inside the pump: the
165
+ // workspace must have opted in (both notices default off), and then only a
166
+ // `child_requires_action` notice and a `limits` / `max_auto_continuations`
167
+ // goal pause reach Slack, so the thread stays quiet for the deferred child
168
+ // lifecycle kinds and for human/API/agent pauses the human already made.
169
+ "system.update.pending",
170
+ "goal.paused",
115
171
  ] as const;
116
172
 
117
173
  const MAX_SLACK_TEXT_CHARS = 3_500;
@@ -125,6 +181,7 @@ const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
125
181
  const MAX_PROGRESS_MESSAGES = 3;
126
182
  const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
127
183
  const INBOX_LEASE_MS = 30_000;
184
+ const APP_HOME_LEASE_MS = 300_000;
128
185
  const DELIVERY_LEASE_MS = 30_000;
129
186
  const MAX_DELIVERY_ATTEMPTS = 8;
130
187
  const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
@@ -132,6 +189,23 @@ const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
132
189
  const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
133
190
  const MAX_SLACK_APPROVALS_PER_CARD = 8;
134
191
  const MAX_SLACK_ACTIONS_PER_CARD = 20;
192
+ /** Blocked-worker cards are pointers, so the question preview stays short. */
193
+ const MAX_SLACK_CHILD_DETAIL_CHARS = 240;
194
+ const SLACK_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
195
+ /**
196
+ * Goal pause reasons the Slack thread announces. `user_pause`, `api`, `agent`,
197
+ * and `no_progress` are deliberately absent.
198
+ *
199
+ * A Map, not a plain object: this lookup is the sole gate of a two-reason
200
+ * invariant, and a plain object indexed by a payload-derived string answers
201
+ * `constructor` (and every other prototype key) with a truthy value that would
202
+ * sail past the `if (!headline)` guard. No payload reaches it with such a
203
+ * reason today; the gate should not depend on that staying true.
204
+ */
205
+ const SLACK_GOAL_PAUSED_HEADLINES = new Map<string, string>([
206
+ ["limits", "Goal paused (budget)"],
207
+ ["max_auto_continuations", "Goal paused (continuation cap)"],
208
+ ]);
135
209
  /**
136
210
  * Slack delivery restrictions are durable session-level authority, not
137
211
  * attacker-adjacent user-message context. Migration 0240 backfills this exact
@@ -340,6 +414,46 @@ export function slackReactionInboxEntry(
340
414
  };
341
415
  }
342
416
 
417
+ /**
418
+ * Prove a Slack link token may name this workspace.
419
+ *
420
+ * Before routing, a token could only ever name the installation's own
421
+ * workspace, and both intent routes asserted exactly that. A routed
422
+ * conversation asks for access to the workspace the work would actually land
423
+ * in, which is a different one, so the assertion becomes: the installation
424
+ * binding resolves for the token's team, it is the same connection the token
425
+ * was minted against, and the named workspace belongs to that installation's
426
+ * organization.
427
+ *
428
+ * That is the same fence the route tables carry. It does not widen access by
429
+ * itself: this only decides which workspace an access REQUEST may be raised
430
+ * for, and the request still goes through the ordinary approval path.
431
+ */
432
+ async function resolveSlackLinkTargetInstallation(
433
+ deps: ApiRouteDeps,
434
+ link: { slackTeamId: string; connectionId: string },
435
+ workspaceId: string,
436
+ ): Promise<{
437
+ accountId: string;
438
+ connectionId: string;
439
+ workspaceName: string;
440
+ /** Where the identity link lives, which is not the routed workspace. */
441
+ identityHome: { accountId: string; workspaceId: string };
442
+ } | null> {
443
+ const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
444
+ if (!route || route.connectionId !== link.connectionId || route.accountId.length === 0) {
445
+ return null;
446
+ }
447
+ const workspace = await getWorkspace(deps.db, workspaceId);
448
+ if (!workspace || workspace.accountId !== route.accountId) return null;
449
+ return {
450
+ accountId: route.accountId,
451
+ connectionId: route.connectionId,
452
+ workspaceName: workspace.name,
453
+ identityHome: { accountId: route.accountId, workspaceId: route.workspaceId },
454
+ };
455
+ }
456
+
343
457
  export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
344
458
  app.post("/v1/integrations/slack/events", async (c) => {
345
459
  const signed = await readSignedSlackRequest(c, deps);
@@ -357,6 +471,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
357
471
  message: "Slack installation unavailable",
358
472
  });
359
473
  const event = record(payload.event);
474
+ const appHomeEvent = slackAppHomeOpenedEvent(payload);
475
+ if (appHomeEvent) {
476
+ await enqueueSlackAppHomeRefresh(deps.db, {
477
+ accountId: installation.accountId,
478
+ workspaceId: installation.workspaceId,
479
+ connectionId: installation.connectionId,
480
+ slackTeamId: appHomeEvent.slackTeamId,
481
+ slackUserId: appHomeEvent.slackUserId,
482
+ providerEventId: appHomeEvent.eventId,
483
+ providerViewHash: appHomeEvent.viewHash,
484
+ });
485
+ return c.json({ ok: true });
486
+ }
360
487
  if (event?.type === "reaction_added") {
361
488
  const [workspace, connection] = await Promise.all([
362
489
  getWorkspace(deps.db, installation.workspaceId),
@@ -383,7 +510,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
383
510
  app.post("/v1/integrations/slack/commands", async (c) => {
384
511
  const signed = await readSignedSlackRequest(c, deps);
385
512
  const form = new URLSearchParams(signed.rawBody);
386
- if (form.get("command") !== "/opengeni") {
513
+ if (form.get("command") !== deps.settings.slackCommand) {
387
514
  throw new HTTPException(400, { message: "invalid Slack command" });
388
515
  }
389
516
  const entry = normalizedFormInteraction(form, "slash_command");
@@ -392,6 +519,12 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
392
519
  throw new HTTPException(403, {
393
520
  message: "Slack installation unavailable",
394
521
  });
522
+ // `<command> info` is a read-only ephemeral explainer. It never reaches the
523
+ // durable inbox, never verifies channel membership, and never creates a
524
+ // session, so it is safe anywhere the command is available.
525
+ if (isSlackInfoCommand(entry.text)) {
526
+ return c.json(await slackInfoCommandResponse(deps, installation, entry));
527
+ }
395
528
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
396
529
  accountId: installation.accountId,
397
530
  workspaceId: installation.workspaceId,
@@ -424,6 +557,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
424
557
  const form = new URLSearchParams(signed.rawBody);
425
558
  const payload = parseJsonObject(form.get("payload") ?? "");
426
559
  if (payload.type === "block_actions") {
560
+ if (isSlackAppHomeLinkAction(payload)) return c.json({ ok: true });
427
561
  const entry = normalizedBlockActionInteraction(payload);
428
562
  const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
429
563
  if (!installation) {
@@ -482,22 +616,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
482
616
  if (!link || link.workspaceId !== workspaceId) {
483
617
  throw freshSlackLinkRequired();
484
618
  }
485
- const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
486
- if (
487
- !route ||
488
- route.workspaceId !== workspaceId ||
489
- route.connectionId !== link.connectionId ||
490
- route.accountId.length === 0
491
- ) {
492
- throw freshSlackLinkRequired();
493
- }
494
- const workspace = await getWorkspace(deps.db, workspaceId);
495
- if (!workspace || workspace.accountId !== route.accountId) {
619
+ const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
620
+ if (!installation) {
496
621
  throw freshSlackLinkRequired();
497
622
  }
498
623
  try {
499
624
  const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
500
- accountId: route.accountId,
625
+ accountId: installation.accountId,
501
626
  workspaceId,
502
627
  tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
503
628
  connectionId: link.connectionId,
@@ -511,12 +636,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
511
636
  workspaceId,
512
637
  requestId: prepared.id,
513
638
  subjectId: context.subjectId,
639
+ identityHome: installation.identityHome,
514
640
  });
515
641
  if (!completed) throw freshSlackLinkRequired();
516
642
  return c.json(
517
643
  SlackUserLinkAccessRequest.parse({
518
644
  ...completed,
519
- workspaceDisplayName: workspace.name,
645
+ workspaceDisplayName: installation.workspaceName,
520
646
  }),
521
647
  201,
522
648
  );
@@ -624,14 +750,33 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
624
750
  const workspaceId = c.req.param("workspaceId");
625
751
  const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
626
752
  const payload = ApproveSlackUserLinkAccessRequest.parse(await c.req.json());
753
+ // The identity link belongs to the installation, which for a routed
754
+ // request is a different workspace from the one being granted. The
755
+ // request row names its Slack team, so the binding resolves from it.
756
+ const requestId = c.req.param("requestId");
757
+ const requestTeam = await slackUserLinkAccessRequestTeam(deps.db, {
758
+ workspaceId,
759
+ requestId,
760
+ });
761
+ const installation = requestTeam
762
+ ? await resolveSlackInstallationRoute(deps.db, requestTeam.slackTeamId)
763
+ : null;
627
764
  try {
628
765
  const request = await approveSlackUserLinkAccessRequest(deps.db, {
629
766
  workspaceId,
630
- requestId: c.req.param("requestId"),
767
+ requestId,
631
768
  actorSubjectId: grant.subjectId,
632
769
  expectedVersion: payload.expectedVersion,
633
770
  idempotencyKey: payload.idempotencyKey,
634
771
  permissions: payload.permissions,
772
+ ...(installation
773
+ ? {
774
+ identityHome: {
775
+ accountId: installation.accountId,
776
+ workspaceId: installation.workspaceId,
777
+ },
778
+ }
779
+ : {}),
635
780
  ...(payload.role !== undefined ? { role: payload.role } : {}),
636
781
  });
637
782
  const workspace = await getWorkspace(deps.db, workspaceId);
@@ -690,8 +835,8 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
690
835
  message: "invalid or expired Slack identity link",
691
836
  });
692
837
  }
693
- const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
694
- if (!route || route.workspaceId !== workspaceId || route.connectionId !== link.connectionId) {
838
+ const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
839
+ if (!installation) {
695
840
  throw new HTTPException(404, {
696
841
  message: "Slack installation not found",
697
842
  });
@@ -712,6 +857,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
712
857
  workspaceId,
713
858
  requestId: prepared.id,
714
859
  subjectId: grant.subjectId,
860
+ identityHome: installation.identityHome,
715
861
  });
716
862
  if (completed?.status !== "completed") throw freshSlackLinkRequired();
717
863
  const saved = await getSlackBotUserLink(
@@ -745,6 +891,135 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
745
891
  },
746
892
  );
747
893
 
894
+ app.get("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
895
+ const workspaceId = c.req.param("workspaceId");
896
+ // Same gate as the reaction-channel allowlist beside it. The response names
897
+ // workspaces across the organization, including ones the reader may hold no
898
+ // grant in, so it is not an ordinary connections read.
899
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
900
+ const connectionId = boundedString(c.req.query("connectionId"), 64);
901
+ if (!connectionId) throw new HTTPException(400, { message: "connectionId is required" });
902
+ const routes = await listSlackChannelRoutes(
903
+ deps.db,
904
+ { accountId: grant.accountId, workspaceId },
905
+ { connectionId },
906
+ );
907
+ // Names are resolved once per distinct target rather than per route.
908
+ const names = new Map<string, string | null>();
909
+ for (const route of routes) {
910
+ if (names.has(route.targetWorkspaceId)) continue;
911
+ names.set(
912
+ route.targetWorkspaceId,
913
+ (await getWorkspace(deps.db, route.targetWorkspaceId))?.name ?? null,
914
+ );
915
+ }
916
+ return c.json(
917
+ SlackChannelRouteListResponse.parse({
918
+ routingEnabled: deps.settings.slackWorkspaceRoutingEnabled,
919
+ routes: routes.map((route) => ({
920
+ slackChannelId: route.slackChannelId,
921
+ targetWorkspaceId: route.targetWorkspaceId,
922
+ targetWorkspaceName: names.get(route.targetWorkspaceId) ?? null,
923
+ source: route.source,
924
+ updatedAt: route.updatedAt.toISOString(),
925
+ })),
926
+ }),
927
+ );
928
+ });
929
+
930
+ app.put("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
931
+ const workspaceId = c.req.param("workspaceId");
932
+ // Same gate as the reaction-channel allowlist this sits beside: pointing a
933
+ // channel somewhere is an installation-administration act.
934
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
935
+ const payload = UpdateSlackChannelRoutesRequest.parse(await c.req.json());
936
+ const home = { accountId: grant.accountId, workspaceId };
937
+ const installation = await listSlackInstallationBindings(deps.db, home);
938
+ const binding = installation.find(
939
+ (candidate) => candidate.connectionId === payload.connectionId,
940
+ );
941
+ // The connection must be this workspace's own installation, and the route
942
+ // rows carry its team id, so a missing binding is a refusal rather than a
943
+ // row written with an empty team the column's own bound would reject.
944
+ if (!binding) {
945
+ throw new HTTPException(404, { message: "Slack installation not found" });
946
+ }
947
+ // Authorize EVERY target before writing anything. Applying routes one at a
948
+ // time means a refusal partway through would otherwise leave some channels
949
+ // moved and the rest not, behind an error that says the save failed.
950
+ //
951
+ // An admin may only point a channel at a workspace they could start work in
952
+ // themselves, and only inside this organization. The table's own CHECK
953
+ // enforces the second half; this refuses before writing rather than
954
+ // surfacing a constraint error.
955
+ // A channel may never be pointed at a personal workspace. The routing
956
+ // resolver already refuses to treat one as a candidate in a channel, and
957
+ // this is the same rule at the write boundary, where a durable route would
958
+ // otherwise smuggle it back in: every message in that channel would land
959
+ // somewhere nobody else in the channel can see.
960
+ //
961
+ // Only the caller's OWN personal workspace has to be excluded, and that is
962
+ // the whole rule rather than a partial one, because no other person's can
963
+ // reach this loop. `resolveSlackTargetAuthority` admits exactly two shapes:
964
+ // a `workspace_memberships` row this subject holds, or this subject's own
965
+ // personal-workspace pointer. There is no account-admin union, so an
966
+ // administrator does not thereby reach anyone else's personal workspace.
967
+ //
968
+ // Deliberately NOT justified by "the schema forbids a membership row on a
969
+ // personal workspace". It does not. Migration 0219's `42501` is a
970
+ // precondition inside the provisioning function, checked at provisioning
971
+ // time only, and `personal_workspace_has_no_membership_row` is recorded as
972
+ // `basis: "runtime"` - the parity report's own term for a property nothing
973
+ // in the schema prevents. It holds because every membership writer either
974
+ // requires `members:manage` on the target (which a personal-workspace grant
975
+ // deliberately omits) or excludes personal workspaces outright.
976
+ const callerPersonalWorkspaceId = await namedSubjectPersonalWorkspaceId(deps.db, {
977
+ accountId: grant.accountId,
978
+ subjectId: grant.subjectId,
979
+ });
980
+ for (const route of payload.routes) {
981
+ if (route.targetWorkspaceId === null) continue;
982
+ const authorized = await resolveSlackTargetAuthority(deps.db, {
983
+ subjectId: grant.subjectId,
984
+ targetAccountId: grant.accountId,
985
+ targetWorkspaceId: route.targetWorkspaceId,
986
+ });
987
+ if (!authorized || !hasPermission(authorized.permissions, "sessions:create")) {
988
+ throw new HTTPException(403, {
989
+ message: `you cannot start work in the workspace chosen for ${route.slackChannelId}`,
990
+ });
991
+ }
992
+ if (
993
+ callerPersonalWorkspaceId !== null &&
994
+ route.targetWorkspaceId === callerPersonalWorkspaceId
995
+ ) {
996
+ throw new HTTPException(422, {
997
+ message: `a channel cannot be routed to a personal workspace: work started in ${route.slackChannelId} would be invisible to everyone else in it`,
998
+ });
999
+ }
1000
+ }
1001
+ for (const route of payload.routes) {
1002
+ if (route.targetWorkspaceId === null) {
1003
+ await deleteSlackChannelRoute(deps.db, home, {
1004
+ connectionId: payload.connectionId,
1005
+ slackChannelId: route.slackChannelId,
1006
+ });
1007
+ continue;
1008
+ }
1009
+ await upsertSlackChannelRoute(deps.db, home, {
1010
+ connectionId: payload.connectionId,
1011
+ slackTeamId: binding.slackTeamId,
1012
+ slackChannelId: route.slackChannelId,
1013
+ targetAccountId: grant.accountId,
1014
+ targetWorkspaceId: route.targetWorkspaceId,
1015
+ decidedBySubjectId: grant.subjectId,
1016
+ decidedBySlackUserId: "admin",
1017
+ source: "admin",
1018
+ });
1019
+ }
1020
+ return c.json({ ok: true });
1021
+ });
1022
+
748
1023
  app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
749
1024
  const workspaceId = c.req.param("workspaceId");
750
1025
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -783,7 +1058,285 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
783
1058
  });
784
1059
  }
785
1060
 
1061
+ async function publishSlackAppHome(
1062
+ deps: ApiRouteDeps,
1063
+ installation: SlackInstallationRoute,
1064
+ refresh: SlackAppHomeRefresh,
1065
+ renewLease: () => Promise<void>,
1066
+ ): Promise<void> {
1067
+ const link = await getSlackBotUserLink(
1068
+ deps.db,
1069
+ installation.workspaceId,
1070
+ installation.connectionId,
1071
+ refresh.slackUserId,
1072
+ );
1073
+ const grant = link
1074
+ ? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
1075
+ principalKind: "human_session",
1076
+ })
1077
+ : null;
1078
+ if (
1079
+ !grant ||
1080
+ grant.accountId !== installation.accountId ||
1081
+ !hasPermission(grant.permissions, "sessions:read")
1082
+ ) {
1083
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
1084
+ accountId: installation.accountId,
1085
+ workspaceId: installation.workspaceId,
1086
+ connectionId: installation.connectionId,
1087
+ subjectId: "service:slack-app-home",
1088
+ });
1089
+ await publishSlackAppHomeAccessView(
1090
+ client,
1091
+ refresh,
1092
+ renewLease,
1093
+ buildSlackAppHomeAccessBlocks({
1094
+ title: link ? "OpenGeni access needed" : "Connect your OpenGeni account",
1095
+ message: link
1096
+ ? "Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace."
1097
+ : "Link this Slack identity to see your active tasks, requests, and recent results here.",
1098
+ actionLabel: link ? "Request access" : "Connect OpenGeni",
1099
+ actionUrl: slackAppHomeLinkUrl(
1100
+ deps,
1101
+ installation,
1102
+ refresh.slackTeamId,
1103
+ refresh.slackUserId,
1104
+ ),
1105
+ }),
1106
+ );
1107
+ return;
1108
+ }
1109
+
1110
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
1111
+ accountId: installation.accountId,
1112
+ workspaceId: installation.workspaceId,
1113
+ connectionId: installation.connectionId,
1114
+ subjectId: grant.subjectId,
1115
+ });
1116
+ // Host session authorization is resolved at publication time, before the
1117
+ // database query, so App Home cannot leak rows through a stale broad list.
1118
+ const authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "core");
1119
+ const sessions: Awaited<ReturnType<typeof listSessionsForSubject>>["sessions"] = [];
1120
+ try {
1121
+ let cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
1122
+ do {
1123
+ await renewLease();
1124
+ const page = await listSessionsForSubject(deps.db, installation.workspaceId, {
1125
+ subjectId: grant.subjectId,
1126
+ limit: 500,
1127
+ ...(cursor ? { cursor } : {}),
1128
+ ...(authorizationScope ? { authorizationScope } : {}),
1129
+ });
1130
+ if (!cursor) sessions.push(...page.pinned);
1131
+ sessions.push(...page.sessions);
1132
+ if (!page.nextCursor) break;
1133
+ cursor = decodeSessionListCursor(page.nextCursor) ?? undefined;
1134
+ if (!cursor) throw new Error("Slack App Home session cursor was invalid");
1135
+ } while (cursor);
1136
+ } catch (error) {
1137
+ if (!(error instanceof SessionListAccessError)) throw error;
1138
+ await publishSlackAppHomeAccessView(
1139
+ client,
1140
+ refresh,
1141
+ renewLease,
1142
+ buildSlackAppHomeAccessBlocks({
1143
+ title: "OpenGeni access changed",
1144
+ message:
1145
+ "Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
1146
+ actionLabel: "Reconnect OpenGeni",
1147
+ actionUrl: slackAppHomeLinkUrl(
1148
+ deps,
1149
+ installation,
1150
+ refresh.slackTeamId,
1151
+ refresh.slackUserId,
1152
+ ),
1153
+ }),
1154
+ );
1155
+ return;
1156
+ }
1157
+ const currentLink = await getSlackBotUserLink(
1158
+ deps.db,
1159
+ installation.workspaceId,
1160
+ installation.connectionId,
1161
+ refresh.slackUserId,
1162
+ );
1163
+ const currentGrant = currentLink
1164
+ ? await getWorkspaceGrant(deps.db, currentLink.subjectId, installation.workspaceId, {
1165
+ principalKind: "human_session",
1166
+ })
1167
+ : null;
1168
+ if (
1169
+ !currentLink ||
1170
+ currentLink.subjectId !== grant.subjectId ||
1171
+ !currentGrant ||
1172
+ currentGrant.accountId !== installation.accountId ||
1173
+ !hasPermission(currentGrant.permissions, "sessions:read")
1174
+ ) {
1175
+ await publishSlackAppHomeAccessView(
1176
+ client,
1177
+ refresh,
1178
+ renewLease,
1179
+ buildSlackAppHomeAccessBlocks({
1180
+ title: "OpenGeni access changed",
1181
+ message:
1182
+ "Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
1183
+ actionLabel: "Reconnect OpenGeni",
1184
+ actionUrl: slackAppHomeLinkUrl(
1185
+ deps,
1186
+ installation,
1187
+ refresh.slackTeamId,
1188
+ refresh.slackUserId,
1189
+ ),
1190
+ }),
1191
+ );
1192
+ return;
1193
+ }
1194
+ const currentAuthorizationScope = await requireSessionAuthorizationListScope(
1195
+ deps,
1196
+ currentGrant,
1197
+ "core",
1198
+ );
1199
+ if (
1200
+ slackSessionAuthorizationScopeKey(currentAuthorizationScope) !==
1201
+ slackSessionAuthorizationScopeKey(authorizationScope)
1202
+ ) {
1203
+ await publishSlackAppHomeAccessView(
1204
+ client,
1205
+ refresh,
1206
+ renewLease,
1207
+ buildSlackAppHomeAccessBlocks({
1208
+ title: "OpenGeni access changed",
1209
+ message:
1210
+ "Your current task access changed while this view was loading. Reopen Home to refresh it safely.",
1211
+ actionLabel: "Open OpenGeni",
1212
+ actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1213
+ }),
1214
+ );
1215
+ return;
1216
+ }
1217
+ if (!refresh.providerViewHash) {
1218
+ await publishSlackAppHomeAccessView(
1219
+ client,
1220
+ refresh,
1221
+ renewLease,
1222
+ buildSlackAppHomeAccessBlocks({
1223
+ title: "Refresh OpenGeni Home",
1224
+ message:
1225
+ "Reopen Home to refresh your tasks safely. OpenGeni does not publish task data without Slack's current view version.",
1226
+ actionLabel: "Open OpenGeni",
1227
+ actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1228
+ }),
1229
+ );
1230
+ return;
1231
+ }
1232
+ await renewLease();
1233
+ await client.publishHomeView({
1234
+ userId: refresh.slackUserId,
1235
+ hash: refresh.providerViewHash,
1236
+ blocks: buildSlackAppHomeBlocks({
1237
+ sessions,
1238
+ workspaceUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1239
+ sessionUrl: (sessionId) => slackSessionUrl(deps, installation.workspaceId, sessionId),
1240
+ }),
1241
+ });
1242
+ }
1243
+
1244
+ async function publishSlackAppHomeAccessView(
1245
+ client: OpenGeniSlackBotClient,
1246
+ refresh: SlackAppHomeRefresh,
1247
+ renewLease: () => Promise<void>,
1248
+ blocks: ReturnType<typeof buildSlackAppHomeAccessBlocks>,
1249
+ ): Promise<void> {
1250
+ try {
1251
+ await renewLease();
1252
+ await client.publishHomeView({
1253
+ userId: refresh.slackUserId,
1254
+ hash: refresh.providerViewHash,
1255
+ blocks,
1256
+ });
1257
+ } catch (error) {
1258
+ if (!(error instanceof SlackBotProviderError) || error.code !== "hash_conflict") throw error;
1259
+ // Access views contain no task data. If an older authorized publication
1260
+ // advanced Slack's optimistic-concurrency hash, replace it fail-closed
1261
+ // rather than dropping the newer clearing obligation.
1262
+ await renewLease();
1263
+ await client.publishHomeView({
1264
+ userId: refresh.slackUserId,
1265
+ blocks,
1266
+ });
1267
+ }
1268
+ }
1269
+
786
1270
  export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
1271
+ const appHomeHolder = crypto.randomUUID();
1272
+ const refresh = await claimSlackAppHomeRefresh(deps.db, appHomeHolder, APP_HOME_LEASE_MS);
1273
+ if (refresh) {
1274
+ try {
1275
+ const installation = await resolveSlackInstallationRoute(deps.db, refresh.slackTeamId);
1276
+ if (
1277
+ !installation ||
1278
+ installation.accountId !== refresh.accountId ||
1279
+ installation.workspaceId !== refresh.workspaceId ||
1280
+ installation.connectionId !== refresh.connectionId
1281
+ ) {
1282
+ throw new SlackInteractionPermanentError("slack_app_home_installation_changed");
1283
+ }
1284
+ const renewLease = async () => {
1285
+ if (
1286
+ !(await renewSlackAppHomeRefreshClaim(deps.db, {
1287
+ refresh,
1288
+ claimHolderId: appHomeHolder,
1289
+ claimLeaseMs: APP_HOME_LEASE_MS,
1290
+ }))
1291
+ ) {
1292
+ throw new SlackInteractionPermanentError("slack_app_home_claim_lost");
1293
+ }
1294
+ };
1295
+ await renewLease();
1296
+ await publishSlackAppHome(deps, installation, refresh, renewLease);
1297
+ await settleSlackAppHomeRefresh(deps.db, {
1298
+ refresh,
1299
+ claimHolderId: appHomeHolder,
1300
+ });
1301
+ } catch (error) {
1302
+ const code = safeErrorCode(error);
1303
+ console.error("[slack-interactions] App Home refresh failed", {
1304
+ workspaceId: refresh.workspaceId,
1305
+ connectionId: refresh.connectionId,
1306
+ slackUserId: refresh.slackUserId,
1307
+ providerEventId: refresh.providerEventId,
1308
+ desiredRevision: refresh.desiredRevision,
1309
+ attemptCount: refresh.attemptCount,
1310
+ errorCode: code,
1311
+ });
1312
+ if (error instanceof SlackBotProviderError && error.code === "hash_conflict") {
1313
+ await settleSlackAppHomeRefresh(deps.db, {
1314
+ refresh,
1315
+ claimHolderId: appHomeHolder,
1316
+ errorCode: code,
1317
+ });
1318
+ } else if (
1319
+ refresh.attemptCount >= 5 ||
1320
+ error instanceof SlackInteractionPermanentError ||
1321
+ permanentSlackDeliveryError(error)
1322
+ ) {
1323
+ await settleSlackAppHomeRefresh(deps.db, {
1324
+ refresh,
1325
+ claimHolderId: appHomeHolder,
1326
+ errorCode: code,
1327
+ });
1328
+ } else {
1329
+ await releaseSlackAppHomeRefresh(deps.db, {
1330
+ refresh,
1331
+ claimHolderId: appHomeHolder,
1332
+ errorCode: code,
1333
+ retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, refresh.attemptCount)),
1334
+ });
1335
+ }
1336
+ }
1337
+ return true;
1338
+ }
1339
+
787
1340
  const holder = crypto.randomUUID();
788
1341
  const entry = await claimSlackInteractionInbox(deps.db, holder, INBOX_LEASE_MS);
789
1342
  if (entry) {
@@ -890,6 +1443,467 @@ export function startSlackInteractionPump(
890
1443
  };
891
1444
  }
892
1445
 
1446
+ /**
1447
+ * A concurrent creator may have won this thread in another workspace between the
1448
+ * tenancy probe and the insert, in which case `getOrCreateSlackInteraction`
1449
+ * adopts that row. The grant, the imported attachments, and the resolved route
1450
+ * all belong to the workspace this pass chose, so none of them may be used
1451
+ * against the adopted row. Retry instead: the next pass sees the thread through
1452
+ * the probe, routes to it, and authorizes it properly.
1453
+ */
1454
+ function requireSlackInteractionMatchesTarget(
1455
+ interaction: Pick<SlackInteraction, "accountId" | "workspaceId">,
1456
+ target: { accountId: string; workspaceId: string },
1457
+ ): void {
1458
+ if (
1459
+ interaction.accountId !== target.accountId ||
1460
+ interaction.workspaceId !== target.workspaceId
1461
+ ) {
1462
+ throw new SlackInteractionRetryableError("slack_route_creation_pending");
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * Gather the durable facts the routing decision needs, then decide.
1468
+ *
1469
+ * With the flag off nothing is read at all: the resolver short-circuits to the
1470
+ * installation's own workspace, so an existing single-workspace install issues
1471
+ * exactly the queries it issued before.
1472
+ */
1473
+ async function resolveSlackRouteForEntry(
1474
+ deps: ApiRouteDeps,
1475
+ home: SlackRouteTenancy,
1476
+ entry: SlackInteractionInboxEntry,
1477
+ subjectId: string,
1478
+ options: {
1479
+ threadTenancy: SlackRouteTenancy | null;
1480
+ askEnabled: boolean;
1481
+ botUserId: string | null;
1482
+ },
1483
+ ): Promise<SlackRouteResolution> {
1484
+ const base = {
1485
+ home,
1486
+ entry,
1487
+ botUserId: options.botUserId,
1488
+ threadTenancy: options.threadTenancy,
1489
+ channelRoute: null,
1490
+ dmRoute: null,
1491
+ personalWorkspaceId: null,
1492
+ candidates: [] as const,
1493
+ routingEnabled: false,
1494
+ askEnabled: false,
1495
+ } as const;
1496
+ // A mapped thread wins outright, and with the flag off nothing is consulted at
1497
+ // all, so neither case reads anything.
1498
+ if (!deps.settings.slackWorkspaceRoutingEnabled || options.threadTenancy) {
1499
+ return resolveSlackWorkspaceRoute(base);
1500
+ }
1501
+ const directMessage = isSlackDirectMessageConversation(entry);
1502
+ const [channelRoute, dmRoute, candidates] = await Promise.all([
1503
+ directMessage
1504
+ ? Promise.resolve(null)
1505
+ : getSlackChannelRoute(deps.db, home, {
1506
+ connectionId: entry.connectionId,
1507
+ slackChannelId: entry.slackChannelId,
1508
+ }),
1509
+ directMessage
1510
+ ? getSlackUserDmRoute(deps.db, home, {
1511
+ connectionId: entry.connectionId,
1512
+ slackUserId: entry.slackUserId,
1513
+ })
1514
+ : Promise.resolve(null),
1515
+ listNamedSubjectSlackRoutableWorkspaces(deps.db, { accountId: home.accountId, subjectId }),
1516
+ ]);
1517
+ return resolveSlackWorkspaceRoute({
1518
+ ...base,
1519
+ channelRoute,
1520
+ dmRoute,
1521
+ candidates,
1522
+ // The candidate set is built from the same derived pointer, so reading it
1523
+ // again would be a second query for an answer already in hand.
1524
+ personalWorkspaceId: candidates.find((candidate) => candidate.personal)?.workspaceId ?? null,
1525
+ routingEnabled: true,
1526
+ askEnabled: options.askEnabled,
1527
+ });
1528
+ }
1529
+
1530
+ /**
1531
+ * Tell the person exactly why nothing started, in their own bot DM.
1532
+ *
1533
+ * Every branch ends with "No session was created." because the one thing a
1534
+ * refusal must never do is leave someone believing work is under way.
1535
+ *
1536
+ * Only `no_access_to_route` mints an access-request link, and it mints one for
1537
+ * the workspace they actually need rather than the installation's. The other
1538
+ * two branches deliberately do not: a prefix naming something they cannot
1539
+ * reach, and an empty candidate list, both describe a workspace OpenGeni has
1540
+ * not established it will confirm to this person, so they point at an
1541
+ * administrator instead.
1542
+ */
1543
+ async function postSlackRouteRefusal(
1544
+ deps: ApiRouteDeps,
1545
+ client: OpenGeniSlackBotClient,
1546
+ entry: SlackInteractionInboxEntry,
1547
+ refusal: Extract<SlackRouteResolution, { kind: "denied" }> & Partial<SlackRouteTenancy>,
1548
+ ): Promise<void> {
1549
+ const available = refusal.candidates.map((candidate) => candidate.label);
1550
+ const text =
1551
+ refusal.reason === "no_access_to_named"
1552
+ ? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in from here.${
1553
+ available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
1554
+ } No session was created.`
1555
+ : refusal.reason === "no_access_to_route"
1556
+ ? `OpenGeni starts work from this conversation in ${
1557
+ refusal.requested ?? "another workspace"
1558
+ }, and you do not have access to it. Request access: ${linkUrl(
1559
+ deps,
1560
+ {
1561
+ // Minted for the workspace they actually need, not the
1562
+ // installation's. The token only decides which workspace an
1563
+ // access request may be raised for; approval is unchanged.
1564
+ workspaceId: refusal.workspaceId ?? entry.workspaceId,
1565
+ connectionId: entry.connectionId,
1566
+ slackTeamId: entry.slackTeamId,
1567
+ slackUserId: entry.slackUserId,
1568
+ },
1569
+ entry.createdAt.getTime(),
1570
+ )}. No session was created.`
1571
+ : // Deliberately "from this conversation" rather than "for you": in a
1572
+ // channel this also fires for someone whose only workspace is their
1573
+ // own personal one, and telling that person they have no workspace
1574
+ // at all would be false. Their bot DM still works.
1575
+ "OpenGeni has no workspace it can start this task in from this conversation. Ask an OpenGeni administrator to give you access to one, or message OpenGeni directly to work in your own workspace. No session was created.";
1576
+ await client.postMessage({
1577
+ operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
1578
+ userId: entry.slackUserId,
1579
+ text: boundedOutput(text),
1580
+ });
1581
+ }
1582
+
1583
+ /**
1584
+ * A workspace name as a Slack button label.
1585
+ *
1586
+ * Slack renders a button label as plain text and the stored option snapshot caps
1587
+ * it at 128 bytes, so bound it once at the boundary: the button, the row and
1588
+ * every outcome card then agree. Counted in UTF-8 bytes, because that is what
1589
+ * the CHECK counts.
1590
+ */
1591
+ function boundedSlackRouteLabel(label: string): string {
1592
+ const encoder = new TextEncoder();
1593
+ if (encoder.encode(label).length <= 72) return label;
1594
+ let bounded = label;
1595
+ while (bounded.length > 0 && encoder.encode(`${bounded}...`).length > 72) {
1596
+ bounded = bounded.slice(0, -1);
1597
+ }
1598
+ return `${bounded}...`;
1599
+ }
1600
+
1601
+ /**
1602
+ * Say where the work went, quietly.
1603
+ *
1604
+ * The line exists to stop work landing somewhere surprising, so it appears only
1605
+ * when the routing decision was a genuine choice: a null label means routing is
1606
+ * off, or the person had exactly one workspace anyway, and a constant footer on
1607
+ * every message would be noise rather than information.
1608
+ *
1609
+ * The label is frozen on the interaction rather than looked up here, because a
1610
+ * workspace rename between the original post and a reconciliation would make the
1611
+ * post ledger's byte comparison raise.
1612
+ */
1613
+ export function withWorkspaceLine(
1614
+ label: string | null,
1615
+ text: string,
1616
+ blocks?: SlackMessageBlock[],
1617
+ ): { text: string; blocks?: SlackMessageBlock[] } {
1618
+ if (!label) return blocks ? { text, blocks } : { text };
1619
+ const suffix = `\n-> ${label}`;
1620
+ // Reserve the suffix rather than truncating after the fact, so the bounded
1621
+ // body plus the line still fits.
1622
+ const body = boundedOutput(text, MAX_SLACK_TEXT_CHARS - suffix.length);
1623
+ const line: SlackMessageBlock = {
1624
+ type: "context",
1625
+ elements: [{ type: "mrkdwn", text: `-> ${label}` }],
1626
+ };
1627
+ return {
1628
+ text: `${body}${suffix}`,
1629
+ ...(blocks ? { blocks: [...blocks, line] } : {}),
1630
+ };
1631
+ }
1632
+
1633
+ /**
1634
+ * The post-ledger seed for one interaction's messages.
1635
+ *
1636
+ * The ledger binds one operation id to a digest over the rendered bytes, so
1637
+ * adding the workspace line under an unchanged id would wedge every interaction
1638
+ * with a claimed-but-unposted row. But bumping the id for interactions whose
1639
+ * bytes did NOT change is not free either: their ledger rows become unreachable,
1640
+ * so a repair or a cross-deploy retry posts a duplicate instead of finding the
1641
+ * completed original, and an action handle still points at the old operation.
1642
+ *
1643
+ * Only a labelled interaction renders different bytes, and the label is written
1644
+ * once at insert and never updated, so it is a stable discriminator. Version the
1645
+ * id on the label rather than on the release: a labelled interaction can only
1646
+ * have been created by an image that already renders the line, and every
1647
+ * pre-existing and unlabelled interaction keeps its exact ledger identity.
1648
+ */
1649
+ export function slackPostSeed(
1650
+ interaction: Pick<SlackInteraction, "routedWorkspaceLabel">,
1651
+ seed: string,
1652
+ ): string {
1653
+ return interaction.routedWorkspaceLabel ? `${seed}:v2` : seed;
1654
+ }
1655
+
1656
+ /** Sources that mean somebody, or something, actually chose this workspace. */
1657
+ const SLACK_ROUTE_SOURCES_WORTH_NAMING: ReadonlySet<string> = new Set([
1658
+ // Not "thread": a mapped thread reuses the interaction that already exists,
1659
+ // so it inherits the label frozen when that interaction was created and never
1660
+ // reaches the writer below.
1661
+ "prefix",
1662
+ "channel",
1663
+ "dm_route",
1664
+ "dm_personal",
1665
+ ]);
1666
+
1667
+ /**
1668
+ * The label to freeze on a new interaction, or null to say nothing.
1669
+ *
1670
+ * `installation` and `sole_candidate` both mean there was only ever one place
1671
+ * this could go, so naming it on every message would be noise.
1672
+ *
1673
+ * Bounded with the picker's own button cap so the frozen label and the button
1674
+ * that produced it agree; the column allows 128 bytes, which is headroom.
1675
+ */
1676
+ async function routedWorkspaceLabelFor(
1677
+ deps: ApiRouteDeps,
1678
+ resolution: Extract<SlackRouteResolution, { kind: "resolved" }>,
1679
+ ): Promise<string | null> {
1680
+ if (!SLACK_ROUTE_SOURCES_WORTH_NAMING.has(resolution.source)) return null;
1681
+ const label = resolution.label ?? (await getWorkspace(deps.db, resolution.workspaceId))?.name;
1682
+ if (!label) return null;
1683
+ const bounded = boundedSlackRouteLabel(label);
1684
+ return bounded.length > 0 ? bounded : null;
1685
+ }
1686
+
1687
+ /** How long a first-use picker stays answerable. */
1688
+ const SLACK_ROUTE_PROMPT_TTL_MS = 24 * 60 * 60 * 1000;
1689
+
1690
+ /** Buttons a card can carry before it stops being readable. */
1691
+ const MAX_SLACK_ROUTE_PROMPT_BUTTONS = 5;
1692
+
1693
+ /**
1694
+ * Ask, once, where this conversation should work.
1695
+ *
1696
+ * The card is a bot DM rather than an ephemeral message: `chat.postEphemeral`
1697
+ * returns no durable `(channel, ts)` and no `client_msg_id` echo, and the whole
1698
+ * post ledger and its reconciliation are built on both.
1699
+ *
1700
+ * Nothing is created in any workspace here. The request is parked on the prompt
1701
+ * row and re-queued only once the person answers, so a picker that is never
1702
+ * answered leaves no session, no interaction and no half-started work.
1703
+ */
1704
+ async function askSlackRouteChoice(
1705
+ deps: ApiRouteDeps,
1706
+ client: OpenGeniSlackBotClient,
1707
+ home: SlackRouteTenancy,
1708
+ entry: SlackInteractionInboxEntry,
1709
+ candidates: readonly SlackRoutableWorkspace[],
1710
+ ): Promise<void> {
1711
+ const offered = candidates.slice(0, MAX_SLACK_ROUTE_PROMPT_BUTTONS);
1712
+ // Slack renders a button label as plain text and the stored snapshot caps it
1713
+ // at 128 bytes, so bound it once here: the button, the stored row and every
1714
+ // outcome card then agree, and the full name still shows in OpenGeni.
1715
+ const bounded = offered.map((candidate) => ({
1716
+ ...candidate,
1717
+ label: boundedSlackRouteLabel(candidate.label),
1718
+ }));
1719
+ const opened = await openSlackRoutePrompt(deps.db, home, {
1720
+ connectionId: entry.connectionId,
1721
+ inboxId: entry.id,
1722
+ slackTeamId: entry.slackTeamId,
1723
+ slackUserId: entry.slackUserId,
1724
+ slackChannelId: entry.slackChannelId,
1725
+ slackMessageTs: entry.slackMessageTs,
1726
+ providerEventId: entry.providerEventId,
1727
+ triggerKind: entry.triggerKind,
1728
+ requestText: entry.text,
1729
+ hasFiles: entry.hasFiles,
1730
+ slackThreadTs: entry.slackThreadTs,
1731
+ messageOperationId: deterministicUuid(`slack-route-prompt:${entry.id}`),
1732
+ expiresAt: new Date(Date.now() + SLACK_ROUTE_PROMPT_TTL_MS),
1733
+ candidates: bounded,
1734
+ });
1735
+ if (!opened) return;
1736
+ // The prompt commits before the card is posted, so a transient Slack failure
1737
+ // would otherwise leave a pending row with no card in front of anybody and
1738
+ // silently deaden the conversation. Post unconditionally instead: the
1739
+ // operation id is persisted on the prompt and the card is rendered from the
1740
+ // prompt's own stored options, so a replay reproduces identical bytes and the
1741
+ // post ledger returns the completed original rather than posting twice.
1742
+ const where = opened.prompt.slackChannelId.startsWith("D")
1743
+ ? "your messages with OpenGeni"
1744
+ : `<#${opened.prompt.slackChannelId}>`;
1745
+ const blocks: SlackMessageBlock[] = [
1746
+ {
1747
+ type: "section",
1748
+ text: {
1749
+ type: "mrkdwn",
1750
+ text: `OpenGeni can start this task in more than one workspace. Pick where ${where} should work. This choice is remembered.`,
1751
+ },
1752
+ },
1753
+ {
1754
+ type: "actions",
1755
+ block_id: "opengeni-route-choice",
1756
+ elements: [
1757
+ ...opened.prompt.options.map((option) => ({
1758
+ type: "button" as const,
1759
+ action_id: "opengeni.route.select",
1760
+ value: option.id,
1761
+ text: { type: "plain_text" as const, text: option.candidateLabel },
1762
+ })),
1763
+ {
1764
+ type: "button" as const,
1765
+ action_id: "opengeni.route.cancel",
1766
+ value: opened.prompt.options[0]?.id ?? opened.prompt.id,
1767
+ text: { type: "plain_text" as const, text: "Not now" },
1768
+ },
1769
+ ],
1770
+ },
1771
+ ];
1772
+ // Deliberately unconditional rather than derived from the live candidate
1773
+ // count: a replay must render identical bytes, and membership can change
1774
+ // between attempts.
1775
+ const url = slackWorkspaceUrl(deps, home.workspaceId);
1776
+ blocks.push({
1777
+ type: "section",
1778
+ text: {
1779
+ type: "mrkdwn",
1780
+ text: url
1781
+ ? `You can change this later in <${url}|OpenGeni>, under Capabilities, then Slack.`
1782
+ : "You can change this later in OpenGeni, under Capabilities, then Slack.",
1783
+ },
1784
+ });
1785
+ await client.postMessage({
1786
+ operationId: opened.prompt.messageOperationId,
1787
+ userId: opened.prompt.slackUserId,
1788
+ text: boundedOutput(
1789
+ `OpenGeni can start this task in more than one workspace. Pick where ${where} should work.`,
1790
+ ),
1791
+ blocks,
1792
+ });
1793
+ }
1794
+
1795
+ /**
1796
+ * Process one click on a picker.
1797
+ *
1798
+ * The stored option row is a prompt-time snapshot and is NEVER authority: the
1799
+ * chosen workspace is re-authorized live, because access can have been revoked
1800
+ * between the question and the answer.
1801
+ */
1802
+ async function handleSlackRouteChoice(
1803
+ deps: ApiRouteDeps,
1804
+ home: SlackRouteTenancy,
1805
+ entry: SlackInteractionInboxEntry,
1806
+ actionId: string,
1807
+ optionId: string,
1808
+ ): Promise<void> {
1809
+ if (!deps.settings.slackWorkspaceRoutingEnabled) {
1810
+ // A card can outlive the flag being turned back off. Honouring the click
1811
+ // would write a route row and start work through a path the operator has
1812
+ // disabled.
1813
+ throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
1814
+ }
1815
+ const found = await getSlackRoutePromptByOption(deps.db, home, optionId);
1816
+ if (!found) throw new SlackInteractionPermanentError("slack_route_prompt_invalid");
1817
+ const { prompt, chosen } = found;
1818
+ // The same authorization pinning ordinary action handles use: a different
1819
+ // Slack human clicking someone else's card is ignored, not obeyed.
1820
+ if (prompt.slackUserId !== entry.slackUserId) {
1821
+ throw new SlackInteractionPermanentError("slack_route_prompt_user_mismatch");
1822
+ }
1823
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
1824
+ ...home,
1825
+ connectionId: entry.connectionId,
1826
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1827
+ });
1828
+ // The post ledger binds one operation id to a digest over the text, so the
1829
+ // outcome has to be part of the seed. A second click on a settled card
1830
+ // renders different bytes than the first, and reusing one id would wedge the
1831
+ // ledger rather than saying so.
1832
+ const replaceCard = async (outcome: string, text: string) => {
1833
+ await client.updateMessage({
1834
+ operationId: deterministicUuid(
1835
+ `slack-route-answered:${prompt.id}:${actionId}:${chosen.id}:${outcome}`,
1836
+ ),
1837
+ channelId: entry.slackChannelId,
1838
+ timestamp: entry.slackMessageTs,
1839
+ text,
1840
+ // Every button goes with the card, so a sibling option cannot be pressed
1841
+ // after the question is settled.
1842
+ blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
1843
+ });
1844
+ };
1845
+
1846
+ if (actionId === "opengeni.route.cancel") {
1847
+ const cancelled = await settleSlackRoutePrompt(deps.db, home, {
1848
+ promptId: prompt.id,
1849
+ status: "cancelled",
1850
+ });
1851
+ await replaceCard(
1852
+ cancelled ? "cancelled" : "already_settled",
1853
+ cancelled
1854
+ ? "OpenGeni did not start this task. No session was created."
1855
+ : "OpenGeni already settled this choice.",
1856
+ );
1857
+ return;
1858
+ }
1859
+
1860
+ const link = await getSlackBotUserLink(
1861
+ deps.db,
1862
+ home.workspaceId,
1863
+ entry.connectionId,
1864
+ entry.slackUserId,
1865
+ );
1866
+ const grant = link
1867
+ ? await resolveSlackTargetAuthority(deps.db, {
1868
+ subjectId: link.subjectId,
1869
+ targetAccountId: chosen.candidateAccountId,
1870
+ targetWorkspaceId: chosen.candidateWorkspaceId,
1871
+ })
1872
+ : null;
1873
+ if (!grant || !hasPermission(grant.permissions, "sessions:create")) {
1874
+ // Fail closed WITHOUT touching the card: the person may still have another
1875
+ // workspace on it that they can use, and replacing the card would take the
1876
+ // remaining buttons away and leave a pending prompt nobody can answer.
1877
+ await client.postMessage({
1878
+ operationId: deterministicUuid(`slack-route-denied:${prompt.id}:${chosen.id}`),
1879
+ userId: entry.slackUserId,
1880
+ text: boundedOutput(
1881
+ `OpenGeni cannot start work in ${chosen.candidateLabel}: you do not have access to it. No session was created. Pick another workspace on the card, or ask an OpenGeni administrator for access.`,
1882
+ ),
1883
+ });
1884
+ return;
1885
+ }
1886
+
1887
+ const answered = await answerSlackRoutePrompt(deps.db, home, {
1888
+ promptId: prompt.id,
1889
+ targetAccountId: chosen.candidateAccountId,
1890
+ targetWorkspaceId: chosen.candidateWorkspaceId,
1891
+ answeredBySubjectId: grant.subjectId,
1892
+ decidedBySlackUserId: entry.slackUserId,
1893
+ });
1894
+ if (!answered.answered) {
1895
+ await replaceCard(
1896
+ "settled",
1897
+ "OpenGeni already settled this choice. No new session was created.",
1898
+ );
1899
+ return;
1900
+ }
1901
+ await replaceCard(
1902
+ "answered",
1903
+ `OpenGeni will use *${chosen.candidateLabel}* here. You can change this in OpenGeni under Capabilities, then Slack.`,
1904
+ );
1905
+ }
1906
+
893
1907
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
894
1908
  if (entry.triggerKind === "block_action") {
895
1909
  await processSlackBlockAction(deps, entry);
@@ -899,6 +1913,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
899
1913
  await processSlackReactionInboxEntry(deps, entry);
900
1914
  return;
901
1915
  }
1916
+ // HOME is the installation binding's tenancy: it owns the bot credential and
1917
+ // connection, this inbox row, the identity links, and the post ledgers. TARGET
1918
+ // is the tenancy the resulting session lives in. They are equal today; the
1919
+ // routing resolver is what makes them diverge.
1920
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
902
1921
  const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
903
1922
  if (
904
1923
  !installation ||
@@ -909,8 +1928,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
909
1928
  throw new SlackInteractionPermanentError("slack_task_installation_changed");
910
1929
  }
911
1930
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
912
- accountId: entry.accountId,
913
- workspaceId: entry.workspaceId,
1931
+ ...home,
914
1932
  connectionId: entry.connectionId,
915
1933
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
916
1934
  });
@@ -937,17 +1955,21 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
937
1955
  privateHandoff: policyDecision.disposition === "private_handoff",
938
1956
  });
939
1957
  const routeKey = routePolicy.initialRouteKey;
940
- const existing = await getSlackInteractionByRoute(
941
- deps.db,
942
- entry.workspaceId,
943
- entry.connectionId,
1958
+ // A mapped thread keeps the workspace it was created in, so the continuation
1959
+ // lookup is connection-scoped rather than workspace-fenced.
1960
+ const existing = await getSlackInteractionByConnectionRoute(deps.db, {
1961
+ accountId: home.accountId,
1962
+ connectionId: entry.connectionId,
944
1963
  routeKey,
945
- );
1964
+ });
946
1965
  if (entry.triggerKind === "thread_reply" && !existing) return;
947
-
1966
+ // The identity link is a HOME fact: `slack_bot_user_links` is unique on
1967
+ // `(connection_id, slack_user_id)` and RLS-visible only under the
1968
+ // installation's own tenancy. It is read before the target is chosen because
1969
+ // the routing decision needs the subject it names.
948
1970
  const link = await getSlackBotUserLink(
949
1971
  deps.db,
950
- entry.workspaceId,
1972
+ home.workspaceId,
951
1973
  entry.connectionId,
952
1974
  entry.slackUserId,
953
1975
  );
@@ -955,20 +1977,59 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
955
1977
  await client.postMessage({
956
1978
  operationId: deterministicUuid(`slack-link:${entry.id}`),
957
1979
  userId: entry.slackUserId,
958
- text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
1980
+ text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry, entry.createdAt.getTime())}. No session was created.`,
959
1981
  });
960
1982
  return;
961
1983
  }
962
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
963
- principalKind: "human_session",
1984
+ const resolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
1985
+ botUserId: installation.botUserId,
1986
+ threadTenancy: existing
1987
+ ? { accountId: existing.accountId, workspaceId: existing.workspaceId }
1988
+ : null,
1989
+ askEnabled: true,
1990
+ });
1991
+ if (resolution.kind === "ask") {
1992
+ await askSlackRouteChoice(deps, client, home, entry, resolution.candidates);
1993
+ return;
1994
+ }
1995
+ if (resolution.kind === "denied") {
1996
+ await postSlackRouteRefusal(deps, client, entry, resolution);
1997
+ return;
1998
+ }
1999
+ const target: { accountId: string; workspaceId: string } = {
2000
+ accountId: resolution.accountId,
2001
+ workspaceId: resolution.workspaceId,
2002
+ };
2003
+ const grant = await resolveSlackTargetAuthority(deps.db, {
2004
+ subjectId: link.subjectId,
2005
+ targetAccountId: target.accountId,
2006
+ targetWorkspaceId: target.workspaceId,
964
2007
  });
965
- if (!grant || grant.accountId !== entry.accountId) {
966
- throw new SlackInteractionPermanentError("identity_access_revoked");
2008
+ if (!grant) {
2009
+ // Never quietly serve this from a workspace the person did not name. A
2010
+ // routed workspace the subject cannot reach is a refusal with the existing
2011
+ // access-request path, minted for the workspace they actually need.
2012
+ if (resolution.source === "installation" || resolution.source === "thread") {
2013
+ throw new SlackInteractionPermanentError("identity_access_revoked");
2014
+ }
2015
+ await postSlackRouteRefusal(deps, client, entry, {
2016
+ kind: "denied",
2017
+ reason: "no_access_to_route",
2018
+ requested: resolution.label,
2019
+ candidates: [],
2020
+ ...target,
2021
+ });
2022
+ return;
967
2023
  }
2024
+ // An override addresses the message; it is not part of the request.
2025
+ const routedEntry: SlackInteractionInboxEntry = {
2026
+ ...entry,
2027
+ text: slackRoutedRequestText(entry.text, resolution, installation.botUserId),
2028
+ };
968
2029
 
969
2030
  const alreadyDurable = await getSlackInteractionByClientEventId(
970
2031
  deps.db,
971
- entry.workspaceId,
2032
+ target.workspaceId,
972
2033
  entry.connectionId,
973
2034
  `slack:${entry.providerEventId}`,
974
2035
  );
@@ -991,14 +2052,13 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
991
2052
  if (!boundInteraction) {
992
2053
  throw new Error("Durable Slack interaction could not bind its reserved session");
993
2054
  }
994
- await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
2055
+ await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, home, policyResolution);
995
2056
  const shouldRepairAcknowledgement =
996
2057
  interaction.triggeringProviderEventId === entry.providerEventId ||
997
2058
  (usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
998
2059
  if (shouldRepairAcknowledgement) {
999
2060
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
1000
- accountId: entry.accountId,
1001
- workspaceId: entry.workspaceId,
2061
+ ...home,
1002
2062
  connectionId: entry.connectionId,
1003
2063
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1004
2064
  sessionId: eventSessionId,
@@ -1008,7 +2068,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1008
2068
  return;
1009
2069
  }
1010
2070
 
1011
- let preparedEntry = entry;
2071
+ let preparedEntry = routedEntry;
2072
+ let preparedModelContext: string | null = null;
1012
2073
  let preparedAttachments: PreparedSlackReactionTask = {
1013
2074
  resources: [],
1014
2075
  attachments: [],
@@ -1019,7 +2080,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1019
2080
  const prepared = await prepareSlackInvocationEntry(
1020
2081
  deps,
1021
2082
  client,
1022
- entry,
2083
+ routedEntry,
2084
+ target,
1023
2085
  policyDecision.disposition === "private_handoff"
1024
2086
  ? async () => {
1025
2087
  await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
@@ -1027,31 +2089,33 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1027
2089
  : undefined,
1028
2090
  );
1029
2091
  preparedEntry = prepared.entry;
2092
+ preparedModelContext = prepared.modelContext;
1030
2093
  preparedAttachments = prepared.attachments;
1031
2094
  }
1032
2095
 
1033
2096
  if (existing?.sessionId) {
1034
2097
  const remounted = await remountSlackReactionTaskForSession(
1035
2098
  deps,
1036
- entry.workspaceId,
2099
+ target.workspaceId,
1037
2100
  existing.sessionId,
1038
2101
  preparedAttachments,
1039
2102
  );
1040
- await continueSlackSession(
1041
- deps,
1042
- grant,
1043
- existing,
1044
- slackInvocationPreparedEntry(preparedEntry, remounted),
1045
- remounted.resources,
1046
- );
2103
+ const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
2104
+ await continueSlackSession(deps, grant, existing, prepared.entry, remounted.resources, {
2105
+ modelContext: prepared.modelContext,
2106
+ });
1047
2107
  return;
1048
2108
  }
1049
2109
  if (!hasPermission(grant.permissions, "sessions:create")) {
1050
2110
  throw new SlackInteractionPermanentError("sessions_create_denied");
1051
2111
  }
1052
2112
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
1053
- accountId: entry.accountId,
1054
- workspaceId: entry.workspaceId,
2113
+ ...target,
2114
+ // Frozen here, after authorization, so a workspace name is never shown to
2115
+ // someone who could not reach it. Null when the routing decision was not a
2116
+ // genuine choice, which is what keeps the line off every message in an
2117
+ // install that only ever had one workspace.
2118
+ routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, resolution),
1055
2119
  connectionId: entry.connectionId,
1056
2120
  slackTeamId: entry.slackTeamId,
1057
2121
  slackChannelId: entry.slackChannelId,
@@ -1062,32 +2126,36 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1062
2126
  owningSubjectId: grant.subjectId,
1063
2127
  visibility: routePolicy.visibility,
1064
2128
  });
2129
+ requireSlackInteractionMatchesTarget(interaction, target);
1065
2130
  if (interaction.sessionId) {
1066
2131
  const remounted = await remountSlackReactionTaskForSession(
1067
2132
  deps,
1068
- entry.workspaceId,
2133
+ interaction.workspaceId,
1069
2134
  interaction.sessionId,
1070
2135
  preparedAttachments,
1071
2136
  );
1072
- await continueSlackSession(
1073
- deps,
1074
- grant,
1075
- interaction,
1076
- slackInvocationPreparedEntry(preparedEntry, remounted),
1077
- remounted.resources,
1078
- );
2137
+ const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
2138
+ await continueSlackSession(deps, grant, interaction, prepared.entry, remounted.resources, {
2139
+ modelContext: prepared.modelContext,
2140
+ });
1079
2141
  return;
1080
2142
  }
1081
2143
  const preferredModel = await getLatestSessionModelForSubject(
1082
2144
  deps.db,
1083
- entry.workspaceId,
2145
+ interaction.workspaceId,
1084
2146
  grant.subjectId,
1085
2147
  );
1086
2148
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
1087
2149
  try {
1088
- session = await createSessionForRequest(deps, grant, entry.workspaceId, {
2150
+ const prepared = slackInvocationPreparedMessage(
2151
+ preparedEntry,
2152
+ preparedAttachments,
2153
+ preparedModelContext,
2154
+ );
2155
+ session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
1089
2156
  requestedSessionId: interaction.sessionReservationId,
1090
- initialMessage: slackInvocationPreparedEntry(preparedEntry, preparedAttachments).text,
2157
+ initialMessage: prepared.entry.text,
2158
+ ...(prepared.modelContext ? { modelContext: prepared.modelContext } : {}),
1091
2159
  instructions: SLACK_SESSION_INSTRUCTIONS,
1092
2160
  firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
1093
2161
  resources: preparedAttachments.resources,
@@ -1118,10 +2186,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1118
2186
  sessionId: session.id,
1119
2187
  });
1120
2188
  if (!bound) throw new Error("Slack route could not bind its durable session");
1121
- await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
2189
+ await ensureSlackSharedTaskOrigin(deps, bound, entry, home, policyResolution);
1122
2190
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
1123
- accountId: entry.accountId,
1124
- workspaceId: entry.workspaceId,
2191
+ ...home,
1125
2192
  connectionId: entry.connectionId,
1126
2193
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1127
2194
  sessionId: session.id,
@@ -1164,6 +2231,7 @@ async function ensureSlackSharedTaskOrigin(
1164
2231
  deps: ApiRouteDeps,
1165
2232
  interaction: SlackInteraction,
1166
2233
  entry: SlackInteractionInboxEntry,
2234
+ home: { accountId: string; workspaceId: string },
1167
2235
  resolution:
1168
2236
  | Awaited<ReturnType<typeof slackTaskPolicyDecision>>
1169
2237
  | {
@@ -1189,6 +2257,11 @@ async function ensureSlackSharedTaskOrigin(
1189
2257
  sourceChannelId: entry.slackChannelId,
1190
2258
  sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
1191
2259
  initiatingSlackUserId: interaction.initiatingSlackUserId,
2260
+ // The task policy governs what may be read out of the Slack conversation,
2261
+ // which is an installation-surface concern, so its frozen revision keeps
2262
+ // home tenancy while the row itself follows the routed task.
2263
+ policyAccountId: home.accountId,
2264
+ policyWorkspaceId: home.workspaceId,
1192
2265
  policyRevisionId: resolution.activePolicy.revision.id,
1193
2266
  policyHash: resolution.activePolicy.revision.policyHash,
1194
2267
  policyActivationVersion: resolution.activePolicy.head.activationVersion,
@@ -1225,8 +2298,15 @@ async function prepareSlackInvocationEntry(
1225
2298
  deps: ApiRouteDeps,
1226
2299
  client: OpenGeniSlackBotClient,
1227
2300
  entry: SlackInteractionInboxEntry,
2301
+ // Imported attachments become File resources of the session's workspace, so
2302
+ // they follow TARGET tenancy rather than the installation's.
2303
+ target: { accountId: string; workspaceId: string },
1228
2304
  authorizeRead?: () => Promise<void>,
1229
- ): Promise<{ entry: SlackInteractionInboxEntry; attachments: PreparedSlackReactionTask }> {
2305
+ ): Promise<{
2306
+ entry: SlackInteractionInboxEntry;
2307
+ attachments: PreparedSlackReactionTask;
2308
+ modelContext: string | null;
2309
+ }> {
1230
2310
  const context = entry.slackThreadTs
1231
2311
  ? await client.threadReplies({
1232
2312
  channelId: entry.slackChannelId,
@@ -1255,53 +2335,58 @@ async function prepareSlackInvocationEntry(
1255
2335
  exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
1256
2336
  }
1257
2337
  const attachments = exactMessage
1258
- ? await prepareSlackMessageAttachments(deps, client, entry, exactMessage.files)
2338
+ ? await prepareSlackMessageAttachments(
2339
+ deps,
2340
+ client,
2341
+ entry,
2342
+ target,
2343
+ exactMessage.files,
2344
+ authorizeRead,
2345
+ )
1259
2346
  : {
1260
2347
  resources: [],
1261
2348
  attachments: [],
1262
2349
  omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
1263
2350
  omittedCount: entry.hasFiles ? 1 : 0,
1264
2351
  };
1265
- const baseText =
2352
+ const modelContext =
1266
2353
  entry.triggerKind === "app_mention"
1267
- ? slackInvocationTaskText(entry, {
2354
+ ? slackInvocationModelContext(entry.slackMessageTs, {
1268
2355
  messages: context.messages,
1269
2356
  nextCursor: context.nextCursor,
1270
2357
  kind: entry.slackThreadTs ? "thread" : "channel",
1271
2358
  })
1272
- : entry.text;
2359
+ : null;
1273
2360
  return {
1274
- entry: { ...entry, text: baseText },
2361
+ entry,
1275
2362
  attachments,
2363
+ modelContext,
1276
2364
  };
1277
2365
  }
1278
2366
 
1279
- function slackInvocationPreparedEntry(
2367
+ function slackInvocationPreparedMessage(
1280
2368
  entry: SlackInteractionInboxEntry,
1281
2369
  attachments: PreparedSlackReactionTask,
1282
- ): SlackInteractionInboxEntry {
2370
+ modelContext: string | null,
2371
+ ): { entry: SlackInteractionInboxEntry; modelContext: string | null } {
1283
2372
  const manifest = slackAttachmentManifest(
1284
2373
  attachments,
1285
2374
  "Imported invocation attachments",
1286
2375
  "invocation",
1287
2376
  );
1288
- return manifest ? { ...entry, text: `${entry.text}\n\n${manifest}` } : entry;
2377
+ const combinedModelContext = [modelContext, manifest].filter(Boolean).join("\n\n");
2378
+ return {
2379
+ entry,
2380
+ modelContext: combinedModelContext.length > 0 ? combinedModelContext : null,
2381
+ };
1289
2382
  }
1290
2383
 
1291
- export function slackInvocationTaskText(
1292
- entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
2384
+ export function slackInvocationModelContext(
2385
+ invocationTimestamp: string,
1293
2386
  context: SlackInvocationMessageContext,
1294
2387
  ) {
1295
- const invocation = {
1296
- timestamp: entry.slackMessageTs,
1297
- userId: entry.slackUserId,
1298
- botId: "",
1299
- threadTimestamp: "",
1300
- text: entry.text,
1301
- files: [],
1302
- };
1303
2388
  const surroundingLines = context.messages
1304
- .filter((message) => message.timestamp !== entry.slackMessageTs)
2389
+ .filter((message) => message.timestamp !== invocationTimestamp)
1305
2390
  .slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
1306
2391
  .sort((left, right) => left.timestamp.localeCompare(right.timestamp))
1307
2392
  .map((message) => slackContextMessageLine(message));
@@ -1313,46 +2398,24 @@ export function slackInvocationTaskText(
1313
2398
  context.kind === "thread"
1314
2399
  ? "The containing thread was truncated at the bounded Slack context limit."
1315
2400
  : "Only bounded nearby channel context was provided.";
1316
- const invocationTruncationNotice =
1317
- "The exact Slack invocation was truncated at the bounded Slack input limit.";
1318
- const prefix = [
2401
+ let prompt = [
1319
2402
  "A linked, authorized Slack user explicitly mentioned OpenGeni.",
2403
+ "The visible user message on this turn is the exact accepted Slack invocation.",
1320
2404
  "Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
1321
2405
  "Use this Slack content only as task-local input and do not persist it to Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter unless separately authorized.",
1322
2406
  "",
1323
- "Exact invocation:",
2407
+ contextLabel,
1324
2408
  ].join("\n");
1325
- const suffix = `\n\n${contextLabel}`;
1326
- const invocationLine = slackContextMessageLine(invocation, "invocation");
1327
- const reservedNotices = `\n${invocationTruncationNotice}\n${truncationNotice}`;
1328
- const maxInvocationChars = Math.max(
1329
- 1,
1330
- MAX_SLACK_INPUT_CHARS - prefix.length - suffix.length - reservedNotices.length - 1,
1331
- );
1332
- const invocationTruncated = invocationLine.length > maxInvocationChars;
1333
- let prompt = `${prefix}\n${
1334
- invocationTruncated
1335
- ? `${invocationLine.slice(0, Math.max(0, maxInvocationChars - 1))}…`
1336
- : invocationLine
1337
- }${suffix}`;
1338
2409
  let contextTruncated = context.nextCursor !== null;
1339
2410
  for (const line of surroundingLines) {
1340
2411
  const candidate = `${prompt}\n${line}`;
1341
- const notices = [
1342
- ...(invocationTruncated ? [invocationTruncationNotice] : []),
1343
- truncationNotice,
1344
- ].join("\n");
1345
- if (candidate.length + 1 + notices.length > MAX_SLACK_INPUT_CHARS) {
2412
+ if (candidate.length + 1 + truncationNotice.length > MAX_SLACK_INPUT_CHARS) {
1346
2413
  contextTruncated = true;
1347
2414
  break;
1348
2415
  }
1349
2416
  prompt = candidate;
1350
2417
  }
1351
- const notices = [
1352
- ...(invocationTruncated ? [invocationTruncationNotice] : []),
1353
- ...(contextTruncated ? [truncationNotice] : []),
1354
- ];
1355
- return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
2418
+ return contextTruncated ? `${prompt}\n${truncationNotice}` : prompt;
1356
2419
  }
1357
2420
 
1358
2421
  async function acknowledgeSlackSession(
@@ -1368,17 +2431,43 @@ async function acknowledgeSlackSession(
1368
2431
  const privateHandoff =
1369
2432
  !directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
1370
2433
  const privateBotDm = directMessageShortcut || privateHandoff;
1371
- const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
1372
- const text = directMessageShortcut
1373
- ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
2434
+ const operationId = deterministicUuid(slackPostSeed(interaction, `slack-ack:${interaction.id}`));
2435
+ // The acknowledgement carries exactly one session link plus the Status/Stop
2436
+ // buttons. The how-to prose it used to repeat forever now rides along once,
2437
+ // on this Slack identity's first accepted task in this installation.
2438
+ const started = directMessageShortcut
2439
+ ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} The source DM was not opened to the bot or made workspace-visible.`
1374
2440
  : privateHandoff
1375
- ? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. Results stay private unless a separate authorized publication is approved.`
1376
- : `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`;
2441
+ ? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} Results stay private unless a separate authorized publication is approved.`
2442
+ : `OpenGeni started this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}`;
2443
+ // The post ledger binds this fixed operation id to a digest over the message
2444
+ // text, and this function re-runs on every acknowledgement repair. Resolving
2445
+ // the hint freezes it durably before the post, so a repair renders identical
2446
+ // bytes; a failure here raises into the retryable inbox path rather than
2447
+ // binding the ledger to a hint-less acknowledgement it can never revise.
2448
+ const showHint = await resolveSlackInteractionFirstTaskHint(deps.db, {
2449
+ // The identity link is HOME; the interaction row is TARGET.
2450
+ workspaceId: entry.workspaceId,
2451
+ interactionWorkspaceId: interaction.workspaceId,
2452
+ connectionId: entry.connectionId,
2453
+ slackUserId: entry.slackUserId,
2454
+ interactionId: interaction.id,
2455
+ });
2456
+ const text = `${started}${showHint ? slackFirstTaskHintText(deps) : ""}`;
1377
2457
  const controls = await controlActionBlocks(deps, interaction, {
1378
2458
  messageOperationId: operationId,
1379
2459
  sessionEventSequence: 0,
1380
2460
  state: "active",
1381
2461
  });
2462
+ // The approval and control cards let `text` and `blocks[0]` diverge, so the
2463
+ // line is appended to each independently rather than assuming they match.
2464
+ const acknowledgement = withWorkspaceLine(
2465
+ interaction.routedWorkspaceLabel,
2466
+ text,
2467
+ controls.length > 0
2468
+ ? ([{ type: "section", text: { type: "mrkdwn", text } }, ...controls] as SlackMessageBlock[])
2469
+ : undefined,
2470
+ );
1382
2471
  const ack = await client.postMessage({
1383
2472
  operationId,
1384
2473
  ...(privateBotDm
@@ -1389,15 +2478,8 @@ async function acknowledgeSlackSession(
1389
2478
  ? {}
1390
2479
  : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
1391
2480
  }),
1392
- text,
1393
- ...(controls.length > 0
1394
- ? {
1395
- blocks: [
1396
- { type: "section", text: { type: "mrkdwn", text } },
1397
- ...controls,
1398
- ] as SlackMessageBlock[],
1399
- }
1400
- : {}),
2481
+ text: acknowledgement.text,
2482
+ ...(acknowledgement.blocks ? { blocks: acknowledgement.blocks } : {}),
1401
2483
  });
1402
2484
  if (entry.triggerKind === "slash_command" || privateBotDm) {
1403
2485
  const rekeyed = await rekeySlackInteractionRoute(deps.db, {
@@ -1412,14 +2494,38 @@ async function acknowledgeSlackSession(
1412
2494
  }
1413
2495
  }
1414
2496
 
2497
+ /**
2498
+ * The one-time onboarding sentence shown on a Slack identity's first
2499
+ * acknowledged task in one installation.
2500
+ *
2501
+ * Rendering is a pure function of the configured command plus the frozen
2502
+ * `firstTaskHint` fact, so every re-render of the same acknowledgement produces
2503
+ * the same bytes for the digest-bound post ledger.
2504
+ *
2505
+ * Note the one remaining input that is not frozen: the text embeds
2506
+ * `settings.slackCommand`. Changing that setting between an acknowledgement
2507
+ * post and a later repair of the same interaction would diverge the digest and
2508
+ * conflict its post operation. The command is deployment configuration that
2509
+ * must already match the registered Slack slash command, so it does not change
2510
+ * under a live installation; a deployment that does rename it should drain
2511
+ * in-flight Slack interactions first.
2512
+ */
2513
+ function slackFirstTaskHintText(deps: ApiRouteDeps): string {
2514
+ const command = deps.settings.slackCommand;
2515
+ return `\n\nFirst time here: reply in this thread to continue this task, or reply \`stop\` to stop it. Start a new top-level DM or run \`${command}\` again for a new task. Run \`${command} info\` any time for the full summary.`;
2516
+ }
2517
+
1415
2518
  async function processSlackReactionInboxEntry(
1416
2519
  deps: ApiRouteDeps,
1417
2520
  entry: SlackInteractionInboxEntry,
1418
2521
  ) {
2522
+ // Reaction summon is configured on the installation surface, so the settings,
2523
+ // the connection scopes, and the identity link are all HOME reads.
2524
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
1419
2525
  const [workspace, connection, link] = await Promise.all([
1420
- getWorkspace(deps.db, entry.workspaceId),
1421
- getConnectionMetadata(deps.db, entry.workspaceId, entry.connectionId, null),
1422
- getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
2526
+ getWorkspace(deps.db, home.workspaceId),
2527
+ getConnectionMetadata(deps.db, home.workspaceId, entry.connectionId, null),
2528
+ getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
1423
2529
  ]);
1424
2530
  const settings = resolveWorkspaceSlackReactionSummonSettings(workspace?.settings);
1425
2531
  if (
@@ -1433,23 +2539,55 @@ async function processSlackReactionInboxEntry(
1433
2539
  ) {
1434
2540
  return;
1435
2541
  }
1436
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
1437
- principalKind: "human_session",
2542
+ // A reaction is never a direct message, so the routing decision here is the
2543
+ // channel's remembered answer, the sole candidate, or the installation's own
2544
+ // workspace. It runs before the provider context read so an unauthorized
2545
+ // subject never causes OpenGeni to read the Slack conversation.
2546
+ const routeResolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
2547
+ // A reaction carries no message text of the reacting person, so no prefix.
2548
+ botUserId: null,
2549
+ threadTenancy: null,
2550
+ askEnabled: false,
1438
2551
  });
1439
- if (!grant || grant.accountId !== entry.accountId) {
1440
- throw new SlackInteractionPermanentError("identity_access_revoked");
2552
+ if (routeResolution.kind === "ask") {
2553
+ throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
1441
2554
  }
1442
- if (
1443
- !hasPermission(grant.permissions, "sessions:create") ||
1444
- !hasPermission(grant.permissions, "sessions:control")
1445
- ) {
1446
- throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
2555
+ if (routeResolution.kind === "denied") {
2556
+ const summonClient = await createOpenGeniSlackBotInteractionClient(deps, {
2557
+ ...home,
2558
+ connectionId: entry.connectionId,
2559
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
2560
+ });
2561
+ await postSlackRouteRefusal(deps, summonClient, entry, routeResolution);
2562
+ return;
1447
2563
  }
2564
+ let target: { accountId: string; workspaceId: string } = {
2565
+ accountId: routeResolution.accountId,
2566
+ workspaceId: routeResolution.workspaceId,
2567
+ };
2568
+ const authorizeTarget = async () => {
2569
+ const resolved = await resolveSlackTargetAuthority(deps.db, {
2570
+ subjectId: link.subjectId,
2571
+ targetAccountId: target.accountId,
2572
+ targetWorkspaceId: target.workspaceId,
2573
+ });
2574
+ if (!resolved) {
2575
+ throw new SlackInteractionPermanentError("identity_access_revoked");
2576
+ }
2577
+ if (
2578
+ !hasPermission(resolved.permissions, "sessions:create") ||
2579
+ !hasPermission(resolved.permissions, "sessions:control")
2580
+ ) {
2581
+ throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
2582
+ }
2583
+ return resolved;
2584
+ };
2585
+ let grant = await authorizeTarget();
1448
2586
 
1449
2587
  const clientEventId = `slack:${entry.providerEventId}`;
1450
2588
  const durableInteraction = await getSlackInteractionByClientEventId(
1451
2589
  deps.db,
1452
- entry.workspaceId,
2590
+ target.workspaceId,
1453
2591
  entry.connectionId,
1454
2592
  clientEventId,
1455
2593
  );
@@ -1481,8 +2619,7 @@ async function processSlackReactionInboxEntry(
1481
2619
  await reopenSlackInteractionDelivery(deps.db, boundInteraction);
1482
2620
  if (shouldRepairAcknowledgement) {
1483
2621
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
1484
- accountId: entry.accountId,
1485
- workspaceId: entry.workspaceId,
2622
+ ...home,
1486
2623
  connectionId: entry.connectionId,
1487
2624
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1488
2625
  sessionId: eventSessionId,
@@ -1493,8 +2630,7 @@ async function processSlackReactionInboxEntry(
1493
2630
  }
1494
2631
 
1495
2632
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
1496
- accountId: entry.accountId,
1497
- workspaceId: entry.workspaceId,
2633
+ ...home,
1498
2634
  connectionId: entry.connectionId,
1499
2635
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1500
2636
  });
@@ -1525,18 +2661,25 @@ async function processSlackReactionInboxEntry(
1525
2661
  if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
1526
2662
  },
1527
2663
  });
1528
- const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
1529
2664
  const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
1530
- const existing = await getSlackInteractionByRoute(
1531
- deps.db,
1532
- entry.workspaceId,
1533
- entry.connectionId,
2665
+ const existing = await getSlackInteractionByConnectionRoute(deps.db, {
2666
+ accountId: home.accountId,
2667
+ connectionId: entry.connectionId,
1534
2668
  routeKey,
1535
- );
2669
+ });
2670
+ if (
2671
+ existing &&
2672
+ (existing.accountId !== target.accountId || existing.workspaceId !== target.workspaceId)
2673
+ ) {
2674
+ // A mapped thread keeps the workspace it was created in.
2675
+ target = { accountId: existing.accountId, workspaceId: existing.workspaceId };
2676
+ grant = await authorizeTarget();
2677
+ }
2678
+ const preparedTask = await prepareSlackReactionTask(deps, client, entry, target, context);
1536
2679
  if (existing?.sessionId) {
1537
2680
  const appendedTask = await remountSlackReactionTaskForSession(
1538
2681
  deps,
1539
- entry.workspaceId,
2682
+ existing.workspaceId,
1540
2683
  existing.sessionId,
1541
2684
  preparedTask,
1542
2685
  );
@@ -1550,8 +2693,11 @@ async function processSlackReactionInboxEntry(
1550
2693
  return;
1551
2694
  }
1552
2695
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
1553
- accountId: entry.accountId,
1554
- workspaceId: entry.workspaceId,
2696
+ ...target,
2697
+ // A reaction routes exactly like a mention, so it names its workspace the
2698
+ // same way. Resolved after `authorizeTarget`, so a name never reaches
2699
+ // somebody who could not reach the workspace.
2700
+ routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, routeResolution),
1555
2701
  connectionId: entry.connectionId,
1556
2702
  slackTeamId: entry.slackTeamId,
1557
2703
  slackChannelId: entry.slackChannelId,
@@ -1562,10 +2708,11 @@ async function processSlackReactionInboxEntry(
1562
2708
  owningSubjectId: grant.subjectId,
1563
2709
  visibility: "workspace",
1564
2710
  });
2711
+ requireSlackInteractionMatchesTarget(interaction, target);
1565
2712
  if (interaction.sessionId) {
1566
2713
  const appendedTask = await remountSlackReactionTaskForSession(
1567
2714
  deps,
1568
- entry.workspaceId,
2715
+ interaction.workspaceId,
1569
2716
  interaction.sessionId,
1570
2717
  preparedTask,
1571
2718
  );
@@ -1588,13 +2735,13 @@ async function processSlackReactionInboxEntry(
1588
2735
  }
1589
2736
  const preferredModel = await getLatestSessionModelForSubject(
1590
2737
  deps.db,
1591
- entry.workspaceId,
2738
+ interaction.workspaceId,
1592
2739
  grant.subjectId,
1593
2740
  );
1594
2741
  const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
1595
2742
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
1596
2743
  try {
1597
- session = await createSessionForRequest(deps, grant, entry.workspaceId, {
2744
+ session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
1598
2745
  requestedSessionId: interaction.sessionReservationId,
1599
2746
  initialMessage: preparedEntry.text,
1600
2747
  instructions: SLACK_SESSION_INSTRUCTIONS,
@@ -1644,11 +2791,15 @@ async function acknowledgeSlackReactionSession(
1644
2791
  if (!interaction.sessionId) {
1645
2792
  throw new Error("Slack reaction acknowledgement requires a bound session");
1646
2793
  }
2794
+ const started = `OpenGeni started from the :${emoji}: reaction. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} If the intended action is unclear, OpenGeni will ask in this thread. Reply here to continue, or reply \`stop\` to stop.`;
2795
+ const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, started);
1647
2796
  await client.postMessage({
1648
- operationId: deterministicUuid(`slack-reaction-ack:${interaction.id}`),
2797
+ operationId: deterministicUuid(
2798
+ slackPostSeed(interaction, `slack-reaction-ack:${interaction.id}`),
2799
+ ),
1649
2800
  channelId: interaction.slackChannelId,
1650
2801
  threadTimestamp: interaction.slackThreadTs,
1651
- text: `OpenGeni started from the :${emoji}: reaction. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} If the intended action is unclear, OpenGeni will ask in this thread. Reply here to continue, or reply \`stop\` to stop.`,
2802
+ text: rendered.text,
1652
2803
  });
1653
2804
  }
1654
2805
 
@@ -1761,16 +2912,25 @@ async function prepareSlackReactionTask(
1761
2912
  deps: ApiRouteDeps,
1762
2913
  client: OpenGeniSlackBotClient,
1763
2914
  entry: SlackInteractionInboxEntry,
2915
+ target: { accountId: string; workspaceId: string },
1764
2916
  context: SlackReactionMessageContext,
1765
2917
  ): Promise<PreparedSlackReactionTask> {
1766
- return await prepareSlackMessageAttachments(deps, client, entry, context.reactedMessage.files);
2918
+ return await prepareSlackMessageAttachments(
2919
+ deps,
2920
+ client,
2921
+ entry,
2922
+ target,
2923
+ context.reactedMessage.files,
2924
+ );
1767
2925
  }
1768
2926
 
1769
2927
  async function prepareSlackMessageAttachments(
1770
2928
  deps: ApiRouteDeps,
1771
2929
  client: OpenGeniSlackBotClient,
1772
2930
  entry: SlackInteractionInboxEntry,
2931
+ target: { accountId: string; workspaceId: string },
1773
2932
  exactFiles: readonly { id: string; name: string; title: string }[],
2933
+ authorizeSharedRead?: () => Promise<void>,
1774
2934
  ): Promise<PreparedSlackReactionTask> {
1775
2935
  if (exactFiles.length === 0) {
1776
2936
  return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
@@ -1790,12 +2950,13 @@ async function prepareSlackMessageAttachments(
1790
2950
  name: file.name,
1791
2951
  title: file.title,
1792
2952
  })),
2953
+ ...(authorizeSharedRead ? { authorizeSharedRead } : {}),
1793
2954
  });
1794
2955
  const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
1795
2956
  let aggregateBytes = 0;
1796
2957
  for (const image of prepared) {
1797
2958
  try {
1798
- const value = await client.downloadReactionImage(image);
2959
+ const value = await client.downloadReactionImage(image, authorizeSharedRead);
1799
2960
  if (
1800
2961
  value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
1801
2962
  aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
@@ -1828,8 +2989,8 @@ async function prepareSlackMessageAttachments(
1828
2989
  await importSlackReactionImage(
1829
2990
  { db: deps.db, objectStorage: deps.objectStorage },
1830
2991
  {
1831
- accountId: entry.accountId,
1832
- workspaceId: entry.workspaceId,
2992
+ accountId: target.accountId,
2993
+ workspaceId: target.workspaceId,
1833
2994
  connectionId: entry.connectionId,
1834
2995
  slackTeamId: entry.slackTeamId,
1835
2996
  slackChannelId: entry.slackChannelId,
@@ -1968,9 +3129,11 @@ async function acceptSlackReactionTask(
1968
3129
  resources: FileResourceRef[],
1969
3130
  ) {
1970
3131
  const clientEventId = `slack:${entry.providerEventId}`;
3132
+ // Session events and user messages belong to the workspace that owns the
3133
+ // session, which is the workspace the grant authorized, not the installation.
1971
3134
  const existing = await getSessionEventByClientEventId(
1972
3135
  deps.db,
1973
- entry.workspaceId,
3136
+ grant.workspaceId,
1974
3137
  sessionId,
1975
3138
  clientEventId,
1976
3139
  );
@@ -1980,7 +3143,7 @@ async function acceptSlackReactionTask(
1980
3143
  }
1981
3144
  return;
1982
3145
  }
1983
- await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
3146
+ await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
1984
3147
  text: entry.text,
1985
3148
  resources,
1986
3149
  clientEventId,
@@ -1993,6 +3156,7 @@ async function continueSlackSession(
1993
3156
  interaction: SlackInteraction,
1994
3157
  entry: SlackInteractionInboxEntry,
1995
3158
  resources: FileResourceRef[] = [],
3159
+ options: { modelContext?: string | null } = {},
1996
3160
  ) {
1997
3161
  if (
1998
3162
  !interaction.sessionId ||
@@ -2023,7 +3187,7 @@ async function continueSlackSession(
2023
3187
  }
2024
3188
  const pending = await listSessionHumanInputRequests(
2025
3189
  deps.db,
2026
- entry.workspaceId,
3190
+ interaction.workspaceId,
2027
3191
  interaction.sessionId,
2028
3192
  { status: "pending", limit: 2 },
2029
3193
  );
@@ -2063,8 +3227,9 @@ async function continueSlackSession(
2063
3227
  if (!hasPermission(grant.permissions, "sessions:control")) {
2064
3228
  throw new SlackInteractionPermanentError("sessions_control_denied");
2065
3229
  }
2066
- await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
3230
+ await acceptSessionUserMessage(deps, grant, interaction.workspaceId, interaction.sessionId, {
2067
3231
  text: entry.text,
3232
+ ...(options.modelContext ? { modelContext: options.modelContext } : {}),
2068
3233
  resources,
2069
3234
  clientEventId: `slack:${entry.providerEventId}`,
2070
3235
  });
@@ -2085,6 +3250,9 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2085
3250
  const separator = entry.text.lastIndexOf(":");
2086
3251
  const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
2087
3252
  const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
3253
+ // The block-action inbox row carries HOME tenancy, exactly like every other
3254
+ // inbox row: it is the installation binding that received the click.
3255
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
2088
3256
  const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
2089
3257
  if (
2090
3258
  !route ||
@@ -2094,19 +3262,39 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2094
3262
  ) {
2095
3263
  throw new SlackInteractionPermanentError("slack_action_installation_changed");
2096
3264
  }
3265
+ // A route choice is not an action handle: the picker exists BEFORE any
3266
+ // session, so there is no handle row to load and the ordinary path below
3267
+ // would refuse it. Branch first.
3268
+ if (actionId === "opengeni.route.select" || actionId === "opengeni.route.cancel") {
3269
+ await handleSlackRouteChoice(deps, home, entry, actionId, handleId);
3270
+ return;
3271
+ }
3272
+ // A handle lives in the workspace that owns its session, which is not the
3273
+ // installation's workspace once routing is on. Learn that scope from the
3274
+ // content-free probe, then read the full handle under it: every authorization
3275
+ // check below is unchanged and still runs in the handle's own tenancy.
3276
+ const handleTenancy =
3277
+ (await probeSlackActionHandleTenancy(deps.db, {
3278
+ accountId: home.accountId,
3279
+ connectionId: entry.connectionId,
3280
+ handleId,
3281
+ })) ?? home;
2097
3282
  const handle = await getSlackInteractionActionHandle(deps.db, {
2098
- accountId: entry.accountId,
2099
- workspaceId: entry.workspaceId,
3283
+ ...handleTenancy,
2100
3284
  handleId,
2101
3285
  });
2102
3286
  if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
2103
3287
  throw new SlackInteractionPermanentError("slack_action_handle_invalid");
2104
3288
  }
3289
+ // An action handle is a TARGET fact: it is composite-FK'd to its interaction
3290
+ // and its RESTRICTIVE session-visibility policy resolves the session under the
3291
+ // handle's own tenancy, so every read, settle and interaction lookup below
3292
+ // uses the handle's scope rather than the installation's.
3293
+ const target = { accountId: handle.accountId, workspaceId: handle.workspaceId } as const;
2105
3294
  if (handle.status !== "pending") return;
2106
3295
  if (handle.expiresAt.getTime() <= Date.now()) {
2107
3296
  await settleSlackInteractionActionHandles(deps.db, {
2108
- accountId: entry.accountId,
2109
- workspaceId: entry.workspaceId,
3297
+ ...target,
2110
3298
  handleId: handle.id,
2111
3299
  result: "expired",
2112
3300
  stale: true,
@@ -2118,14 +3306,15 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2118
3306
  }
2119
3307
  const [interaction, link, post] = await Promise.all([
2120
3308
  getSlackInteractionById(deps.db, {
2121
- accountId: entry.accountId,
2122
- workspaceId: entry.workspaceId,
3309
+ ...target,
2123
3310
  interactionId: handle.interactionId,
2124
3311
  }),
2125
- getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
3312
+ // Identity stays HOME.
3313
+ getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
3314
+ // The post ledger is written by the bot credential, so it stays HOME too.
2126
3315
  getSlackBotPostOperation(
2127
3316
  deps.db,
2128
- entry.workspaceId,
3317
+ home.workspaceId,
2129
3318
  entry.connectionId,
2130
3319
  handle.messageOperationId,
2131
3320
  ),
@@ -2146,34 +3335,51 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2146
3335
  ) {
2147
3336
  throw new SlackInteractionPermanentError("slack_action_authority_changed");
2148
3337
  }
2149
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
2150
- principalKind: "human_session",
3338
+ const grant = await resolveSlackTargetAuthority(deps.db, {
3339
+ subjectId: link.subjectId,
3340
+ targetAccountId: target.accountId,
3341
+ targetWorkspaceId: target.workspaceId,
2151
3342
  });
2152
- if (
2153
- !grant ||
2154
- grant.accountId !== entry.accountId ||
2155
- !hasPermission(grant.permissions, "sessions:control")
2156
- ) {
3343
+ if (!grant || !hasPermission(grant.permissions, "sessions:control")) {
2157
3344
  throw new SlackInteractionPermanentError("sessions_control_denied");
2158
3345
  }
2159
3346
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2160
- accountId: entry.accountId,
2161
- workspaceId: entry.workspaceId,
3347
+ ...home,
2162
3348
  connectionId: entry.connectionId,
2163
3349
  subjectId: grant.subjectId,
2164
3350
  sessionId: handle.sessionId,
2165
3351
  });
2166
3352
  const outcome = await executeSlackAction(deps, grant, interaction, handle);
3353
+ // A control click replaces the message it was pressed on. When that message
3354
+ // is this interaction's acknowledgement and this interaction won the one-time
3355
+ // onboarding hint, the update carries the hint forward: otherwise pressing
3356
+ // Status would permanently destroy the only copy of prose a Slack identity is
3357
+ // ever shown. The handle's message operation identifies the acknowledgement
3358
+ // exactly, independent of route rekeying. Later control cards have their own
3359
+ // operation ids, so the hint still appears on exactly one message.
3360
+ const acknowledgementOperationId = deterministicUuid(
3361
+ slackPostSeed(interaction, `slack-ack:${interaction.id}`),
3362
+ );
3363
+ const updateText =
3364
+ interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
3365
+ ? `${outcome.text}${slackFirstTaskHintText(deps)}`
3366
+ : outcome.text;
3367
+ // A control click replaces a message that already carried the line, so the
3368
+ // replacement carries it too rather than quietly stripping it.
3369
+ const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, updateText, [
3370
+ { type: "section", text: { type: "mrkdwn", text: updateText } },
3371
+ ]);
2167
3372
  await client.updateMessage({
2168
- operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
3373
+ operationId: deterministicUuid(
3374
+ slackPostSeed(interaction, `slack-action-update:${handle.id}:${outcome.result}`),
3375
+ ),
2169
3376
  channelId: entry.slackChannelId,
2170
3377
  timestamp: entry.slackMessageTs,
2171
- text: outcome.text,
2172
- blocks: [{ type: "section", text: { type: "mrkdwn", text: outcome.text } }],
3378
+ text: rendered.text,
3379
+ ...(rendered.blocks ? { blocks: rendered.blocks } : {}),
2173
3380
  });
2174
3381
  await settleSlackInteractionActionHandles(deps.db, {
2175
- accountId: entry.accountId,
2176
- workspaceId: entry.workspaceId,
3382
+ ...target,
2177
3383
  handleId: handle.id,
2178
3384
  result: outcome.result,
2179
3385
  ...(outcome.stale ? { stale: true } : {}),
@@ -2348,9 +3554,20 @@ async function executeSlackAction(
2348
3554
  }
2349
3555
  const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
2350
3556
  if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
3557
+ // The result post no longer advertises recurrence. The Status card is where
3558
+ // the requester finds it, and only while this exact requester still holds
3559
+ // schedule authority in this workspace. The deep link and the schedules
3560
+ // authority behind it are unchanged: the browser rechecks both.
3561
+ const recurring =
3562
+ hasPermission(grant.permissions, "sessions:read") &&
3563
+ hasPermission(grant.permissions, "scheduled_tasks:manage")
3564
+ ? makeRecurringText(deps, grant.workspaceId, handle.sessionId)
3565
+ : null;
2351
3566
  return {
2352
3567
  result: "status",
2353
- text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.`,
3568
+ text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.${
3569
+ recurring ? `\n\n${recurring}` : ""
3570
+ }`,
2354
3571
  ...(session.status === "cancelled" || session.status === "failed"
2355
3572
  ? {}
2356
3573
  : { controlState: "active" as const }),
@@ -2367,18 +3584,25 @@ async function publishSlackSharedResult(
2367
3584
  if (!handle.targetId || !interaction.sessionId) {
2368
3585
  throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
2369
3586
  }
2370
- const [origin, activePolicy, event] = await Promise.all([
3587
+ const publicationHome = await resolveSlackDeliveryHome(deps, interaction);
3588
+ const [origin, event] = await Promise.all([
2371
3589
  getSlackSharedTaskOrigin(deps.db, {
2372
3590
  accountId: interaction.accountId,
2373
3591
  workspaceId: interaction.workspaceId,
2374
3592
  interactionId: interaction.id,
2375
3593
  }),
2376
- getActiveSlackTaskPolicy(deps.db, {
2377
- accountId: interaction.accountId,
2378
- workspaceId: interaction.workspaceId,
2379
- }),
2380
3594
  getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
2381
3595
  ]);
3596
+ // The Slack task policy governs what may be read out of, and published back
3597
+ // to, the Slack conversation, so it is an installation-surface fact. The
3598
+ // origin froze which tenancy that was; a row written before routing existed
3599
+ // carries null and implied its own, because the two could not differ then.
3600
+ const activePolicy = origin
3601
+ ? await getActiveSlackTaskPolicy(deps.db, {
3602
+ accountId: origin.policyAccountId ?? origin.accountId,
3603
+ workspaceId: origin.policyWorkspaceId ?? origin.workspaceId,
3604
+ })
3605
+ : null;
2382
3606
  if (
2383
3607
  !origin ||
2384
3608
  origin.connectionId !== interaction.connectionId ||
@@ -2400,8 +3624,7 @@ async function publishSlackSharedResult(
2400
3624
  };
2401
3625
  }
2402
3626
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2403
- accountId: interaction.accountId,
2404
- workspaceId: interaction.workspaceId,
3627
+ ...publicationHome,
2405
3628
  connectionId: interaction.connectionId,
2406
3629
  subjectId: grant.subjectId,
2407
3630
  sessionId: interaction.sessionId,
@@ -2480,31 +3703,22 @@ async function validatedSlackRequester(
2480
3703
  SlackInteraction,
2481
3704
  "accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
2482
3705
  >,
3706
+ // Identity links are HOME facts, unique on (connection_id, slack_user_id) and
3707
+ // RLS-visible only under the installation's own tenancy.
3708
+ home: { accountId: string; workspaceId: string },
2483
3709
  ) {
2484
3710
  if (!interaction.initiatingSlackUserId) {
2485
- return { authorized: false, canSchedule: false, mention: "" };
3711
+ return { authorized: false, mention: "" };
2486
3712
  }
2487
- const [link, grant] = await Promise.all([
2488
- getSlackBotUserLink(
2489
- deps.db,
2490
- interaction.workspaceId,
2491
- interaction.connectionId,
2492
- interaction.initiatingSlackUserId,
2493
- ),
2494
- getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
2495
- principalKind: "human_session",
2496
- }),
2497
- ]);
3713
+ const link = await getSlackBotUserLink(
3714
+ deps.db,
3715
+ home.workspaceId,
3716
+ interaction.connectionId,
3717
+ interaction.initiatingSlackUserId,
3718
+ );
2498
3719
  const authorized = link?.subjectId === interaction.owningSubjectId;
2499
- const canSchedule =
2500
- authorized &&
2501
- grant?.accountId === interaction.accountId &&
2502
- hasPermission(grant.permissions, "sessions:read") &&
2503
- hasPermission(grant.permissions, "sessions:control") &&
2504
- hasPermission(grant.permissions, "scheduled_tasks:manage");
2505
3720
  return {
2506
3721
  authorized,
2507
- canSchedule,
2508
3722
  mention: authorized ? slackRequesterMention(interaction) : "",
2509
3723
  };
2510
3724
  }
@@ -2610,7 +3824,7 @@ async function slackApprovalCard(
2610
3824
  requesterAuthorized: boolean,
2611
3825
  ): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
2612
3826
  const operationId = deterministicUuid(
2613
- `slack-delivery:${interaction.id}:${event.sequence}:approval`,
3827
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:approval`),
2614
3828
  );
2615
3829
  const approvals = slackApprovalSummaries(event.payload);
2616
3830
  const fallback = approvals.length
@@ -2763,7 +3977,7 @@ async function slackHumanInputCard(
2763
3977
  );
2764
3978
  if (!request || request.status !== "pending") return null;
2765
3979
  const operationId = deterministicUuid(
2766
- `slack-delivery:${interaction.id}:${event.sequence}:human-input`,
3980
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:human-input`),
2767
3981
  );
2768
3982
  const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
2769
3983
  if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
@@ -2831,6 +4045,157 @@ async function slackHumanInputCard(
2831
4045
  return { text, blocks, operationId };
2832
4046
  }
2833
4047
 
4048
+ /**
4049
+ * A child of a Slack-originated session is never itself mapped to a Slack
4050
+ * thread, so the parent's bounded `child_requires_action` notice is the only
4051
+ * path that can tell the human that a worker they started is blocked. The card
4052
+ * is a pointer, not a second question card: one bounded preview of the first
4053
+ * question (or the waiting approval count) plus the child link. The child's own
4054
+ * OpenGeni card remains the place the human actually answers.
4055
+ *
4056
+ * Every deferred child lifecycle kind (`child_progress`,
4057
+ * `child_waiting_capacity`, `child_requires_action_resolved`, `child_paused`)
4058
+ * returns null here so Slack stays quiet, and so does every non-child machine
4059
+ * input that shares the `system.update.pending` event type.
4060
+ *
4061
+ * The caller has already proven that this workspace turned the notice on. The
4062
+ * card defaults off per workspace because the in-app rail and priority feed
4063
+ * already surface a blocked child.
4064
+ */
4065
+ /**
4066
+ * The exact durable notice behind one `system.update.pending` event, or null
4067
+ * when it no longer describes a blocked worker.
4068
+ *
4069
+ * Slack delivery runs behind the session: a widened retry window, a replica
4070
+ * claim, or simply a later page can reach this event after the child already
4071
+ * got its answer. Two facts have to agree before a card is worth posting.
4072
+ *
4073
+ * A resolution supersedes a still-`pending` notice in the same commit, so
4074
+ * `superseded` (and an explicitly `cancelled` notice) is already a "no longer
4075
+ * blocked" fact. But a notice the parent's turn has claimed is `delivered` and
4076
+ * keeps that state forever, so state alone is not enough - the resolution row
4077
+ * for that exact (child, turn, generation) boundary is checked as well, on the
4078
+ * parent's own rows. A re-freeze is a new generation and a new notice.
4079
+ *
4080
+ * Every read is wrapped: a malformed or unreadable durable row is not a
4081
+ * delivery failure. Throwing would burn a delivery attempt and, after
4082
+ * MAX_DELIVERY_ATTEMPTS, close this interaction's whole delivery over one
4083
+ * notice nobody could have posted anyway. Silence is the correct outcome, and
4084
+ * it is the same outcome every other unpostable notice already takes.
4085
+ */
4086
+ async function resolveSlackBlockedChildNotice(
4087
+ deps: ApiRouteDeps,
4088
+ interaction: SlackInteraction,
4089
+ sessionId: string,
4090
+ updateId: string,
4091
+ ): Promise<ChildRequiresActionPayload | null> {
4092
+ try {
4093
+ const update = await getSessionSystemUpdateById(
4094
+ deps.db,
4095
+ interaction.workspaceId,
4096
+ sessionId,
4097
+ updateId,
4098
+ );
4099
+ if (!update || update.payload.type !== "child_requires_action") return null;
4100
+ if (update.state === "superseded" || update.state === "cancelled") return null;
4101
+ const resolved = await childRequiresActionResolutionExists(
4102
+ deps.db,
4103
+ interaction.workspaceId,
4104
+ sessionId,
4105
+ {
4106
+ childSessionId: update.payload.childSessionId,
4107
+ childTurnId: update.payload.childTurnId,
4108
+ childTurnGeneration: update.payload.childTurnGeneration,
4109
+ },
4110
+ );
4111
+ return resolved ? null : update.payload;
4112
+ } catch {
4113
+ return null;
4114
+ }
4115
+ }
4116
+
4117
+ async function slackChildRequiresActionCard(
4118
+ deps: ApiRouteDeps,
4119
+ interaction: SlackInteraction,
4120
+ event: SessionEvent,
4121
+ mention: string,
4122
+ ): Promise<{ text: string } | null> {
4123
+ const sessionId = interaction.sessionId;
4124
+ if (!sessionId) return null;
4125
+ const preview = record(event.payload);
4126
+ if (boundedString(preview?.kind, 64) !== "child_requires_action") return null;
4127
+ const updateId = boundedString(preview?.updateId, 64);
4128
+ if (!updateId || !SLACK_UUID_PATTERN.test(updateId)) return null;
4129
+ // The bounded event preview is lossy by construction. Resolve the exact
4130
+ // durable notice under the ordinary workspace scope; the child session is
4131
+ // never read, only linked.
4132
+ const notice = await resolveSlackBlockedChildNotice(deps, interaction, sessionId, updateId);
4133
+ if (!notice) return null;
4134
+ const questions = notice.requests.filter((request) => request.kind === "human_input");
4135
+ const approvals = notice.requests.filter((request) => request.kind === "approval");
4136
+ const first = questions[0];
4137
+ const detail = first
4138
+ ? `${boundedSlackChildDetail(first.firstQuestion)}${
4139
+ first.questionCount > 1 ? ` (+${first.questionCount - 1} more)` : ""
4140
+ }`
4141
+ : approvals.length > 0
4142
+ ? `${approvals.length} tool approval${approvals.length > 1 ? "s are" : " is"} waiting for a human.`
4143
+ : "";
4144
+ const link = slackSessionUrl(deps, interaction.workspaceId, notice.childSessionId);
4145
+ const lines = [`${mention}A worker you started needs input.`];
4146
+ if (detail) lines.push(`> ${detail}`);
4147
+ if (link) lines.push(`<${link}|Open in OpenGeni>`);
4148
+ return { text: boundedOutput(lines.join("\n")) };
4149
+ }
4150
+
4151
+ /**
4152
+ * A goal that paused because it ran out of budget or hit the continuation cap
4153
+ * stops making progress with nobody watching, so the Slack thread gets one
4154
+ * bounded line. A `user_pause` / `api` / `agent` pause is a decision the human
4155
+ * or their agent already made, and `no_progress` is not a stop the human must
4156
+ * act on, so none of them post. `goal.resumed` never posts.
4157
+ *
4158
+ * The caller has already proven that this workspace turned the notice on; it
4159
+ * defaults off per workspace.
4160
+ */
4161
+ function slackGoalPausedText(
4162
+ deps: ApiRouteDeps,
4163
+ interaction: SlackInteraction,
4164
+ event: SessionEvent,
4165
+ mention: string,
4166
+ ): string | null {
4167
+ if (!interaction.sessionId) return null;
4168
+ const reason = boundedString(record(event.payload)?.reason, 64);
4169
+ const headline = reason ? SLACK_GOAL_PAUSED_HEADLINES.get(reason) : undefined;
4170
+ if (!headline) return null;
4171
+ const link = slackSessionUrl(deps, interaction.workspaceId, interaction.sessionId);
4172
+ return boundedOutput(`${mention}${headline}.${link ? ` <${link}|Open in OpenGeni>` : ""}`);
4173
+ }
4174
+
4175
+ /**
4176
+ * The installation tenancy that owns this interaction's Slack credential.
4177
+ *
4178
+ * `slack_interactions.connection_id` deliberately has no composite
4179
+ * `(workspace_id, connection_id)` foreign key, so a routed interaction may point
4180
+ * at the installation's connection from another workspace.
4181
+ *
4182
+ * A binding that does not resolve, or that now names a different connection, is
4183
+ * NOT turned into a new delivery precondition: this code previously used the
4184
+ * interaction's own tenancy unconditionally, so falling back to it keeps every
4185
+ * delivery that used to succeed succeeding. The fallback cannot post from the
4186
+ * wrong workspace either, because `claimSlackBotPostOperation` selects the
4187
+ * connection under the tenancy it is given and fails when it does not own it.
4188
+ */
4189
+ async function resolveSlackDeliveryHome(
4190
+ deps: ApiRouteDeps,
4191
+ interaction: Pick<SlackInteraction, "accountId" | "workspaceId" | "connectionId" | "slackTeamId">,
4192
+ ): Promise<{ accountId: string; workspaceId: string }> {
4193
+ const installation = await resolveSlackInstallationRoute(deps.db, interaction.slackTeamId);
4194
+ return installation && installation.connectionId === interaction.connectionId
4195
+ ? { accountId: installation.accountId, workspaceId: installation.workspaceId }
4196
+ : { accountId: interaction.accountId, workspaceId: interaction.workspaceId };
4197
+ }
4198
+
2834
4199
  async function deliverSlackSessionEvents(
2835
4200
  deps: ApiRouteDeps,
2836
4201
  interaction: SlackInteraction,
@@ -2850,14 +4215,65 @@ async function deliverSlackSessionEvents(
2850
4215
  });
2851
4216
  return;
2852
4217
  }
4218
+ // The bot credential is owned by the installation's workspace and every
4219
+ // provider call is fenced on it, so the delivery client is always built from
4220
+ // HOME even when the session it reports on lives in another workspace.
4221
+ const home = await resolveSlackDeliveryHome(deps, interaction);
2853
4222
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2854
- accountId: interaction.accountId,
2855
- workspaceId: interaction.workspaceId,
4223
+ ...home,
2856
4224
  connectionId: interaction.connectionId,
2857
4225
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
2858
4226
  sessionId: interaction.sessionId,
2859
4227
  });
2860
- const requester = await validatedSlackRequester(deps, interaction);
4228
+ const requester = await validatedSlackRequester(deps, interaction, home);
4229
+ // Both orchestration notices are per-workspace and OFF unless this workspace
4230
+ // turned them on. Resolved lazily so an ordinary page of turn/progress events
4231
+ // costs no extra workspace read, and memoized so one page resolves once. A
4232
+ // disabled notice takes the same "nothing to post for this event" path as an
4233
+ // undeliverable one: no post operation, no progress slot, and the delivery
4234
+ // cursor still advances past the event exactly as it would have.
4235
+ let orchestrationNotices: ResolvedWorkspaceSlackOrchestrationNoticeSettings | null = null;
4236
+ const orchestrationNoticeEnabled = async (
4237
+ notice: keyof ResolvedWorkspaceSlackOrchestrationNoticeSettings,
4238
+ ): Promise<boolean> => {
4239
+ orchestrationNotices ??= resolveWorkspaceSlackOrchestrationNoticeSettings(
4240
+ (await getWorkspace(deps.db, interaction.workspaceId))?.settings,
4241
+ );
4242
+ return orchestrationNotices[notice];
4243
+ };
4244
+ /**
4245
+ * Post one orchestration notice through the SAME durable per-interaction slot
4246
+ * budget as assistant progress, so an orchestration that fans out to many
4247
+ * blocked children cannot turn one Slack task into an unbounded feed. That
4248
+ * budget is deliberately shared rather than a second private allowance: the
4249
+ * ceiling worth enforcing is the total number of posts the human did not ask
4250
+ * for, not a per-category one, and this whole feature exists because an
4251
+ * unsolicited post is worse than a missed one. Beyond the cap the notice goes
4252
+ * silent exactly like a fourth progress message.
4253
+ *
4254
+ * The slot is claimed only once a card is actually going to be posted, so a
4255
+ * skipped, stale, or already-resolved notice never burns one. Claims are
4256
+ * durable and keyed on the session event sequence, so a reaper retry, a
4257
+ * replica claim, or a replayed page reuses the same slot and the same post
4258
+ * operation instead of posting twice or consuming a second slot. The claimed
4259
+ * row never becomes terminal-coalescing evidence: that lookup only joins
4260
+ * `agent.message.completed` events.
4261
+ */
4262
+ const postUnsolicitedNotice = async (event: SessionEvent, text: string, kind: string) => {
4263
+ const slot = await claimSlackInteractionProgressDelivery(deps.db, {
4264
+ accountId: interaction.accountId,
4265
+ workspaceId: interaction.workspaceId,
4266
+ interactionId: interaction.id,
4267
+ claimHolderId,
4268
+ sessionEventSequence: event.sequence,
4269
+ maxProgress: MAX_PROGRESS_MESSAGES,
4270
+ });
4271
+ if (slot.kind === "not_owned") {
4272
+ throw new Error("Slack orchestration notice lost its durable interaction claim");
4273
+ }
4274
+ if (slot.kind !== "claimed") return;
4275
+ await postDelivery(client, interaction, event, text, kind, slot.delivery.operationId);
4276
+ };
2861
4277
  let lastSequence = interaction.lastDeliveredSessionEventSequence;
2862
4278
  let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
2863
4279
  let latestAssistantText = "";
@@ -2985,14 +4401,21 @@ async function deliverSlackSessionEvents(
2985
4401
  card.blocks,
2986
4402
  );
2987
4403
  }
4404
+ } else if (event.type === "system.update.pending") {
4405
+ const card = (await orchestrationNoticeEnabled("childRequiresAction"))
4406
+ ? await slackChildRequiresActionCard(deps, interaction, event, requester.mention)
4407
+ : null;
4408
+ if (card) await postUnsolicitedNotice(event, card.text, "child-blocked");
4409
+ } else if (event.type === "goal.paused") {
4410
+ const paused = (await orchestrationNoticeEnabled("goalPaused"))
4411
+ ? slackGoalPausedText(deps, interaction, event, requester.mention)
4412
+ : null;
4413
+ if (paused) await postUnsolicitedNotice(event, paused, "goal-paused");
2988
4414
  } else if (event.type === "turn.completed") {
2989
4415
  const payloadOutput = safePayloadText(event.payload, "output");
2990
4416
  const hasPublishableOutput = payloadOutput.trim().length > 0;
2991
4417
  const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
2992
4418
  const normalizedOutput = output.trim();
2993
- const recurringLink = requester.canSchedule
2994
- ? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
2995
- : "";
2996
4419
  const existingProgress = progressEvidence.find(
2997
4420
  (delivery) =>
2998
4421
  slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
@@ -3022,46 +4445,49 @@ async function deliverSlackSessionEvents(
3022
4445
  );
3023
4446
  const posted = await getSlackBotPostOperation(
3024
4447
  deps.db,
3025
- interaction.workspaceId,
4448
+ home.workspaceId,
3026
4449
  interaction.connectionId,
3027
4450
  existingProgress.operationId,
3028
4451
  );
3029
4452
  if (posted?.slackChannelId && posted.slackMessageTimestamp) {
3030
- const text = boundedOutputWithSuffix(
3031
- `${requester.mention}${existingProgress.text}`,
3032
- recurringLink,
4453
+ // The result is the result. Continuation prose and the recurring
4454
+ // action live on the control/Status card and `<command> info`.
4455
+ const text = boundedOutput(`${requester.mention}${existingProgress.text}`);
4456
+ // This rewrites a message that already carried the line, so it has to
4457
+ // carry it too, or the update would quietly strip it.
4458
+ const rendered = withWorkspaceLine(
4459
+ interaction.routedWorkspaceLabel,
4460
+ text,
4461
+ publicationBlocks.length > 0
4462
+ ? ([
4463
+ { type: "section", text: { type: "mrkdwn", text } },
4464
+ ...publicationBlocks,
4465
+ ] as SlackMessageBlock[])
4466
+ : undefined,
3033
4467
  );
3034
4468
  await client.updateMessage({
3035
4469
  operationId: deterministicUuid(
3036
- `slack-terminal-update:${interaction.id}:${event.sequence}`,
4470
+ slackPostSeed(
4471
+ interaction,
4472
+ `slack-terminal-update:${interaction.id}:${event.sequence}`,
4473
+ ),
3037
4474
  ),
3038
4475
  channelId: posted.slackChannelId,
3039
4476
  timestamp: posted.slackMessageTimestamp,
3040
- text,
3041
- ...(publicationBlocks.length > 0
4477
+ text: rendered.text,
4478
+ ...(rendered.blocks
3042
4479
  ? {
3043
- blocks: [
3044
- {
3045
- type: "section",
3046
- text: {
3047
- type: "mrkdwn",
3048
- text,
3049
- },
3050
- },
3051
- ...publicationBlocks,
3052
- ],
4480
+ blocks: rendered.blocks,
3053
4481
  }
3054
4482
  : {}),
3055
4483
  });
3056
4484
  }
3057
4485
  } else {
3058
4486
  const operationId = deterministicUuid(
3059
- `slack-delivery:${interaction.id}:${event.sequence}:final`,
4487
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:final`),
3060
4488
  );
3061
- const finalSuffix = `\n\nReply in this thread to continue.${recurringLink}`;
3062
- const text = boundedOutputWithSuffix(
4489
+ const text = boundedOutput(
3063
4490
  `${requester.mention}${output || "OpenGeni finished this task."}`,
3064
- finalSuffix,
3065
4491
  );
3066
4492
  const publicationBlocks = hasPublishableOutput
3067
4493
  ? await slackSharedResultPublicationBlocks(
@@ -3159,15 +4585,18 @@ async function postDelivery(
3159
4585
  event: SessionEvent,
3160
4586
  text: string,
3161
4587
  kind: string,
3162
- operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
4588
+ operationId = deterministicUuid(
4589
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
4590
+ ),
3163
4591
  blocks?: SlackMessageBlock[],
3164
4592
  ) {
4593
+ const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, boundedOutput(text), blocks);
3165
4594
  await client.postMessage({
3166
4595
  operationId,
3167
4596
  channelId: interaction.slackChannelId,
3168
4597
  threadTimestamp: interaction.slackThreadTs,
3169
- text: boundedOutput(text),
3170
- ...(blocks ? { blocks } : {}),
4598
+ text: rendered.text,
4599
+ ...(rendered.blocks ? { blocks: rendered.blocks } : {}),
3171
4600
  });
3172
4601
  }
3173
4602
 
@@ -3247,6 +4676,8 @@ const SLACK_BLOCK_ACTION_IDS = new Set([
3247
4676
  "opengeni.session.pause",
3248
4677
  "opengeni.session.resume",
3249
4678
  "opengeni.shared_result.publish",
4679
+ "opengeni.route.select",
4680
+ "opengeni.route.cancel",
3250
4681
  ]);
3251
4682
 
3252
4683
  export function normalizedBlockActionInteraction(
@@ -3311,7 +4742,14 @@ function humanInputResponse(questions: HumanInputQuestion[], text: string) {
3311
4742
  const matches = question.options.filter(
3312
4743
  (option) => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized,
3313
4744
  );
3314
- if (matches.length !== 1) return null;
4745
+ if (matches.length !== 1) {
4746
+ const other = text.trim();
4747
+ if (!other) return null;
4748
+ return {
4749
+ outcome: "answered" as const,
4750
+ answers: [{ questionId: question.id, values: [], other }],
4751
+ };
4752
+ }
3315
4753
  return {
3316
4754
  outcome: "answered" as const,
3317
4755
  answers: [{ questionId: question.id, values: [matches[0]!.id] }],
@@ -3326,11 +4764,123 @@ function formatQuestions(questions: HumanInputQuestion[]) {
3326
4764
  .slice(0, 10)
3327
4765
  .map((option) => option.label)
3328
4766
  .join(", ");
3329
- return `${index + 1}. ${boundedOutput(question.prompt)}${options ? ` (${options})` : ""}`;
4767
+ return `${index + 1}. ${boundedOutput(question.prompt)}${
4768
+ options ? ` (${options}, or reply with another value)` : ""
4769
+ }`;
3330
4770
  })
3331
4771
  .join("\n");
3332
4772
  }
3333
4773
 
4774
+ export function isSlackInfoCommand(text: string): boolean {
4775
+ return text.trim().toLowerCase() === "info";
4776
+ }
4777
+
4778
+ type SlackSlashResponse = {
4779
+ response_type: "ephemeral";
4780
+ text: string;
4781
+ unfurl_links: false;
4782
+ unfurl_media: false;
4783
+ blocks?: SlackMessageBlock[];
4784
+ };
4785
+
4786
+ /**
4787
+ * The ephemeral `<command> info` card.
4788
+ *
4789
+ * It is a projection of what the caller can already do, not an action: no
4790
+ * session, no durable inbox row, no provider post. It re-proves the exact
4791
+ * Slack identity link plus live workspace grants before any
4792
+ * workspace-identifying text is echoed, so an unlinked or access-revoked
4793
+ * identity receives the ordinary connect view instead.
4794
+ */
4795
+ async function slackInfoCommandResponse(
4796
+ deps: ApiRouteDeps,
4797
+ installation: SlackInstallationRoute,
4798
+ entry: NormalizedSlackInteraction,
4799
+ ): Promise<SlackSlashResponse> {
4800
+ const identity = {
4801
+ workspaceId: installation.workspaceId,
4802
+ connectionId: installation.connectionId,
4803
+ slackTeamId: entry.slackTeamId,
4804
+ slackUserId: entry.slackUserId,
4805
+ };
4806
+ const link = await getSlackBotUserLink(
4807
+ deps.db,
4808
+ installation.workspaceId,
4809
+ installation.connectionId,
4810
+ entry.slackUserId,
4811
+ );
4812
+ const grant = link
4813
+ ? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
4814
+ principalKind: "human_session",
4815
+ })
4816
+ : null;
4817
+ if (
4818
+ !grant ||
4819
+ grant.accountId !== installation.accountId ||
4820
+ !hasPermission(grant.permissions, "sessions:read")
4821
+ ) {
4822
+ return ephemeralSlackResponse(
4823
+ link
4824
+ ? `Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace. Request access: ${linkUrl(deps, identity)}. No session was created.`
4825
+ : `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, identity)}. No session was created.`,
4826
+ );
4827
+ }
4828
+ const workspace = await getWorkspace(deps.db, installation.workspaceId);
4829
+ const command = deps.settings.slackCommand;
4830
+ const botMention = slackBotMention(installation.botUserId);
4831
+ // Every line is gated on the grant that actually authorizes it, so the card
4832
+ // stays a projection of what this caller can do rather than a generic manual.
4833
+ const canControl = hasPermission(grant.permissions, "sessions:control");
4834
+ const canCreate = hasPermission(grant.permissions, "sessions:create");
4835
+ const schedules = hasPermission(grant.permissions, "scheduled_tasks:manage")
4836
+ ? slackSchedulesUrl(deps, installation.workspaceId)
4837
+ : null;
4838
+ const workspaceUrl = slackWorkspaceUrl(deps, installation.workspaceId);
4839
+ const workspaceName = (workspace?.name ?? "").trim().slice(0, 120);
4840
+ const destination = workspaceName
4841
+ ? `the *${escapeSlackMrkdwn(workspaceName)}* workspace`
4842
+ : "your OpenGeni workspace";
4843
+ const lines = [
4844
+ "*Working with OpenGeni in Slack*",
4845
+ "",
4846
+ ...(canControl
4847
+ ? [
4848
+ "• *Continue a task:* reply in its Slack thread.",
4849
+ "• *Stop a task:* press *Stop* on its card, or reply `stop` in its thread.",
4850
+ ]
4851
+ : []),
4852
+ ...(canCreate
4853
+ ? [
4854
+ `• *Start a new task:* mention ${botMention} in a channel, run \`${command} <task>\`, or send a new top-level direct message.`,
4855
+ ]
4856
+ : []),
4857
+ ...(schedules
4858
+ ? [
4859
+ `• *Make a result recurring:* press *Status* on the task card and open *Make recurring*, or open <${schedules}|Schedules>.`,
4860
+ ]
4861
+ : []),
4862
+ `• *Where work lands:* ${destination}${workspaceUrl ? ` (<${workspaceUrl}|open OpenGeni>)` : ""}.`,
4863
+ ];
4864
+ const text = lines.join("\n");
4865
+ return ephemeralSlackResponse(text, [
4866
+ { type: "section", text: { type: "mrkdwn", text } },
4867
+ ] as SlackMessageBlock[]);
4868
+ }
4869
+
4870
+ function ephemeralSlackResponse(text: string, blocks?: SlackMessageBlock[]): SlackSlashResponse {
4871
+ return {
4872
+ response_type: "ephemeral",
4873
+ text,
4874
+ unfurl_links: false,
4875
+ unfurl_media: false,
4876
+ ...(blocks ? { blocks } : {}),
4877
+ };
4878
+ }
4879
+
4880
+ function slackBotMention(botUserId: string | null): string {
4881
+ return botUserId && /^[UWB][A-Z0-9]{1,63}$/.test(botUserId) ? `<@${botUserId}>` : "@OpenGeni";
4882
+ }
4883
+
3334
4884
  function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
3335
4885
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3336
4886
  if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
@@ -3338,15 +4888,105 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
3338
4888
  return `<${url}|Open in OpenGeni>`;
3339
4889
  }
3340
4890
 
3341
- function makeRecurringText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
4891
+ function slackWorkspaceUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
4892
+ return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}`);
4893
+ }
4894
+
4895
+ function slackSessionAuthorizationScopeKey(scope: SessionAuthorizationListScope | null): string {
4896
+ if (scope === null) return "standalone";
4897
+ if (scope.kind === "all") return "all";
4898
+ return JSON.stringify({
4899
+ kind: scope.kind,
4900
+ rootSessionIds: [...scope.rootSessionIds].sort(),
4901
+ sessionIds: [...scope.sessionIds].sort(),
4902
+ });
4903
+ }
4904
+
4905
+ function slackSessionUrl(
4906
+ deps: ApiRouteDeps,
4907
+ workspaceId: string,
4908
+ sessionId: string,
4909
+ ): string | null {
4910
+ return safeSlackActionUrl(
4911
+ deps,
4912
+ `/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
4913
+ );
4914
+ }
4915
+
4916
+ function safeSlackActionUrl(deps: ApiRouteDeps, pathname: string): string | null {
3342
4917
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3343
- if (!base) throw new Error("Slack recurring action requires an absolute web base URL");
3344
- const url = new URL(`/workspaces/${workspaceId}/schedules`, base);
4918
+ if (!base) return null;
4919
+ try {
4920
+ const url = new URL(pathname, base);
4921
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
4922
+ return null;
4923
+ }
4924
+ return url.toString();
4925
+ } catch {
4926
+ return null;
4927
+ }
4928
+ }
4929
+
4930
+ function slackAppHomeLinkUrl(
4931
+ deps: ApiRouteDeps,
4932
+ installation: SlackInstallationRoute,
4933
+ slackTeamId: string,
4934
+ slackUserId: string,
4935
+ ): string | null {
4936
+ const signingSecret = deps.settings.slackSigningSecret;
4937
+ const base = slackWorkspaceUrl(deps, installation.workspaceId);
4938
+ if (!signingSecret || !base) return base;
4939
+ const url = new URL(
4940
+ `/workspaces/${encodeURIComponent(installation.workspaceId)}/capabilities`,
4941
+ base,
4942
+ );
4943
+ url.hash = new URLSearchParams({
4944
+ slack_link: createSlackUserLinkToken(signingSecret, {
4945
+ workspaceId: installation.workspaceId,
4946
+ connectionId: installation.connectionId,
4947
+ slackTeamId,
4948
+ slackUserId,
4949
+ }),
4950
+ }).toString();
4951
+ return url.toString();
4952
+ }
4953
+
4954
+ function makeRecurringText(
4955
+ deps: ApiRouteDeps,
4956
+ workspaceId: string,
4957
+ sessionId: string,
4958
+ ): string | null {
4959
+ // Unchanged deep-link contract: the schedules editor plus the exact source
4960
+ // session UUID, and nothing copied out of Slack. A deployment without an
4961
+ // absolute web base URL simply omits the action instead of failing a card.
4962
+ const base = slackSchedulesUrl(deps, workspaceId);
4963
+ if (!base) return null;
4964
+ const url = new URL(base);
3345
4965
  url.searchParams.set("sourceSessionId", sessionId);
3346
4966
  return `<${url.toString()}|Make recurring>`;
3347
4967
  }
3348
4968
 
3349
- function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
4969
+ function slackSchedulesUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
4970
+ return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}/schedules`);
4971
+ }
4972
+
4973
+ function linkUrl(
4974
+ deps: ApiRouteDeps,
4975
+ entry: Pick<
4976
+ SlackInteractionInboxEntry,
4977
+ "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
4978
+ >,
4979
+ /**
4980
+ * When this URL goes into a message posted under a deterministic operation
4981
+ * id, the token has to be deterministic too. The post ledger binds that id to
4982
+ * a digest over the text, so a retry that mints a fresh token renders
4983
+ * different bytes and is refused as a conflicting request, permanently. Pass
4984
+ * the originating inbox row's creation time: the token is then stable for that
4985
+ * row and still expires fifteen minutes after the message could first have
4986
+ * been read.
4987
+ */
4988
+ mintedAtMs?: number,
4989
+ ) {
3350
4990
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3351
4991
  const signingSecret = deps.settings.slackSigningSecret;
3352
4992
  if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
@@ -3354,7 +4994,7 @@ function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
3354
4994
  // Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
3355
4995
  // headers, and managed-auth callback URLs. Query-form bearers are rejected.
3356
4996
  url.hash = new URLSearchParams({
3357
- slack_link: createSlackUserLinkToken(signingSecret, entry),
4997
+ slack_link: createSlackUserLinkToken(signingSecret, entry, mintedAtMs),
3358
4998
  }).toString();
3359
4999
  return url.toString();
3360
5000
  }
@@ -3519,17 +5159,16 @@ function boundedText(value: unknown): string | null {
3519
5159
  return trimmed.slice(0, MAX_SLACK_INPUT_CHARS);
3520
5160
  }
3521
5161
 
3522
- function boundedOutput(value: string) {
3523
- return value.length <= MAX_SLACK_TEXT_CHARS
3524
- ? value
3525
- : `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
5162
+ /** Single-line, character-bounded preview for a blocked-worker pointer card. */
5163
+ function boundedSlackChildDetail(value: string) {
5164
+ const collapsed = value.replace(/\s+/gu, " ").trim();
5165
+ return collapsed.length <= MAX_SLACK_CHILD_DETAIL_CHARS
5166
+ ? collapsed
5167
+ : `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
3526
5168
  }
3527
5169
 
3528
- function boundedOutputWithSuffix(value: string, suffix: string) {
3529
- const maxValueChars = Math.max(0, MAX_SLACK_TEXT_CHARS - suffix.length);
3530
- if (value.length <= maxValueChars) return `${value}${suffix}`;
3531
- const truncation = "\n… output truncated";
3532
- return `${value.slice(0, Math.max(0, maxValueChars - truncation.length))}${truncation}${suffix}`;
5170
+ function boundedOutput(value: string, limit = MAX_SLACK_TEXT_CHARS) {
5171
+ return value.length <= limit ? value : `${value.slice(0, limit - 20)}\n… output truncated`;
3533
5172
  }
3534
5173
 
3535
5174
  function safePayloadText(payload: unknown, field: string) {