@open-everywhere/provider-slack 0.1.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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,2349 @@
1
+ // Component 2 wake path — MOUNTED).
2
+ //
3
+ // Slack delivers EVERY subscribed event to ONE Request URL. Eve's `slackChannel` owns that
4
+ // URL and only acts on `app_mention` + `message.im` (DM); a plain `message.channels` reply is
5
+ // silently dropped, and Eve exposes no `onMessage` hook. So to get "reply without re-tagging"
6
+ // we FRONT the public URL with our own route and forward the events slackChannel handles to
7
+ // its own handler — no dispatch fork (option (b)), no upstream dependency (option (a)).
8
+ //
9
+ // The design (why it's clean):
10
+ // • The wrapped `slackChannel` is pointed at an INTERNAL route (`/eve/v1/slack/mention`).
11
+ // • OUR route sits at the PUBLIC `/eve/v1/slack` (the URL Slack POSTs to).
12
+ // • Both routes live on the SAME channel object (one file → one channel name → ONE session
13
+ // namespace and ONE `send`). So forwarding `args` straight through gives slackChannel the
14
+ // exact `send`/continuation machinery it would get if mounted directly, AND our wake uses
15
+ // the same `send` → `slackContinuationToken(channel, thread)` continues the very session
16
+ // the mention started. That shared namespace is the whole reason (c) works.
17
+ //
18
+ // The front route:
19
+ // 1. Buffers the RAW body ONCE (needed for BOTH signature verification AND forwarding).
20
+ // 2. Verifies the Slack signature over that raw body (fail-closed 401). We re-implement the
21
+ // HMAC here because Eve's `verifySlackRequest` isn't a public export; slackChannel still
22
+ // re-verifies on the forward path (a fresh Request re-reads the body), so the mention
23
+ // path's auth is unchanged — we only ADD a gate, never weaken one.
24
+ // 3. Classifies the envelope: url_verification / block_actions interaction → FORWARD to
25
+ // slackChannel's handler; message.im and message.mpim (the DM FAMILY) → divert into the
26
+ // surface's own standing DM window (OPE-351 stages 2 + 4), never forwarded; app_mention →
27
+ // FORWARD too, UNLESS either the single-window mention policy (unified-window.ts seam) says
28
+ // the unified window owns it — then the app_mention EVENT ITSELF is diverted to the unified
29
+ // supervisor as an addressed direct-mention wake (single-leg: ONE decision on ONE webhook),
30
+ // and its message.channels sibling is dropped exactly as always — or the mention landed in a
31
+ // GROUP DM, which the envelope does not say and a conversations.info read has to answer, in
32
+ // which case it diverts into that mpim's DM window and its message.mpim sibling is dropped;
33
+ // plain message.channels/groups → run the engagement wake (routeMessageEvent);
34
+ // member_joined_channel (the bot's own join) → the channel-join intro
35
+ // (routeChannelJoinEvent); a urlencoded `block_actions` payload whose actions carry an
36
+ // `oe_approval:` action id → OURS,
37
+ // handled locally via `handleApprovalPayload` and NEVER forwarded — every other
38
+ // urlencoded body (Eve's own `eve_input:` HITL clicks, view_submission, slash
39
+ // commands) still forwards exactly as before.
40
+ // 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
41
+
42
+ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
43
+ import { SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER, slackSignature, slackSignatureMatches } from "./slack-signature.js";
44
+ import { slackContinuationToken } from "eve/channels/slack";
45
+ import { callSlackWebApi } from "./slack-web-api.js";
46
+ import {
47
+ auditDmBotAuthorDrop,
48
+ auditDmDriverAdmitted,
49
+ dmDoorAdmitsDriver,
50
+ parseMessageEvent,
51
+ routeMessageEvent,
52
+ liveDeps,
53
+ routeNameDropEvent,
54
+ liveNameDropDeps,
55
+ parseChannelJoinEvent,
56
+ routeChannelJoinEvent,
57
+ liveChannelJoinDeps,
58
+ parseSlackReactionEvent,
59
+ routeReactionEvent,
60
+ liveReactionDeps,
61
+ routeSupervisorEvent,
62
+ liveSupervisorDeps,
63
+ supervisorFifoKey,
64
+ bindableFor,
65
+ bindableForJoin,
66
+ bindableForReaction,
67
+ slackSingleWindowMentionPolicy,
68
+ slackDomainUsesUnifiedWindow,
69
+ slackFrontTenantFenceDeclined,
70
+ slackInteractivityTenantFenceDeclined,
71
+ type SlackEventEnvelope,
72
+ type ParsedMessageEvent,
73
+ type NameDropContextHydrator,
74
+ type RouteOutcome,
75
+ } from "./slack-events.js";
76
+ import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
77
+ import {
78
+ EventDedupe,
79
+ ThreadFifo,
80
+ getOrCreateDmSeat as liveGetOrCreateDmSeat,
81
+ touchDmSeat as liveTouchDmSeat,
82
+ dmSeatThresholds,
83
+ resolvePartition,
84
+ domainKeyOf,
85
+ isThreadEngaged,
86
+ divertInboundToUnifiedWindow,
87
+ isPrunedDeploymentWakeError,
88
+ markUndeliveredAfterHeal,
89
+ isUndeliveredAfterHeal,
90
+ healPrunedDeploymentStrand,
91
+ mintWakeId,
92
+ slackVendorEventTime,
93
+ spreadVendorTs,
94
+ resolveWakeTrust,
95
+ ambientSupervisorEnabled,
96
+ getConfig,
97
+ getOperationalStore,
98
+ OE_APPROVAL_PREFIX,
99
+ handleApprovalPayload as liveHandleApprovalPayload,
100
+ OE_SETKEY_ACTION_PREFIX,
101
+ OE_SETKEY_MODAL_CALLBACK,
102
+ handleSetKeyClick as liveHandleSetKeyClick,
103
+ handleSetKeyViewSubmission as liveHandleSetKeyViewSubmission,
104
+ OE_CONNECT_ACTION_PREFIX,
105
+ handleConnectClick as liveHandleConnectClick,
106
+ OE_GRANT_ACTION_PREFIX,
107
+ OE_INPUT_CLICK_PREFIX,
108
+ handleGrantCardClick as liveHandleGrantCardClick,
109
+ buildRecord,
110
+ enqueue as enqueueLogRecord,
111
+ flushLogShip,
112
+ type SingleWindowMentionPolicy,
113
+ type DmSeat,
114
+ type SetKeyViewResult,
115
+ stampNonMemberChatAuth,
116
+ externalPolicyIsInert,
117
+ recordWakeTail,
118
+ tailAuthorGrain,
119
+ dmWindowRegistryKey,
120
+ approvalCardRespondVia,
121
+ } from "@open-everywhere/core/agent/adapter-api.js";
122
+ import {
123
+ renderWakeMessage,
124
+ slackChannelFactsFor,
125
+ slackAuthorIsMember,
126
+ slackClickerIsGuest,
127
+ slackWakeAuthorFacts,
128
+ slackExternalPolicyForRoom,
129
+ applySlackExternalPolicy,
130
+ stripUnfencedSlackAttachments,
131
+ slackEnterpriseIdFromEnvelope,
132
+ _resetAuthorMemberCache,
133
+ } from "./slack-events.js";
134
+ import { buildSlackAuth, withForwardedSlackEnvelope, type ForwardedSlackEnvelope } from "./slack-identity.js";
135
+ import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
136
+ import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
137
+ import {
138
+ handleSlackApprovalCardClick as liveHandleApprovalCardClick,
139
+ type ApprovalCardClickOutcome,
140
+ type ApprovalRespondFn,
141
+ } from "./slack-approval-card.js";
142
+ import {
143
+ parseSlackShortcutPayload,
144
+ shortcutMessageEvent,
145
+ shortcutWakeKey,
146
+ type ParsedSlackShortcut,
147
+ } from "./slack-shortcut.js";
148
+
149
+ /** An HTTP route handler — `RouteHandler` isn't a public eve export, so we mirror its shape. */
150
+ type RouteHandler = (req: Request, args: RouteHandlerArgs) => Promise<Response>;
151
+
152
+ /** The public URL Slack POSTs every subscribed event to (Event Subscriptions Request URL). */
153
+ export const PUBLIC_SLACK_ROUTE = "/eve/v1/slack";
154
+ /** The internal path the wrapped `slackChannel` is mounted at (never hit by Slack directly). */
155
+ export const INTERNAL_SLACK_ROUTE = "/eve/v1/slack/mention";
156
+
157
+ // --- request classification (pure) ------------------------------------------
158
+
159
+ /** The decision the front route makes for one inbound request. `env` is carried on the
160
+ * wake/join branches, and the decoded `payload` on the approval-click branch, so the handler
161
+ * needn't re-parse what it already parsed to classify. */
162
+ export type FrontDecision =
163
+ | { readonly kind: "forward"; readonly reason: string }
164
+ | { readonly kind: "wake"; readonly reason: string; readonly env: SlackEventEnvelope }
165
+ | { readonly kind: "join"; readonly reason: string; readonly env: SlackEventEnvelope }
166
+ | { readonly kind: "reaction"; readonly reason: string; readonly env: SlackEventEnvelope }
167
+ | { readonly kind: "approval-click"; readonly reason: string; readonly payload: unknown }
168
+ /** A "Set API key" button click (credentials/intake.ts) — ours: verify the clicker is the intake's
169
+ * requester, then views.open a native modal. Never forwarded. */
170
+ | { readonly kind: "setkey-click"; readonly reason: string; readonly payload: unknown }
171
+ /** A `view_submission` from our credential-intake modal — ours: encrypt + store the pasted value
172
+ * host-side, and RESPOND with the modal-close/errors JSON. A `view_submission` for anyone else's
173
+ * modal (Eve's own) still forwards. */
174
+ | { readonly kind: "setkey-submit"; readonly reason: string; readonly payload: unknown }
175
+ /** An `oe_connect:` connector-card button click (connections/connector-card.ts) — ours: re-verify
176
+ * standing host-side, then mint an ephemeral OAuth URL / open the api-key intake. Never forwarded. */
177
+ | { readonly kind: "connect-click"; readonly reason: string; readonly payload: unknown }
178
+ /** An `oe_grant:` credential-grant card button click (access/credential-grant-card.ts) — ours: the
179
+ * OWNER of the ask answered it in their own DM. Core verifies the clicker IS that owner, then
180
+ * approves or declines. Never forwarded. */
181
+ | { readonly kind: "grant-click"; readonly reason: string; readonly payload: unknown }
182
+ /** A tap on a BRIDGED APPROVAL CARD the unified post arm rendered (OPE-1720). OURS, never
183
+ * forwarded: eve's own interactivity route resolves a click against its Slack channel's state and
184
+ * session address, and this card's parked request lives on the unified window — see
185
+ * `slack-approval-card.ts`'s header for why a forward could only drop it. */
186
+ | { readonly kind: "input-click"; readonly reason: string; readonly payload: unknown }
187
+ /** An `app_mention` — the native slackChannel path owns it UNLESS the handler's single-window
188
+ * policy says the unified window does (then the forward is suppressed and the mention's
189
+ * `message.channels` sibling carries it as an addressed wake). Carries `env` so the handler
190
+ * needn't re-parse what classification already parsed — same pattern as `wake`/`join`. */
191
+ | { readonly kind: "mention"; readonly reason: string; readonly env: SlackEventEnvelope }
192
+ /** A DM-FAMILY message — a 1:1 (`message.im`, OPE-351 stage 2) or a GROUP DM (`message.mpim`,
193
+ * stage 4). OURS, never forwarded: the surface's seat is a first-class standing window, so the
194
+ * event is diverted into it (`scheduleDmWake`) instead of going to eve's native `onDirectMessage`
195
+ * (a 1:1) or being judged by the name-drop gate that refused it outright (a group DM). `dmKind`
196
+ * is the classification the envelope DID carry, so the handler needn't re-read `channel_type`;
197
+ * `env` rides along like every other wake branch. */
198
+ | { readonly kind: "dm-wake"; readonly reason: string; readonly env: SlackEventEnvelope; readonly dmKind: "im" | "mpim" }
199
+ /** A Slack SHORTCUT — a message shortcut (`message_action`) or a global one (`shortcut`),
200
+ * OPE-1376. OURS, never forwarded: eve's `onShortcut` hook is unconfigured on this channel, so
201
+ * a forward is a guaranteed no-op, and eve's shortcut context carries no session to wake from
202
+ * anyway. The decoded shortcut rides along so the handler needn't re-parse it — same pattern as
203
+ * the click branches, which carry a raw `payload` because their handlers own the decode. */
204
+ | { readonly kind: "shortcut"; readonly reason: string; readonly shortcut: ParsedSlackShortcut };
205
+
206
+ /**
207
+ * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
208
+ * payload — `undefined` for anything that isn't one (no `payload` field, unparseable JSON, a
209
+ * non-`block_actions` type, or a `block_actions` whose action ids are all someone else's, e.g.
210
+ * Eve's own `eve_input:` HITL buttons). Never throws: any parse failure just means "not ours".
211
+ */
212
+ function parseOeApprovalPayload(rawBody: string): unknown | undefined {
213
+ const raw = new URLSearchParams(rawBody).get("payload");
214
+ if (!raw) return undefined;
215
+ let payload: unknown;
216
+ try {
217
+ payload = JSON.parse(raw);
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ if (typeof payload !== "object" || payload === null) return undefined;
222
+ const p = payload as Record<string, unknown>;
223
+ if (p.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
224
+ const isOurs = (p.actions as unknown[]).some(
225
+ (a) =>
226
+ typeof a === "object" &&
227
+ a !== null &&
228
+ typeof (a as Record<string, unknown>).action_id === "string" &&
229
+ ((a as Record<string, unknown>).action_id as string).startsWith(OE_APPROVAL_PREFIX),
230
+ );
231
+ return isOurs ? payload : undefined;
232
+ }
233
+
234
+ /** Decode the interactivity `payload` field into a plain object, or undefined if it isn't parseable
235
+ * JSON-in-urlencoded. Shared by the setkey click + view_submission classifiers below. */
236
+ function decodeInteractivityPayload(rawBody: string): Record<string, unknown> | undefined {
237
+ const raw = new URLSearchParams(rawBody).get("payload");
238
+ if (!raw) return undefined;
239
+ let payload: unknown;
240
+ try {
241
+ payload = JSON.parse(raw);
242
+ } catch {
243
+ return undefined;
244
+ }
245
+ return typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : undefined;
246
+ }
247
+
248
+ /**
249
+ * Best-effort decode of a urlencoded body into an `oe_setkey:` "Set API key" `block_actions`
250
+ * payload (credentials/intake.ts) — undefined for anything that isn't one. Never throws.
251
+ */
252
+ function parseOeSetKeyClickPayload(rawBody: string): unknown | undefined {
253
+ const p = decodeInteractivityPayload(rawBody);
254
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
255
+ const isOurs = (p.actions as unknown[]).some(
256
+ (a) =>
257
+ typeof a === "object" &&
258
+ a !== null &&
259
+ typeof (a as Record<string, unknown>).action_id === "string" &&
260
+ ((a as Record<string, unknown>).action_id as string).startsWith(OE_SETKEY_ACTION_PREFIX),
261
+ );
262
+ return isOurs ? p : undefined;
263
+ }
264
+
265
+ /**
266
+ * Best-effort decode of a urlencoded body into an `oe_connect:` connector-card `block_actions` payload
267
+ * (connections/connector-card.ts) — undefined for anything that isn't one. Never throws.
268
+ */
269
+ function parseOeConnectClickPayload(rawBody: string): unknown | undefined {
270
+ const p = decodeInteractivityPayload(rawBody);
271
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
272
+ const isOurs = (p.actions as unknown[]).some(
273
+ (a) =>
274
+ typeof a === "object" &&
275
+ a !== null &&
276
+ typeof (a as Record<string, unknown>).action_id === "string" &&
277
+ ((a as Record<string, unknown>).action_id as string).startsWith(OE_CONNECT_ACTION_PREFIX),
278
+ );
279
+ return isOurs ? p : undefined;
280
+ }
281
+
282
+ /**
283
+ * Best-effort decode of a urlencoded body into an `oe_grant:` credential-grant card `block_actions`
284
+ * payload (access/credential-grant-card.ts) — undefined for anything that isn't one. Never throws.
285
+ */
286
+ function parseOeGrantClickPayload(rawBody: string): unknown | undefined {
287
+ const p = decodeInteractivityPayload(rawBody);
288
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
289
+ const isOurs = (p.actions as unknown[]).some(
290
+ (a) =>
291
+ typeof a === "object" &&
292
+ a !== null &&
293
+ typeof (a as Record<string, unknown>).action_id === "string" &&
294
+ ((a as Record<string, unknown>).action_id as string).startsWith(OE_GRANT_ACTION_PREFIX),
295
+ );
296
+ return isOurs ? p : undefined;
297
+ }
298
+
299
+ /**
300
+ * Best-effort decode of a urlencoded body into an `oe_input:` bridged-approval-card `block_actions`
301
+ * payload (core channels/approval-card.ts) — undefined for anything that isn't one. Never throws.
302
+ *
303
+ * 🔴 NOT eve's `eve_input:`, which still FORWARDS. The two prefixes are one character apart on
304
+ * purpose — both name an input request — and only one of them is ours. `startsWith` distinguishes
305
+ * them cleanly because neither is a prefix of the other, and the order of the two branches never
306
+ * matters: eve's clicks reach no branch here at all and fall to the urlencoded forward.
307
+ */
308
+ function parseOeInputClickPayload(rawBody: string): unknown | undefined {
309
+ const p = decodeInteractivityPayload(rawBody);
310
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions)) return undefined;
311
+ const isOurs = (p.actions as unknown[]).some(
312
+ (a) =>
313
+ typeof a === "object" &&
314
+ a !== null &&
315
+ typeof (a as Record<string, unknown>).action_id === "string" &&
316
+ ((a as Record<string, unknown>).action_id as string).startsWith(OE_INPUT_CLICK_PREFIX),
317
+ );
318
+ return isOurs ? p : undefined;
319
+ }
320
+
321
+ /**
322
+ * Best-effort decode of a urlencoded body into OUR credential-intake `view_submission` (its
323
+ * `view.callback_id` is our modal's) — undefined for any other view_submission (Eve's own modals
324
+ * still forward). Never throws.
325
+ */
326
+ function parseOeSetKeyViewSubmission(rawBody: string): unknown | undefined {
327
+ const p = decodeInteractivityPayload(rawBody);
328
+ if (!p || p.type !== "view_submission") return undefined;
329
+ const view = p.view as Record<string, unknown> | undefined;
330
+ return view?.callback_id === OE_SETKEY_MODAL_CALLBACK ? p : undefined;
331
+ }
332
+
333
+ /**
334
+ * The ids one already-decoded INTERACTIVITY payload names, for the tenant fence at the six click
335
+ * branches (OPE-1680). The tenant half comes from {@link slackInteractivityTenantIds}, which is the
336
+ * one reader of Slack's three Grid spellings; the room half is read here because only the fence's
337
+ * AUDIT ROW wants it — the admission decision never turns on a channel.
338
+ *
339
+ * A `view_submission` names no `channel` at all (it names a view), which is exactly why the row
340
+ * writer takes an optional channel rather than demanding one.
341
+ */
342
+ function interactivityFenceIds(payload: unknown): {
343
+ readonly teamId?: string;
344
+ readonly enterpriseId?: string;
345
+ readonly channelId?: string;
346
+ readonly userId?: string;
347
+ } {
348
+ const p = payload as Record<string, unknown> | null | undefined;
349
+ const obj = (v: unknown): Record<string, unknown> | undefined => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined);
350
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
351
+ const channelId = str(obj(p?.channel)?.id);
352
+ const userId = str(obj(p?.user)?.id);
353
+ return {
354
+ ...slackInteractivityTenantIds(payload),
355
+ ...(channelId === undefined ? {} : { channelId }),
356
+ ...(userId === undefined ? {} : { userId }),
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Decide how the front route handles a raw inbound body. FORWARD is the fail-safe default:
362
+ * anything slackChannel owns (the challenge handshake, `app_mention`, a `block_actions`/slash
363
+ * interaction) or anything unrecognized goes to slackChannel unchanged. The DM FAMILY is ours:
364
+ * `message.im` (OPE-351 stage 2) and `message.mpim` (stage 4) classify `dm-wake` and are diverted
365
+ * into the surface's own standing window. The other events slackChannel drops route to us too: a
366
+ * plain channel/group `message` → the engagement wake, `member_joined_channel` → the channel-join
367
+ * intro, and a urlencoded `block_actions` payload carrying an `oe_approval:` action id → the
368
+ * approval-click handler (ours alone). A urlencoded SHORTCUT payload (`message_action` /
369
+ * `shortcut`, OPE-1376) is ours too — every OTHER urlencoded body, including Eve's `eve_input:`
370
+ * clicks, its view_submissions and slash commands, still forwards. No network I/O and NO side
371
+ * effects, so every branch is unit-tested. Classification decides what a request IS; nothing here
372
+ * may write process state, because every gate that decides whether the request WAKES anything runs
373
+ * later in the handler (OPE-1442). The absence of a lookup is also why the
374
+ * GROUP-DM app_mention leg is decided in the HANDLER and not here: it needs a Slack lookup, because
375
+ * an app_mention envelope carries no `channel_type` to classify on.
376
+ */
377
+ export function classifyEnvelope(rawBody: string, contentType: string): FrontDecision {
378
+ // Interactions (button clicks, slash commands) arrive urlencoded (`payload=...`).
379
+ if (contentType.includes("application/x-www-form-urlencoded")) {
380
+ const approvalPayload = parseOeApprovalPayload(rawBody);
381
+ if (approvalPayload !== undefined) {
382
+ return { kind: "approval-click", reason: "block_actions oe_approval: click — ours, never forwarded", payload: approvalPayload };
383
+ }
384
+ const setKeyClick = parseOeSetKeyClickPayload(rawBody);
385
+ if (setKeyClick !== undefined) {
386
+ return { kind: "setkey-click", reason: "block_actions oe_setkey: click — ours, never forwarded", payload: setKeyClick };
387
+ }
388
+ const setKeySubmit = parseOeSetKeyViewSubmission(rawBody);
389
+ if (setKeySubmit !== undefined) {
390
+ return { kind: "setkey-submit", reason: "view_submission for our credential-intake modal — ours, never forwarded", payload: setKeySubmit };
391
+ }
392
+ const connectClick = parseOeConnectClickPayload(rawBody);
393
+ if (connectClick !== undefined) {
394
+ return { kind: "connect-click", reason: "block_actions oe_connect: click — ours, never forwarded", payload: connectClick };
395
+ }
396
+ const grantClick = parseOeGrantClickPayload(rawBody);
397
+ if (grantClick !== undefined) {
398
+ return { kind: "grant-click", reason: "block_actions oe_grant: click — ours, never forwarded", payload: grantClick };
399
+ }
400
+ const inputClick = parseOeInputClickPayload(rawBody);
401
+ if (inputClick !== undefined) {
402
+ return { kind: "input-click", reason: "block_actions oe_input: click — ours, never forwarded", payload: inputClick };
403
+ }
404
+ // A SHORTCUT (OPE-1376) — a `message_action` or `shortcut` payload. Ours, never forwarded; see
405
+ // the `shortcut` arm of FrontDecision for why a forward could not do anything with it. It is
406
+ // matched LAST among the urlencoded branches and cannot race any of them: every branch above
407
+ // reads a `block_actions` or a `view_submission`, and a shortcut is neither.
408
+ const shortcut = parseSlackShortcutPayload(decodeInteractivityPayload(rawBody));
409
+ if (shortcut) {
410
+ return { kind: "shortcut", reason: `${shortcut.kind} shortcut "${shortcut.callbackId}" — ours, never forwarded`, shortcut };
411
+ }
412
+ return { kind: "forward", reason: "urlencoded interaction/slash — slackChannel owns it" };
413
+ }
414
+ let env: SlackEventEnvelope;
415
+ try {
416
+ env = JSON.parse(rawBody) as SlackEventEnvelope;
417
+ } catch {
418
+ return { kind: "forward", reason: "unparseable body — let slackChannel ack it" };
419
+ }
420
+ if (env?.type === "url_verification") {
421
+ return { kind: "forward", reason: "url_verification challenge — slackChannel answers it" };
422
+ }
423
+ if (env?.type !== "event_callback") {
424
+ return { kind: "forward", reason: `outer type "${String(env?.type)}" — not an event we wake on` };
425
+ }
426
+ const eventType = typeof env.event?.type === "string" ? env.event.type : undefined;
427
+ if (eventType === "app_mention") {
428
+ // Not a bare "forward": the handler must first ask the single-window policy whether the
429
+ // unified window owns this mention (suppress the native forward) — the default answer is
430
+ // "forward to the mention path", byte-identical to before the branch existed.
431
+ return { kind: "mention", reason: "app_mention — the mention path owns it unless single-window suppresses", env };
432
+ }
433
+ if (eventType === "member_joined_channel") {
434
+ return { kind: "join", reason: "member_joined_channel — channel-join intro candidate", env };
435
+ }
436
+ if (eventType === "reaction_added" || eventType === "reaction_removed") {
437
+ // Inbound reactions: slackChannel drops them (no handler), so they're ours to ingest
438
+ // (reaction-ingest.ts — a feedback signal on the bot's own replies + volume telemetry).
439
+ return { kind: "reaction", reason: `${eventType} — reaction ingestion candidate`, env };
440
+ }
441
+ if (eventType !== "message") {
442
+ return { kind: "forward", reason: `event "${String(eventType)}" — not a message` };
443
+ }
444
+ const channelType = typeof (env.event as Record<string, unknown>).channel_type === "string"
445
+ ? ((env.event as Record<string, unknown>).channel_type as string)
446
+ : undefined;
447
+ if (channelType === "im") {
448
+ // OPE-351 stage 2: a 1:1 DM is no longer forwarded to eve's native `onDirectMessage`. Its seat is
449
+ // a first-class standing WINDOW (channels/dm-seat.ts), so the DM is diverted onto the `unified`
450
+ // channel exactly like the single-window app_mention above — one decision, one webhook.
451
+ return { kind: "dm-wake", reason: "message.im (DM) — the DM window seat owns it", env, dmKind: "im" };
452
+ }
453
+ if (channelType === "mpim") {
454
+ // OPE-351 stage 4: a GROUP DM joins the family. Its previous route was the engagement wake below,
455
+ // where `shouldNameDropWake` refused it outright on channel_type — so a non-mention message in a
456
+ // group DM woke NOTHING, and the room's only live path was an `app_mention` forwarded to eve's
457
+ // native per-thread session. Both halves move into the mpim's own standing window (the mention
458
+ // half in the `mention` branch of the handler, which has to ASK Slack what surface it landed on —
459
+ // an app_mention envelope carries no channel_type).
460
+ return { kind: "dm-wake", reason: "message.mpim (group DM) — the DM window seat owns it", env, dmKind: "mpim" };
461
+ }
462
+ // channels / groups — the plain reply slackChannel drops. Ours to wake.
463
+ return { kind: "wake", reason: `message.${channelType ?? "?"} — engagement wake candidate`, env };
464
+ }
465
+
466
+ /** The FIFO key a DM's wakes serialize on. Generation-INDEPENDENT (the same reasoning as
467
+ * `supervisorFifoKey`): the FIFO's job is to serialize this DM's wakes, which must hold ACROSS a
468
+ * rotation boundary too, so it keys on the channel and never on the generation-bearing token. Its
469
+ * own namespace, so a DM's queue is never shared with a channel supervisor's or a thread's. */
470
+ export function dmFifoKey(dmChannelId: string): string {
471
+ return `${dmChannelId}:oe-dm`;
472
+ }
473
+
474
+ // --- signature verification (Slack HMAC) ------------------------------------
475
+
476
+ const SLACK_MAX_SKEW_SECONDS = 60 * 5; // Slack's own replay window.
477
+
478
+ /**
479
+ * Verify a Slack request signature over the RAW body (the exact bytes Slack signed).
480
+ * `v0=` HMAC-SHA256 of `v0:{timestamp}:{body}` keyed by the signing secret, constant-time
481
+ * compared, with a 5-minute replay window. Fail-closed: any missing header / secret, a stale
482
+ * timestamp, or a mismatch returns false. `nowMs` is injectable so the replay window is tested.
483
+ *
484
+ * The BASESTRING AND DIGEST are `channels/slack-signature.ts`'s (OPE-197), shared verbatim with the
485
+ * webhook-registry entry for this scheme — a difference there would be a signature that verifies on
486
+ * one route and 401s on the other. What stays HERE is what the two routes legitimately differ on:
487
+ * this one reads one env-homed secret and returns a bare boolean, because a Slack Request URL has no
488
+ * failure vocabulary to report into.
489
+ */
490
+ export function verifySlackSignature(
491
+ rawBody: string,
492
+ headers: Headers,
493
+ signingSecret: string | undefined,
494
+ nowMs: number = Date.now(),
495
+ ): boolean {
496
+ const timestamp = headers.get(SLACK_TIMESTAMP_HEADER);
497
+ const signature = headers.get(SLACK_SIGNATURE_HEADER);
498
+ if (!signingSecret || !timestamp || !signature) return false;
499
+ const ts = Number(timestamp);
500
+ if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > SLACK_MAX_SKEW_SECONDS) return false;
501
+ return slackSignatureMatches(signingSecret, timestamp, rawBody, signature);
502
+ }
503
+
504
+ // --- retry de-dupe + per-thread FIFO: LIFTED to core (OPE-114 stage 4) ----------------------
505
+ // EventDedupe and ThreadFifo are provider-neutral front-door plumbing (teams-front rides them
506
+ // too) — they now live in core channels/front-queue.ts, imported through the barrel below and
507
+ // re-exported here so bundle-internal import sites (slack-commands, tests) are unchanged.
508
+ export { EventDedupe, ThreadFifo };
509
+
510
+ // --- bot identity (mention suppression) -------------------------------------
511
+
512
+ /**
513
+ * The bot's own Slack user id, resolved once via `auth.test` (cached). Needed because Slack
514
+ * fires BOTH `app_mention` AND `message.channels` for a message that @mentions the app; the
515
+ * mention path already owns the `app_mention`, so on the wake path we DROP a message whose
516
+ * AUTHORED text still carries `<@bot>` to avoid a double turn. Authored, not the parsed `text`:
517
+ * that now carries forwarded content too, and a forward quoting a bot mention must still wake
518
+ * (OPE-1374). Best-effort: if it can't be resolved
519
+ * (no token / API error) we don't suppress — an occasional double is better than dropping a
520
+ * genuine engaged reply. `SLACK_BOT_TOKEN` is already required for the bot to post at all, so
521
+ * in practice this resolves.
522
+ */
523
+ let botIdentityPromise: Promise<{ readonly userId?: string; readonly botId?: string }> | undefined;
524
+
525
+ export function resolveBotIdentity(): Promise<{ readonly userId?: string; readonly botId?: string }> {
526
+ if (!botIdentityPromise) {
527
+ botIdentityPromise = (async () => {
528
+ try {
529
+ // Via OE's `callSlackWebApi` (the 429-backoff seam); a missing token makes it throw, and any
530
+ // throw → {} (best-effort — we simply don't suppress on failure).
531
+ const json = (await callSlackWebApi({ botToken: undefined, operation: "auth.test", body: {} })) as {
532
+ ok?: boolean;
533
+ user_id?: string;
534
+ bot_id?: string;
535
+ };
536
+ return json.ok ? { userId: json.user_id, botId: json.bot_id } : {};
537
+ } catch {
538
+ return {};
539
+ }
540
+ })();
541
+ }
542
+ return botIdentityPromise;
543
+ }
544
+
545
+ /** Reset the cached identity (tests only). */
546
+ export function _resetBotIdentity(): void {
547
+ botIdentityPromise = undefined;
548
+ }
549
+
550
+ // --- the composed channel ---------------------------------------------------
551
+
552
+ /** Injectable seams so the wake path is testable without live crypto / Slack. */
553
+ export interface FrontOptions {
554
+ readonly signingSecret?: string;
555
+ readonly dedupe?: EventDedupe;
556
+ readonly fifo?: ThreadFifo;
557
+ readonly getBotIdentity?: () => Promise<{ readonly userId?: string; readonly botId?: string }>;
558
+ readonly now?: () => number;
559
+ /** Durable first-sighting check (true = proceed). Defaults to the operational store. */
560
+ readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
561
+ /** Is ambient-supervisor mode enabled for this channel's domain? (sync config lookup.)
562
+ * Defaults to the live domain resolver (memory/domain.ts). Fail-closed off: when this
563
+ * returns false the unowned-message dispatch is BYTE-IDENTICAL to today's name-drop path. */
564
+ readonly ambientEnabledFor?: (surfaceKey: string) => boolean;
565
+ /** Does this channel have a STANDING SEAT (OPE-1695)? Defaults to the live predicate
566
+ * (slack-events.ts slackDomainUsesUnifiedWindow: the capability plus a seat domain). It decides
567
+ * ONE thing here — whether an ADDRESSED wake has a seat road to take when the chattiness flag is
568
+ * off. False keeps the pre-seat drop, so a deployment with the capability unarmed is unchanged. */
569
+ readonly seatedSurfaceFor?: (surfaceKey: string) => boolean;
570
+ /** Approval-click handler (defaults to the real `agent/approvals/flow.ts`). Injectable so
571
+ * tests can stub it without the propose/store/executor machinery it drags in.
572
+ *
573
+ * 🔴 IT TAKES THE ROUTE'S `waitUntil` (OPE-1709), for the same reason the Discord front's
574
+ * approval branch does: "Approve for session" releases the sibling cards the grant already
575
+ * covers, and each release runs a real `execute`. That work belongs off the interactivity
576
+ * response path. The handler falls back to its old awaited shape when this is absent, which is
577
+ * exactly what a stub that ignores the second argument gets. */
578
+ readonly handleApprovalPayload?: (payload: unknown, deps?: { waitUntil?: (p: Promise<unknown>) => void }) => Promise<boolean>;
579
+ /** "Set API key" click handler (defaults to the real `agent/credentials/intake.ts`) — verifies the
580
+ * clicker owns the intake, then views.open the modal. Injectable for tests. */
581
+ readonly handleSetKeyClick?: (payload: unknown) => Promise<boolean>;
582
+ /** Credential-modal `view_submission` handler (defaults to `agent/credentials/intake.ts`) —
583
+ * encrypts + stores the value and returns the modal-close/errors JSON. Injectable for tests. */
584
+ readonly handleSetKeyViewSubmission?: (payload: unknown) => Promise<SetKeyViewResult>;
585
+ /** Connector-card `oe_connect:` click handler (defaults to `agent/connections/connector-card.ts`) —
586
+ * re-verifies standing host-side, then mints an ephemeral OAuth URL / opens the api-key intake.
587
+ * Injectable for tests. */
588
+ readonly handleConnectClick?: (payload: unknown) => Promise<boolean>;
589
+ /** Injected in tests; the live default is core's `handleGrantCardClick`. The signed grant-click
590
+ * branch schedules it through the route's `waitUntil`, so this handler may outlive the empty
591
+ * Slack acknowledgement. */
592
+ readonly handleGrantCardClick?: (payload: unknown) => Promise<boolean>;
593
+ /** Bridged-approval-card `oe_input:` tap handler (OPE-1720, defaults to
594
+ * `./slack-approval-card.ts`). Injectable so the classification and the branch are testable
595
+ * without a store, a Slack token or eve's `respond` primitive. */
596
+ readonly handleApprovalCardClick?: (payload: unknown, respond: ApprovalRespondFn) => Promise<ApprovalCardClickOutcome>;
597
+ /** SINGLE-WINDOW mention policy for one (channel, surface) — should the unified window own an
598
+ * @mention here (suppress the native forward + keep the sibling as an addressed wake)?
599
+ * Defaults to the live {@link slackSingleWindowMentionPolicy} (grain + fail-closed
600
+ * internal-channel confirmation). Injectable so the two mention legs are testable without a
601
+ * live config/Slack; fail-closed off: the default answers "native owns it" for every
602
+ * non-single-window domain, byte-identical to today. */
603
+ readonly mentionPolicyFor?: (channelId: string, surfaceKey: string) => Promise<SingleWindowMentionPolicy>;
604
+ /** Resolve (create/adopt/rotate/resume) the DM seat for a wake, KEYED ON THE SURFACE KEY since
605
+ * OPE-351 stage 2 (the window token and its registry row are both surface-keyed). `channelId` is
606
+ * the raw platform id the rotation distiller fetches history with; `legacyChannelId` is the
607
+ * one-time adopt-from-OPE-90 probe, supplied for a 1:1 only — a group DM never had such a row.
608
+ * Defaults to the live store-backed {@link liveGetOrCreateDmSeat} with the tail-carrying
609
+ * distiller. Injectable for tests. */
610
+ readonly resolveDmSeat?: (org: string, dm: { surfaceKey: string; channelId?: string; legacyChannelId?: string }, atIso: string) => Promise<DmSeat>;
611
+ /** Record one wake on the DM seat (lastWakeAt/turnCount). Defaults to {@link liveTouchDmSeat}. */
612
+ readonly touchDmSeatFn?: (org: string, dmSurfaceKey: string, atIso: string) => Promise<void>;
613
+ /** DM-partner trust lookup (`users.info`) — feeds `dm_partner_trust`, which gates a 1:1 DM's SHARED
614
+ * read layer (internal ⇒ `dm` trust, external ⇒ `public`). FAIL-CLOSED: undefined ⇒ the attribute
615
+ * is omitted ⇒ the binding treats the partner as external. Defaults to the live cached lookup.
616
+ * NOT consulted for a GROUP DM — see `scheduleDmWake`'s note on why the attribute is 1:1-only. */
617
+ readonly dmPartnerTrustFor?: (userId: string, teamId: string | undefined) => Promise<"internal" | "external" | undefined>;
618
+ /** The channel FACTS the envelope doesn't carry (`conversations.info`, TTL-cached, fail-soft):
619
+ * the surface's `channelType` — the only way to tell a GROUP-DM `app_mention` from a channel's,
620
+ * since an app_mention envelope has no `channel_type` — and the trust gate's confirmed-internal
621
+ * verdict, which stands in for the DM-partner check a group DM cannot make. Defaults to the live
622
+ * {@link slackChannelFactsFor}. Injectable so both mpim legs are testable without Slack; a stub
623
+ * answering `{ internal: false }` reproduces the missing-`mpim:read` degradation exactly.
624
+ *
625
+ * `appCanRead` is the shortcut route's file gate (OPE-1442) and is FAIL-CLOSED: an omitted or
626
+ * false value withholds the target message's files. A stub that leaves it out therefore
627
+ * reproduces "the app cannot read this room", which is the safe default for a test to inherit.
628
+ *
629
+ * `extShared`/`trustChecked` are the RAW platform halves `internal` joins away (OPE-1486) — the
630
+ * group-DM wake stamps them so `bindSlack` grades an mpim on the same two facts a channel is
631
+ * graded on. Both are POSITIVE-ONLY: an omitted value stamps nothing, so a stub that leaves them
632
+ * out reproduces "the platform said nothing about sharing", which demotes no room. */
633
+ readonly channelFactsFor?: (
634
+ channelId: string,
635
+ ) => Promise<{ internal: boolean; channelType?: string; appCanRead?: boolean; extShared?: boolean; trustChecked?: boolean }>;
636
+ /** Open (or reuse) the 1:1 DM channel with a Slack member, returning its `D…` id — the landing
637
+ * place a GLOBAL shortcut wakes in (OPE-1376), since a global shortcut names no conversation.
638
+ * `conversations.open` is idempotent, so this is a resolve rather than a create. Needs the
639
+ * `im:write` bot scope, which the runbook and the wizard manifest already require for the
640
+ * governance DM-delivery arm. Defaults to the live call; FAIL-CLOSED: undefined ⇒ the shortcut
641
+ * wakes nothing and says so, never a wake in a guessed room. Injectable for tests. */
642
+ readonly openDirectMessageChannel?: (userId: string) => Promise<string | undefined>;
643
+ /** The trigger author's two membership facts, from ONE `users.info` probe — the exact pair every
644
+ * wake route in slack-events.ts resolves. Two readers: the native `app_mention` forward's
645
+ * external-policy gate, which calls it only when that policy is not inert (so a deployment on the
646
+ * shipped grains pays nothing there), and every GROUP-DM wake (OPE-1486), which reads its
647
+ * `refuted` half unconditionally — a guest who writes in an mpim must mark the turn whatever the
648
+ * policy grain is. Defaults to the live {@link slackWakeAuthorFacts}; injectable so both are
649
+ * testable without Slack. */
650
+ readonly authorFactsFor?: (
651
+ channelInternal: boolean,
652
+ userId: string | undefined,
653
+ teamId: string | undefined,
654
+ ) => Promise<{ readonly surfaceInternal: boolean; readonly refuted: boolean }>;
655
+ }
656
+
657
+ const OK = () => new Response("ok");
658
+
659
+ /**
660
+ * The Enterprise Grid rung a forwarded delivery carries, paired with the message it describes
661
+ * (OPE-1559). `undefined` for an ordinary single-workspace install, for an interactivity body, and
662
+ * for anything this route could not parse — the native forward then stows nothing and `slack.ts`
663
+ * stamps no rung, exactly as before.
664
+ *
665
+ * The rung itself comes from {@link slackEnterpriseIdFromEnvelope}, the ONE reader of
666
+ * `enterprise_id`; this function only pairs it with the inner event's `(channel, ts)`, which is what
667
+ * lets `forwardedSlackEnterpriseId` (slack-identity.ts) answer for that message alone. A Grid
668
+ * envelope whose inner event names neither returns `undefined`: an unpairable rung is a rung this
669
+ * route will not carry.
670
+ */
671
+ function forwardedEnvelopeOf(rawBody: string): ForwardedSlackEnvelope | undefined {
672
+ let parsed: unknown;
673
+ try {
674
+ parsed = JSON.parse(rawBody);
675
+ } catch {
676
+ return undefined;
677
+ }
678
+ // `JSON.parse` answers `null` for the body `null`, and a scalar for `1` or `"x"` — none of which
679
+ // throws, and all of which would make the field reads below throw INSTEAD OF returning undefined.
680
+ // A throw here escapes `forwardNative` and turns a forward that used to ack 200 into a 500 that
681
+ // Slack retries, so the shape is checked rather than assumed.
682
+ if (parsed === null || typeof parsed !== "object") return undefined;
683
+ const env = parsed as SlackEventEnvelope;
684
+ const enterpriseId = slackEnterpriseIdFromEnvelope(env);
685
+ if (enterpriseId === undefined) return undefined;
686
+ const event = env.event;
687
+ const channelId = typeof event?.channel === "string" ? event.channel : undefined;
688
+ const ts = typeof event?.ts === "string" ? event.ts : undefined;
689
+ if (!channelId || !ts) return undefined;
690
+ return { enterpriseId, channelId, ts };
691
+ }
692
+
693
+ /** The event_id (retry-stable) or `(channel, ts)` key we de-dupe a wake candidate on.
694
+ * Prefixed with the RECEIVING bot identity (constant "default" today): with N Slack apps
695
+ * in one channel (item 27 tier 2) each app receives its own copy of every message, and
696
+ * an un-prefixed key would swallow one app's copy as the other's retry — silently, per
697
+ * message, maddening to debug. The TTL'd dedupe store self-heals across the change. */
698
+ function wakeKey(env: SlackEventEnvelope, channelId: string, ts: string, botIdentity = "default"): string {
699
+ const eventId = typeof env.event_id === "string" ? env.event_id : undefined;
700
+ return `${botIdentity}:${eventId ?? `${channelId}:${ts}`}`;
701
+ }
702
+
703
+ /**
704
+ * Resolve the 1:1 DM channel with one Slack member (OPE-1376's global-shortcut landing place).
705
+ *
706
+ * `conversations.open` is idempotent — repeated calls return the SAME `im` channel id — so there is
707
+ * no "already open" branch, and this opens no conversation a global shortcut's invoker did not
708
+ * themselves ask for by pressing the shortcut.
709
+ *
710
+ * FAIL-CLOSED and QUIET-ON-THE-WIRE: any failure (no token, a workspace without `im:write`, a user
711
+ * who cannot be DM'd) returns `undefined`, which the caller turns into "wake nothing, log why".
712
+ * Returning a guess would put a person's shortcut into a room they did not choose.
713
+ */
714
+ async function liveOpenDirectMessageChannel(userId: string): Promise<string | undefined> {
715
+ try {
716
+ const opened = await callSlackWebApi({ botToken: undefined, operation: "conversations.open", body: { users: userId } });
717
+ if (opened?.ok !== true) return undefined;
718
+ const channel = opened.channel as { id?: unknown } | undefined;
719
+ return typeof channel?.id === "string" && channel.id.length > 0 ? channel.id : undefined;
720
+ } catch {
721
+ return undefined;
722
+ }
723
+ }
724
+
725
+ // Durable half of the retry dedupe: the in-memory seen-set only catches a retry landing
726
+ // on the SAME instance; on serverless the redelivery often lands on a fresh one. FAIL-OPEN
727
+ // by design — dedupe prevents an annoyance (a double reply), not a security hole, so a
728
+ // store outage must never silence genuine wakes. TTL matches the in-memory set; Slack
729
+ // stops retrying long before it expires.
730
+ const DEDUPE_TTL_MS = 10 * 60_000;
731
+ let warnedDedupeStore = false;
732
+ async function liveMarkEventSeen(org: string, key: string, atIso: string): Promise<boolean> {
733
+ try {
734
+ return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
735
+ } catch (err) {
736
+ if (!warnedDedupeStore) {
737
+ warnedDedupeStore = true;
738
+ console.warn(`[open-everywhere:slack-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
739
+ }
740
+ return true;
741
+ }
742
+ }
743
+
744
+ // Route decisions used to be silently discarded, which made a gate decline, a judged
745
+ // NO_REPLY, and a broken path indistinguishable from the outside (bit us live: "the
746
+ // name-drop didn't reply" had no way to tell WHY). One line per candidate event; the
747
+ // posted reply itself is the positive signal, this line is the story when there isn't one.
748
+ const logOutcome =
749
+ (path: string, channelId: string, ts: string) =>
750
+ (outcome: RouteOutcome): void => {
751
+ console.log(
752
+ `[open-everywhere:slack-front] ${path} ${channelId}/${ts}: ${outcome.woke ? "woke" : "no wake"} — ${outcome.reason}`,
753
+ );
754
+ };
755
+
756
+ /**
757
+ * Turn a swallowed FIFO wake failure into a DURABLE, queryable signal (resilience S-1 / #551
758
+ * §3.4): a log-ship "error" record (the primary win — the failure survives the ~1h console
759
+ * retention) plus a best-effort `surface.wake_failed` audit row (mirrors recordQuietFailure's
760
+ * posture). The FIFO key encodes the channel (`${channelId}:${threadTs}` for a thread wake,
761
+ * `${channelId}:oe-sup` for a supervisor wake); team is unknown at this layer, so the audit
762
+ * partition falls back to the empty-team default exactly like recordQuietFailure's own
763
+ * `teamId ?? ""` — best-effort, never blocks. Never throws.
764
+ */
765
+ /**
766
+ * Deliver a wake, and RE-DELIVER IT ONCE when the first attempt died on a pruned deployment
767
+ * (OPE-963, channels/stranded-seat.ts).
768
+ *
769
+ * The first attempt's failure is not wasted: `divertToUnifiedWindow` classified it and MARKED the
770
+ * seat's `channel_session` row for rotation before it rethrew. So the second attempt re-resolves the
771
+ * seat, `rotationTrigger` answers `operator-marked`, the seat takes its own rotate leg (fresh
772
+ * generation, carried tail), and eve opens a NEW run under the CURRENT deployment. Without this the
773
+ * member loses the message that hit the strand, even though the seat itself is healed.
774
+ *
775
+ * EXACTLY ONE retry, and only for this class. Every other failure rethrows untouched, straight to
776
+ * the FIFO's loud handler ({@link recordWakeFailure}). A second failure of the same class rethrows
777
+ * too — the seat could not be marked, so a third attempt would resume the same dead run.
778
+ *
779
+ * THE ROUTE FUNCTION MUST BE RE-RUNNABLE, and both wrapped routes are. Everything they do before
780
+ * the divert is a read or an idempotent write: TTL-cached `conversations.info` / `users.info`
781
+ * probes, a deterministic external-policy verdict (its private notice posts only on a verdict that
782
+ * RETURNS rather than throws, so a retry never reaches it), a read-only spend gate, and
783
+ * `markEngaged`, which upserts. `recordWakeTail` and `touchDmSeat`/`touchSession` all run AFTER the
784
+ * divert, so a failed attempt records nothing to duplicate. The wake id is minted fresh, which is
785
+ * correct — the retry is a new delivery.
786
+ *
787
+ * Applied only to the WINDOW-bearing routes — a thread seat has no `channel_session` row to mark,
788
+ * so a retry there could only resume the same dead run.
789
+ *
790
+ * OPE-970: when the RETRY ALSO throws, the seat is still healed for its NEXT wake — but THIS
791
+ * member's message got no answer, and that fact must not collapse into an ordinary send-failure
792
+ * row indistinguishable from a transient blip. The retry's error is tagged
793
+ * ({@link markUndeliveredAfterHeal}) before it rethrows, so {@link recordWakeFailure} can give the
794
+ * row its own reason.
795
+ */
796
+ export async function deliverWithStrandRetry<T>(deliver: () => Promise<T>): Promise<T> {
797
+ try {
798
+ return await deliver();
799
+ } catch (err) {
800
+ if (!isPrunedDeploymentWakeError(err)) throw err;
801
+ console.warn(`[open-everywhere:slack-front] wake hit a pruned deployment — seat marked for rotation, re-delivering once: ${String(err)}`);
802
+ try {
803
+ return await deliver();
804
+ } catch (retryErr) {
805
+ markUndeliveredAfterHeal(retryErr);
806
+ throw retryErr;
807
+ }
808
+ }
809
+ }
810
+
811
+ export function recordWakeFailure(key: string, err: unknown): void {
812
+ const channelId = key.split(":")[0] ?? key;
813
+ try {
814
+ enqueueLogRecord(buildRecord("error", "wake", "supervisor/thread wake delivery failed", { fields: { fifoKey: key, channelId }, err }));
815
+ void flushLogShip();
816
+ } catch {
817
+ /* log-ship best-effort */
818
+ }
819
+ // Held on the route (OPE-1648): the FIFO promise this runs under is itself in `waitUntil`, but a
820
+ // `void`-ed write sits OUTSIDE that promise and so races the freeze after the 200 all the same.
821
+ void holdRouteWork(
822
+ (async () => {
823
+ try {
824
+ const p = resolvePartition({
825
+ channel: { kind: "slack" },
826
+ session: { auth: { current: { attributes: { team_id: "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
827
+ } as Parameters<typeof resolvePartition>[0]);
828
+ // OPE-970: a retry that ALSO failed after the pruned-deployment heal gets its own reason —
829
+ // otherwise it is indistinguishable from any other send failure, and the only way to tell
830
+ // "the seat is fine now but this message was lost" from "a transient blip" was to read the
831
+ // raw message text and recognise the run-key sentence by eye.
832
+ const undeliveredAfterHeal = isUndeliveredAfterHeal(err);
833
+ await getOperationalStore().appendAudit({
834
+ ts: new Date().toISOString(),
835
+ org: p.org,
836
+ platform: p.platform,
837
+ workspace: p.workspace,
838
+ surface: p.surface,
839
+ action: "surface.wake_failed",
840
+ detail: {
841
+ fifoKey: key,
842
+ message: String(err instanceof Error ? err.message : err).slice(0, 500),
843
+ ...(undeliveredAfterHeal
844
+ ? { reason: "pruned-deployment-heal-retry-failed", remedy: "seat healed for its next wake; this message got no answer" }
845
+ : {}),
846
+ },
847
+ });
848
+ } catch {
849
+ /* audit is best-effort — the log-ship record above is the durable signal */
850
+ }
851
+ })(),
852
+ );
853
+ }
854
+
855
+ /**
856
+ * Build the front route handler. Buffers the body once, verifies the signature, then forwards
857
+ * or wakes per {@link classifyEnvelope}. `innerHandler` is slackChannel's own POST handler,
858
+ * invoked with a FRESH Request (re-readable body) + the same {@link RouteHandlerArgs}, so
859
+ * slackChannel re-verifies and dispatches exactly as if it owned the URL.
860
+ */
861
+ /**
862
+ * SINGLE-WINDOW diversion (channels/unified-window.ts): layer the cross-channel hop onto the
863
+ * supervisor deps. When routeSupervisorEvent minted a `window` for a wake (the domain's
864
+ * crossSurfaceWindow gates hold — the wakeId is already rendered into the envelope), deliver it
865
+ * through eve's cross-channel `to(...).send(...)` into the synthetic `unified` channel instead of the
866
+ * Slack send: the effective token becomes `unified:<raw>`, shared with every surface in the
867
+ * domain — ONE context window. The unified channel's `receive` registers the wakeId→origin map
868
+ * entry (durably, BEFORE the send) and re-checks the gates fail-closed. This wrapper lives HERE
869
+ * because only the route handler holds `args.to` (and slack-events.ts must not import
870
+ * unified.ts — unified.ts imports it). A window-less wake passes through untouched, so flag-off
871
+ * is byte-identical. A diversion failure PROPAGATES to the FIFO's loud catch (fail-closed: a
872
+ * wake whose reply map couldn't be written is not delivered into the shared window).
873
+ *
874
+ * THREAD-OWNERSHIP ingress routing (§S2): the divert goes through the INBOUND variant
875
+ * (unified.ts divertInboundToUnifiedWindow), which — only under delegation.oneshotAgent — consults the
876
+ * durable ownership index and dispatches a wake landing in an OWNED thread to the owning task/
877
+ * thread-seat session's token instead of the standing seat's. Flag-off, and on every unowned
878
+ * thread, the inbound variant is a byte-identical passthrough to divertToUnifiedWindow.
879
+ *
880
+ * OPE-971: the `!input.window` (NATIVE, flag-off) branch below is Slack's own un-windowed
881
+ * supervisor session — the send this domain takes when it never opted into windowing at all. That
882
+ * send was completely unwrapped: a pruned-deployment strand there stranded the seat silently,
883
+ * forever (see channels/stranded-seat.ts). `deps.wake`'s own registryKey for this branch IS
884
+ * `surfaceKey` (supervisorGrainFor returns `{unified:false, registryKey:surfaceKey}` flag-off — the
885
+ * exact row `liveSupervisorDeps`'s `getOrCreateSession` already rotates), so the heal is told that
886
+ * key directly rather than trying to parse it back out of the per-channel token.
887
+ */
888
+ export function withSingleWindowDiversion(
889
+ deps: ReturnType<typeof liveSupervisorDeps>,
890
+ to: RouteHandlerArgs["to"],
891
+ surfaceKey: string,
892
+ ): ReturnType<typeof liveSupervisorDeps> {
893
+ return {
894
+ ...deps,
895
+ async wake(input) {
896
+ if (!input.window) {
897
+ try {
898
+ await deps.wake(input);
899
+ } catch (err) {
900
+ await healPrunedDeploymentStrand({
901
+ token: input.token,
902
+ registryKey: surfaceKey,
903
+ surfaceKey,
904
+ workspace: surfaceKey.split("/")[1] ?? "unknown",
905
+ platform: "slack",
906
+ atIso: new Date().toISOString(),
907
+ err,
908
+ });
909
+ throw err;
910
+ }
911
+ return;
912
+ }
913
+ await divertInboundToUnifiedWindow(to, {
914
+ message: input.message,
915
+ // The seat wake's untrusted-wrapped side-channel context (the name-drop hydration,
916
+ // OPE-1725). Omitted on every other wake, so a context-less divert is byte-identical.
917
+ ...(input.context && input.context.length > 0 ? { context: input.context } : {}),
918
+ token: input.token,
919
+ window: input.window,
920
+ surfaceKey,
921
+ auth: input.auth as { attributes?: Record<string, unknown> },
922
+ });
923
+ },
924
+ };
925
+ }
926
+
927
+ // DM-PARTNER TRUST, front-route copy (OPE-351 stage 2). `dm_partner_trust` gates the DM's SHARED read
928
+ // layer (internal partner ⇒ `dm` trust, external ⇒ `public`), so the diverted DM's auth must carry it
929
+ // or every DM silently degrades to the external tier — a regression the native path would not have had.
930
+ // The native path resolves it in slack.ts (`enrichDmPartner`) off `ctx.slack.request`, which does not
931
+ // exist here (the front route holds no eve slack context, and slack.ts imports THIS module, so the
932
+ // helper cannot simply be shared without a cycle).
933
+ //
934
+ // OPE-466: the PROBE ITSELF now lives in slack-events.ts (`slackAuthorIsMember`), because the same
935
+ // question — is this person a full member of this workspace? — is what a CHANNEL author's wake tier
936
+ // needs, and two caches of one answer drift. This function is the DM leg's reading of that verdict,
937
+ // and it keeps the DM leg's own FAIL-CLOSED direction: an unresolvable probe leaves the attribute
938
+ // unset and the binding fails closed to the external tier, unchanged from before.
939
+ async function liveDmPartnerTrust(userId: string, teamId: string | undefined): Promise<"internal" | "external" | undefined> {
940
+ const member = await slackAuthorIsMember(userId, teamId);
941
+ return member === undefined ? undefined : member ? "internal" : "external";
942
+ }
943
+
944
+ /** Reset the cached DM-partner trust (tests only). */
945
+ export function _resetDmTrustCache(): void {
946
+ _resetAuthorMemberCache();
947
+ }
948
+
949
+ // `wrapDmSeatFrom` (OPE-90) is DELETED at OPE-351 stage 2. It existed to remap the address of eve's
950
+ // NATIVE DM send onto a rotatable seat token — a bolt-on to a dispatch path a DM no longer takes. The
951
+ // DM is now diverted onto the `unified` channel before the forward ever happens (`scheduleDmWake`
952
+ // below), so the seat token is chosen at DISPATCH time by the same code every other standing seat
953
+ // uses, and there is no native send left to intercept. What it carried forward, and where each part
954
+ // lives now: the seat resolve → `resolveDmSeat` in `scheduleDmWake`; the fresh-generation tail
955
+ // prepended as `context` → the divert's own `context` argument; the off-critical-path wake touch →
956
+ // the same `waitUntil(touch…)` there. What it does NOT carry forward is its FAIL-OPEN fallback to the
957
+ // native send: there is nothing honest to fall back TO, since eve's native DM dispatch would open a
958
+ // session on an address the seat has no window row for. A DM whose seat cannot be resolved now fails
959
+ // LOUDLY into the FIFO's catch (audited via recordWakeFailure) instead of silently answering from a
960
+ // second, un-rotatable session — the same fail-closed posture every other divert has.
961
+
962
+ /**
963
+ * Does this `message` event DUPLICATE an `app_mention` Slack also fires, so the wake path must drop it?
964
+ *
965
+ * Slack sends TWO events for a new message that @mentions the bot: the `app_mention`, which the
966
+ * front's own `mention` branch owns, and a sibling carrying the same text — `message.channels` in a
967
+ * channel, `message.mpim` in a group DM. Acting on both is a double turn, so both the channel wake
968
+ * path and the group-DM leg drop the sibling through this one rule.
969
+ *
970
+ * 🔴 AN EDIT IS NEVER A SIBLING, AND SUPPRESSING ONE IS A LOST WAKE (OPE-1673). Slack delivers an
971
+ * edit ONLY as `message` with `subtype: "message_changed"`; no `app_mention` accompanies it, and
972
+ * `parseMessageEvent` reads the POST-EDIT text — so a person who edits a message in an engaged
973
+ * thread to add `<@bot>`, or who corrects the wording of a mention that is already there, matched
974
+ * this rule and was answered by nothing at all. There is no second event to prefer. The exemption is
975
+ * narrow by construction: an edit cannot reach the `app_mention` branch, because the front classifies
976
+ * the envelope on its event TYPE and a `message_changed` always arrives as a `message`.
977
+ *
978
+ * UNVERIFIED, and stated rather than assumed: Slack's docs do not say whether an edit that ADDS a
979
+ * mention also raises a fresh `app_mention`. If it does, the cost of this exemption is an occasional
980
+ * double turn — the same trade the next paragraph already takes, for the same reason.
981
+ *
982
+ * MATCH THE AUTHOR'S OWN TEXT, NEVER THE MERGED ONE (OPE-1374). `ev.text` carries forwarded and
983
+ * quoted content, so testing it would suppress a forward whose QUOTED message mentions the bot.
984
+ * UNVERIFIED for the same reason as above: whether Slack fires an `app_mention` for a mention that
985
+ * lives inside an attachment. If it does NOT — the shape we expect — testing the merged text loses
986
+ * the wake outright, because no sibling ever arrives to deliver it. If it DOES, the cost is a double
987
+ * turn. We take the double: an occasional double beats a drop, and a deaf agent on a forward is the
988
+ * failure OPE-1374 exists to prevent.
989
+ *
990
+ * Pure, so both the suppression and its exemption are unit-testable without Slack or a store. An
991
+ * unresolved bot user id suppresses nothing — there is no needle to match, and inventing one would
992
+ * drop every message.
993
+ */
994
+ export function mentionSiblingSuppressed(input: {
995
+ readonly authoredText: string;
996
+ readonly subtype?: string;
997
+ readonly botUserId?: string;
998
+ }): boolean {
999
+ if (input.botUserId === undefined || input.botUserId.length === 0) return false;
1000
+ if (input.subtype === "message_changed") return false;
1001
+ return input.authoredText.includes(`<@${input.botUserId}>`);
1002
+ }
1003
+
1004
+ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions = {}): RouteHandler {
1005
+ const dedupe = opts.dedupe ?? new EventDedupe();
1006
+ const fifo = opts.fifo ?? new ThreadFifo(recordWakeFailure);
1007
+ const getBotIdentity = opts.getBotIdentity ?? resolveBotIdentity;
1008
+ const now = opts.now ?? (() => Date.now());
1009
+ const signingSecret = () => opts.signingSecret ?? process.env.SLACK_SIGNING_SECRET;
1010
+ const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
1011
+ // OPE-589 leg D: reuse the taint-marking member probe (slack-events.ts `slackClickerIsGuest`,
1012
+ // itself `slackAuthorIsMember` inverted). Same `users.info` call and cache the channel-taint leg
1013
+ // already pays for, so a clicker who recently posted a message in the same workspace is often a
1014
+ // free lookup, not a new one.
1015
+ const handleApproval =
1016
+ opts.handleApprovalPayload ??
1017
+ ((payload: unknown, deps?: { waitUntil?: (p: Promise<unknown>) => void }) =>
1018
+ liveHandleApprovalPayload(payload, { clickerIsGuest: slackClickerIsGuest, ...(deps?.waitUntil ? { waitUntil: deps.waitUntil } : {}) }));
1019
+ const handleSetKeyClick = opts.handleSetKeyClick ?? liveHandleSetKeyClick;
1020
+ const handleSetKeyViewSubmission = opts.handleSetKeyViewSubmission ?? liveHandleSetKeyViewSubmission;
1021
+ const handleConnectClick = opts.handleConnectClick ?? liveHandleConnectClick;
1022
+ const handleGrantCardClick = opts.handleGrantCardClick ?? liveHandleGrantCardClick;
1023
+ const handleApprovalCardClick = opts.handleApprovalCardClick ?? liveHandleApprovalCardClick;
1024
+ const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
1025
+ const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
1026
+ const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
1027
+ const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
1028
+ const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
1029
+ const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
1030
+ const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
1031
+ const channelFactsFor = opts.channelFactsFor ?? slackChannelFactsFor;
1032
+ const authorFactsFor = opts.authorFactsFor ?? ((internal: boolean, userId: string | undefined, teamId: string | undefined) => slackWakeAuthorFacts(internal, userId, teamId));
1033
+
1034
+ // THE one single-window mention decision, shared by BOTH mention legs (the `app_mention`
1035
+ // native-forward below and its `message.channels` sibling on the wake path) so they stay
1036
+ // exactly complementary — never a double turn (native + unified), never a gap (neither). True ⇔
1037
+ // the channel has a STANDING SEAT and the policy's privacy floor holds (channel POSITIVELY
1038
+ // confirmed internal, fail-closed). Any error ⇒ false ⇒ the native split.
1039
+ // 🔴 THE CHATTINESS PRECONDITION IS GONE (OPE-1695). Requiring `ambientSupervisor` here is what
1040
+ // made a mention in an addressed-only room open eve's native per-thread session — the fallback
1041
+ // the ruling deletes. Its complement moved with it: `scheduleUnownedWake` now takes the seat road
1042
+ // for every addressed wake, so suppressing the native leg can no longer leave the mention with
1043
+ // nowhere to land.
1044
+ const singleWindowOwnsMention = async (channelId: string, surfaceKey: string): Promise<boolean> =>
1045
+ (await mentionPolicyFor(channelId, surfaceKey)).suppressNative;
1046
+
1047
+ // 🔴 THE WHOLE HANDLER RUNS INSIDE THE ROUTE HOLD (OPE-1648). Every door below answers its 200 at
1048
+ // once and finishes its work afterwards — this route through `args.waitUntil`, eve's native
1049
+ // handler through the same `args` on the forward — so a `void`-ed audit write anywhere under here
1050
+ // races the serverless freeze that follows the response and loses the row.
1051
+ //
1052
+ // 🔴 EVERY `waitUntil` THE DELIVERY REGISTERS IS QUIESCED FIRST, and that is what makes the hold
1053
+ // reach the wake routes. eve snapshots its background-task array the instant this handler
1054
+ // resolves, so a `waitUntil` called later — from an engagement route running inside one of the
1055
+ // FIFO promises below, long after the 200 — lands in a list nobody awaits. Every such route runs
1056
+ // INSIDE a promise registered here while the handler was still going, so handing the platform a
1057
+ // promise that outlives that route's own writes is enough, and nothing extra is registered: a
1058
+ // delivery that queues no background work still hands the platform nothing at all.
1059
+ return (req: Request, args: RouteHandlerArgs): Promise<Response> =>
1060
+ withSlackRouteHold(() => {
1061
+ // `Object.create`, not a spread: the route args are eve's object, and a spread would keep only
1062
+ // its OWN enumerable properties. Shadowing one method on a prototype-linked copy leaves
1063
+ // everything else — `from`, `to`, `attachSession`, `params` — resolving to eve's own.
1064
+ const held: RouteHandlerArgs = Object.assign(Object.create(args) as RouteHandlerArgs, {
1065
+ waitUntil: (p: Promise<unknown>) => args.waitUntil(routeHoldQuiesced(p)),
1066
+ });
1067
+ return handleFrontRequest(req, held);
1068
+ });
1069
+
1070
+ async function handleFrontRequest(req: Request, args: RouteHandlerArgs): Promise<Response> {
1071
+ const rawBody = await req.text();
1072
+ if (!verifySlackSignature(rawBody, req.headers, signingSecret(), now())) {
1073
+ return new Response("unauthorized", { status: 401 });
1074
+ }
1075
+ const decision = classifyEnvelope(rawBody, req.headers.get("content-type") ?? "");
1076
+
1077
+ /**
1078
+ * Hand this request to eve's native `slackChannel`, with the OPE-1374 attachment boundary
1079
+ * applied to what eve is given (OPE-1380).
1080
+ *
1081
+ * 🔴 EVERY NATIVE FORWARD GOES THROUGH HERE, not just the `@mention` one. A fence on the mention
1082
+ * path alone would be no fence at all — the same reasoning `slackWakeSpendGate` states for its
1083
+ * own placement: an `im` message forwards natively too, and eve merges its attachments the same
1084
+ * way. The click and view_submission fall-throughs carry no `event`, so for them this is the
1085
+ * identity below and their forward stays byte-identical.
1086
+ *
1087
+ * 🔴 THE RE-SIGN IS WHAT MAKES A REWRITE POSSIBLE. The inner handler re-verifies the Slack
1088
+ * signature over the body it receives, so a changed body needs a signature over those bytes. The
1089
+ * secret is the one this route just VERIFIED the inbound signature with, so a deployment where
1090
+ * the two secrets disagree would have 401'd above rather than reaching here.
1091
+ *
1092
+ * FAIL-SOFT TO TODAY'S BEHAVIOUR. If the body carried nothing to strip (the common path), or the
1093
+ * signature cannot be reminted (no secret, no timestamp header), the ORIGINAL bytes and headers
1094
+ * are forwarded. Losing the fence on a body we cannot re-sign is recoverable; dropping the
1095
+ * message on a signature the inner handler then refuses is not.
1096
+ *
1097
+ * 🔴 IT ALSO CARRIES THE ENTERPRISE RUNG ACROSS THE FORWARD (OPE-1559). `enterprise_id` lives on
1098
+ * the envelope, and eve hands its handler the INNER event only, so the rung would be lost here
1099
+ * otherwise — and a Grid deployment that declared only the enterprise would resolve every native
1100
+ * mention as an undeclared room. The stow wraps the WHOLE forward, including the fenced-body
1101
+ * branch, because both branches end in the same inner handler. See
1102
+ * {@link withForwardedSlackEnvelope} for why the carrier is async-scoped and message-matched.
1103
+ * EVERY native forward is wrapped for the same reason the attachment fence is: an `im` message
1104
+ * forwards natively too, so a rung carried on the mention path alone would be half a rung.
1105
+ */
1106
+ const forwardNative = (): Promise<Response> =>
1107
+ withForwardedSlackEnvelope(forwardedEnvelopeOf(rawBody), () => {
1108
+ const fenced = stripUnfencedSlackAttachments(rawBody);
1109
+ const ts = req.headers.get(SLACK_TIMESTAMP_HEADER);
1110
+ const secret = signingSecret();
1111
+ if (fenced === rawBody || !ts || !secret) {
1112
+ return innerHandler(new Request(req.url, { method: "POST", headers: req.headers, body: rawBody }), args);
1113
+ }
1114
+ // A fresh Headers, because the inbound `content-length` describes the ORIGINAL bytes and the
1115
+ // fenced body is shorter. Dropped rather than recomputed: `Request` sets it from the body.
1116
+ const headers = new Headers(req.headers);
1117
+ headers.set(SLACK_SIGNATURE_HEADER, slackSignature(secret, ts, fenced));
1118
+ headers.delete("content-length");
1119
+ console.log("[open-everywhere:slack-front] stripped an unfenced link-unfurl attachment before the native forward (OPE-1380)");
1120
+ return innerHandler(new Request(req.url, { method: "POST", headers, body: fenced }), args);
1121
+ });
1122
+
1123
+ // `liveDeps` types `send`'s auth as `unknown` on purpose (it doesn't import Eve's auth type);
1124
+ // the cast bridges that gap — at runtime the payload/token/auth pass straight through. Hoisted
1125
+ // because BOTH the single-window app_mention divert (mention branch) and the unowned-message
1126
+ // wake path feed the same supervisor/name-drop dispatcher.
1127
+ //
1128
+ // eve 0.31 replaced the route's flat `send(payload, { continuationToken, … })` with
1129
+ // `from(address).send(message, { … })` — the address is bound at `from()` time instead of
1130
+ // riding in the options, and `context` moved out of the payload into the options. OE's own
1131
+ // `WakeSend` (slack-events.ts) is UNCHANGED by design: it is an OE-authored adapter interface,
1132
+ // not eve's, so the whole wake/name-drop/supervisor/join layer below stays byte-identical and
1133
+ // the version-shaped difference is confined to this one adapter.
1134
+ const send = ((input: { message: string; context?: readonly string[] }, opts: { auth: unknown; continuationToken: string; state?: unknown }) =>
1135
+ args.from(opts.continuationToken).send(input.message, {
1136
+ auth: opts.auth,
1137
+ ...(opts.state === undefined ? {} : { state: opts.state }),
1138
+ ...(input.context && input.context.length > 0 ? { context: input.context } : {}),
1139
+ } as never)) as unknown as Parameters<typeof liveDeps>[0];
1140
+
1141
+ // Two-layer idempotency (in-memory first, then the durable cross-instance store) shared by the
1142
+ // wake path AND the single-window mention divert: a Slack RETRY (same event_id) must never
1143
+ // double-wake. Returns false when the event was already handled (caller 200s and stops).
1144
+ // Fail-open on an unresolvable partition — dedupe prevents a double reply, not a security hole.
1145
+ /** The two-layer dedupe, keyed EXPLICITLY. Split out from {@link notYetSeen} for the shortcut
1146
+ * branch (OPE-1376), whose idempotency key is Slack's per-click `trigger_id` rather than an
1147
+ * Events API `event_id` — see `shortcutWakeKey` for why the message's own ts is wrong there. */
1148
+ const notYetSeenKey = async (key: string, ev: ParsedMessageEvent): Promise<boolean> => {
1149
+ if (dedupe.seen(key, now())) return false;
1150
+ dedupe.mark(key, now());
1151
+ try {
1152
+ const org = resolvePartition(bindableFor(ev)).org;
1153
+ if (!(await markEventSeen(org, key, new Date(now()).toISOString()))) return false;
1154
+ } catch {
1155
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
1156
+ }
1157
+ return true;
1158
+ };
1159
+
1160
+ /** The Events API form: key one `event_id` (falling back to `channel:ts`). Every existing wake
1161
+ * route reads this one; only the shortcut branch keys itself. */
1162
+ const notYetSeen = (env: SlackEventEnvelope, ev: ParsedMessageEvent): Promise<boolean> =>
1163
+ notYetSeenKey(wakeKey(env, ev.channelId, ev.ts), ev);
1164
+
1165
+ // UNOWNED message dispatch (top-level, or in-thread but not engaged): today's default is the
1166
+ // name-drop path (per-domain opt-in, fail-closed). When the channel's domain opts into
1167
+ // ambientSupervisor, the SAME unowned traffic routes to the channel-supervisor session instead
1168
+ // (slack-events.ts routeSupervisorEvent) — the "always-on teammate" leg. `directMention` is the
1169
+ // SINGLE-WINDOW addressed-mention divert (an app_mention event routed here from the mention
1170
+ // branch): it forces the supervisor path (the only road into the unified window) and renders an
1171
+ // addressed `direct-mention` wake. The supervisor wake serializes on its OWN FIFO key (the
1172
+ // supervisor token), never shared with a thread FIFO. Fail-closed: any partition/config error,
1173
+ // or the flag off, falls through to the UNCHANGED name-drop dispatch — so ambientSupervisor-off
1174
+ // is byte-identical to today. Parametrised on `ev` so both the app_mention divert and a plain
1175
+ // message.channels wake feed one dispatcher.
1176
+ // `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled — and never on the
1177
+ // addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
1178
+ // registry only for a click that actually wakes something; every other caller omits it.
1179
+ const scheduleUnownedWake = (ev: ParsedMessageEvent, label: string, directMention = false, onWake?: () => void, seatEngaged = false): void => {
1180
+ let ambient = false;
1181
+ let surfaceKey: string | undefined;
1182
+ try {
1183
+ surfaceKey = resolvePartition(bindableFor(ev)).surfaceKey;
1184
+ ambient = ambientEnabledFor(surfaceKey);
1185
+ } catch {
1186
+ ambient = false; // unresolvable partition / config load → today's name-drop path
1187
+ }
1188
+ // 🔴 AN ADDRESSED WAKE TAKES THE SEAT ROAD, WHATEVER THE CHATTINESS FLAG SAYS (OPE-1695). The
1189
+ // drop that stood here refused an addressed wake on an ambient-off channel, because the seat
1190
+ // itself only existed where ambient was on — so a mention and a channel-message shortcut both
1191
+ // died there. Seats are universal now: the flag decides whether UNADDRESSED talk wakes the
1192
+ // seat, and addressing decides nothing about the session's shape. A mention in an
1193
+ // addressed-only room therefore lands in that room's own seat, with the earlier exchange in
1194
+ // context, instead of opening eve's native per-thread session (the fallback this ruling
1195
+ // deletes) or being dropped.
1196
+ // 🔴 THE DROP SURVIVES WHERE THERE IS NO SEAT, AND THAT KEEPS FLAG-OFF BYTE-IDENTICAL. With the
1197
+ // standing-seat capability unarmed there is no seat road at all, so an addressed wake that fell
1198
+ // through here would take the NAME-DROP path — a silently mis-judged wake, which is exactly what
1199
+ // the old drop existed to prevent. The mention route never reaches it (its own policy leaves the
1200
+ // native leg in charge when nothing windows); the SHORTCUT route does, and on such a deployment
1201
+ // it is dropped loudly, exactly as before.
1202
+ // An unresolvable partition leaves no surface key, so there is no seat to ask about: that
1203
+ // reads as UNSEATED, which keeps the drop — the fail-closed direction the old branch had.
1204
+ const seated = surfaceKey !== undefined && seatedSurface(surfaceKey);
1205
+ if (directMention && !ambient && !seated) {
1206
+ console.warn(`[open-everywhere:slack-front] ${label} ${ev.channelId}/${ev.ts}: addressed wake with no standing seat and ambient off — dropped (no road to a seat)`);
1207
+ return;
1208
+ }
1209
+ // 🔴 `seatEngaged` IS NOT `directMention`, and the distinction is load-bearing. An ENGAGED
1210
+ // thread's follow-up must reach the seat that is holding the conversation even in an
1211
+ // addressed-only room — otherwise the seat answers a mention once and goes deaf on the very
1212
+ // thread it opened. But it must NOT be treated as an address: `directMention` also lifts the
1213
+ // app_mention/channel_type disqualifiers AND bypasses the stood-down suppression
1214
+ // (slack-events.ts routeSupervisorEvent, `ev.threadTs && !directMention && engRow?.stopped`),
1215
+ // so passing it here would make `stand_down` stop working in threads. It only says "there is a
1216
+ // seat road for this message", which is what the chattiness flag would otherwise have said.
1217
+ const addressedOrAmbient = directMention || ambient || seatEngaged;
1218
+ onWake?.();
1219
+ if (addressedOrAmbient) {
1220
+ // Generation-INDEPENDENT FIFO key (T1.5): the FIFO's job is to serialize supervisor
1221
+ // wakes for this CHANNEL, which must hold ACROSS a rotation boundary too — keying on
1222
+ // the generation-bearing token (the old hardcoded gen-1 assumption) would split one
1223
+ // channel's serialized queue in two the instant a rotation minted a new generation.
1224
+ // supervisorFifoKey needs no store read either, so it stays a stable, synchronous key
1225
+ // on the hot path exactly like the token-based key did.
1226
+ const supKey = supervisorFifoKey(ev.channelId);
1227
+ args.waitUntil(
1228
+ fifo.enqueue(supKey, () =>
1229
+ deliverWithStrandRetry(() =>
1230
+ routeSupervisorEvent(
1231
+ ev,
1232
+ withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey!),
1233
+ directMention ? { directMention: true } : undefined,
1234
+ ).then(logOutcome(`${label}-supervisor`, ev.channelId, ev.ts)),
1235
+ ),
1236
+ ),
1237
+ );
1238
+ return;
1239
+ }
1240
+ // 🔴 A NAME-DROP WAKES THE ROOM'S STANDING SEAT (OPE-1725). `routeNameDropEvent` stays the
1241
+ // JUDGE — the name match, the domain's `nameDropWake` opt-in, the internal confirmation, the
1242
+ // wake arm — and on a SEATED surface its `seatRoute` hands the judged wake to that seat instead
1243
+ // of opening eve's native per-thread session. Addressing decides what the seat is owed; it
1244
+ // never decides the shape of the session, so the same seat an @mention reaches answers a
1245
+ // name-drop, with the room's earlier exchange already in its context.
1246
+ // UNSEATED ⇒ no `seatRoute`, and the native per-thread wake is byte-identical to today. That
1247
+ // arm is the whole pre-seat deployment, so the fallback is not a corner case.
1248
+ //
1249
+ // 🔴 THE JUDGE RUNS OUTSIDE THE SEAT'S FIFO, and only a message it ADMITS enters one. Putting
1250
+ // the judge inside would make every unaddressed message in a seated room occupy the channel's
1251
+ // serialized queue for the length of a decision that almost always declines — a real cost on a
1252
+ // busy room, and it would hold a genuine seat wake behind it. `fifo.enqueue` resolves void and
1253
+ // SWALLOWS a failure, so the outcome is captured in the closure and the default stays the
1254
+ // honest "did not wake" for a delivery that threw.
1255
+ // 🔴 THE HYDRATOR RIDES ACROSS THE HAND-OFF (OPE-1725). The judge fetches the room's
1256
+ // surrounding conversation for the wake it admits, and the SEAT wake must carry that same
1257
+ // block — withheld by the same transform — or a seated deployment quietly stops hydrating
1258
+ // name-drops at all. It is passed as a thunk, so the seat road pays for the history page only
1259
+ // after its own tenant fence, spend gate and external-participant policy admit the wake.
1260
+ const seatRoute = seated
1261
+ ? async (hydrateContext: NameDropContextHydrator): Promise<RouteOutcome> => {
1262
+ let outcome: RouteOutcome = { deliver: false, reason: "seat wake delivery failed", woke: false };
1263
+ await fifo.enqueue(supervisorFifoKey(ev.channelId), async () => {
1264
+ outcome = await deliverWithStrandRetry(() =>
1265
+ routeSupervisorEvent(ev, withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey!), {
1266
+ nameDrop: true,
1267
+ nameDropContext: hydrateContext,
1268
+ }),
1269
+ );
1270
+ });
1271
+ return outcome;
1272
+ }
1273
+ : undefined;
1274
+ args.waitUntil(
1275
+ routeNameDropEvent(ev, { ...liveNameDropDeps(send), ...(seatRoute ? { seatRoute } : {}) }).then(
1276
+ logOutcome(seatRoute ? `${label}-name-drop-seat` : label, ev.channelId, ev.ts),
1277
+ ),
1278
+ );
1279
+ };
1280
+
1281
+ /**
1282
+ * THE DM WAKE (OPE-351 stage 2; group DMs at stage 4) — a `message.im` or `message.mpim` diverted
1283
+ * into its own first-class standing window, the DM analogue of the single-window app_mention
1284
+ * divert above. `dmKind` names which family member it is; everything the two differ in is
1285
+ * called out below, and nothing else does.
1286
+ *
1287
+ * 🔴 IT DELIBERATELY BYPASSES routeSupervisorEvent, engagementTierFor AND THE NAME-DROP JUDGE.
1288
+ * Every one of those exists to answer "was the agent addressed, and is this worth replying to?"
1289
+ * — a question a 1:1 DM has already answered. A DM IS ALWAYS ANSWERED; routing it through a
1290
+ * silence-biased judge would let the agent ignore someone talking to it directly, which is the
1291
+ * one place it must never be quiet. So this path renders an ADDRESSED wake (`slackDmWake`,
1292
+ * `addressing:"direct-mention"`) and dispatches it, full stop. The spend gate is NOT bypassed:
1293
+ * `divertToUnifiedWindow`'s own attended-arrival gate (windowSpendGate) runs on this wake exactly
1294
+ * as it does on a mention, which is what replaces the native door's gate.
1295
+ *
1296
+ * A GROUP DM WAKES ON THE SAME UNCONDITIONAL RULE, AND THAT IS A REAL WIDENING — its previous
1297
+ * route (`shouldNameDropWake`) refused every non-mention message on channel_type, so the room woke
1298
+ * only on an @mention. The justification is not that every message in a group DM is addressed to
1299
+ * the agent (it plainly is not), but that a closed room the agent was added to person-by-person is
1300
+ * a conversation it is IN, and the judgement of whether to speak belongs to the model, which has
1301
+ * the text, rather than to a front-door gate keyed on a channel type. What carries that is the
1302
+ * wake's own silence escape plus the GROUP opening of the `[direct]` preamble
1303
+ * (channels/normalized-wake.ts), which says the room holds other people and tells the seat to stay
1304
+ * out of what is not asked of it. The COST of the widening lands on the spend gate above, not on
1305
+ * a judge: a chatty group DM is a budget question, and it is already answered by one.
1306
+ *
1307
+ * THE ENVELOPE IS MINIMAL, ON PURPOSE — no backscroll. The native DM path deliberately skipped
1308
+ * `loadConversationContext` (slack.ts: "DMs are skipped — the 1:1 history IS the conversation and
1309
+ * the durable session already carries it"), and that reasoning survives the re-seat unchanged: the
1310
+ * seat is one long-lived session over the whole DM, so injecting history would duplicate what the
1311
+ * session already holds. The one thing a FRESH generation carries is the recorded tail of the
1312
+ * generation it replaced (OPE-794) — the case that argument does not cover, since a rotation is
1313
+ * exactly when the session stops holding it.
1314
+ *
1315
+ * A STRICT IMPROVEMENT IN FENCING: eve's native DM dispatch delivered the human's text as the raw
1316
+ * turn body. Going through the shared wake renderer means a DM body is now `wrapUntrusted`-fenced
1317
+ * like every other surface's — a DM was the last inbound text that was not.
1318
+ *
1319
+ * FAIL-CLOSED, unlike the OPE-90 remap it replaces: no seat, no partition or no dispatch means the
1320
+ * wake FAILS into the FIFO's loud catch (audited by recordWakeFailure) rather than falling back to
1321
+ * a native send, which would answer the human from a second session with no window row behind it.
1322
+ */
1323
+ const scheduleDmWake = async (
1324
+ ev: ParsedMessageEvent,
1325
+ dmKind: "im" | "mpim",
1326
+ /** The DM door admitted this bot author as the deployment's named e2e driver (OPE-1023).
1327
+ * Defaults FALSE, so every existing call site is unchanged. */
1328
+ driverAdmitted = false,
1329
+ ): Promise<void> => {
1330
+ // 🔴 THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
1331
+ // this line `slack-front.ts` consulted the fence NOWHERE, while the mention door, the four
1332
+ // dispatching engagement routes, the channel-join door and the `/oe` slash door all did — so a
1333
+ // DM from a workspace this deployment never declared woke the DM seat. That seat is the
1334
+ // LARGEST grant the deployment makes: it holds a window token, delegation and memory, and a
1335
+ // DM is exactly where a colliding `U…` id resolves somebody's PERSONAL credentials.
1336
+ //
1337
+ // EVERY road into a DM window passes through here — the `message.im`/`message.mpim` branch,
1338
+ // the DM-shaped shortcut and the group-DM mention divert — so the fence is placed here rather
1339
+ // than at each of the three, and no road can be added that skips it. The two callers that CAN
1340
+ // decide earlier do (the DM branch and the shortcut branch below), because a refusal there
1341
+ // also spares the deployment a foreign workspace's attachment fetch and dedupe write; this
1342
+ // check is what makes those an optimisation rather than the fence itself.
1343
+ //
1344
+ // SILENT, like every other door: nothing is posted back into the DM. We cannot safely identify
1345
+ // anybody in an unadmitted workspace, and a foreign tenant learning it reached a live
1346
+ // deployment is more than it needs to know. The audit row and the operator log line are the
1347
+ // whole signal.
1348
+ if (slackFrontTenantFenceDeclined(ev, `dm-${dmKind}`, "scheduleDmWake")) return;
1349
+ const p = resolvePartition(bindableFor(ev));
1350
+ const atIso = new Date(now()).toISOString();
1351
+ const isGroup = dmKind === "mpim";
1352
+ // 🔴 WHERE THE SURFACE'S TRUST COMES FROM, and why the two families differ.
1353
+ // • 1:1 — the DM PARTNER, via users.info. It rides BOTH the delivery auth (`dm_partner_trust`
1354
+ // gates the DM's shared read layer) and the envelope's trust tier, so one lookup answers
1355
+ // both. Fail-closed to `external`/`unauth`.
1356
+ // • GROUP — there is no partner. `dm_partner_trust` is a singular fact about "the other person
1357
+ // in this DM", a question an mpim does not have an answer to, and `bindSlack` ignores the
1358
+ // attribute for `mode:"mpim"` anyway (its trust comes from `slackTrust`). Stamping one would
1359
+ // be inventing a fact. What stands in for the WAKE tier is the surface's own confirmed-
1360
+ // internal verdict — the same substitution routeSupervisorEvent makes for a channel — read
1361
+ // from the TTL-cached conversations.info the mention leg already needs. Fail-closed: an
1362
+ // unresolvable lookup (a workspace without `mpim:read`) leaves every author `unauth`, which
1363
+ // renders the reference-only system note rather than dropping the wake. The group's AUTHOR
1364
+ // is a separate probe, resolved just below — the room says nothing about who wrote here.
1365
+ const partnerTrust = !isGroup && ev.userId ? await dmPartnerTrustFor(ev.userId, ev.teamId) : undefined;
1366
+ const roomFacts = isGroup ? await channelFactsFor(ev.channelId) : undefined;
1367
+ const surfaceInternal = isGroup ? roomFacts?.internal === true : partnerTrust === "internal";
1368
+ // ── THE AUTHOR LEG (OPE-1486, Tommy's 2026-09-07 ruling) ────────────────────────────────────
1369
+ //
1370
+ // 🔴 A GROUP DM IS A ROOM, SO ITS AUTHOR IS PROBED LIKE A CHANNEL'S. `partnerTrust` answers a
1371
+ // 1:1's author (`dmPartnerTrustFor` probes `ev.userId` — the writer, not some third party), so
1372
+ // the 1:1 leg has always carried a real refutation. An mpim resolved none at all, which left
1373
+ // `oe_chat_nonmember` ABSENT on a turn a guest's own bytes opened. That is an INGESTION-grain
1374
+ // hole under the 2026-08-13 ruling: taint keys on whose words entered the context.
1375
+ //
1376
+ // The probe is `slackWakeAuthorFacts` — the SAME `users.info` verdict every channel route
1377
+ // reads, TTL-cached, so a person who wrote in this workspace recently costs no new call. It
1378
+ // fails OPEN: an unresolvable probe refutes nobody.
1379
+ //
1380
+ // 🔴 PRESENCE MARKS NOTHING. This asks who WROTE the trigger, never who sits in the room. No
1381
+ // roster is read here and none should be: a guest who has not spoken has put no bytes in the
1382
+ // context, and marking the turn for them would refuse the member plane on a room's occupancy.
1383
+ //
1384
+ // 🔴 THE WAKE TIER IS DELIBERATELY NOT NARROWED, AND THE FENCE IS CARRIED SEPARATELY. This is
1385
+ // the explicit decision the ticket asked for rather than an inherited one, and the two halves
1386
+ // of what the tier controls are what make it safe:
1387
+ // • the tier decides the RENDER — a `peer`/`principal` body is delivered unfenced, and only
1388
+ // `unauth` adds the reference-only system note. It does NOT decide whether the wake fires:
1389
+ // an mpim whose room cannot be confirmed already wakes at `unauth` rather than dropping;
1390
+ // • so the refutation rides to the render on its own field (`triggerRefuted` →
1391
+ // `WakeMessageInput.refuted`), which is a ONE-WAY FENCE BACKSTOP: it fences a refuted
1392
+ // author's body without touching the tier or the note.
1393
+ // What that buys, against folding the refutation into `surfaceInternal` as a channel does: a
1394
+ // guest the operator's own people invited into an addressed room still gets the addressed
1395
+ // envelope and an answer, while their bytes still reach the model INSIDE the fence and the
1396
+ // member plane still refuses a personal credential on the turn. Narrowing the tier as well
1397
+ // would add only the demotion of the envelope itself, which no part of this ruling asks for.
1398
+ const authorRefuted = isGroup ? (await authorFactsFor(surfaceInternal, ev.userId, ev.teamId)).refuted : partnerTrust === "external";
1399
+ const wakeTrust = resolveWakeTrust({
1400
+ // 🔴 THE ADMITTED DRIVER IS NOT A BOT AUTHOR HERE (OPE-1023) — the same expression the
1401
+ // engagement and mention doors already use (`Boolean(ev.botId) && !isAllowedSlackDriver(…)`),
1402
+ // applied to the DM door's own narrower lever. Left as a plain `Boolean(ev.botId)`, an
1403
+ // admitted driver would wake at the `relay` floor and the DM legs would exercise a trust tier
1404
+ // no human DM ever produces — which is not the real front door the carve-out exists to open.
1405
+ //
1406
+ // NO TRUST IS INVENTED. The driver posts through a human `xoxp` token, so `ev.userId` is a
1407
+ // REAL member id and the tier below is resolved from that human by the ordinary rules (an
1408
+ // unvouched surface still lands `unauth`, the admin roster still decides `principal`). What
1409
+ // the flag removes is only the machine floor that Slack's `bot_id` stamp would otherwise
1410
+ // impose on a message a person actually wrote.
1411
+ authorIsBot: Boolean(ev.botId) && !driverAdmitted,
1412
+ authorId: ev.userId,
1413
+ surfaceInternal,
1414
+ channelKey: p.surfaceKey,
1415
+ });
1416
+ // ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain) ──────────────────────────────────
1417
+ //
1418
+ // A DM is ALWAYS addressed, so an excluded sender here is owed BEHAVIOUR 1: the private notice
1419
+ // and nothing else. The notice posts back into this very DM — it is already private, so there
1420
+ // is no `conversations.open` to make and no room to disclose anything to.
1421
+ //
1422
+ // 🔴 IT SITS ABOVE `resolveDmSeat`, WHICH IS WHY THE TRUST BLOCK MOVED UP. A suppressed DM must
1423
+ // mint no seat, rotate no generation and consume no tail. The probe above is the same
1424
+ // one the seat path needed anyway, so the reorder costs nothing.
1425
+ //
1426
+ // A DM contributes NO ROOM LEG — on a DM the trust plane asks about the PARTNER, not about the
1427
+ // room — so only the AUTHOR grain can suppress here.
1428
+ //
1429
+ // 🔴 A GROUP DM IS SUPPRESSIBLE ON THE AUTHOR GRAIN TOO (OPE-1486). It was not before, because
1430
+ // an mpim resolved no refutation at all — the limit the old note here recorded. The author leg
1431
+ // above now resolves one from the same probe a channel uses, so an operator who set
1432
+ // `externalPolicy.authorGrain: "withhold"` gets that rule in a group DM as well. Nothing moves
1433
+ // on the SUPPRESSION PATH for a default deployment: `withholdsBody` is inert under the shipped
1434
+ // `taint` grain, so only a deployment that explicitly excluded refuted authors is suppressed
1435
+ // here — which is the rule it asked for. That is a claim about SUPPRESSION alone: on `taint`
1436
+ // the same refutation still newly stamps `oe_chat_nonmember` and still newly fences the body,
1437
+ // which is the fix. The room leg stays off for the whole DM family by the note above: on a DM
1438
+ // the trust plane asks about the PARTNER, not about the room.
1439
+ {
1440
+ // `authorRefuted` is already resolved above, from ONE probe per family — the 1:1's partner
1441
+ // lookup or the group's author lookup — so this gate makes no second call.
1442
+ const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorRefuted }, "scheduleDmWake");
1443
+ if (verdict !== "proceed") {
1444
+ console.log(`[open-everywhere:slack-front] dm ${p.surfaceKey}: suppressed — externalPolicy verdict "${verdict}"`);
1445
+ return;
1446
+ }
1447
+ }
1448
+ // `channelId` is what the ROTATION distiller fetches history with (always supplied);
1449
+ // `legacyChannelId` is the one-time adopt-from-OPE-90 probe and is 1:1-ONLY. OPE-90 never minted
1450
+ // an `oe-dm-seat::<id>` row for a group DM, so probing for one could only ever miss — and a miss
1451
+ // that cannot be distinguished from a store outage is worse than no probe, because the OPE-346
1452
+ // note-loss audit row exists precisely to tell those two apart.
1453
+ const seat = await resolveDmSeat(
1454
+ p.org,
1455
+ { surfaceKey: p.surfaceKey, channelId: ev.channelId, ...(isGroup ? {} : { legacyChannelId: ev.channelId }) },
1456
+ atIso,
1457
+ );
1458
+ const wakeId = mintWakeId();
1459
+ // buildSlackAuth's DM attributes VERBATIM — including the rootDmGrainAttrs `oe_grain:"standing"`
1460
+ // stamp, which is what narrows a DM-family root's hands (window-tool-gating §D-DM, which already
1461
+ // covered mpim). `isPrivate:true` holds for both: a 1:1 is unambiguous, and a group DM has no
1462
+ // public form. `channelType` is the dmKind verbatim, so `slackMode` binds `dm` / `mpim` and every
1463
+ // mode-keyed carve-out (the lean-seat identity trio, the memory route, the skills refusal) sees
1464
+ // the SAME mode it saw on the native token.
1465
+ //
1466
+ // OPE-466 CARRIAGE: a DM/group-DM wake ingests exactly one message, so the join runs over the
1467
+ // trigger's own tier. The 1:1 leg already resolves `unauth` for a guest or Connect stranger
1468
+ // (the partner probe above IS the membership probe); the group leg keeps the ROOM's tier and
1469
+ // carries its author's refutation separately — see the author-leg note above for why the two
1470
+ // are not folded together on an mpim.
1471
+ const auth = stampNonMemberChatAuth(
1472
+ buildSlackAuth({
1473
+ userId: ev.userId ?? "",
1474
+ teamId: ev.teamId,
1475
+ // The Grid rung (OPE-1508), the same conditional spread `authForEvent` uses: a group DM
1476
+ // in an Enterprise Grid carries the enterprise the operator's declaration is written at,
1477
+ // and a non-Grid event stamps nothing.
1478
+ ...(ev.enterpriseId === undefined ? {} : { enterpriseId: ev.enterpriseId }),
1479
+ channelId: ev.channelId,
1480
+ threadTs: ev.threadTs,
1481
+ channelType: dmKind,
1482
+ isPrivate: true,
1483
+ ...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
1484
+ // 🔴 THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
1485
+ // channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
1486
+ // reads the pair through `slackTrust`, so this is the binding's only route to the fact
1487
+ // that a group DM spans another organisation — and `binding.trust` is the DEMOTION half of
1488
+ // the member plane's G4b vouch. Without it a Slack Connect group DM was vouched internal
1489
+ // under a workspace-wide declaration, because `slackTrust` graded every mpim `internal`.
1490
+ //
1491
+ // POSITIVE-ONLY, and both halves are spread conditionally for it: a workspace without
1492
+ // `mpim:read` resolves neither fact, stamps neither attribute, and keeps today's verdict.
1493
+ // Absence of an answer must never demote a room — only the platform SAYING so does.
1494
+ ...(roomFacts?.extShared === undefined ? {} : { isExternal: roomFacts.extShared }),
1495
+ ...(roomFacts?.trustChecked === undefined ? {} : { trustChecked: roomFacts.trustChecked }),
1496
+ }),
1497
+ // OPE-489: the author leg reads the POSITIVE refutation. On a 1:1 that is exactly the
1498
+ // partner probe's `external` verdict — the same users.info answer, so no second lookup.
1499
+ // OPE-1486: a GROUP DM resolves the same refutation from the same probe a channel's author
1500
+ // leg uses, so a guest or Connect stranger who WRITES here marks the turn. The ROOM leg is
1501
+ // still off for the whole DM family — `dm: true` puts it out of scope for the `public` tier,
1502
+ // because on a DM `trust` asks about the PARTNER, not about the room.
1503
+ [{ trust: wakeTrust, refuted: authorRefuted }],
1504
+ { dm: true },
1505
+ );
1506
+ // OPE-794 — a fresh DM generation opens with the OUTGOING generation's RECORDED TAIL as its
1507
+ // backscroll, in place of the deleted handoff note. The DM envelope is otherwise still minimal
1508
+ // by design (the seat is one long-lived session over the whole DM, so replaying history a live
1509
+ // session already holds would duplicate it) — the tail is exactly the case that reasoning does
1510
+ // not cover, because a rotation is when the session stops holding it.
1511
+ const message = renderWakeMessage(ev, () => atIso, {
1512
+ dm: true,
1513
+ trust: wakeTrust,
1514
+ // The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
1515
+ // and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
1516
+ // no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
1517
+ // on a GROUP DM it is what fences a refuted author whose tier the room keeps at member grade.
1518
+ triggerRefuted: authorRefuted || undefined,
1519
+ wakeId,
1520
+ ...(seat.freshGeneration && seat.tail.length > 0 ? { backscroll: seat.tail } : {}),
1521
+ });
1522
+
1523
+ // 🔴 NO `replyAnchorTs` ON A TOP-LEVEL DM — the reply stays FLAT in the conversation. The
1524
+ // CHANNEL divert DOES set one (slack-events.ts), because threading an answer under its trigger
1525
+ // keeps a busy shared channel tidy; the slack post arm reads `threadTs ?? replyAnchorTs`
1526
+ // as its thread target, so setting it here would thread EVERY DM answer under the message that
1527
+ // asked — shattering a 1:1 conversation into a pile of one-reply threads. That is also not what
1528
+ // the native path did: eve replied to the wake's own `thread_ts` (OPE-98's per-wake anchor), which
1529
+ // is absent on a top-level DM message, so the answer posted flat. Behaviour preserved deliberately.
1530
+ // A DM message that genuinely IS in a thread still carries its threadTs and is answered in it.
1531
+ //
1532
+ // 🔴 A GROUP DM IS FLAT TOO — and here that DEPARTS from the native behaviour rather than
1533
+ // preserving it, so the reasoning has to stand on its own. Today an mpim @mention is answered by
1534
+ // eve's native path, which anchors on the wake's own `thread_ts` and therefore THREADS under the
1535
+ // mention. Keeping that would put two reply geometries in one room served by one seat: the
1536
+ // mention-carried wake threaded, every other message in the same conversation flat — the
1537
+ // placement flipping on whether the human happened to type `<@bot>`. Between "match what mpim did
1538
+ // yesterday" and "one seat, one room, one placement", the second wins: the seat is a single
1539
+ // long-lived session over the WHOLE group DM (the same property that makes a 1:1 flat), and a
1540
+ // Slack group DM is capped at nine people, so it has none of the tidiness problem threading
1541
+ // solves in a busy channel. A genuinely threaded mpim message still carries its threadTs and is
1542
+ // still answered inside that thread, exactly as in a 1:1.
1543
+ const origin = { platform: "slack" as const, channelId: ev.channelId, ...(ev.threadTs ? { threadTs: ev.threadTs } : {}) };
1544
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719), on the DM leg too — and the reason is the
1545
+ // `threadTs` two comment blocks above. A Slack DM CAN hold a thread, so a DM thread can be
1546
+ // owned: the DM seat runs on a standing window token, `spawn_thread_session` mounts on it, and
1547
+ // a seat spawned inside a DM thread records a real ownership row. On the raw divert that row
1548
+ // was never read, so the seat was orphaned until its TTL — the exact defect this ticket closes,
1549
+ // on the one surface whose DM carries a thread signal at all. Discord's and Teams' DM legs stay
1550
+ // on the raw divert because THEIR DM origins carry none, not because a DM is special.
1551
+ // A top-level DM message has no `threadTs`, so it is not thread-scoped and goes standing with
1552
+ // no store read — flat DM behaviour is byte-unchanged.
1553
+ await divertInboundToUnifiedWindow(args.to, {
1554
+ message,
1555
+ token: seat.token,
1556
+ // `vendorTs` (OPE-1716): the trigger's OWN ts, which on Slack is its send time — the first
1557
+ // latency anchor, recorded on the ingress row and acted on by nothing.
1558
+ window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(slackVendorEventTime(ev.ts)) },
1559
+ surfaceKey: p.surfaceKey,
1560
+ auth,
1561
+ });
1562
+ // Record the wake off the critical path (best-effort — a touch failure never affects the reply).
1563
+ args.waitUntil(touchDmSeatFn(p.org, p.surfaceKey, atIso).catch(() => {}));
1564
+ // OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
1565
+ // the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
1566
+ args.waitUntil(
1567
+ recordWakeTail(p.org, dmWindowRegistryKey(p.surfaceKey), seat.token, {
1568
+ kind: "wake",
1569
+ at: atIso,
1570
+ from: "human",
1571
+ trust: wakeTrust,
1572
+ // THE AUTHOR GRAIN (OPE-1747) — the same `authorRefuted` the DM's own taint join and
1573
+ // external-policy verdict above read, through the one core road.
1574
+ ...tailAuthorGrain({ trust: wakeTrust, refuted: authorRefuted }),
1575
+ author: ev.userId ?? "unknown",
1576
+ ...(ev.userId ? { authorId: ev.userId } : {}),
1577
+ ...(ev.ts ? { id: ev.ts } : {}),
1578
+ platform: "slack",
1579
+ container: p.surfaceKey,
1580
+ text: ev.text ?? "",
1581
+ }),
1582
+ );
1583
+ };
1584
+
1585
+ // THE IN-MEMORY ATTACHMENT REGISTRY, seeded from an event whose files the front door has already
1586
+ // ADMITTED. Every Events API path gets this inside `parseMessageEvent`, beside its own
1587
+ // `parseSlackFiles` call. The SHORTCUT route cannot (OPE-1442): its files must clear the
1588
+ // readability gate and the wake gates first, and both of those live here rather than in the
1589
+ // parser, so that route seeds through this helper instead. Image-only, matching what
1590
+ // `describe_image` resolves; the durable half for another process is `persistAttachments` below.
1591
+ const registerAttachments = (ev: ParsedMessageEvent): void => {
1592
+ for (const a of ev.attachments ?? []) {
1593
+ if (a.kind === "image" && a.id && a.url) {
1594
+ registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, ...(ev.teamId ? { teamId: ev.teamId } : {}) });
1595
+ }
1596
+ }
1597
+ };
1598
+
1599
+ // DURABLE ATTACHMENT REGISTRY (task #107): mirror this message's uploads into the durable
1600
+ // operational store so a DELEGATED worker — a one-shot task in a SEPARATE process from this
1601
+ // seat — can resolve a file id the seat saw (the in-memory registry is process-local). Trusted
1602
+ // provenance: the url_private is the EVENT-supplied string (never a model string), org resolved
1603
+ // from the same event. Off the hot path (waitUntil); fail-open on an unresolvable partition (the
1604
+ // in-memory registry still serves the same-process seat). Persists NON-IMAGE files too, which the
1605
+ // image-only in-memory registry never records — the raw-bytes read path (read_file_upload) needs them.
1606
+ const persistAttachments = (ev: ParsedMessageEvent): void => {
1607
+ const atts = ev.attachments;
1608
+ if (!atts || atts.length === 0) return;
1609
+ let org: string;
1610
+ try {
1611
+ org = resolvePartition(bindableFor(ev)).org;
1612
+ } catch {
1613
+ return; // unresolvable partition → in-memory registry only (fail-open)
1614
+ }
1615
+ for (const a of atts) {
1616
+ if (a.id && a.url) {
1617
+ args.waitUntil(persistSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId: ev.teamId }, org));
1618
+ }
1619
+ }
1620
+ };
1621
+
1622
+ // 🔴 A TENANT REFUSAL STILL HAS TO HAND THE PLATFORM ITS WRITES (cold review, 2026-09-10).
1623
+ // The fence tracks its `surface.wake_dropped` row with `holdRouteWork`, which only pushes the
1624
+ // promise into THIS delivery's pending bag (slack-route-hold.ts). The bag is drained by
1625
+ // `routeHoldQuiesced`, and the one place that runs it is the wrapped `waitUntil` this handler was
1626
+ // given (see `held` in the route builder above). So a branch that refuses and returns 200 WITHOUT
1627
+ // calling `args.waitUntil` hands the platform nothing: the serverless invocation freezes the
1628
+ // moment the response is written and the row never lands. The refusal itself is unaffected — it
1629
+ // is decided before any of this — but the audit row is the drop's ONLY durable signal, because a
1630
+ // webhook function's console output does not reach the operator. One empty `waitUntil` is enough:
1631
+ // the wrapper quiesces the whole tracked bag, not just the promise handed to it.
1632
+ const fencedAck = (res: Response): Response => {
1633
+ args.waitUntil(Promise.resolve());
1634
+ return res;
1635
+ };
1636
+
1637
+ if (decision.kind === "forward") {
1638
+ // A fresh Request so slackChannel can re-read the body and re-verify the signature — and, on a
1639
+ // body carrying an unfenced attachment, one built over the fenced bytes. See `forwardNative`.
1640
+ return forwardNative();
1641
+ }
1642
+
1643
+ if (decision.kind === "dm-wake") {
1644
+ // A DM-FAMILY message — a 1:1 (OPE-351 stage 2) or a group DM (stage 4). OURS, UNCONDITIONALLY:
1645
+ // diverted into the surface's own standing window rather than forwarded to eve's native
1646
+ // `onDirectMessage` (1:1) or handed to the engagement gate that refused it (group DM). The
1647
+ // `OE_DM_SEAT_ROTATION=0` escape hatch that used to short-circuit to a native forward here is
1648
+ // gone (OPE-368) — it was the last supported way to leave a DM un-windowed, i.e. un-wakeable by
1649
+ // a delegated worker's completion, which is precisely the seat class the retired OPE-306
1650
+ // delegation anchor existed to serve.
1651
+ const dev = parseMessageEvent(decision.env);
1652
+ // An unparseable envelope has nothing to wake on — a no-op 200. It carries no channel and no
1653
+ // ts, so it cannot be keyed to a surface and gets a log line rather than an audit row.
1654
+ if (!dev) {
1655
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} wake dropped — the envelope parsed to no channel/ts`);
1656
+ return OK();
1657
+ }
1658
+ // 🔴 THE TENANT FENCE, AS EARLY AS THIS BRANCH CAN ASK IT (OPE-1644). `scheduleDmWake` below
1659
+ // is the chokepoint and refuses the same delivery on its own, so this is not the fence — it is
1660
+ // what keeps an unadmitted workspace from spending THESE on the way there: the bot-author
1661
+ // probe's audit rows, `persistAttachments` (which registers a FOREIGN workspace's
1662
+ // `url_private` files for a delegated worker to fetch) and a durable dedupe write all sit
1663
+ // between here and it. The parse above has already seeded the in-memory attachment LRU, which
1664
+ // is the one cost no placement in this branch avoids.
1665
+ if (slackFrontTenantFenceDeclined(dev, `dm-${decision.dmKind}`, "front-dm-branch")) return fencedAck(OK());
1666
+ // A bot-authored DM is never a wake — a no-op 200. NOT a forward: forwarding a bot_message
1667
+ // would hand eve's native door a turn the DM window is meant to own.
1668
+ //
1669
+ // 🔴 THE DROP IS COUNTED (OPE-1022). This used to be a bare `return OK()`, which made the DM
1670
+ // door the one wake path in the deployment that could swallow a real, well-formed event with
1671
+ // no audit row and no log line. Slack stamps `bot_id` on every Web-API post, a user token
1672
+ // included, so any script-driven probe lands here — and on 2026-08-23 that silence read as a
1673
+ // dead Slack surface for a drop that was working exactly as designed. Our own reply echo is
1674
+ // excluded inside the helper, so the row means "somebody else's bot spoke here", not chatter.
1675
+ //
1676
+ // 🔴 NAMED EXCEPTIONS ONLY (OPE-1023, OPE-1739, OPE-1754). `wake.e2eDrivers` lists the
1677
+ // deployment's e2e driver identities across every surface, and this door reads its `slack`
1678
+ // entries alone — each a `teamId:botId` pair. A LISTED identity's DM is admitted and woken
1679
+ // like a human's. Nothing but a whole-pair match is admitted. Empty is the
1680
+ // shipped value, and an empty list matches nothing in `isE2eDriver`, so an unconfigured
1681
+ // deployment reaches the drop below with the same verdict and the same row it always had.
1682
+ // `getBotIdentity()` is only awaited AFTER a pair matches, which is why the hot echo leg
1683
+ // gains no lookup.
1684
+ //
1685
+ // The self-check is the loop guard: an operator who wrote OUR OWN bot record into the setting
1686
+ // would otherwise have every reply re-wake the seat forever. Refusing the match costs nothing
1687
+ // (the identity read is a cached promise) and removes the only configuration that could loop.
1688
+ //
1689
+ // The leading `dev.botId` test is what keeps the HUMAN path free: without it every human DM
1690
+ // would resolve the settings object to ask a question only a bot author can answer.
1691
+ const driverAdmitted = Boolean(dev.botId) && dmDoorAdmitsDriver(dev) && dev.botId !== (await getBotIdentity()).botId;
1692
+ if (driverAdmitted) {
1693
+ // LOUD, not silent: the row is what lets a soak review tell an admitted driver DM from a
1694
+ // human DM. It rides `waitUntil` for the same reason the drop's row does — the route 200s
1695
+ // and wakes before the write would land.
1696
+ args.waitUntil(
1697
+ auditDmDriverAdmitted(dev, decision.dmKind).catch(() => {
1698
+ /* observability only — never block or change the decision */
1699
+ }),
1700
+ );
1701
+ console.log(
1702
+ `[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM ADMITTED — the named e2e driver identity (bot ${dev.botId} in team ${dev.teamId ?? "?"})`,
1703
+ );
1704
+ // …and then FALLS THROUGH to the ordinary DM path below. No separate wake: the whole point
1705
+ // is that the driver's DM takes the same route a human's does.
1706
+ } else if (dev.botId) {
1707
+ // The identity read and the audit write both ride `waitUntil`, so neither delays the 200.
1708
+ // That matters most on the echo leg: Slack re-delivers every reply the bot posts, so this
1709
+ // is the hottest path through the DM door, and it must stay as fast as the bare drop was.
1710
+ // `.catch` because an unhandled rejection inside `waitUntil` can fail the invocation, and
1711
+ // an observability write must never change what the route does.
1712
+ args.waitUntil(
1713
+ (async () => {
1714
+ const { botId: selfBotId } = await getBotIdentity();
1715
+ if (await auditDmBotAuthorDrop(dev, decision.dmKind, selfBotId)) {
1716
+ console.log(
1717
+ `[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM dropped — no turn (bot ${dev.botId} in team ${dev.teamId ?? "?"}; the DM door admits no bot author)`,
1718
+ );
1719
+ }
1720
+ })().catch(() => {
1721
+ /* observability only — never block or change the decision */
1722
+ }),
1723
+ );
1724
+ return OK();
1725
+ }
1726
+ // 🔴 MENTION-SIBLING SUPPRESSION, GROUP DM ONLY. Slack fires BOTH an `app_mention` and a
1727
+ // `message.mpim` for a group-DM message that @mentions the bot, and the mention branch below
1728
+ // diverts the app_mention into this same seat — so without this drop the room would answer
1729
+ // twice. It is the exact rule the channel wake path applies to a `message.channels` sibling,
1730
+ // applied here because the DM branch returns before ever reaching that code.
1731
+ //
1732
+ // …AND DELIBERATELY NOT FOR A 1:1, where it would be the more dangerous mistake. The mention
1733
+ // branch does not divert an `im` (see there), so nothing else owns a 1:1's mention-bearing
1734
+ // message; suppressing it would turn "@bot are you there?" in a DM into total silence. Whether
1735
+ // Slack even fires app_mention inside an `im` is unmeasured here, and a rule whose failure mode
1736
+ // is a DEAF DM is not the place to bet on a vendor behaviour nobody has checked. Fail-open on an
1737
+ // unresolvable bot identity (same posture as the channel path): an occasional double beats a drop.
1738
+ //
1739
+ // 🔴 AND NOT FOR AN EDIT, for the reason {@link mentionSiblingSuppressed} states in full: Slack
1740
+ // raises no `app_mention` for a `message_changed`, so there is no second event to prefer and
1741
+ // the drop is total silence. The rule is the SHARED helper rather than a copy — this leg and
1742
+ // the channel leg refused the same edit for the same wrong reason, and a second copy is how
1743
+ // the next exemption reaches only one of them.
1744
+ if (decision.dmKind === "mpim") {
1745
+ const { userId: dmBotUserId } = await getBotIdentity();
1746
+ if (
1747
+ mentionSiblingSuppressed({
1748
+ authoredText: dev.authoredText,
1749
+ ...(dev.subtype === undefined ? {} : { subtype: dev.subtype }),
1750
+ ...(dmBotUserId === undefined ? {} : { botUserId: dmBotUserId }),
1751
+ })
1752
+ ) {
1753
+ return OK();
1754
+ }
1755
+ }
1756
+ persistAttachments(dev); // durable upload registry (task #107) — so a delegated worker can read this DM's files
1757
+ // Same two-layer idempotency as every other wake candidate: a Slack RETRY must not double-wake.
1758
+ if (!(await notYetSeen(decision.env, dev))) return OK();
1759
+ // Serialized on the surface's OWN FIFO key — a DM's bursts must not interleave, and its queue is
1760
+ // never shared with a channel supervisor's or a thread's. The mention-carried divert below
1761
+ // enqueues on the SAME key, so a group DM's two legs can never run concurrently on one seat.
1762
+ args.waitUntil(
1763
+ fifo.enqueue(dmFifoKey(dev.channelId), async () => {
1764
+ await deliverWithStrandRetry(() => scheduleDmWake(dev, decision.dmKind, driverAdmitted));
1765
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke — diverted to the DM window seat`);
1766
+ }),
1767
+ );
1768
+ return OK();
1769
+ }
1770
+
1771
+ if (decision.kind === "shortcut") {
1772
+ // 🔴 A SHORTCUT IS AN ADDRESSED WAKE. Somebody pressed a named button aimed at this app, so
1773
+ // there is nothing to judge: it takes the `direct-mention` road, exactly like a single-window
1774
+ // @mention and a DM, and never the engagement or name-drop judge that exists to answer "was
1775
+ // the agent addressed?". What it does NOT bypass is any gate: the shortcut is mapped onto the
1776
+ // ordinary ParsedMessageEvent and handed to the SAME dispatchers, so the partition, seat,
1777
+ // window, spend gate, external-participant policy and wake-trust tier are all derived by the
1778
+ // code a mention already goes through. This branch adds a wake ORIGIN, not a plane.
1779
+ const sc = decision.shortcut;
1780
+ // A GLOBAL shortcut names no conversation, so the invoker's own DM window is where it lands —
1781
+ // the one room the person unambiguously chose by pressing a button with no message in scope.
1782
+ // A message shortcut lands in the conversation the message lives in.
1783
+ const shortcutChannelId = sc.kind === "message" ? sc.channelId : await openDirectMessageChannel(sc.userId);
1784
+ if (!shortcutChannelId) {
1785
+ // Fail-closed, and NAMED: this is the one degradation an operator can act on (grant
1786
+ // `im:write` and reinstall). Slack still gets its fast ack — a shortcut that wakes nothing
1787
+ // must not also look like a broken endpoint.
1788
+ console.log(
1789
+ `[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" from ${sc.userId}: no channel to wake in ` +
1790
+ `(a global shortcut opens the invoker's DM, which needs the im:write bot scope) — nothing woken`,
1791
+ );
1792
+ return new Response(null, { status: 200 });
1793
+ }
1794
+ // A message shortcut can be invoked anywhere the app can see a message, so two facts have to
1795
+ // be ASKED for — a `message_action` payload carries neither. A global shortcut skips the
1796
+ // lookup: its channel is the `im` we just opened, so both answers are already known.
1797
+ // • `channelType` picks the road (DM window vs channel supervisor). It is the same gap an
1798
+ // `app_mention` envelope has;
1799
+ // • `appCanRead` says whether this app is in the room at all, which gates the target
1800
+ // message's FILES below.
1801
+ const shortcutFacts =
1802
+ sc.kind === "global"
1803
+ ? { channelType: "im" as string | undefined, appCanRead: true }
1804
+ : await channelFactsFor(shortcutChannelId)
1805
+ .then((f) => ({ channelType: f.channelType, appCanRead: f.appCanRead === true }))
1806
+ .catch(() => ({ channelType: undefined as string | undefined, appCanRead: false }));
1807
+ const shortcutChannelType = shortcutFacts.channelType;
1808
+ if (!shortcutChannelType) {
1809
+ // 🔴 AN UNRESOLVED CHANNEL TYPE DROPS (OPE-1442). This used to fall through to the channel
1810
+ // road, which is wrong for the case that actually produces an unresolved type: a DM or a
1811
+ // group DM whose `conversations.info` blipped. A shortcut is ADDRESSED, so the channel road
1812
+ // carries `directMention` into the CHANNEL-SUPERVISOR session for this id — and a DM's
1813
+ // content would land in a channel-shaped seat on a transient API failure. There is no
1814
+ // reading of "I could not tell what room this is" that justifies routing it anyway.
1815
+ console.warn(
1816
+ `[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
1817
+ `conversations.info resolved no channel type — dropped (a shortcut never routes on an unresolved room)`,
1818
+ );
1819
+ return new Response(null, { status: 200 });
1820
+ }
1821
+ // 🔴 THE FILE GATE (OPE-1442). Slack delivers a `message_action` for a room the app is NOT in,
1822
+ // and the payload carries that message's `url_private`. Registering it would let the bot token
1823
+ // download bytes out of a room the agent's own channel reads refuse, through a door the
1824
+ // channel-read allowlist never sees. So the files ride only on a POSITIVE readability answer;
1825
+ // otherwise the references are dropped from the event as well, and the wake carries the
1826
+ // message's text alone. The wake itself still happens — the invoker is the principal and the
1827
+ // body is fenced, so the TEXT crossing is the ruled Slack-forward precedent. The BYTES are not.
1828
+ if (sc.kind === "message" && !shortcutFacts.appCanRead && (sc.message?.attachments?.length ?? 0) > 0) {
1829
+ console.warn(
1830
+ `[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
1831
+ `withheld ${sc.message?.attachments?.length} file reference(s) — the app cannot read that conversation`,
1832
+ );
1833
+ }
1834
+ const sev = shortcutMessageEvent(sc, shortcutChannelId, shortcutChannelType, now(), { admitAttachments: shortcutFacts.appCanRead });
1835
+ // 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1644 / OPE-1645). Both roads out of
1836
+ // this branch are fenced further down — a DM-shaped shortcut by `scheduleDmWake`, a channel
1837
+ // shortcut by `routeSupervisorEvent` — so this refuses the delivery before either, and before
1838
+ // the attachment registry is seeded from it. It is also the door the ORG-WIDE Grid payload
1839
+ // arrives at: such a payload names an enterprise and NO workspace, which `sev` carries as
1840
+ // `enterpriseId` with `teamId` absent, and which the fence now judges on the enterprise
1841
+ // instead of admitting as "names no tenant".
1842
+ //
1843
+ // IT SITS BELOW THE TWO LOOKUPS ABOVE, not above them, because it needs `sev` to write a row
1844
+ // keyed to a surface. Those lookups cost an unadmitted workspace one `conversations.open` or
1845
+ // `conversations.info` on OUR OWN bot token, which answers `user_not_found` /
1846
+ // `channel_not_found` for a foreign id and discloses nothing.
1847
+ if (slackFrontTenantFenceDeclined(sev, `${sc.kind}-shortcut`, "front-shortcut-branch")) return fencedAck(new Response(null, { status: 200 }));
1848
+ // Keyed on the click, not the message — a person may invoke the same shortcut on the same
1849
+ // message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
1850
+ if (!(await notYetSeenKey(shortcutWakeKey(sc), sev))) return new Response(null, { status: 200 });
1851
+ // BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
1852
+ // the in-memory one for this process's seat, the durable one for a delegated worker in
1853
+ // another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
1854
+ // fail-closed internal confirmation does; that residual click touches the LRU.)
1855
+ // They sit behind every gate above for the reason `parseSlackShortcutPayload`'s header states:
1856
+ // the in-memory map is a 1000-entry LRU shared with every real upload, so seeding it from
1857
+ // clicks that wake nothing evicts records `describe_image` needs.
1858
+ const seedAttachments = (): void => {
1859
+ registerAttachments(sev);
1860
+ persistAttachments(sev);
1861
+ };
1862
+ if (shortcutChannelType === "im" || shortcutChannelType === "mpim") {
1863
+ seedAttachments();
1864
+ args.waitUntil(
1865
+ fifo.enqueue(dmFifoKey(shortcutChannelId), async () => {
1866
+ await deliverWithStrandRetry(() => scheduleDmWake(sev, shortcutChannelType));
1867
+ console.log(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}": woke the ${shortcutChannelType} window ${shortcutChannelId}`);
1868
+ }),
1869
+ );
1870
+ return new Response(null, { status: 200 });
1871
+ }
1872
+ // A CHANNEL shortcut takes the addressed single-window road. On a deployment whose domain does
1873
+ // NOT run the unified window there is no road into a channel seat for anything but a native
1874
+ // @mention, which a shortcut cannot become — `scheduleUnownedWake` refuses a `directMention`
1875
+ // it cannot deliver with a loud warn rather than falling through to the silence-biased
1876
+ // name-drop judge, and that refusal is the honest outcome here too. See the runbook.
1877
+ scheduleUnownedWake(sev, `${sc.kind}-shortcut`, true, seedAttachments);
1878
+ return new Response(null, { status: 200 });
1879
+ }
1880
+
1881
+ if (decision.kind === "mention") {
1882
+ // An `app_mention`. Default: forward to eve's native slackChannel exactly as before —
1883
+ // byte-identical for every non-single-window domain. SINGLE-WINDOW (SINGLE-LEG divert): when
1884
+ // the shared seam says the unified window owns mentions on this POSITIVELY-internal channel,
1885
+ // we divert the app_mention EVENT ITSELF into the supervisor as an addressed direct-mention
1886
+ // wake and NEVER forward. ONE decision on ONE webhook: the `message.channels` sibling is
1887
+ // ALWAYS dropped (wake path below, unchanged from today), so nothing depends on it — there is
1888
+ // no separate suppress-leg / deliver-leg pair that could disagree across a webhook, TTL
1889
+ // eviction, or a cold instance, so neither a lost-mention nor a double-answer is possible.
1890
+ // It also handles a public-channel app_mention that fires NO sibling at all. Fail-closed: an
1891
+ // unparseable event, unresolvable partition, or any policy error ⇒ forward (today's path).
1892
+ const mev = parseMessageEvent(decision.env);
1893
+ if (mev) persistAttachments(mev); // durable upload registry (task #107) — before the owned/forward branch
1894
+ let owned = false;
1895
+ if (mev) {
1896
+ try {
1897
+ owned = await singleWindowOwnsMention(mev.channelId, resolvePartition(bindableFor(mev)).surfaceKey);
1898
+ } catch {
1899
+ owned = false; // fail-closed → the native mention path keeps ownership
1900
+ }
1901
+ }
1902
+ if (owned && mev) {
1903
+ // Idempotency on the app_mention's own event_id (a Slack retry must not double-wake).
1904
+ if (!(await notYetSeen(decision.env, mev))) return OK();
1905
+ console.log(
1906
+ `[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: single-window — diverting to the unified supervisor (native forward suppressed)`,
1907
+ );
1908
+ // The supervisor route lifts the app_mention/channel_type disqualifiers for this
1909
+ // directMention path (slack-events.ts routeSupervisorEvent) and re-checks its own
1910
+ // fail-closed internal confirmation — the SAME process-local TTL-cached conversations.info
1911
+ // the policy just populated, so the two reads on this one webhook are cache-coherent.
1912
+ scheduleUnownedWake(mev, "single-window-mention", true);
1913
+ return OK();
1914
+ }
1915
+ // 🔴 THE GROUP-DM MENTION LEG (OPE-351 stage 4). An @mention is how a human addresses the bot in
1916
+ // a multi-party room, so it is the MOST common way an mpim wakes — and left forwarding it would
1917
+ // have split the group DM in two: mentions answered by a native, un-wakeable, per-thread eve
1918
+ // session, everything else by the standing window this stage just gave the room. That split is
1919
+ // not a cosmetic inconsistency; the native session is exactly the seat OPE-351 exists to
1920
+ // abolish, so shipping the message leg alone would leave the headline defect (a seat that
1921
+ // delegates and can never be told the answer arrived) unfixed for the room's main entrance.
1922
+ //
1923
+ // IT COSTS A LOOKUP, because a raw `app_mention` envelope carries NO `channel_type` — the front
1924
+ // route genuinely cannot tell a group DM's mention from a channel's without asking Slack. The
1925
+ // read is the shared TTL-cached `conversations.info` (see slackChannelFactsFor for why that is
1926
+ // cheaper than the uncached read the forward it replaces triggers inside eve).
1927
+ //
1928
+ // SCOPED TO `mpim`, NOT TO "DM-FAMILY": an `im` mention still forwards, byte-identically to
1929
+ // stage 2. The 1:1 message leg does not suppress its mention-bearing sibling (see the dm-wake
1930
+ // branch), so diverting an `im` app_mention as well would double-answer every DM in which
1931
+ // somebody typed the bot's name. Fail-closed everywhere: an unresolved channelType (a workspace
1932
+ // without `mpim:read`) or any error ⇒ the native forward below, which is precisely pre-stage-4
1933
+ // behaviour. 🔴 THAT RESIDUAL FORWARD IS THE ONE REMAINING WAY A DM-FAMILY MESSAGE REACHES A
1934
+ // NATIVE-TOKEN SEAT (OPE-368 removed the deliberate `OE_DM_SEAT_ROTATION=0` route). Such a seat
1935
+ // has no window token, so `canDelegateFromSeat` is false, `agent` does not mount, and the memory
1936
+ // index says `"none"` — the honest pre-OPE-306 posture, not a dead affordance.
1937
+ if (mev) {
1938
+ const mentionChannelType = await channelFactsFor(mev.channelId)
1939
+ .then((f) => f.channelType)
1940
+ .catch(() => undefined);
1941
+ if (mentionChannelType === "mpim") {
1942
+ if (!(await notYetSeen(decision.env, mev))) return OK();
1943
+ console.log(
1944
+ `[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: group DM — diverting to the mpim window seat (native forward suppressed)`,
1945
+ );
1946
+ // The event's own `channel_type` is absent, so the divert is TOLD it is an mpim rather than
1947
+ // re-reading a field that was never there; `scheduleDmWake` stamps it onto the wake auth,
1948
+ // which is what makes `bindSlack` bind mode `mpim` on this leg exactly as on the other.
1949
+ args.waitUntil(
1950
+ fifo.enqueue(dmFifoKey(mev.channelId), async () => {
1951
+ await deliverWithStrandRetry(() => scheduleDmWake({ ...mev, channelType: "mpim" }, "mpim"));
1952
+ }),
1953
+ );
1954
+ return OK();
1955
+ }
1956
+ if (mentionChannelType === undefined) {
1957
+ // NAMED, not silent: this is the one degradation an operator can act on (grant `mpim:read`
1958
+ // and reinstall). Everything else about the mention is unchanged, so it is a log line, not
1959
+ // a failure — a channel mention reaches here too whenever conversations.info is unavailable.
1960
+ console.log(
1961
+ `[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: channel type unresolved (conversations.info unavailable; a group-DM mention needs the mpim:read scope) — forwarding to the native mention path`,
1962
+ );
1963
+ }
1964
+ }
1965
+ // ── THE EXTERNAL-PARTICIPANT POLICY, ON THE NATIVE FORWARD (OPE-507 / OPE-504) ──────────────
1966
+ //
1967
+ // 🔴 THIS IS THE LAST UNGATED SLACK WAKE, AND IT IS THE MOST LIKELY ONE. The three wake routes
1968
+ // in slack-events.ts and the DM seat above all read the policy — but an `app_mention` in an
1969
+ // ordinary NON-single-window channel takes none of them: it is handed to eve's native
1970
+ // `slackChannel`, which owns the turn end to end and has no seam the policy could reach. So the
1971
+ // gate has to sit HERE, above the forward, exactly where teams-front.ts puts its own.
1972
+ //
1973
+ // 🔴 IT COSTS A PROBE, SO IT IS SKIPPED WHEN IT CANNOT MATTER. Unlike the three routes, this
1974
+ // leg resolves no author facts of its own, so gating it means one `users.info` call. The inert
1975
+ // check runs first: on the shipped `taint` / `engage` pair no evidence is resolved at all and
1976
+ // this whole block is a single config read. The probe is TTL-cached per author on top of that.
1977
+ //
1978
+ // FAIL-OPEN on anything unreadable — an unparseable event, an unresolvable partition, a probe
1979
+ // that cannot answer. Every carriage leg in this plane fails open, and a policy error must not
1980
+ // be the thing that takes a workspace's mentions off the air.
1981
+ if (mev) {
1982
+ try {
1983
+ const p = resolvePartition(bindableFor(mev));
1984
+ const roomKey = domainKeyOf(p);
1985
+ if (!externalPolicyIsInert(slackExternalPolicyForRoom(roomKey))) {
1986
+ const internal = (await channelFactsFor(mev.channelId).catch(() => ({ internal: false }))).internal;
1987
+ const facts = await authorFactsFor(internal, mev.userId, mev.teamId);
1988
+ const wakeTrust = resolveWakeTrust({
1989
+ authorIsBot: Boolean(mev.botId),
1990
+ authorId: mev.userId,
1991
+ surfaceInternal: facts.surfaceInternal,
1992
+ channelKey: roomKey,
1993
+ });
1994
+ const verdict = await applySlackExternalPolicy(mev, p, { trust: wakeTrust, refuted: facts.refuted }, "native-mention-forward");
1995
+ if (verdict !== "proceed") {
1996
+ console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: suppressed — externalPolicy verdict "${verdict}" (native forward withheld)`);
1997
+ return OK();
1998
+ }
1999
+ }
2000
+ } catch {
2001
+ // fail-open → the native forward below keeps today's behaviour
2002
+ }
2003
+ }
2004
+ return forwardNative();
2005
+ }
2006
+
2007
+ if (decision.kind === "approval-click") {
2008
+ // 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1680). ABOVE the handler, because the
2009
+ // handler is the whole consequence: it loads the pending row, CASes a decision, tests the
2010
+ // clicker against the config-as-code admin roster and EXECUTES the approved act. A foreign
2011
+ // workspace's `U…` reaching that is the aliasing the fence exists for — most sharply on a
2012
+ // payload naming NO workspace (an org-wide Grid install), where `handleApprovalPayload` passes
2013
+ // no `clickContext` and `resolveApprovalClick` then reads the clicker's tenant off the CARD's
2014
+ // own room, i.e. off the ADMITTED workspace. See `slackInteractivityTenantFenceDeclined`.
2015
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "approval-click", "front-interactivity-branch")) {
2016
+ return fencedAck(new Response(null, { status: 200 }));
2017
+ }
2018
+ // Ours: handled entirely by our code (load → CAS decide → approver check → execute →
2019
+ // chat.update), never Eve's. Fast empty 200 — Slack interactivity wants a quick ack,
2020
+ // not a rendered reply.
2021
+ //
2022
+ // 🔴 `args.waitUntil` RIDES ALONG (OPE-1709), and it is the HELD one (see the route hold
2023
+ // above), so the deferred work is quiesced before the delivery's own promise resolves. What
2024
+ // it moves off this path is the session-grant release: an "Approve for session" click on a
2025
+ // fan-out decides and EXECUTES the sibling cards the grant already covers, and that loop has
2026
+ // no business inside Slack's three-second interactivity budget.
2027
+ if (await handleApproval(decision.payload, { waitUntil: args.waitUntil })) {
2028
+ return new Response(null, { status: 200 });
2029
+ }
2030
+ // Defensive: classification said "ours" but the handler disagreed (a mismatch, e.g. a
2031
+ // stale/foreign action_id shape) — fall through to forwarding so the click is never
2032
+ // silently swallowed.
2033
+ return forwardNative();
2034
+ }
2035
+
2036
+ if (decision.kind === "setkey-click") {
2037
+ // THE TENANT FENCE (OPE-1680), above the handler for the same reason the approval branch states.
2038
+ // The intake's owner check is over a bare per-workspace member id, so an unadmitted workspace's
2039
+ // click is a claim on somebody else's credential intake.
2040
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-click", "front-interactivity-branch")) {
2041
+ return fencedAck(new Response(null, { status: 200 }));
2042
+ }
2043
+ // Ours (credentials/intake.ts): verify the clicker owns the intake, then views.open the modal —
2044
+ // the trigger_id lives in the payload we already classified. Fast empty 200 (Slack wants a quick
2045
+ // interactivity ack; the modal opens via the views.open API call, not this response body).
2046
+ if (await handleSetKeyClick(decision.payload)) {
2047
+ return new Response(null, { status: 200 });
2048
+ }
2049
+ return forwardNative();
2050
+ }
2051
+
2052
+ if (decision.kind === "connect-click") {
2053
+ // THE TENANT FENCE (OPE-1680), above the handler. This branch MINTS AN OAUTH URL against a
2054
+ // connector, so an unadmitted workspace reaching it is a foreign tenant driving this
2055
+ // deployment's connector plane on our own credentials.
2056
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "connect-click", "front-interactivity-branch")) {
2057
+ return fencedAck(new Response(null, { status: 200 }));
2058
+ }
2059
+ // Ours (connections/connector-card.ts): re-verify standing host-side, then mint an ephemeral
2060
+ // OAuth URL / open the api-key intake. Fast empty 200 (Slack wants a quick interactivity ack;
2061
+ // the ephemeral URL / intake button is posted via a separate API call, not this response body).
2062
+ if (await handleConnectClick(decision.payload)) {
2063
+ return new Response(null, { status: 200 });
2064
+ }
2065
+ return forwardNative();
2066
+ }
2067
+
2068
+ if (decision.kind === "grant-click") {
2069
+ // THE TENANT FENCE (OPE-1680), above the handler — and above the vendor round trip below, which
2070
+ // is the most expensive thing an unadmitted workspace could make this deployment spend: the
2071
+ // approve leg opens a session under the OWNER's account (a consent probe, a token mint, an MCP
2072
+ // handshake and a `tools/list`) before it CASes.
2073
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "grant-click", "front-interactivity-branch")) {
2074
+ return fencedAck(new Response(null, { status: 200 }));
2075
+ }
2076
+ // Ours (access/credential-grant-card.ts): the OWNER answered a credential-grant ask in their
2077
+ // own DM. Core verifies the signed payload's `user.id` IS that ask's owner, then approves or
2078
+ // declines and answers the owner ephemerally through a separate API call.
2079
+ //
2080
+ // 🔴 ACK BEFORE THE VENDOR READ. An approval opens the owner's MCP connection before its
2081
+ // CAS — consent probe, token mint, handshake and `tools/list` — and a healthy cold catalogue
2082
+ // can cross Slack's three-second interactivity deadline. Classification and the tenant fence
2083
+ // have already proved this is our signed `oe_grant:` action, so the empty 200 goes out now and
2084
+ // the route's held `waitUntil` keeps exactly one resolution alive afterwards. The approve CAS
2085
+ // remains the exactly-once decider if Slack redelivers a click whose first ACK was lost.
2086
+ //
2087
+ // A handler disagreement is an invariant violation after classification, not a reason to
2088
+ // forward after acknowledging: eve does not own `oe_grant:` and cannot answer it. Keep it loud
2089
+ // without rejecting the host's background promise or changing the already-sent response.
2090
+ args.waitUntil(
2091
+ Promise.resolve()
2092
+ .then(async () => {
2093
+ if (!(await handleGrantCardClick(decision.payload))) {
2094
+ console.warn("[open-everywhere:slack-front] classified grant click was not claimed by the grant handler");
2095
+ }
2096
+ })
2097
+ .catch(() => {
2098
+ // The handler is total in production. Keep an impossible injected/runtime rejection
2099
+ // visible without putting vendor or credential-bearing error text into the route log.
2100
+ console.warn("[open-everywhere:slack-front] grant click failed after acknowledgement");
2101
+ }),
2102
+ );
2103
+ return new Response(null, { status: 200 });
2104
+ }
2105
+
2106
+ if (decision.kind === "input-click") {
2107
+ // THE TENANT FENCE (OPE-1680), above the handler, for the same reason every other interactivity
2108
+ // branch carries it: a tap RESUMES a parked turn, which is this deployment spending on a scope
2109
+ // an unadmitted workspace never had standing to decide.
2110
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "input-click", "front-interactivity-branch")) {
2111
+ return fencedAck(new Response(null, { status: 200 }));
2112
+ }
2113
+ // Ours (core channels/approval-card.ts): somebody tapped a bridged eve approval this
2114
+ // deployment posted onto a unified origin. The handler resolves the ticket, admits the tap
2115
+ // through the SAME floor the typed answer takes, and delivers `respond([{requestId, optionId}])`
2116
+ // to the parked WINDOW session.
2117
+ //
2118
+ // 🔴 THE DELIVERY GOES BY SESSION ID (OPE-1863). `args.from` is CHANNEL-LOCAL — on this route
2119
+ // it addresses `slack:<token>`, while the parked session lives at `unified:<token>` — so the
2120
+ // settle threw "the target session was not found" and every tap left the approval parked.
2121
+ // `args.attachSession` dispatches by durable session id from any channel, and
2122
+ // `approvalCardRespondVia` is the one road every surface binds. Bound HERE and injected because
2123
+ // the route handler is the only place eve hands out either primitive.
2124
+ //
2125
+ // The 200 goes out AFTER the settle, deliberately, and the cost is stated rather than hidden:
2126
+ // Slack expects this endpoint to answer in about three seconds, and a settle that runs longer
2127
+ // leaves the tapper waiting on whatever Slack does then. Acking first and settling in the
2128
+ // background would fix the delay and WIDEN the handler's own non-atomic window — see the
2129
+ // "THE LIMIT OF THAT" paragraph in `slack-approval-card.ts`, which also records that the
2130
+ // past-deadline behaviour is unmeasured here.
2131
+ const respondToApproval: ApprovalRespondFn = approvalCardRespondVia(args.attachSession);
2132
+ const tap = await handleApprovalCardClick(decision.payload, respondToApproval);
2133
+ console.log(`[open-everywhere:slack] oe_input: tap — ${tap.reason}`);
2134
+ // `handled:false` means the payload carried our prefix but not a shape the handler could read.
2135
+ // Forward it rather than swallow it, the same defensive shape the grant and setkey branches
2136
+ // take: a click this deployment cannot act on is better handed on than silently dropped.
2137
+ if (!tap.handled) return forwardNative();
2138
+ return new Response(null, { status: 200 });
2139
+ }
2140
+
2141
+ if (decision.kind === "setkey-submit") {
2142
+ // THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
2143
+ // this deployment's store, keyed to the intake the payload names. A `view_submission` carries
2144
+ // no `channel`, which is the case the fence's row writer keys to `SLACK_INTERACTIVITY_SURFACE`.
2145
+ // The refusal answers with the SAME empty 200 a successful close does, never the `errors`
2146
+ // object: an inline validation message would tell a foreign tenant it reached a live modal.
2147
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-submit", "front-interactivity-branch")) {
2148
+ return fencedAck(new Response(null, { status: 200 }));
2149
+ }
2150
+ // Ours: encrypt + store the pasted value host-side (the model never sees it). A view_submission
2151
+ // must answer with a JSON body — empty ⇒ Slack closes the modal (success), an `errors` object ⇒
2152
+ // inline validation (expired/used/blank). `handled:false` (a callback_id we don't own slipped
2153
+ // through) falls through to forwarding.
2154
+ const result = await handleSetKeyViewSubmission(decision.payload);
2155
+ if (result.handled) {
2156
+ return result.response
2157
+ ? new Response(JSON.stringify(result.response), { status: 200, headers: { "content-type": "application/json" } })
2158
+ : new Response(null, { status: 200 });
2159
+ }
2160
+ return forwardNative();
2161
+ }
2162
+
2163
+ if (decision.kind === "join") {
2164
+ // Channel-join intro. Only the BOT's own join is an invitation — a human joining a
2165
+ // channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
2166
+ // opposite polarity from the mention suppression below: unresolved identity means we
2167
+ // can't prove the joiner is us, so nobody wakes (vs. "an occasional double is better
2168
+ // than a dropped reply" for suppression).
2169
+ const jev = parseChannelJoinEvent(decision.env);
2170
+ if (!jev) return OK();
2171
+ const { userId: joinBotUserId } = await getBotIdentity();
2172
+ if (!joinBotUserId || jev.userId !== joinBotUserId) return OK();
2173
+
2174
+ // Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
2175
+ // double intro is exactly the annoyance the durable half exists to prevent.
2176
+ const joinKey = wakeKey(decision.env, jev.channelId, jev.ts);
2177
+ if (dedupe.seen(joinKey, now())) return OK();
2178
+ dedupe.mark(joinKey, now());
2179
+ try {
2180
+ const org = resolvePartition(bindableForJoin(jev)).org;
2181
+ if (!(await markEventSeen(org, joinKey, new Date(now()).toISOString()))) return OK();
2182
+ } catch {
2183
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
2184
+ }
2185
+
2186
+ const joinSend = send; // the same 0.31 `from(address).send(…)` adapter built above
2187
+ // A fresh join can't contend with an existing thread FIFO (its thread root is the
2188
+ // join notice itself), so no queueing — same reasoning as the name-drop wake.
2189
+ args.waitUntil(
2190
+ routeChannelJoinEvent(jev, liveChannelJoinDeps(joinSend)).then(logOutcome("channel-join", jev.channelId, jev.ts)),
2191
+ );
2192
+ return OK();
2193
+ }
2194
+
2195
+ if (decision.kind === "reaction") {
2196
+ // Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
2197
+ const rev = parseSlackReactionEvent(decision.env);
2198
+ if (!rev) return OK();
2199
+
2200
+ // 🔴 THE TENANT FENCE (OPE-1680), and this branch is on the fence's list for a reason the other
2201
+ // five do not share: the ✋ STOP arm reaches `args.attachSession`, eve's I/O-free CANCEL door,
2202
+ // so an unadmitted workspace's reaction can HALT a running session in the admitted one. The
2203
+ // rest of the branch also writes a durable dedupe row and records feedback against our own
2204
+ // replies. A reaction arrives on the Events API rather than the interactivity URL, so its
2205
+ // workspace is the one the PARSER already read off the envelope, and its Grid rung comes from
2206
+ // `slackEnterpriseIdFromEnvelope` — the same two fields `fenceDeclined` reads for a message.
2207
+ //
2208
+ // IT SITS BELOW THE PARSE, not above it: the parse is pure and free, and it is what lets the
2209
+ // refusal row name the channel the reaction happened in rather than a placeholder.
2210
+ const reactionEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
2211
+ if (
2212
+ slackInteractivityTenantFenceDeclined(
2213
+ {
2214
+ ...(rev.teamId === undefined ? {} : { teamId: rev.teamId }),
2215
+ ...(reactionEnterpriseId === undefined ? {} : { enterpriseId: reactionEnterpriseId }),
2216
+ channelId: rev.channelId,
2217
+ userId: rev.reactorId,
2218
+ },
2219
+ "reaction",
2220
+ "front-reaction-branch",
2221
+ )
2222
+ ) {
2223
+ return fencedAck(OK());
2224
+ }
2225
+
2226
+ // Same two-layer idempotency as every other wake candidate: a Slack RETRY (same
2227
+ // event_id) must not double-record a reaction. In-memory first, then the durable store
2228
+ // (a retry usually lands on a fresh serverless instance). Fail-open on an unresolvable
2229
+ // partition — dedupe prevents a double row, not a security hole.
2230
+ const rkey = wakeKey(decision.env, rev.channelId, rev.messageTs);
2231
+ if (dedupe.seen(rkey, now())) return OK();
2232
+ dedupe.mark(rkey, now());
2233
+ try {
2234
+ const org = resolvePartition(bindableForReaction(rev)).org;
2235
+ if (!(await markEventSeen(org, rkey, new Date(now()).toISOString()))) return OK();
2236
+ } catch {
2237
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
2238
+ }
2239
+
2240
+ // Resolve the bot identity once (cached) so the route can tell the bot's own reaction
2241
+ // from a human's and whether the target is the bot's own reply. A reaction can't
2242
+ // contend with any thread FIFO (it starts no turn), so no queueing — just fire it.
2243
+ // `args.attachSession` rides in for the ✋ STOP arm (OPE-418): it is eve's I/O-free cancel
2244
+ // door, and handing it in here is what lets a stop happen PRE-DISPATCH — no session mint,
2245
+ // no model call, and therefore no spend and no cap to be over.
2246
+ const identity = await getBotIdentity();
2247
+ args.waitUntil(
2248
+ routeReactionEvent(rev, liveReactionDeps(identity, args.attachSession)).then((o) =>
2249
+ console.log(`[open-everywhere:slack-front] reaction ${rev.channelId}/${rev.messageTs}: ${o.recorded ? "recorded" : "not recorded"} — ${o.reason}`),
2250
+ ),
2251
+ );
2252
+ return OK();
2253
+ }
2254
+
2255
+ // Wake path. Parse the message; a non-message or id-less envelope is a no-op 200.
2256
+ const ev = parseMessageEvent(decision.env);
2257
+ if (!ev) return OK();
2258
+ persistAttachments(ev); // durable upload registry (task #107) — so a delegated worker can read this message's files
2259
+
2260
+ // Mention-sibling suppression — see {@link mentionSiblingSuppressed} for the whole rule and for
2261
+ // why an EDIT is exempt from it.
2262
+ const { userId: botUserId } = await getBotIdentity();
2263
+ if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
2264
+ return OK();
2265
+ }
2266
+
2267
+ // Idempotency: a retried (or duplicated) event must not double-wake. In-memory first (free,
2268
+ // same-instance), then the durable store (cross-instance — a Slack retry usually lands on a
2269
+ // fresh serverless instance). One small statement, well inside Slack's 3s ack window.
2270
+ if (!(await notYetSeen(decision.env, ev))) return OK();
2271
+
2272
+ if (!ev.threadTs) {
2273
+ // TOP-LEVEL message: never an engagement wake — but possibly a name-drop candidate
2274
+ // (per-domain opt-in, fail-closed; slack-events.ts routeNameDropEvent), or — when
2275
+ // ambientSupervisor is on for this domain — a supervisor wake. The reply thread it may
2276
+ // start is rooted at this message's ts, so a name-drop can't contend with any existing
2277
+ // FIFO chain; the supervisor wake serializes on the supervisor token instead.
2278
+ scheduleUnownedWake(ev, "name-drop");
2279
+ return OK();
2280
+ }
2281
+
2282
+ // SEATED-CHANNEL short-circuit (task #100): where the channel has a standing seat, that seat owns
2283
+ // ALL in-thread traffic. A mention was diverted into it (no native per-thread session exists), so
2284
+ // an engaged thread's follow-up must ride the seat, NEVER the native per-thread token below
2285
+ // (routeMessageEvent), or it would wake a fresh empty session off the wrong token. The seat's own
2286
+ // deterministic stop leg (slack-events.ts routeSupervisorEvent) honours the engagement row a
2287
+ // diverted mention enrolls.
2288
+ //
2289
+ // 🔴 THE ENGAGEMENT READ IS BACK, AND OPE-1695 IS WHY. This branch used to fire only on a WINDOW
2290
+ // domain, which implied `ambientSupervisor`, so reaching the seat road was guaranteed. Every
2291
+ // channel is seated now, so in an ADDRESSED-ONLY room the same call would fall through to the
2292
+ // name-drop judge and answer nothing — the seat would reply to a mention and then go deaf on its
2293
+ // own thread. The read says which it is: an ENGAGED thread (the seat is holding this
2294
+ // conversation) takes the seat road whatever the chattiness flag says; a thread nobody engaged is
2295
+ // ordinary room traffic and still obeys the flag. Fail-closed: an unreadable row is NOT engaged.
2296
+ const p = resolvePartition(bindableFor(ev));
2297
+ if (seatedSurface(p.surfaceKey)) {
2298
+ const seatEngaged = await isThreadEngaged(p, ev.threadTs).catch(() => false);
2299
+ scheduleUnownedWake(ev, "in-thread-window", false, undefined, seatEngaged);
2300
+ return OK();
2301
+ }
2302
+
2303
+ // In-thread branch (one engagement read decides it): an ENGAGED thread takes the
2304
+ // delivery path (rolled-in reply); a NON-engaged in-thread message runs the trigger
2305
+ // check instead — a name-drop can now pull the bot into a live sub-thread it wasn't in
2306
+ // (slack-events.ts shouldNameDropWake no longer disqualifies inThread).
2307
+ // Fail-closed isThreadEngaged reads an error as NOT engaged → the hard-gated trigger
2308
+ // path, never a spurious delivery.
2309
+ const engaged = await isThreadEngaged(p, ev.threadTs);
2310
+ if (!engaged) {
2311
+ // Trigger-check path: like the top-level name-drop it roots on the existing thread
2312
+ // and can't contend with a FIFO chain, so it follows the same non-queued waitUntil
2313
+ // shape rather than the per-thread queue (the supervisor branch enqueues on its own key).
2314
+ scheduleUnownedWake(ev, "name-drop-thread");
2315
+ return OK();
2316
+ }
2317
+
2318
+ // Per-thread FIFO: serialize this thread's bursts; hand the promise to waitUntil so the
2319
+ // route returns 200 now while the wake continues in the background. (The FIFO is
2320
+ // deliberately per-instance: cross-instance ORDERING would need a lease/queue, and the
2321
+ // failure mode — two burst replies racing — is cosmetic, unlike the duplicate-delivery
2322
+ // problem the durable dedupe above closes.)
2323
+ const threadKey = slackContinuationToken(ev.channelId, ev.threadTs);
2324
+ args.waitUntil(
2325
+ fifo.enqueue(threadKey, () => routeMessageEvent(ev, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs!))),
2326
+ );
2327
+ return OK();
2328
+ }
2329
+ }
2330
+
2331
+ /**
2332
+ * Compose the engagement front route with a wrapped `slackChannel`. `inner` must have been
2333
+ * built with `route: INTERNAL_SLACK_ROUTE`. Returns the SAME channel value (preserving Eve's
2334
+ * internal `__kind`/adapter/receive) with the public front route prepended — so Slack's URL
2335
+ * hits us, and both routes share one channel name → one session namespace.
2336
+ */
2337
+ export function frontSlackChannel<C extends Channel<any, any, any>>(inner: C, opts: FrontOptions = {}): C {
2338
+ const innerPost = inner.routes.find(
2339
+ (r): r is HttpRouteDefinition<any> => r.method === "POST" && r.path === INTERNAL_SLACK_ROUTE,
2340
+ );
2341
+ if (!innerPost) {
2342
+ throw new Error(
2343
+ `frontSlackChannel: wrapped slackChannel must be built with route: "${INTERNAL_SLACK_ROUTE}" ` +
2344
+ `(found paths: ${inner.routes.map((r) => r.path).join(", ")})`,
2345
+ );
2346
+ }
2347
+ const front = buildFrontHandler(innerPost.handler, opts);
2348
+ return { ...inner, routes: [POST(PUBLIC_SLACK_ROUTE, front), ...inner.routes] };
2349
+ }