@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,248 @@
1
+ // Component 3 of the Slack engagement model (slack-engagement-spec.md): domain-scoped
2
+ // channel reads. "If a domain has 3 channels, the agent knows all 3 and can read them โ€”
3
+ // and NOTHING else." The Slack bot token is workspace-wide (it can read any channel the
4
+ // bot is in), so the boundary is OURS to enforce at the tool layer โ€” the same shape as the
5
+ // connector hard-gate (access/connector-scope.ts): broad credential underneath, narrow
6
+ // allowlist on top, FAIL CLOSED. The domain's channel list IS the allowlist.
7
+ //
8
+ // This module is the shared spine for the Slack read path (channels/channel-read.ts's SLACK
9
+ // adapter, behind the generic read verbs): resolve the allowed channel ids for the session, assert a
10
+ // requested channel is in them (or throw), and make the actual Slack Web API call. The call
11
+ // goes through OE's `callSlackWebApi` (channels/slack-web-api.ts) โ€” the ONE Slack Web API seam,
12
+ // which carries the 429/`Retry-After` backoff โ€” form-encoded with the same `SLACK_BOT_TOKEN` env
13
+ // fallback the Slack channel itself uses.
14
+
15
+ import { callSlackWebApi } from "./slack-web-api.js";
16
+ import {
17
+ auditChannelReadRefusal,
18
+ bindFrom,
19
+ getConfig,
20
+ domainForChannel,
21
+ resolvePartition,
22
+ refutedReadAuthorIds,
23
+ ChannelScopeError,
24
+ markNotApplied,
25
+ type BindableContext,
26
+ type ReadScopeAuditDeps,
27
+ type RenderedLine,
28
+ type WakeReadAddress,
29
+ domainRoster,
30
+ } from "@open-everywhere/core/agent/adapter-api.js";
31
+ // The SAME per-author membership probe the wake leg uses (OPE-474) โ€” see the adapter's own note on
32
+ // why this is reused rather than re-derived. The direction is safe: slack-events.ts does not import
33
+ // this module, so nothing here closes a cycle.
34
+ import { slackAuthorIsMember, type SlackMemberProbe } from "./slack-events.js";
35
+
36
+ // ChannelScopeError now LIVES in core (channels/channel-read.ts) โ€” it predates the generic read
37
+ // verbs and the platform-parameterised assertSurfaceAllowed throws the same class, so the ONE
38
+ // class identity must sit on the core side of the boundary. Re-exported here so the bundle's own
39
+ // callers/tests keep their import site.
40
+ export { ChannelScopeError };
41
+
42
+ // A fully-qualified surface key is `platform/workspace/surface`; for Slack the surface
43
+ // segment IS the channel id. Returns null for non-Slack keys (defensive โ€” domains can span
44
+ // platforms in principle, and we only gate Slack here).
45
+ function channelIdFromKey(key: string): string | null {
46
+ const parts = key.split("/");
47
+ if (parts.length !== 3 || parts[0] !== "slack") return null;
48
+ return parts[2] || null;
49
+ }
50
+
51
+ /**
52
+ * The Slack channel ids this session may read: every Slack channel in its explicit domain,
53
+ * PLUS its own current channel (always readable โ€” it's where the agent was engaged). A DM,
54
+ * an unassigned channel, or a criteria pseudo-domain (org/public/dm โ€” no channel list)
55
+ * yields just the current channel. Empty only when the surface isn't a Slack channel.
56
+ */
57
+ export function allowedChannelIds(ctx: BindableContext): { ids: Set<string>; domain?: string } {
58
+ const p = resolvePartition(ctx);
59
+ const ids = new Set<string>();
60
+ const own = channelIdFromKey(p.surfaceKey);
61
+ if (own) ids.add(own); // the agent can always read its own channel
62
+ const domain = domainForChannel(p.surfaceKey, getConfig().loadDomains());
63
+ if (domain) {
64
+ for (const key of domainRoster(domain)) {
65
+ const id = channelIdFromKey(key);
66
+ if (id) ids.add(id);
67
+ }
68
+ }
69
+ return { ids, domain: domain?.name };
70
+ }
71
+
72
+ /**
73
+ * Assert a requested channel id is readable by this session โ€” else AUDIT the refusal and throw
74
+ * (fail closed). The error names the domain so the model gets an actionable message, not a bare
75
+ * denial. The decision is unchanged from the silent version; only the row is new
76
+ * (core channels/read-scope-audit.ts).
77
+ */
78
+ export async function assertChannelAllowed(ctx: BindableContext, channelId: string, deps?: ReadScopeAuditDeps): Promise<void> {
79
+ const { ids, domain } = allowedChannelIds(ctx);
80
+ if (ids.has(channelId)) return;
81
+ const p = resolvePartition(ctx);
82
+ await auditChannelReadRefusal(
83
+ ctx,
84
+ { platform: "slack", surfaceId: channelId, callerKey: p.surfaceKey, callerDomain: domain, domains: getConfig().loadDomains() },
85
+ deps,
86
+ );
87
+ const scope = domain ? `domain "${domain}"` : "this channel's (no multi-channel domain)";
88
+ throw new ChannelScopeError(
89
+ `channel "${channelId}" is not in ${scope} read scope. The agent may only read channels in its ` +
90
+ `memory domain (memory/config/domains.ts). Add the channel to the domain's \`channels\` to grant it.`,
91
+ );
92
+ }
93
+
94
+ interface SlackReadResponse {
95
+ readonly ok: boolean;
96
+ readonly error?: string;
97
+ readonly [key: string]: unknown;
98
+ }
99
+
100
+ /**
101
+ * Low-level POST to a (read-only) Slack Web API method with the bot token, via OE's
102
+ * `callSlackWebApi` (form-encoded โ€” Slack's JSON support is partial: `conversations.replies`/
103
+ * `history` want form). Throws on a missing token or a non-ok Slack response so the tool
104
+ * surfaces a real error. The explicit env pre-check keeps the actionable "not set" message
105
+ * (the seam itself throws a terser "SLACK_BOT_TOKEN is required.").
106
+ */
107
+ export async function callSlackRead(operation: string, params: Record<string, string | number | boolean>): Promise<SlackReadResponse> {
108
+ if (!process.env.SLACK_BOT_TOKEN) {
109
+ throw new Error("SLACK_BOT_TOKEN is not set โ€” the Slack read tools need the bot token to call the Web API.");
110
+ }
111
+ // botToken omitted โ†’ the seam resolves it from SLACK_BOT_TOKEN (same fallback as before).
112
+ const json = (await callSlackWebApi({ botToken: undefined, operation, body: params })) as SlackReadResponse;
113
+ // DEFINITIVE (OPE-821): Slack answers HTTP 200 with `ok:false` when it READ the request and
114
+ // refused it, so the call had no effect. `react` is the caller that acts on this โ€” a refused
115
+ // `reactions.add` proves no reaction landed. A throw from the seam (transport, or a non-2xx)
116
+ // never reaches this line and stays AMBIGUOUS, which is the honest answer for a call that got no
117
+ // reply. Marking it here rather than in `react` keeps ONE statement of what Slack's `ok:false`
118
+ // means, for every method that goes through this transport.
119
+ if (!json.ok) throw markNotApplied(new Error(`Slack ${operation} failed: ${json.error ?? "unknown error"}`));
120
+ return json;
121
+ }
122
+
123
+ /** One Slack message rendered to a compact, model-friendly line. */
124
+ export interface RenderedMessage {
125
+ readonly author: string;
126
+ readonly ts: string;
127
+ readonly text: string;
128
+ /** The author's Slack USER id, for the OPE-474 non-member probe. Absent on a machine-authored or
129
+ * unattributable line. It never reaches the model โ€” the read verbs project author/ts/text only. */
130
+ readonly authorId?: string;
131
+ /** A machine author (`bot_id` present). Excluded from the probe: a bot is below a member but is
132
+ * not a person outside the org โ€” the same exclusion access/taint-signal.ts makes for `relay`. */
133
+ readonly bot?: boolean;
134
+ }
135
+
136
+ /**
137
+ * Normalize a raw Slack messages array (history/replies) to rendered lines, oldest-first.
138
+ *
139
+ * `author` IS UNCHANGED and stays the display string the model reads (`user`, else `bot_id`, else
140
+ * `(unknown)`) โ€” every render golden depends on it. OPE-474 adds the two GRADING fields beside it
141
+ * rather than reinterpreting it: `author` is chosen for legibility and cannot be parsed back into an
142
+ * identity, which is exactly why the read leg had nothing to probe before.
143
+ *
144
+ * `bot_id` WINS over `user` for the machine classification, even on the messages that carry both (an
145
+ * app posting as a bot user). That direction is fail-OPEN โ€” a machine is dropped from the probe and
146
+ * marks nothing โ€” which is the posture the whole author leg takes.
147
+ */
148
+ export function renderMessages(raw: unknown, opts: { oldestFirst?: boolean } = {}): RenderedMessage[] {
149
+ if (!Array.isArray(raw)) return [];
150
+ const out: RenderedMessage[] = [];
151
+ for (const m of raw as Array<Record<string, unknown>>) {
152
+ if (typeof m.subtype === "string" && m.subtype.length > 0) continue; // skip joins/edits/system
153
+ const text = typeof m.text === "string" ? m.text : "";
154
+ if (!text) continue;
155
+ const user = typeof m.user === "string" && m.user.length > 0 ? m.user : undefined;
156
+ const botId = typeof m.bot_id === "string" && m.bot_id.length > 0 ? m.bot_id : undefined;
157
+ out.push({
158
+ author: (m.user as string) ?? (m.bot_id as string) ?? "(unknown)",
159
+ ts: (m.ts as string) ?? "",
160
+ text,
161
+ ...(botId !== undefined ? { bot: true } : user !== undefined ? { authorId: user } : {}),
162
+ });
163
+ }
164
+ // conversations.history returns newest-first; replies returns oldest-first.
165
+ if (opts.oldestFirst === false) out.reverse();
166
+ return out;
167
+ }
168
+
169
+ // --- the SLACK read adapter (moved from core channels/channel-read.ts in OPE-114 stage 4) ---
170
+ // Registered by registerSlackProvider() via registerReadAdapter โ€” the generic read verbs
171
+ // (read_channel / read_thread) resolve it by platform string; behaviour is byte-identical to the
172
+ // in-core adapter (the EXACT calls the retired per-surface Slack read tools made).
173
+
174
+ const SLACK_CHANNEL_DEFAULT_LIMIT = 50;
175
+ const SLACK_THREAD_DEFAULT_LIMIT = 100;
176
+
177
+ /** The Slack read transport โ€” injectable for offline tests (defaults to the live callSlackRead). */
178
+ export type SlackReadCall = typeof callSlackRead;
179
+
180
+ // --- the OPE-474 non-member author leg ---------------------------------------------------------
181
+ //
182
+ // Slack is the one chat surface that can refute an author BY ITSELF: `users.info` reports
183
+ // `is_restricted` / `is_ultra_restricted` / `is_stranger`, which slack-events.ts's
184
+ // {@link slackAuthorIsMember} already folds into one TTL-cached boolean for the wake leg. The read
185
+ // leg reuses THAT probe verbatim โ€” a second implementation of "is this person a member?" is how a
186
+ // wake and a read come to disagree about the same author.
187
+ //
188
+ // ๐Ÿ”ด THE COST IS BOUNDED BY PEOPLE, NOT BY MESSAGES (Tommy, 2026-08-14: no up-front member list).
189
+ // `refutedReadAuthorIds` probes the page's DISTINCT authors, and the probe is TTL-cached across
190
+ // calls, so a 200-message read of a busy channel is a handful of authors and mostly cache hits. An
191
+ // up-front roster would instead size with the WORKSPACE and go stale between reads.
192
+ //
193
+ // THE THREE-WAY VERDICT IS KEPT THREE-WAY. `slackAuthorIsMember` answers `true` (member), `false`
194
+ // (positively refuted) or `undefined` (lookup unavailable), and only `false` refutes. Collapsing
195
+ // `undefined` into a refusal would mark every read on a deployment whose bot token cannot call
196
+ // `users.info` โ€” an always-on bit, arriving through this door.
197
+
198
+ /** The Slack team id + the register's arming key for this session's own workspace. Both come from
199
+ * the BINDING, never from inspecting an author id: the platform and the tenant are surface facts. */
200
+ function slackReadTenant(ctx: BindableContext): { teamId: string; tenantKey: string } {
201
+ const p = resolvePartition(ctx);
202
+ return { teamId: p.workspace, tenantKey: p.workspaceKey };
203
+ }
204
+
205
+ /** Slack read via OE's callSlackWebApi โ€” domain-allowlist-gated, rendered oldest-first. */
206
+ export const slackReadAdapter = {
207
+ platform: "slack" as const,
208
+ /**
209
+ * OPE-929 โ€” Slack's own spelling of "which room and which thread is this turn in".
210
+ *
211
+ * The CONTAINER is the channel the post-back target names. The THREAD ROOT is `threadTs`, which
212
+ * eve's Slack adapter falls back to the triggering message's own ts โ€” so a bare channel mention
213
+ * carries one too, and reading "this thread" on such a turn reads the one-message thread the
214
+ * mention started, which is what a person means by it.
215
+ */
216
+ wakeAddress(ctx: BindableContext): WakeReadAddress | undefined {
217
+ const b = bindFrom(ctx);
218
+ const container = typeof b.target?.["channelId"] === "string" ? (b.target["channelId"] as string) : undefined;
219
+ if (!container) return undefined;
220
+ return { container, ...(b.threadTs ? { threadRoot: b.threadTs } : {}) };
221
+ },
222
+ async readChannel(ctx: BindableContext, channel: string, limit?: number, call: SlackReadCall = callSlackRead): Promise<readonly RenderedLine[]> {
223
+ await assertChannelAllowed(ctx, channel);
224
+ const res = await call("conversations.history", { channel, limit: limit ?? SLACK_CHANNEL_DEFAULT_LIMIT, inclusive: true });
225
+ return renderMessages(res.messages, { oldestFirst: false });
226
+ },
227
+ async readThread(ctx: BindableContext, channel: string, threadTs: string, limit?: number, call: SlackReadCall = callSlackRead): Promise<readonly RenderedLine[]> {
228
+ await assertChannelAllowed(ctx, channel);
229
+ const res = await call("conversations.replies", { channel, ts: threadTs, limit: limit ?? SLACK_THREAD_DEFAULT_LIMIT });
230
+ return renderMessages(res.messages, { oldestFirst: true });
231
+ },
232
+ /** OPE-474 / OPE-534. Both legs run: Slack's own `users.info` refutation, and the operator's
233
+ * author-trust register (which most Slack deployments leave unarmed, so it costs nothing there).
234
+ * It answers with the refuted AUTHOR IDS, because core reads the one answer twice โ€” the turn mark
235
+ * and the `withhold` body replacement. `probe` is injected ONLY so a test can drive the grading
236
+ * without a live Slack โ€” every live call defaults. */
237
+ async refutedAuthorIds(ctx: BindableContext, lines: readonly RenderedLine[], probe: SlackMemberProbe = slackAuthorIsMember): Promise<readonly string[]> {
238
+ const { teamId, tenantKey } = slackReadTenant(ctx);
239
+ return await refutedReadAuthorIds(lines, tenantKey, {
240
+ surfaceRefuted: async (authorId) => {
241
+ const member = await probe(authorId, teamId);
242
+ return member === undefined ? undefined : !member; // undefined stays "cannot check"
243
+ },
244
+ // No `memberships` leg: Slack has no group-rung key a message carries, so an armed register
245
+ // matches this surface on principal keys alone (`slack/<team>/<user>`).
246
+ });
247
+ },
248
+ };
@@ -0,0 +1,107 @@
1
+ // THE ROUTE HOLD (OPE-1648) โ€” "keep this best-effort write alive until it lands".
2
+ //
3
+ // WHAT IT CLOSES. Every Slack door answers its 200 IMMEDIATELY: Slack retries a delivery it does
4
+ // not see acked inside three seconds, and eve's own dispatcher returns the response and then
5
+ // finishes the turn in the route's `waitUntil`. On Vercel the serverless invocation can freeze the
6
+ // moment nothing is holding it, so a `void`-ed audit write races that freeze and the row never
7
+ // lands. Those rows are not decoration: `slack.ts`'s `auditWakeDropped` header states that the
8
+ // webhook function's console output never reaches `vercel logs`, so the row is the ONLY durable
9
+ // signal that a wake was refused.
10
+ //
11
+ // ๐Ÿ”ด A LATE `waitUntil` CALL IS NOT A HOLD, WHICH IS WHY THIS FILE EXISTS AT ALL. eve collects the
12
+ // route's background tasks in an array and, the moment the handler resolves, hands the platform
13
+ // `Promise.allSettled(tasks)` (`internal/nitro/routes/channel-dispatch.js`, `flushBackgroundTasks`).
14
+ // `allSettled` reads that array ONCE, synchronously โ€” so a promise pushed after the handler returned
15
+ // is in a list nobody is awaiting, and calling `waitUntil` from a wake route that runs after the 200
16
+ // buys exactly nothing. Almost every write this module holds is in that position.
17
+ //
18
+ // SO THE HOLD RIDES THE WORK THE ROUTE ALREADY REGISTERED. Every one of these writes happens inside
19
+ // a promise the front route handed to `waitUntil` while it was still running โ€” eve's own turn
20
+ // dispatch on the native forward, a FIFO wake, a name-drop. `slack-front.ts` wraps each of those in
21
+ // {@link routeHoldQuiesced} before passing it on, so the promise the platform waits on settles only
22
+ // after the delivery's tracked writes have. Nothing extra is registered: a delivery that queues no
23
+ // background work (a 401, a dropped sibling event) hands the platform nothing, exactly as before.
24
+ //
25
+ // WHY AsyncLocalStorage AND NOT A PARAMETER. The writers do not hold the route args: the mention
26
+ // door's row is written inside eve's `onAppMention` hook, several frames below a hand-off that
27
+ // discards them, and the engagement doors' rows are written from route functions that take deps.
28
+ // Threading a serverless-runtime concern through every wake route's signature to reach them would
29
+ // put it in the type of each one. The front route stows it once instead, for the extent of ONE
30
+ // delivery. One process serves many Slack deliveries at once, and a module global would mix
31
+ // delivery A's writes into delivery B's hold. Same primitive and same reasoning as
32
+ // slack-identity.ts's forwarded-envelope carrier, which is proven to survive the hand-off into eve's
33
+ // handler (slack-enterprise-rung-test.ts ยง2).
34
+ //
35
+ // ๐Ÿ”ด IT NEVER CHANGES A DECISION, AND IT NEVER THROWS. A hold is observability plumbing: every
36
+ // tracked write already swallows its own failures, and the quiesced promise settles rather than
37
+ // rejects, so a broken sink can never turn a drop into a turn.
38
+
39
+ import { AsyncLocalStorage } from "node:async_hooks";
40
+
41
+ /** What one delivery is still waiting on. */
42
+ interface RouteHold {
43
+ /** Work registered since the last drain round. Swapped out wholesale each round. */
44
+ pending: Promise<unknown>[];
45
+ }
46
+
47
+ /**
48
+ * How many drain rounds one quiesce gets. Each round awaits everything registered so far, so a round
49
+ * only follows work that another round's work started. Ten is far past any real chain (a wake queues
50
+ * one audit write, which queues nothing) and it bounds a writer that re-registers itself forever โ€”
51
+ * which would otherwise hold the invocation open until the platform killed it.
52
+ */
53
+ const MAX_DRAIN_ROUNDS = 10;
54
+
55
+ const routeHold = new AsyncLocalStorage<RouteHold>();
56
+
57
+ /**
58
+ * Run `fn` as ONE delivery, so the writes below it can be tracked.
59
+ *
60
+ * ONE caller: `slack-front.ts`'s public route handler, wrapping its whole body. Do not wrap anything
61
+ * else; the value means "the delivery in flight right now", and a second writer would make that
62
+ * false.
63
+ */
64
+ export function withSlackRouteHold<T>(fn: () => T): T {
65
+ return routeHold.run({ pending: [] }, fn);
66
+ }
67
+
68
+ /**
69
+ * Track a best-effort write on the delivery in flight, and hand it back so a caller that can also
70
+ * await it may.
71
+ *
72
+ * `work` MUST already swallow its own failures โ€” every caller here is an audit write whose contract
73
+ * is "never throws, never changes the decision". Nothing is caught for it: swallowing a rejection
74
+ * here would let a genuinely broken writer look held.
75
+ *
76
+ * Outside a delivery (a unit test calling a door directly, a future non-route caller) there is
77
+ * nothing to track and the work comes straight back โ€” so `await holdRouteWork(...)` is correct in
78
+ * both worlds and a caller never has to ask which one it is in.
79
+ */
80
+ export function holdRouteWork<T>(work: Promise<T>): Promise<T> {
81
+ routeHold.getStore()?.pending.push(work);
82
+ return work;
83
+ }
84
+
85
+ /**
86
+ * The promise to hand the platform in place of `work`: it settles when `work` has settled AND every
87
+ * write {@link holdRouteWork} tracked has settled too.
88
+ *
89
+ * ๐Ÿ”ด CALL IT SYNCHRONOUSLY, INSIDE THE DELIVERY โ€” it reads the delivery's tracked set at call time
90
+ * and closes over it, so a call from outside one silently degrades to "just `work`". `slack-front.ts`
91
+ * calls it from the `waitUntil` it hands down, which is exactly that position.
92
+ *
93
+ * It never rejects: a rejected `work` is the caller's own business (eve already logs its background
94
+ * task failures), and a hold that rejected would turn an observability write into a route error.
95
+ */
96
+ export function routeHoldQuiesced(work: Promise<unknown>): Promise<void> {
97
+ const hold = routeHold.getStore();
98
+ return (async () => {
99
+ await Promise.allSettled([work]);
100
+ if (!hold) return;
101
+ for (let round = 0; round < MAX_DRAIN_ROUNDS && hold.pending.length > 0; round++) {
102
+ const batch = hold.pending;
103
+ hold.pending = [];
104
+ await Promise.allSettled(batch);
105
+ }
106
+ })();
107
+ }