@opengeni/api-router 2.1.0-canary.0 → 2.3.2-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 (44) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +5 -2
  3. package/dist/auth/managed-email.d.ts +29 -0
  4. package/dist/auth/organization-user-setup.d.ts +57 -0
  5. package/dist/{chunk-QKDFBBUE.js → chunk-IBV7Z6F4.js} +3872 -1845
  6. package/dist/chunk-IBV7Z6F4.js.map +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/integrations/slack-app-home.d.ts +1 -1
  9. package/dist/integrations/slack-bot.d.ts +8 -0
  10. package/dist/integrations/slack-interactions.d.ts +35 -2
  11. package/dist/mcp/server.d.ts +1 -1
  12. package/dist/mcp/session-view.d.ts +1 -0
  13. package/dist/routes/automations.d.ts +13 -0
  14. package/dist/routes/insights.d.ts +2 -1
  15. package/dist/routes/managed-onboarding.d.ts +29 -0
  16. package/dist/routes/pr-review-github.d.ts +3 -0
  17. package/package.json +18 -18
  18. package/src/app.ts +64 -5
  19. package/src/auth/managed-auth.ts +29 -34
  20. package/src/auth/managed-email.ts +174 -0
  21. package/src/auth/organization-user-setup.ts +217 -0
  22. package/src/http/auth.ts +15 -0
  23. package/src/http/sse.ts +62 -13
  24. package/src/integrations/slack-app-home.ts +2 -2
  25. package/src/integrations/slack-bot.ts +5 -0
  26. package/src/integrations/slack-interactions.ts +653 -71
  27. package/src/integrations/slack-routing.ts +25 -12
  28. package/src/mcp/company-brain-governed-writes.ts +4 -4
  29. package/src/mcp/company-profile-agent-admin.ts +11 -18
  30. package/src/mcp/remember.ts +4 -4
  31. package/src/mcp/server.ts +50 -4
  32. package/src/mcp/session-view.ts +8 -2
  33. package/src/routes/automations.ts +3 -3
  34. package/src/routes/documents.ts +136 -3
  35. package/src/routes/insights.ts +61 -19
  36. package/src/routes/managed-onboarding.ts +317 -0
  37. package/src/routes/organization-memberships.ts +212 -155
  38. package/src/routes/pr-review-github.ts +844 -0
  39. package/src/routes/pr-review.ts +20 -0
  40. package/src/routes/rigs.ts +37 -4
  41. package/src/routes/sessions.ts +101 -0
  42. package/src/sandbox/channel-a.ts +34 -7
  43. package/src/sandbox/viewer.ts +47 -11
  44. package/dist/chunk-QKDFBBUE.js.map +0 -1
@@ -9,7 +9,9 @@ import {
9
9
  evaluateSlackTaskPolicy,
10
10
  resolveWorkspaceSlackOrchestrationNoticeSettings,
11
11
  resolveWorkspaceSlackReactionSummonSettings,
12
+ SlackChannelRouteListResponse,
12
13
  SlackReactionChannelListResponse,
14
+ UpdateSlackChannelRoutesRequest,
13
15
  SlackUserLinkAccessMutationRequest,
14
16
  SlackUserLinkAccessRequest,
15
17
  type AccessGrant,
@@ -61,7 +63,15 @@ import {
61
63
  getSlackInteractionById,
62
64
  getSlackInteractionByConnectionRoute,
63
65
  probeSlackActionHandleTenancy,
66
+ openSlackRoutePrompt,
67
+ getSlackRoutePromptByOption,
68
+ settleSlackRoutePrompt,
69
+ answerSlackRoutePrompt,
70
+ type SlackRoutableWorkspace,
64
71
  getSlackChannelRoute,
72
+ listSlackChannelRoutes,
73
+ upsertSlackChannelRoute,
74
+ deleteSlackChannelRoute,
65
75
  getSlackUserDmRoute,
66
76
  listNamedSubjectSlackRoutableWorkspaces,
67
77
  getActiveSlackTaskPolicy,
@@ -69,11 +79,14 @@ import {
69
79
  getSessionEventByClientEventId,
70
80
  getWorkspace,
71
81
  getWorkspaceGrant,
82
+ namedSubjectPersonalWorkspaceId,
72
83
  resolveSlackTargetAuthority,
73
84
  listSlackInteractionProgressDeliveryEvidence,
74
85
  listSessionEventPage,
75
86
  listSessionHumanInputRequests,
76
87
  listPendingSlackUserLinkAccessRequests,
88
+ listSlackInstallationBindings,
89
+ slackUserLinkAccessRequestTeam,
77
90
  rekeySlackInteractionRoute,
78
91
  reopenSlackInteractionDelivery,
79
92
  reserveSlackInteractionActionHandles,
@@ -401,6 +414,46 @@ export function slackReactionInboxEntry(
401
414
  };
402
415
  }
403
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
+
404
457
  export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
405
458
  app.post("/v1/integrations/slack/events", async (c) => {
406
459
  const signed = await readSignedSlackRequest(c, deps);
@@ -563,22 +616,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
563
616
  if (!link || link.workspaceId !== workspaceId) {
564
617
  throw freshSlackLinkRequired();
565
618
  }
566
- const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
567
- if (
568
- !route ||
569
- route.workspaceId !== workspaceId ||
570
- route.connectionId !== link.connectionId ||
571
- route.accountId.length === 0
572
- ) {
573
- throw freshSlackLinkRequired();
574
- }
575
- const workspace = await getWorkspace(deps.db, workspaceId);
576
- if (!workspace || workspace.accountId !== route.accountId) {
619
+ const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
620
+ if (!installation) {
577
621
  throw freshSlackLinkRequired();
578
622
  }
579
623
  try {
580
624
  const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
581
- accountId: route.accountId,
625
+ accountId: installation.accountId,
582
626
  workspaceId,
583
627
  tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
584
628
  connectionId: link.connectionId,
@@ -592,12 +636,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
592
636
  workspaceId,
593
637
  requestId: prepared.id,
594
638
  subjectId: context.subjectId,
639
+ identityHome: installation.identityHome,
595
640
  });
596
641
  if (!completed) throw freshSlackLinkRequired();
597
642
  return c.json(
598
643
  SlackUserLinkAccessRequest.parse({
599
644
  ...completed,
600
- workspaceDisplayName: workspace.name,
645
+ workspaceDisplayName: installation.workspaceName,
601
646
  }),
602
647
  201,
603
648
  );
@@ -705,14 +750,33 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
705
750
  const workspaceId = c.req.param("workspaceId");
706
751
  const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
707
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;
708
764
  try {
709
765
  const request = await approveSlackUserLinkAccessRequest(deps.db, {
710
766
  workspaceId,
711
- requestId: c.req.param("requestId"),
767
+ requestId,
712
768
  actorSubjectId: grant.subjectId,
713
769
  expectedVersion: payload.expectedVersion,
714
770
  idempotencyKey: payload.idempotencyKey,
715
771
  permissions: payload.permissions,
772
+ ...(installation
773
+ ? {
774
+ identityHome: {
775
+ accountId: installation.accountId,
776
+ workspaceId: installation.workspaceId,
777
+ },
778
+ }
779
+ : {}),
716
780
  ...(payload.role !== undefined ? { role: payload.role } : {}),
717
781
  });
718
782
  const workspace = await getWorkspace(deps.db, workspaceId);
@@ -771,8 +835,8 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
771
835
  message: "invalid or expired Slack identity link",
772
836
  });
773
837
  }
774
- const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
775
- if (!route || route.workspaceId !== workspaceId || route.connectionId !== link.connectionId) {
838
+ const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
839
+ if (!installation) {
776
840
  throw new HTTPException(404, {
777
841
  message: "Slack installation not found",
778
842
  });
@@ -793,6 +857,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
793
857
  workspaceId,
794
858
  requestId: prepared.id,
795
859
  subjectId: grant.subjectId,
860
+ identityHome: installation.identityHome,
796
861
  });
797
862
  if (completed?.status !== "completed") throw freshSlackLinkRequired();
798
863
  const saved = await getSlackBotUserLink(
@@ -826,6 +891,135 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
826
891
  },
827
892
  );
828
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
+
829
1023
  app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
830
1024
  const workspaceId = c.req.param("workspaceId");
831
1025
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
@@ -1339,11 +1533,12 @@ async function resolveSlackRouteForEntry(
1339
1533
  * Every branch ends with "No session was created." because the one thing a
1340
1534
  * refusal must never do is leave someone believing work is under way.
1341
1535
  *
1342
- * No access-request link is minted here. The existing link flow signs a token
1343
- * for the installation's own workspace, and offering one for a workspace it
1344
- * cannot yet resolve would send people to a page that refuses them. Until that
1345
- * flow accepts a routed workspace, the refusal names the workspace and points
1346
- * at an administrator.
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.
1347
1542
  */
1348
1543
  async function postSlackRouteRefusal(
1349
1544
  deps: ApiRouteDeps,
@@ -1354,14 +1549,30 @@ async function postSlackRouteRefusal(
1354
1549
  const available = refusal.candidates.map((candidate) => candidate.label);
1355
1550
  const text =
1356
1551
  refusal.reason === "no_access_to_named"
1357
- ? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in.${
1552
+ ? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in from here.${
1358
1553
  available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
1359
1554
  } No session was created.`
1360
1555
  : refusal.reason === "no_access_to_route"
1361
1556
  ? `OpenGeni starts work from this conversation in ${
1362
1557
  refusal.requested ?? "another workspace"
1363
- }, and you do not have access to it. Ask an OpenGeni administrator for access to that workspace, or point this conversation somewhere else in OpenGeni under Capabilities, then Slack. No session was created.`
1364
- : "OpenGeni has no workspace it can start this task in for you. Ask an OpenGeni administrator to give you access to one. No session was created.";
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.";
1365
1576
  await client.postMessage({
1366
1577
  operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
1367
1578
  userId: entry.slackUserId,
@@ -1369,6 +1580,330 @@ async function postSlackRouteRefusal(
1369
1580
  });
1370
1581
  }
1371
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
+
1372
1907
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
1373
1908
  if (entry.triggerKind === "block_action") {
1374
1909
  await processSlackBlockAction(deps, entry);
@@ -1442,7 +1977,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1442
1977
  await client.postMessage({
1443
1978
  operationId: deterministicUuid(`slack-link:${entry.id}`),
1444
1979
  userId: entry.slackUserId,
1445
- 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.`,
1446
1981
  });
1447
1982
  return;
1448
1983
  }
@@ -1451,14 +1986,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1451
1986
  threadTenancy: existing
1452
1987
  ? { accountId: existing.accountId, workspaceId: existing.workspaceId }
1453
1988
  : null,
1454
- // The first-use picker does not exist yet, so genuine ambiguity keeps the
1455
- // installation's workspace rather than inventing an answer.
1456
- askEnabled: false,
1989
+ askEnabled: true,
1457
1990
  });
1458
1991
  if (resolution.kind === "ask") {
1459
- // Unreachable: `askEnabled` is false above because the first-use picker
1460
- // lands in a later change. Fail loudly rather than guessing a workspace.
1461
- throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
1992
+ await askSlackRouteChoice(deps, client, home, entry, resolution.candidates);
1993
+ return;
1462
1994
  }
1463
1995
  if (resolution.kind === "denied") {
1464
1996
  await postSlackRouteRefusal(deps, client, entry, resolution);
@@ -1579,6 +2111,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1579
2111
  }
1580
2112
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
1581
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),
1582
2119
  connectionId: entry.connectionId,
1583
2120
  slackTeamId: entry.slackTeamId,
1584
2121
  slackChannelId: entry.slackChannelId,
@@ -1894,7 +2431,7 @@ async function acknowledgeSlackSession(
1894
2431
  const privateHandoff =
1895
2432
  !directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
1896
2433
  const privateBotDm = directMessageShortcut || privateHandoff;
1897
- const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
2434
+ const operationId = deterministicUuid(slackPostSeed(interaction, `slack-ack:${interaction.id}`));
1898
2435
  // The acknowledgement carries exactly one session link plus the Status/Stop
1899
2436
  // buttons. The how-to prose it used to repeat forever now rides along once,
1900
2437
  // on this Slack identity's first accepted task in this installation.
@@ -1922,6 +2459,15 @@ async function acknowledgeSlackSession(
1922
2459
  sessionEventSequence: 0,
1923
2460
  state: "active",
1924
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
+ );
1925
2471
  const ack = await client.postMessage({
1926
2472
  operationId,
1927
2473
  ...(privateBotDm
@@ -1932,15 +2478,8 @@ async function acknowledgeSlackSession(
1932
2478
  ? {}
1933
2479
  : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
1934
2480
  }),
1935
- text,
1936
- ...(controls.length > 0
1937
- ? {
1938
- blocks: [
1939
- { type: "section", text: { type: "mrkdwn", text } },
1940
- ...controls,
1941
- ] as SlackMessageBlock[],
1942
- }
1943
- : {}),
2481
+ text: acknowledgement.text,
2482
+ ...(acknowledgement.blocks ? { blocks: acknowledgement.blocks } : {}),
1944
2483
  });
1945
2484
  if (entry.triggerKind === "slash_command" || privateBotDm) {
1946
2485
  const rekeyed = await rekeySlackInteractionRoute(deps.db, {
@@ -2155,6 +2694,10 @@ async function processSlackReactionInboxEntry(
2155
2694
  }
2156
2695
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
2157
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),
2158
2701
  connectionId: entry.connectionId,
2159
2702
  slackTeamId: entry.slackTeamId,
2160
2703
  slackChannelId: entry.slackChannelId,
@@ -2248,11 +2791,15 @@ async function acknowledgeSlackReactionSession(
2248
2791
  if (!interaction.sessionId) {
2249
2792
  throw new Error("Slack reaction acknowledgement requires a bound session");
2250
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);
2251
2796
  await client.postMessage({
2252
- operationId: deterministicUuid(`slack-reaction-ack:${interaction.id}`),
2797
+ operationId: deterministicUuid(
2798
+ slackPostSeed(interaction, `slack-reaction-ack:${interaction.id}`),
2799
+ ),
2253
2800
  channelId: interaction.slackChannelId,
2254
2801
  threadTimestamp: interaction.slackThreadTs,
2255
- 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,
2256
2803
  });
2257
2804
  }
2258
2805
 
@@ -2715,6 +3262,13 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2715
3262
  ) {
2716
3263
  throw new SlackInteractionPermanentError("slack_action_installation_changed");
2717
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
+ }
2718
3272
  // A handle lives in the workspace that owns its session, which is not the
2719
3273
  // installation's workspace once routing is on. Learn that scope from the
2720
3274
  // content-free probe, then read the full handle under it: every authorization
@@ -2803,17 +3357,26 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2803
3357
  // ever shown. The handle's message operation identifies the acknowledgement
2804
3358
  // exactly, independent of route rekeying. Later control cards have their own
2805
3359
  // operation ids, so the hint still appears on exactly one message.
2806
- const acknowledgementOperationId = deterministicUuid(`slack-ack:${interaction.id}`);
3360
+ const acknowledgementOperationId = deterministicUuid(
3361
+ slackPostSeed(interaction, `slack-ack:${interaction.id}`),
3362
+ );
2807
3363
  const updateText =
2808
3364
  interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
2809
3365
  ? `${outcome.text}${slackFirstTaskHintText(deps)}`
2810
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
+ ]);
2811
3372
  await client.updateMessage({
2812
- operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
3373
+ operationId: deterministicUuid(
3374
+ slackPostSeed(interaction, `slack-action-update:${handle.id}:${outcome.result}`),
3375
+ ),
2813
3376
  channelId: entry.slackChannelId,
2814
3377
  timestamp: entry.slackMessageTs,
2815
- text: updateText,
2816
- blocks: [{ type: "section", text: { type: "mrkdwn", text: updateText } }],
3378
+ text: rendered.text,
3379
+ ...(rendered.blocks ? { blocks: rendered.blocks } : {}),
2817
3380
  });
2818
3381
  await settleSlackInteractionActionHandles(deps.db, {
2819
3382
  ...target,
@@ -3261,7 +3824,7 @@ async function slackApprovalCard(
3261
3824
  requesterAuthorized: boolean,
3262
3825
  ): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
3263
3826
  const operationId = deterministicUuid(
3264
- `slack-delivery:${interaction.id}:${event.sequence}:approval`,
3827
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:approval`),
3265
3828
  );
3266
3829
  const approvals = slackApprovalSummaries(event.payload);
3267
3830
  const fallback = approvals.length
@@ -3414,7 +3977,7 @@ async function slackHumanInputCard(
3414
3977
  );
3415
3978
  if (!request || request.status !== "pending") return null;
3416
3979
  const operationId = deterministicUuid(
3417
- `slack-delivery:${interaction.id}:${event.sequence}:human-input`,
3980
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:human-input`),
3418
3981
  );
3419
3982
  const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
3420
3983
  if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
@@ -3890,32 +4453,38 @@ async function deliverSlackSessionEvents(
3890
4453
  // The result is the result. Continuation prose and the recurring
3891
4454
  // action live on the control/Status card and `<command> info`.
3892
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,
4467
+ );
3893
4468
  await client.updateMessage({
3894
4469
  operationId: deterministicUuid(
3895
- `slack-terminal-update:${interaction.id}:${event.sequence}`,
4470
+ slackPostSeed(
4471
+ interaction,
4472
+ `slack-terminal-update:${interaction.id}:${event.sequence}`,
4473
+ ),
3896
4474
  ),
3897
4475
  channelId: posted.slackChannelId,
3898
4476
  timestamp: posted.slackMessageTimestamp,
3899
- text,
3900
- ...(publicationBlocks.length > 0
4477
+ text: rendered.text,
4478
+ ...(rendered.blocks
3901
4479
  ? {
3902
- blocks: [
3903
- {
3904
- type: "section",
3905
- text: {
3906
- type: "mrkdwn",
3907
- text,
3908
- },
3909
- },
3910
- ...publicationBlocks,
3911
- ],
4480
+ blocks: rendered.blocks,
3912
4481
  }
3913
4482
  : {}),
3914
4483
  });
3915
4484
  }
3916
4485
  } else {
3917
4486
  const operationId = deterministicUuid(
3918
- `slack-delivery:${interaction.id}:${event.sequence}:final`,
4487
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:final`),
3919
4488
  );
3920
4489
  const text = boundedOutput(
3921
4490
  `${requester.mention}${output || "OpenGeni finished this task."}`,
@@ -4016,15 +4585,18 @@ async function postDelivery(
4016
4585
  event: SessionEvent,
4017
4586
  text: string,
4018
4587
  kind: string,
4019
- operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
4588
+ operationId = deterministicUuid(
4589
+ slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
4590
+ ),
4020
4591
  blocks?: SlackMessageBlock[],
4021
4592
  ) {
4593
+ const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, boundedOutput(text), blocks);
4022
4594
  await client.postMessage({
4023
4595
  operationId,
4024
4596
  channelId: interaction.slackChannelId,
4025
4597
  threadTimestamp: interaction.slackThreadTs,
4026
- text: boundedOutput(text),
4027
- ...(blocks ? { blocks } : {}),
4598
+ text: rendered.text,
4599
+ ...(rendered.blocks ? { blocks: rendered.blocks } : {}),
4028
4600
  });
4029
4601
  }
4030
4602
 
@@ -4104,6 +4676,8 @@ const SLACK_BLOCK_ACTION_IDS = new Set([
4104
4676
  "opengeni.session.pause",
4105
4677
  "opengeni.session.resume",
4106
4678
  "opengeni.shared_result.publish",
4679
+ "opengeni.route.select",
4680
+ "opengeni.route.cancel",
4107
4681
  ]);
4108
4682
 
4109
4683
  export function normalizedBlockActionInteraction(
@@ -4402,6 +4976,16 @@ function linkUrl(
4402
4976
  SlackInteractionInboxEntry,
4403
4977
  "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
4404
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,
4405
4989
  ) {
4406
4990
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
4407
4991
  const signingSecret = deps.settings.slackSigningSecret;
@@ -4410,7 +4994,7 @@ function linkUrl(
4410
4994
  // Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
4411
4995
  // headers, and managed-auth callback URLs. Query-form bearers are rejected.
4412
4996
  url.hash = new URLSearchParams({
4413
- slack_link: createSlackUserLinkToken(signingSecret, entry),
4997
+ slack_link: createSlackUserLinkToken(signingSecret, entry, mintedAtMs),
4414
4998
  }).toString();
4415
4999
  return url.toString();
4416
5000
  }
@@ -4583,10 +5167,8 @@ function boundedSlackChildDetail(value: string) {
4583
5167
  : `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
4584
5168
  }
4585
5169
 
4586
- function boundedOutput(value: string) {
4587
- return value.length <= MAX_SLACK_TEXT_CHARS
4588
- ? value
4589
- : `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
5170
+ function boundedOutput(value: string, limit = MAX_SLACK_TEXT_CHARS) {
5171
+ return value.length <= limit ? value : `${value.slice(0, limit - 20)}\n… output truncated`;
4590
5172
  }
4591
5173
 
4592
5174
  function safePayloadText(payload: unknown, field: string) {