@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,275 @@
1
+ // THE SLACK RETURN LEG FOR A BRIDGED APPROVAL CARD (OPE-1720) — what makes the button the unified
2
+ // post arm now renders actually settle the approval it names.
3
+ //
4
+ // WHY THIS EXISTS RATHER THAN EVE'S OWN HITL ROUTE. eve's Slack channel has an interactivity seam for
5
+ // its own `eve_input:` cards, and `slack-input-response.ts` is OE's admission on it. That seam cannot
6
+ // serve this card, and the reason is structural rather than a not-yet: eve's own handler stores the
7
+ // card it minted in the SLACK channel's state (`defaults.js` writes `state.pendingApprovalCards`) and
8
+ // resolves the click against that channel's session address. A DM seat's parked request lives on the
9
+ // UNIFIED window — another channel, another session, another state bag — so a click on a card OE
10
+ // posted there has nothing for eve's route to resolve. It is OE's card, so it is OE's click.
11
+ //
12
+ // 🔴 THE SAME ADMISSION, THOUGH. `admitInputResponse` (core approvals/input-response-floor.ts) is the
13
+ // ONE decision both HITL answer doors take, and `ingressSpendGate` with door `"answer"` is the same
14
+ // spend floor `admitSlackInputResponse` consults FIRST for the same reason: a tap RESUMES a parked
15
+ // turn, which is a decision to spend more on a scope nobody asked whether it could afford. This
16
+ // module adds no floor of its own — it resolves the click, asks those two, and delivers.
17
+ //
18
+ // 🔴 A REFUSAL LEAVES THE CARD LIVE. Nothing here consumes a row on any outcome but a delivered
19
+ // answer: the approval stays parked, the card stays tappable for somebody who may answer it, and the
20
+ // question stays answerable IN WORDS through the text leg (`question-bridge.ts`), which is untouched.
21
+ // That is the same "a refusal is not a drop" posture the text leg holds.
22
+
23
+ import {
24
+ admitInputResponse,
25
+ APPROVAL_BUTTON_LABELS,
26
+ APPROVAL_CARD_REFUSED_AUDIT_ACTION as CORE_APPROVAL_CARD_REFUSED_AUDIT_ACTION,
27
+ APPROVAL_CARD_SETTLED_AUDIT_ACTION as CORE_APPROVAL_CARD_SETTLED_AUDIT_ACTION,
28
+ auditApprovalCardTap,
29
+ deploymentOrg,
30
+ forgetApprovalCardTicket,
31
+ forgetBridgedQuestion,
32
+ getOperationalStore,
33
+ ingressSpendGate,
34
+ parseApprovalClickId,
35
+ readBridgedQuestion,
36
+ readApprovalCardTicket,
37
+ resolveApprovalCardClick,
38
+ resolvePartition,
39
+ unifiedThreadRef,
40
+ SLACK_SPEND_BADGES,
41
+ type ApprovalCardRespondFn,
42
+ type BindableContext,
43
+ } from "@open-everywhere/core/agent/adapter-api.js";
44
+ import { slackClickerIsGuest, slackForm } from "./slack-events.js";
45
+
46
+ const log = (m: string): void => console.log(`[open-everywhere:slack] approval card: ${m}`);
47
+
48
+ /** The `input_response.routed` sibling for a tap: an approval settled through a BUTTON rather than a
49
+ * typed reply. RE-EXPORTED FROM CORE, never re-spelled here — one action name for every surface, so
50
+ * a scorer that joins on the row cannot under-count one of them (OPE-1856). */
51
+ export const APPROVAL_CARD_SETTLED_AUDIT_ACTION = CORE_APPROVAL_CARD_SETTLED_AUDIT_ACTION;
52
+ /** A verified tap that settled nothing, carrying WHICH refusal it was. Core-owned, as above. */
53
+ export const APPROVAL_CARD_REFUSED_AUDIT_ACTION = CORE_APPROVAL_CARD_REFUSED_AUDIT_ACTION;
54
+
55
+ /** The settle delivery this leg needs. CORE-OWNED and bound by the front route through
56
+ * `approvalCardRespondVia(args.attachSession)` — re-exported here, never re-spelled, so the address
57
+ * the answer takes cannot drift per surface (OPE-1863). */
58
+ export type ApprovalRespondFn = ApprovalCardRespondFn;
59
+
60
+ /** What one verified tap did, for the front route's log line. Never thrown — this endpoint answers a
61
+ * person who is waiting, so every path returns. */
62
+ export interface ApprovalCardClickOutcome {
63
+ readonly handled: boolean;
64
+ readonly reason: string;
65
+ /** A line to show the tapper alone, or undefined when the outcome needs no note. */
66
+ readonly notice?: string;
67
+ }
68
+
69
+ /** The `block_actions` fields this leg reads, narrowed so nothing here needs Slack's own types. */
70
+ interface ParsedApprovalClick {
71
+ readonly actionId: string;
72
+ readonly channelId: string;
73
+ readonly userId: string;
74
+ readonly teamId?: string;
75
+ }
76
+
77
+ /**
78
+ * Read the one `oe_input:` action out of a verified `block_actions` payload. `undefined` when the
79
+ * payload is not one of ours or is missing a field the decision needs — the front route then leaves
80
+ * it alone rather than half-handling it.
81
+ *
82
+ * THE FIRST `oe_input:` ACTION WINS and the rest are ignored: Slack sends one action per click, and a
83
+ * payload carrying two of ours is not a shape this host mints.
84
+ */
85
+ export function parseSlackApprovalClick(payload: unknown): ParsedApprovalClick | undefined {
86
+ if (typeof payload !== "object" || payload === null) return undefined;
87
+ const p = payload as Record<string, unknown>;
88
+ if (!Array.isArray(p.actions)) return undefined;
89
+ let actionId: string | undefined;
90
+ for (const a of p.actions as readonly unknown[]) {
91
+ const id = typeof a === "object" && a !== null ? (a as Record<string, unknown>).action_id : undefined;
92
+ if (typeof id === "string" && parseApprovalClickId(id) !== undefined) {
93
+ actionId = id;
94
+ break;
95
+ }
96
+ }
97
+ if (actionId === undefined) return undefined;
98
+ const channel = typeof p.channel === "object" && p.channel !== null ? (p.channel as Record<string, unknown>).id : undefined;
99
+ const user = typeof p.user === "object" && p.user !== null ? (p.user as Record<string, unknown>).id : undefined;
100
+ const team = typeof p.team === "object" && p.team !== null ? (p.team as Record<string, unknown>).id : undefined;
101
+ if (typeof channel !== "string" || channel.length === 0) return undefined;
102
+ if (typeof user !== "string" || user.length === 0) return undefined;
103
+ return { actionId, channelId: channel, userId: user, ...(typeof team === "string" && team.length > 0 ? { teamId: team } : {}) };
104
+ }
105
+
106
+ /** The bindable a partition resolve needs from a raw interactivity payload. The team id is the ONE
107
+ * attribute that matters here, and it is read off the wire rather than through a placeholder — the
108
+ * same reading, and the same reason, as `slackAuthTeamId` in slack-input-response.ts. */
109
+ function bindableForClick(click: ParsedApprovalClick): BindableContext {
110
+ return {
111
+ channel: { kind: "slack" },
112
+ session: { auth: { current: { attributes: { ...(click.teamId ? { team_id: click.teamId } : {}), channel_id: click.channelId } } } },
113
+ } as unknown as BindableContext;
114
+ }
115
+
116
+ /**
117
+ * The host copy that rides the delivered answer's `text` — the LABEL THIS DEPLOYMENT PUT ON THE
118
+ * BUTTON, read back out of the same allowlist that minted it, so the transcript says what the person
119
+ * actually chose. A tap carries no words, and writing the person's prose for them is not an option.
120
+ * The fallback capitalises the eve-minted option id, which can only be reached by a row written
121
+ * before an id left the allowlist; the DECISION is never this value, because eve settles on
122
+ * `optionId` alone.
123
+ */
124
+ function approvalAnswerText(optionId: string): string {
125
+ return APPROVAL_BUTTON_LABELS.get(optionId.toLowerCase())?.label ?? optionId.charAt(0).toUpperCase() + optionId.slice(1);
126
+ }
127
+
128
+ /** Show the tapper alone why nothing happened. EPHEMERAL, never a message in the room: the card is
129
+ * still live for somebody who may answer it, and a public "you may not answer" note tells the room
130
+ * nothing it needs. Best-effort — a failed notice never changes the verdict. */
131
+ async function notifyTapper(channelId: string, userId: string, text: string): Promise<void> {
132
+ try {
133
+ await slackForm("chat.postEphemeral", new URLSearchParams({ channel: channelId, user: userId, text }).toString());
134
+ } catch (err) {
135
+ log(`refusal notice failed to post: ${String(err)}`);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Handle one verified `oe_input:` click: resolve it, admit it, deliver the answer to eve, consume the
141
+ * rows. NEVER THROWS — the front route has already 200'd the interaction, and an unhandled throw here
142
+ * would only lose the log line.
143
+ *
144
+ * 🔴 THE ANSWER TEXT IS HOST COPY, and it must be. A tap carries no words, so a transcript that read
145
+ * the button as the person's own prose would be inventing their words. What rides is the host label
146
+ * this deployment put on that button (`Approve` / `Cancel`), which is what the person actually chose,
147
+ * and the `optionId` beside it is what eve settles on — `resolveApprovalOutcome` reads `optionId`
148
+ * ALONE, so the text is transcript only and never the decision.
149
+ *
150
+ * 🔴 CONSUME-ONCE, AND BOTH ROWS. The ticket goes so a second tap on the same card cannot answer a
151
+ * request eve has already settled; the outstanding-question row goes so the room's NEXT message is an
152
+ * ordinary wake again rather than a second answer to a settled request. Both happen only after eve
153
+ * accepted the delivery, exactly as the text leg orders them.
154
+ *
155
+ * THE LIMIT OF THAT, stated rather than implied: the read and the delete are not one atomic step, and
156
+ * the delete runs only AFTER `respond` returns, so anything arriving while the first tap is still in
157
+ * flight resolves against rows that are both still present. Two such deliveries each spend an
158
+ * admission, each call `respond`, and each can write a settled audit row — so one approval can read
159
+ * as decided by two taps. It is not a second DECISION: both had to be admitted and both had to name
160
+ * an option the request offered, and eve's own resolution refuses the second delivery for a request
161
+ * it already settled. A delivery that arrives after the first settle RETURNED is the covered case —
162
+ * the ticket is gone and it resolves `no-ticket`.
163
+ *
164
+ * 🔴 A SECOND DELIVERY IS MOST LIKELY TO BE SLACK'S OWN, NOT A SECOND PERSON, and this handler does
165
+ * not ack until the settle returns. Slack expects an interactivity endpoint to answer within about
166
+ * three seconds; what it does past that — show the tapper a connection error, redeliver, or both —
167
+ * is VENDOR BEHAVIOUR THIS TREE HAS NOT MEASURED, so do not read the bound above as covering it.
168
+ * Acking first and settling in the background would remove the delay and widen exactly the window
169
+ * this paragraph describes; the ordering here takes the narrower window instead. Whoever measures
170
+ * the real behaviour should revisit that trade, with the measurement written down.
171
+ */
172
+ export async function handleSlackApprovalCardClick(payload: unknown, respond: ApprovalRespondFn): Promise<ApprovalCardClickOutcome> {
173
+ // 🔴 HOISTED OUT OF THE `try` SO THE `catch` CAN STAMP ITS ROW (OPE-1856). A throw anywhere below
174
+ // — including from the partition resolve itself — must still leave a row saying this deployment
175
+ // took a tap and settled nothing; core's auditor accepts an absent partition for that case.
176
+ let tapper = "";
177
+ let auditPartition: ReturnType<typeof resolvePartition> | undefined;
178
+ try {
179
+ const click = parseSlackApprovalClick(payload);
180
+ if (click === undefined) return { handled: false, reason: "not an oe_input: click" };
181
+ const parsed = parseApprovalClickId(click.actionId);
182
+ if (parsed === undefined) return { handled: false, reason: "oe_input: action id did not parse" };
183
+ tapper = click.userId;
184
+ const org = deploymentOrg();
185
+ const store = getOperationalStore();
186
+ const partition = resolvePartition(bindableForClick(click));
187
+ auditPartition = partition;
188
+ // THE SPEND GATE FIRST, ahead of the identity admission — the same order and the same reason
189
+ // `admitSlackInputResponse` takes: over budget outranks identity, and a decline must not spend a
190
+ // roster lookup. Its refusal copy is its own; a decline here leaves the request PENDING.
191
+ const spend = await ingressSpendGate({
192
+ bindable: bindableForClick(click),
193
+ door: "answer",
194
+ principalId: click.userId,
195
+ workspaceRef: click.teamId ?? null,
196
+ badges: SLACK_SPEND_BADGES,
197
+ post: async (text) => {
198
+ await notifyTapper(click.channelId, click.userId, text);
199
+ },
200
+ });
201
+ if (spend === "declined") {
202
+ // AUDITED, not only logged (OPE-1856): a decline is this deployment refusing a person's tap,
203
+ // and a refusal nobody can count is indistinguishable from a tap that never arrived.
204
+ await auditApprovalCardTap({ platform: "slack", principalId: click.userId, partition, outcome: { kind: "refused", reason: "spend-declined" } });
205
+ log(`tap declined by the spend gate — the approval stays parked`);
206
+ return { handled: true, reason: "spend-declined" };
207
+ }
208
+ // The room the tap came from, in the SAME vocabulary the card's ticket stored — derived through
209
+ // core's own codec rather than spelled here, so the two sides cannot drift on the key's shape.
210
+ const containerKey = unifiedThreadRef({ platform: "slack", channelId: click.channelId }).channelKey;
211
+ const routing = await resolveApprovalCardClick(
212
+ { ticket: parsed.ticket, optionId: parsed.optionId, containerKey },
213
+ {
214
+ read: (ticket) => readApprovalCardTicket(store, org, ticket),
215
+ // The join with the TEXT leg — see the dep's own note. Fail-CLOSED on an unreadable row: a
216
+ // store this leg cannot read cannot say the request is still parked, and settling on that
217
+ // guess is the one direction that spends a governed decision twice.
218
+ stillOutstanding: async (bareToken, requestId) => {
219
+ const outstanding = await readBridgedQuestion(store, org, bareToken).catch(() => undefined);
220
+ return outstanding?.requestId === requestId;
221
+ },
222
+ admit: async () => {
223
+ // Leg B, the same positive guest verdict both other clicker floors use, and inert on an
224
+ // unreadable probe (`undefined` never refuses).
225
+ const answererIsGuest = await slackClickerIsGuest(click.userId, click.teamId).catch(() => undefined);
226
+ const verdict = await admitInputResponse({
227
+ org: partition.org,
228
+ platform: partition.platform,
229
+ workspace: partition.workspace,
230
+ surface: partition.surface,
231
+ surfaceKey: partition.surfaceKey,
232
+ principalId: click.userId,
233
+ responses: 1,
234
+ ...(answererIsGuest === undefined ? {} : { answererIsGuest }),
235
+ });
236
+ return { ok: verdict.ok, floor: verdict.ok ? verdict.floor : "", notice: verdict.ok ? "" : verdict.notice };
237
+ },
238
+ },
239
+ );
240
+ if (routing.kind === "refuse") {
241
+ await auditApprovalCardTap({ platform: "slack", principalId: click.userId, partition, outcome: { kind: "refused", reason: routing.reason } });
242
+ if (routing.notice !== undefined) await notifyTapper(click.channelId, click.userId, routing.notice);
243
+ log(`tap settled nothing (${routing.reason}) — the approval stays parked and answerable in words`);
244
+ return { handled: true, reason: routing.reason, ...(routing.notice ? { notice: routing.notice } : {}) };
245
+ }
246
+ const label = approvalAnswerText(routing.optionId);
247
+ // 🔴 DELIVERED BY SESSION ID, never by the window token (OPE-1863): a continuation token is
248
+ // channel-local, and this route is the SLACK channel's, so `from(bareToken)` here named a
249
+ // session that never existed and the settle threw into the catch below. The token's job is the
250
+ // outstanding-question row on the two lines that follow.
251
+ await respond(routing.sessionId, { requestId: routing.requestId, optionId: routing.optionId, text: label });
252
+ // CONSUME-ONCE, and only now that eve accepted the delivery. A throw above skips both, which
253
+ // leaves the card tappable and the question answerable — the safe direction.
254
+ await forgetApprovalCardTicket(store, org, parsed.ticket);
255
+ await forgetBridgedQuestion(store, org, routing.bareToken);
256
+ await auditApprovalCardTap({
257
+ platform: "slack",
258
+ principalId: click.userId,
259
+ partition,
260
+ outcome: { kind: "settled", requestId: routing.requestId, optionId: routing.optionId, floor: routing.floor },
261
+ });
262
+ log(`tap settled ${routing.requestId} as "${routing.optionId}" (floor ${routing.floor})`);
263
+ return { handled: true, reason: "settled" };
264
+ } catch (err) {
265
+ // The approval is still parked and the card is still tappable, so the honest outcome is to say so
266
+ // and leave both rows in place. Never a throw: this runs after the interaction was acknowledged.
267
+ // AUDITED, not only logged (OPE-1856): ship #212's S-EVE-CARD leg could not say whether its tap
268
+ // had been refused or had thrown, because neither arm left a row. `auditApprovalCardTap` never
269
+ // throws, so this cannot turn a failed tap into a failed response.
270
+ await auditApprovalCardTap({ platform: "slack", principalId: tapper, ...(auditPartition === undefined ? {} : { partition: auditPartition }), outcome: { kind: "refused", reason: "error" } });
271
+ log(`tap failed (${(err as Error)?.message ?? String(err)}) — nothing was settled, the approval stays parked`);
272
+ return { handled: true, reason: "error" };
273
+ }
274
+ }
275
+
@@ -0,0 +1,195 @@
1
+ // Trusted Slack attachment registry — the provenance bridge between a Slack event's
2
+ // `files[].url_private` and the `describe_image` tool.
3
+ //
4
+ // THE PROBLEM IT SOLVES. A Slack image sits behind `url_private`, which needs the bot token to
5
+ // download. That token must NEVER ride a model-chosen URL (an exfil channel). So describe_image
6
+ // can't let the model hand it a Slack URL string — but the model still needs to point at *which*
7
+ // image it wants to see. This registry is the indirection: the wake pipeline (parseMessageEvent)
8
+ // registers each image attachment's `url_private` under its Slack FILE ID (trusted provenance —
9
+ // the event, not the model). describe_image's slack-attachment path takes a model-named file id,
10
+ // resolves it HERE to the event-supplied `url_private`, and hands THAT to the authenticated fetch
11
+ // helper (access/slack-attachment-fetch.ts). A model-supplied URL string can therefore never reach
12
+ // the token-bearing fetch — the model can only *name* an id that a real event already recorded.
13
+ //
14
+ // BOUNDED: a long-lived process must not accumulate url_private entries without limit, so the map
15
+ // evicts oldest-first past a cap. An evicted id simply fails closed in describe_image ("no such
16
+ // attachment") — a re-post (or a fresh wake) re-registers it. url_private links are themselves
17
+ // short-lived Slack-side, so unbounded retention would be pointless as well as leaky.
18
+ //
19
+ // THE CROSS-PROCESS HALF (task #107). The map above is PROCESS-LOCAL, so a DELEGATED worker — a
20
+ // one-shot task in a SEPARATE session/process from the seat that saw the upload (delegation-reshape-
21
+ // plan-2026-07-16.md) — can't resolve a file id it never received, and "read this uploaded file"
22
+ // couldn't be delegated. `persistSlackAttachment` mirrors each event attachment into the DURABLE
23
+ // operational store (org-scoped, TTL-pruned), and `resolveSlackAttachmentDurable` reads in-memory
24
+ // first (same-process fast path) then the store (cross-process). Written ONLY from the trusted parse
25
+ // path — never a model string — the same provenance contract the in-memory map holds. Non-image
26
+ // uploads are persisted too (the in-memory map is image-only for describe_image), so the raw-bytes
27
+ // read path (read_file_upload) can resolve a CSV/text/log an image reader never registered.
28
+
29
+ import {
30
+ getOperationalStore,
31
+ deploymentOrg,
32
+ resolvePartition,
33
+ registerAttachmentResolver,
34
+ AttachmentOversizeError,
35
+ type SlackAttachmentRow,
36
+ } from "@open-everywhere/core/agent/adapter-api.js";
37
+ import { fetchSlackAttachment } from "../access/slack-attachment-fetch.js";
38
+
39
+ /** A resolved attachment record — its `urlPrivate` came from a Slack event's `files[].url_private`
40
+ * (trusted provenance), never a model-supplied string. */
41
+ export interface SlackAttachmentRecord {
42
+ /** The Slack `url_private` download URL from the event's `files[]`. */
43
+ readonly urlPrivate: string;
44
+ /** IANA media type from the event (`files[].mimetype`), when known — describe_image uses it
45
+ * because the id-based ref carries no filename extension to infer from. */
46
+ readonly mimeType?: string;
47
+ /** Original filename from the event, when known — the raw-bytes read path (read_file_upload)
48
+ * infers a type from its extension when the mimetype is absent. */
49
+ readonly name?: string;
50
+ /** The Slack team id the event belonged to (for future multi-bot token routing). */
51
+ readonly teamId?: string;
52
+ /** The platform that minted this attachment id — always "slack" for records written here. Stamped
53
+ * at ingest so the attachment plane's provenance is explicit end to end (channels/attachments.ts). */
54
+ readonly platform?: "slack";
55
+ }
56
+
57
+ /** How long a durable attachment row is retained. Slack `url_private` links are short-lived
58
+ * server-side, and a delegated worker resolves its brief within hours — a day bounds the table
59
+ * without stranding a legitimately slow task. */
60
+ const SLACK_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
61
+
62
+ const MAX_ENTRIES = 1000;
63
+ const registry = new Map<string, SlackAttachmentRecord>();
64
+
65
+ /**
66
+ * Record an image attachment's trusted `url_private` under its Slack file id. THIS LIST IS THE
67
+ * AUDIT LIST for "who may write a fetchable url_private into the registry", so keep it exact.
68
+ * Two writers, both of trusted provenance:
69
+ * • `parseMessageEvent` (slack-events.ts) — an Events API message, which only arrives for a room
70
+ * the app is subscribed to and in;
71
+ * • `registerAttachments` in the front route's SHORTCUT branch (slack-front.ts) — a message
72
+ * shortcut, which Slack delivers even for a room the app is NOT in. It therefore registers
73
+ * only what that branch's readability and wake gates admitted (OPE-1442). The shortcut PARSER
74
+ * registers nothing: it runs at classification time, in front of those gates.
75
+ * A falsy id or an empty url is ignored (nothing to resolve later). Re-registering an id refreshes
76
+ * its recency.
77
+ */
78
+ export function registerSlackAttachment(fileId: string | undefined, rec: SlackAttachmentRecord): void {
79
+ if (!fileId || !rec.urlPrivate) return;
80
+ if (registry.has(fileId)) registry.delete(fileId); // refresh insertion order (LRU-ish)
81
+ registry.set(fileId, { ...rec, platform: "slack" }); // stamp provenance for the attachment plane
82
+ while (registry.size > MAX_ENTRIES) {
83
+ const oldest = registry.keys().next().value;
84
+ if (oldest === undefined) break;
85
+ registry.delete(oldest);
86
+ }
87
+ }
88
+
89
+ /** The trusted record for a Slack file id, or undefined when this session never saw an event
90
+ * carrying it (→ describe_image fails closed rather than fetching an arbitrary URL). PROCESS-LOCAL:
91
+ * a cross-process worker must go through {@link resolveSlackAttachmentDurable}. */
92
+ export function resolveSlackAttachment(fileId: string): SlackAttachmentRecord | undefined {
93
+ return registry.get(fileId);
94
+ }
95
+
96
+ /**
97
+ * Mirror one event attachment into the DURABLE store (task #107) so a delegated worker in a
98
+ * SEPARATE process can resolve it. Called ONLY from the trusted parse-consumption path (slack-front),
99
+ * with `org` resolved from the same event — never a model-supplied string. Best-effort: a store
100
+ * failure must never break the wake (the in-memory path still serves the same-process seat), so it
101
+ * swallows and logs. `org` defaults to the deployment org (single-tenant) when omitted.
102
+ */
103
+ export async function persistSlackAttachment(fileId: string | undefined, rec: SlackAttachmentRecord, org?: string): Promise<void> {
104
+ if (!fileId || !rec.urlPrivate) return;
105
+ const row: SlackAttachmentRow = {
106
+ urlPrivate: rec.urlPrivate,
107
+ ...(rec.mimeType ? { mimeType: rec.mimeType } : {}),
108
+ ...(rec.name ? { name: rec.name } : {}),
109
+ ...(rec.teamId ? { teamId: rec.teamId } : {}),
110
+ };
111
+ try {
112
+ await getOperationalStore().putSlackAttachment(org ?? deploymentOrg(), fileId, row, new Date().toISOString(), SLACK_ATTACHMENT_TTL_MS);
113
+ } catch (err) {
114
+ console.warn(`[open-everywhere:slack-attachments] dropped a durable attachment row for "${fileId}": ${String(err)}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Resolve a file id ACROSS processes: the in-memory registry first (same-process fast path), then
120
+ * the durable store (the cross-process bridge a delegated worker needs). Returns undefined when
121
+ * neither knows the id (→ the caller fails closed rather than fetching an arbitrary URL). `org`
122
+ * defaults to the deployment org (single-tenant) when omitted.
123
+ */
124
+ export async function resolveSlackAttachmentDurable(fileId: string, org?: string): Promise<SlackAttachmentRecord | undefined> {
125
+ const local = registry.get(fileId);
126
+ if (local) return local;
127
+ try {
128
+ const row = await getOperationalStore().getSlackAttachment(org ?? deploymentOrg(), fileId);
129
+ // Stamp provenance on the durable read too (the store is Slack-specific) so every resolved
130
+ // SlackAttachmentRecord carries `platform`, cross-process included.
131
+ return row ? { ...row, platform: "slack" } : undefined;
132
+ } catch (err) {
133
+ console.warn(`[open-everywhere:slack-attachments] durable attachment lookup for "${fileId}" failed: ${String(err)}`);
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Register Slack as an attachment-plane resolver (channels/attachments.ts) — the neutral seam
140
+ * describe_image consults so its contract carries no per-platform union arm. The resolver wraps the
141
+ * SAME two guarantees the in-memory/durable registry + host-pinned fetch already provide:
142
+ * • it resolves a model-named id ONLY through the trusted registry (in-memory then durable,
143
+ * org-scoped) — an id no real Slack event recorded returns `undefined`, so the plane fails
144
+ * closed rather than fetching an arbitrary URL;
145
+ * • the download rides `fetchSlackAttachment` (bot token, HOST-PINNED to Slack, SSRF-guarded,
146
+ * redirect:error) against the event-supplied `url_private` — never a model string.
147
+ * A recognised id whose fetch fails THROWS (the plane surfaces it as a fetch failure), distinct
148
+ * from an unknown id (undefined). Idempotent per platform key.
149
+ */
150
+ export function registerSlackAttachmentResolver(): void {
151
+ registerAttachmentResolver({
152
+ platform: "slack",
153
+ async resolve(attachmentId, ctx) {
154
+ const rec = await resolveSlackAttachmentDurable(attachmentId, resolvePartition(ctx).org);
155
+ if (!rec) return undefined; // not a Slack id we saw → let the plane fail closed
156
+ const res = await fetchSlackAttachment(ctx, { urlPrivate: rec.urlPrivate, id: attachmentId });
157
+ if (!res.ok) throw new Error(`fetching Slack attachment "${attachmentId}" failed (HTTP ${res.status})`);
158
+ return { bytes: new Uint8Array(await res.arrayBuffer()), ref: `slack-file:${attachmentId}`, mimeType: rec.mimeType };
159
+ },
160
+ // Size-guarded variant (task #107) for read_file_upload's raw-bytes read path. It resolves the
161
+ // TRUSTED record up front (no fetch) so the caller can refuse a type before any download, then
162
+ // defers the host-pinned, bot-token fetch to `pull` — which preserves the Content-Length
163
+ // pre-check EXACTLY: it rejects (AttachmentOversizeError, phase "declared") off the declared
164
+ // length BEFORE reading the body, and re-checks the actual byte count after (phase "actual").
165
+ async resolveGuarded(attachmentId, ctx) {
166
+ const rec = await resolveSlackAttachmentDurable(attachmentId, resolvePartition(ctx).org);
167
+ if (!rec) return undefined; // not a Slack id we saw → let the plane fail closed
168
+ return {
169
+ ref: `slack-file:${attachmentId}`,
170
+ mimeType: rec.mimeType,
171
+ name: rec.name,
172
+ async pull({ maxBytes }) {
173
+ const res = await fetchSlackAttachment(ctx, { urlPrivate: rec.urlPrivate, id: attachmentId });
174
+ if (!res.ok) throw new Error(`fetching the upload failed (HTTP ${res.status})`);
175
+ // Content-Length pre-check off the declared length — reject before reading the body.
176
+ const declared = Number.parseInt(res.headers.get("content-length") ?? "", 10);
177
+ if (Number.isFinite(declared) && declared > maxBytes) throw new AttachmentOversizeError(declared, "declared", maxBytes);
178
+ const bytes = new Uint8Array(await res.arrayBuffer());
179
+ // Post-read cap — a chunked response can exceed the (possibly-absent) Content-Length.
180
+ if (bytes.byteLength > maxBytes) throw new AttachmentOversizeError(bytes.byteLength, "actual", maxBytes);
181
+ return bytes;
182
+ },
183
+ };
184
+ },
185
+ });
186
+ }
187
+
188
+ // NOTE (OPE-114 stage 4): the resolver is no longer wired at module load — registerSlackProvider()
189
+ // (agent/register.ts) calls registerSlackAttachmentResolver() at boot, the same "register in every
190
+ // process" posture (register-adapters.ts runs in every process that can execute describe_image).
191
+
192
+ /** Test seam: clear the in-memory registry so a test starts from empty. */
193
+ export function _resetSlackAttachmentsForTest(): void {
194
+ registry.clear();
195
+ }