@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,336 @@
1
+ // SLACK SHORTCUTS AS A WAKE ORIGIN (OPE-1376).
2
+ //
3
+ // Slack gives a person two ways to hand something to an app without typing at it: a MESSAGE
4
+ // shortcut (the `…` overflow menu on one message) and a GLOBAL shortcut (the ⚡ composer /
5
+ // search-bar entry). Both arrive on the app's INTERACTIVITY Request URL as a urlencoded
6
+ // `payload=…` body — the same URL and the same body family as a button click, never as an
7
+ // Events API `event_callback`.
8
+ //
9
+ // 🔴 A SHORTCUT IS A WAKE ORIGIN, NOT A NEW PLANE. This module does exactly one thing: it turns a
10
+ // shortcut payload into the {@link ParsedMessageEvent} the rest of the Slack front door already
11
+ // speaks. Every downstream property — the partition, the seat, the window, the dedupe, the spend
12
+ // gate, the external-participant policy, the wake trust tier — is then derived by the SAME code a
13
+ // mention or a DM goes through. Nothing here decides posture, and nothing here posts.
14
+ //
15
+ // WHY OE OWNS THE PAYLOAD RATHER THAN FORWARDING IT TO EVE. eve 0.48.0 added an `onShortcut` hook
16
+ // to `slackChannel`, and `slack.ts` does not configure one. So a forwarded shortcut is a
17
+ // guaranteed no-op today. The hook could not carry this work either: `SlackShortcutContext`
18
+ // (eve's own type) hands the handler a workspace API handle and NOTHING else — no session, no
19
+ // resolved seat, no `send`. The machinery that turns an inbound Slack event into a wake on the
20
+ // right window lives in `slack-front.ts`, so the shortcut is classified and handled there, beside
21
+ // the approval click, and never forwarded.
22
+ //
23
+ // 🔴 THE PRINCIPAL IS THE INVOKER, NEVER THE MESSAGE AUTHOR. A message shortcut names two people:
24
+ // whoever clicked it, and whoever wrote the message it was clicked on. The wake binds to the
25
+ // CLICKER. Binding to the author would let one person invoke a shortcut on another person's
26
+ // message and get a turn that runs with the author's access, memory scope and admin standing —
27
+ // the message body is the CONTENT, and content never selects the principal. See
28
+ // {@link shortcutMessageEvent}, which is the only place the two identities meet.
29
+
30
+ import {
31
+ ingestUntrusted,
32
+ noteIngestionVerdict,
33
+ taintMarkLands,
34
+ type WakeAttachment,
35
+ } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { parseSlackFiles, type ParsedMessageEvent } from "./slack-events.js";
37
+ import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
38
+
39
+ /**
40
+ * A decoded Slack shortcut invocation, narrowed to the fields a wake needs.
41
+ *
42
+ * The two shortcut families share every field but the target: a `message` shortcut carries the
43
+ * channel it was invoked in and the message it was invoked on; a `global` shortcut carries
44
+ * neither, because Slack fires it from the composer with no conversation in scope.
45
+ *
46
+ * Field-compatible with eve's own `SlackShortcut` union (`eve/channels/slack`), which is the
47
+ * public contract for the same two payloads. We parse the raw payload ourselves because eve's
48
+ * `parseShortcutPayload` is internal to its interactivity handler and is not exported.
49
+ */
50
+ export interface ParsedSlackShortcut {
51
+ /** `message` for a `message_action` payload, `global` for a `shortcut` payload. */
52
+ readonly kind: "message" | "global";
53
+ /** The shortcut's registered callback id — the operator-chosen name of this trigger. */
54
+ readonly callbackId: string;
55
+ /** Slack's per-invocation trigger id — unique per click. Used as the wake's idempotency key on
56
+ * the ASSUMPTION that a Slack redelivery of one click carries the same trigger id. UNMEASURED:
57
+ * Slack documents stable-`event_id` retry semantics for the Events API, not for interactivity
58
+ * payloads. If the assumption is wrong the cost is a double wake, which is the same fail-open
59
+ * posture the rest of the dedupe layer already takes. See {@link shortcutWakeKey}. */
60
+ readonly triggerId: string;
61
+ /** The Slack user who invoked the shortcut. The wake's principal. */
62
+ readonly userId: string;
63
+ readonly teamId?: string;
64
+ /**
65
+ * The Enterprise Grid organisation the invocation's workspace sits in (`E…`), when the payload
66
+ * named one (OPE-1559). A shortcut never arrives as an event envelope, so there is no
67
+ * `enterprise_id` envelope field to read; the interactivity route names the Grid on the payload
68
+ * itself, in THREE spellings, and all three are read ({@link parseSlackShortcutPayload}).
69
+ *
70
+ * 🔴 IT IS THE ROOM'S GRID, NEVER THE INVOKER'S. Both spellings sit on the payload's own
71
+ * `enterprise` / `team` object, which describes WHERE the invocation happened. The invoker's
72
+ * `user.enterprise_user.enterprise_id` describes where THEY belong, and reading it would let a
73
+ * Slack Connect stranger's own Grid declaration vouch our room. So the author leg keeps its own
74
+ * source (`users.info`, slack-identity.ts) and this one never touches it.
75
+ */
76
+ readonly enterpriseId?: string;
77
+ /** Present on a `message` shortcut only — the conversation the message lives in. */
78
+ readonly channelId?: string;
79
+ /** Present on a `message` shortcut only. */
80
+ readonly message?: {
81
+ /** The target message's text, verbatim. UNTRUSTED — it is somebody's Slack message. */
82
+ readonly text: string;
83
+ readonly ts: string;
84
+ /** The thread the target message sits in, when it sits in one. */
85
+ readonly threadTs?: string;
86
+ /** The target message's AUTHOR — never the wake's principal (see the file header). */
87
+ readonly userId?: string;
88
+ /** Image/file REFERENCES off the target message's `files[]`, parsed by the SAME
89
+ * {@link parseSlackFiles} the Events API path uses. A shortcut on a message carrying a
90
+ * screenshot must surface that screenshot, exactly as reading the message in the channel
91
+ * would — otherwise the same message means less through this door than through that one.
92
+ * A REFERENCE ONLY: it reaches a wake, and the bot token, solely through
93
+ * {@link shortcutMessageEvent}'s `admitAttachments` gate. */
94
+ readonly attachments?: readonly WakeAttachment[];
95
+ };
96
+ }
97
+
98
+ function str(v: unknown): string | undefined {
99
+ return typeof v === "string" && v.length > 0 ? v : undefined;
100
+ }
101
+
102
+ function obj(v: unknown): Record<string, unknown> | undefined {
103
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
104
+ }
105
+
106
+ /**
107
+ * Decode an already-JSON-parsed interactivity payload into a {@link ParsedSlackShortcut}, or
108
+ * `null` for anything that is not a shortcut. TOTAL AND PURE: it never throws and it writes
109
+ * nothing.
110
+ *
111
+ * 🔴 PURITY IS A SECURITY PROPERTY HERE, NOT A STYLE CHOICE (OPE-1442). This runs inside
112
+ * `classifyEnvelope`, which decides what a request IS — long before the dedupe, the readability
113
+ * gate and the ambient-off drop decide whether it wakes anything. An earlier revision registered
114
+ * the target message's attachments right here, so every click seeded the process-global registry
115
+ * (a 1000-entry LRU) even when the shortcut went on to wake NOTHING: repeated clicks on a domain
116
+ * with the unified window off evicted the records real uploads depend on, and `describe_image`
117
+ * then failed closed on a genuine image. The registry is now seeded by the front door's shortcut
118
+ * branch, after the gates it must sit behind.
119
+ *
120
+ * A payload is REFUSED (null) when it cannot produce a wake:
121
+ * • the outer `type` is neither `message_action` nor `shortcut`;
122
+ * • `callback_id`, `trigger_id` or `user.id` is missing — the first two are the trigger's name
123
+ * and its idempotency key, and the third is the principal, so a wake without any of them
124
+ * would have to be invented rather than derived;
125
+ * • a `message_action` that carries no `channel.id` or no `message.ts` — a message shortcut
126
+ * whose target cannot be addressed is not a message shortcut.
127
+ * Refusing is FAIL-CLOSED: the front route acks Slack and wakes nothing.
128
+ *
129
+ * `message.text` is allowed to be EMPTY and still parses. Slack sends an empty `text` for a message
130
+ * whose content lives entirely in blocks or files, and dropping a click a person made is the worse
131
+ * answer. The wake then renders an empty body — it says nothing ABOUT the emptiness. Files on that
132
+ * message ride through as attachments only where the front door admits them
133
+ * ({@link shortcutMessageEvent}'s `admitAttachments`), so a shortcut on a screenshot in a room the
134
+ * app cannot read wakes with an empty body and no file.
135
+ */
136
+ export function parseSlackShortcutPayload(raw: unknown): ParsedSlackShortcut | null {
137
+ const p = obj(raw);
138
+ if (!p) return null;
139
+ const type = str(p.type);
140
+ if (type !== "message_action" && type !== "shortcut") return null;
141
+ const callbackId = str(p.callback_id);
142
+ const triggerId = str(p.trigger_id);
143
+ const userId = str(obj(p.user)?.id);
144
+ if (!callbackId || !triggerId || !userId) return null;
145
+ // THE WORKSPACE AND THE GRID RUNG (OPE-1559), read by the ONE reader every interactivity ingress
146
+ // spends — `slackInteractivityTenantIds` (slack-tenant-fence.ts), which also states which three
147
+ // spellings of the Grid id it unions and why `context_enterprise_id` is excluded. A shortcut is
148
+ // one interactivity payload among six click families (OPE-1680), and a second copy of the reading
149
+ // rule here is the drift that leaf module exists to prevent.
150
+ const { teamId, enterpriseId } = slackInteractivityTenantIds(p);
151
+ if (type === "shortcut") {
152
+ return { kind: "global", callbackId, triggerId, userId, ...(teamId ? { teamId } : {}), ...(enterpriseId ? { enterpriseId } : {}) };
153
+ }
154
+ const channelId = str(obj(p.channel)?.id);
155
+ const message = obj(p.message);
156
+ const ts = str(message?.ts);
157
+ if (!channelId || !message || !ts) return null;
158
+ const threadTs = str(message.thread_ts);
159
+ const authorId = str(message.user);
160
+ // File REFERENCES only. Parsing is all this function does with them: registering an id is what
161
+ // makes it fetchable with the bot token, and that decision belongs to the front door, which knows
162
+ // whether this shortcut wakes anything and whether the app may read the room at all. See
163
+ // {@link shortcutMessageEvent}'s `admitAttachments`.
164
+ const attachments = parseSlackFiles(message.files);
165
+ return {
166
+ kind: "message",
167
+ callbackId,
168
+ triggerId,
169
+ userId,
170
+ ...(teamId ? { teamId } : {}),
171
+ ...(enterpriseId ? { enterpriseId } : {}),
172
+ channelId,
173
+ message: {
174
+ text: typeof message.text === "string" ? message.text : "",
175
+ ts,
176
+ ...(threadTs ? { threadTs } : {}),
177
+ ...(authorId ? { userId: authorId } : {}),
178
+ ...(attachments.length > 0 ? { attachments } : {}),
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The idempotency key for one shortcut invocation.
185
+ *
186
+ * Keyed on `trigger_id`, NOT on the target message. What that buys is certain: a person stays free
187
+ * to invoke the same shortcut on the same message twice on purpose, which keying on `channel:ts`
188
+ * (the shape an Events API wake falls back to) would have swallowed for ten minutes.
189
+ *
190
+ * What it de-dupes rests on an UNMEASURED assumption — that a Slack redelivery of one click carries
191
+ * the same trigger id (see {@link ParsedSlackShortcut.triggerId}). Wrong ⇒ a double wake, never a
192
+ * missed one, which is the direction the whole dedupe layer already fails in.
193
+ *
194
+ * It carries no bot-identity prefix, unlike `slack-front.ts`'s `wakeKey`. A trigger id is minted by
195
+ * Slack per app, so two apps in one workspace cannot collide on one; the `oe-shortcut:` prefix
196
+ * keeps it out of the Events API key space.
197
+ */
198
+ export function shortcutWakeKey(sc: ParsedSlackShortcut): string {
199
+ return `oe-shortcut:${sc.triggerId}`;
200
+ }
201
+
202
+ /**
203
+ * Fence the target message's body, and declare the ingestion route this door opens.
204
+ *
205
+ * The declaration is the kind-binding entry for the route (access/ingestion-binding-manifest.ts). A
206
+ * message shortcut carries a Slack message body in through the INTERACTIVITY door, which no Events
207
+ * API route uses, so the route says what it ingests: `chat-message`.
208
+ *
209
+ * 🔴 WHY IT FENCES, AND WHAT THE FENCE STANDS IN FOR. The wake envelope attributes every message to
210
+ * ONE author and grades it with ONE trust tier, and on this path both are the INVOKER'S:
211
+ * `triggerMessage` (slack-events.ts) renders `author`/`author-id` from `ev.userId` and `trust` from
212
+ * the tier the front door resolved for the person who pressed the button. So the target message's
213
+ * OWN author is never graded here — this route resolves no author facts for them and makes no
214
+ * `users.info` probe on them, which means the OPE-466 non-member taint join that the channel door
215
+ * applies does not see them either.
216
+ *
217
+ * The consequence, stated plainly: read through the channel door, a guest's or a Connect stranger's
218
+ * message is graded `unauth` and the envelope fences it. Handed over by a `principal` invoker's
219
+ * shortcut, the SAME bytes would render under that invoker's tier — and
220
+ * `authorTrustAdmitsUnfencedBody` (channels/wake-envelope.ts) lets a `peer`/`principal` human body
221
+ * through UNFENCED. This fence is what closes that gap: the body carries its own wrapper because it
222
+ * cannot carry its own tier.
223
+ *
224
+ * That is deliberately MORE conservative than the ruled Slack-forward precedent (slack-events.ts's
225
+ * note on quoted attachment content, which inherits the forwarder's tier unfenced). The difference
226
+ * is who chose: a forward is text a person retyped into their own message, while a shortcut hands
227
+ * over somebody else's message untouched, so there is no editorial act to inherit a tier from.
228
+ *
229
+ * `taintMarkLands()` reports the RUNTIME effect rather than asserting one, for the reason that
230
+ * function's header states: this runs in the INGRESS process, outside any turn, so the taint half
231
+ * of `ingestUntrusted` no-ops here and the declaration says so honestly.
232
+ */
233
+ function fenceAndDeclareShortcutIngestion(text: string): string {
234
+ noteIngestionVerdict({ kind: "chat-message", label: "slack:shortcut" }, taintMarkLands());
235
+ return ingestUntrusted(text, { source: "slack message shortcut" });
236
+ }
237
+
238
+ /**
239
+ * The body a GLOBAL shortcut wakes with. It has no message, so the trigger's own name is the whole
240
+ * content — which is the honest thing to hand the model: somebody pressed a named button.
241
+ *
242
+ * The callback id is operator-authored configuration, not somebody's Slack message, so it is NOT
243
+ * fenced as untrusted content — the same reasoning that leaves a slash-command name unfenced.
244
+ */
245
+ export function globalShortcutText(sc: ParsedSlackShortcut): string {
246
+ return `Shortcut "${sc.callbackId}" invoked.`;
247
+ }
248
+
249
+ /**
250
+ * A Slack-shaped `ts` for the one wake that has no message behind it: a global shortcut.
251
+ *
252
+ * Slack's format is `<seconds>.<microseconds>`, and the wake envelope reads it back with
253
+ * `Number.parseFloat` to render `sent-at`. Minting from the clock therefore makes `sent-at` say
254
+ * exactly what is true — when the person pressed the button. The microsecond field carries this
255
+ * instant's milliseconds; the last three digits are zero, because a millisecond clock cannot
256
+ * honestly claim more precision than it has.
257
+ */
258
+ function slackTsFromMs(nowMs: number): string {
259
+ const seconds = Math.floor(nowMs / 1000);
260
+ const millis = nowMs % 1000;
261
+ return `${seconds}.${String(millis).padStart(3, "0")}000`;
262
+ }
263
+
264
+ /**
265
+ * Map a shortcut onto the shared {@link ParsedMessageEvent} shape, so the front door's existing
266
+ * wake spine (partition → seat → window → dedupe → gates → divert) runs over it unchanged.
267
+ *
268
+ * 🔴 `userId` IS THE INVOKER on both kinds. See the file header: the target message's author never
269
+ * becomes the wake's principal.
270
+ *
271
+ * THE TWO TEXT FIELDS CARRY THE MESSAGE-SHORTCUT SPLIT EXACTLY AS THEY ALREADY MEAN IT. A message
272
+ * shortcut is a FORWARD with no covering note — the invoker typed nothing and pointed at somebody
273
+ * else's message — and {@link ParsedMessageEvent} already draws that line: `text` is "own text
274
+ * plus any quoted message", `authoredText` is "the author's OWN typed text". So `text` carries the
275
+ * target message and `authoredText` is empty. Any rule that asks "what did this person SAY?" then
276
+ * correctly answers "nothing", which is true, and the model still receives the message as the
277
+ * wake's content.
278
+ *
279
+ * `ts` differs by kind, and deliberately:
280
+ * • a MESSAGE shortcut takes the target message's real ts, so the reply anchors under the
281
+ * message the shortcut was invoked on (`routeSupervisorEvent` reads `threadTs ?? ts`);
282
+ * • a GLOBAL shortcut has no message, so it MINTS one from `nowMs` in Slack's own
283
+ * `seconds.microseconds` shape. 🔴 It must be ts-SHAPED, not merely unique: the envelope
284
+ * renders `sent-at` by running `Number.parseFloat` over this field (`slackTsToIso`), so a
285
+ * non-numeric id silently becomes a nonsense date — the trigger id `13345224609.7384…` parses
286
+ * to the year 2392. Minting from the clock makes `sent-at` the truth: the moment the person
287
+ * pressed the button. Uniqueness is not this field's job — the wake's idempotency key is
288
+ * {@link shortcutWakeKey}, which does key on the trigger id.
289
+ *
290
+ * `isAppMention` is false on both. It reports whether the TEXT contains `<@bot>`, and neither body
291
+ * does; the "was the agent addressed?" question is answered by the caller passing `directMention`,
292
+ * which is the same seam the single-window mention divert uses.
293
+ *
294
+ * 🔴 `admitAttachments` GATES THE TARGET MESSAGE'S FILES, AND IT DEFAULTS TO FALSE (OPE-1442).
295
+ * Slack delivers a `message_action` for a room the app is not in, and the payload carries that
296
+ * message's `files[].url_private` — bytes the app's own channel reads refuse. So the files ride
297
+ * through only when the caller has POSITIVELY established that the app can read the room
298
+ * (`slackChannelFactsFor`'s `appCanRead`). The default is closed because the cost of the two
299
+ * mistakes is not symmetric: a withheld file is a wake that says less, an admitted one is the bot
300
+ * token downloading somebody's private upload from a room the agent cannot open.
301
+ *
302
+ * WITHHOLDING DROPS THE REFERENCE TOO, rather than passing an id that resolves to nothing. The
303
+ * envelope renders an attachment as an id and `describe_image` fails closed on an id the registry
304
+ * never saw, so an advertised-but-unresolvable file tells the model an image exists and then
305
+ * cannot show it — worse than not mentioning it.
306
+ */
307
+ export function shortcutMessageEvent(
308
+ sc: ParsedSlackShortcut,
309
+ channelId: string,
310
+ channelType?: string,
311
+ nowMs: number = Date.now(),
312
+ opts: { readonly admitAttachments?: boolean } = {},
313
+ ): ParsedMessageEvent {
314
+ const attachments = opts.admitAttachments === true ? sc.message?.attachments : undefined;
315
+ const text = sc.kind === "message" && sc.message ? fenceAndDeclareShortcutIngestion(sc.message.text) : globalShortcutText(sc);
316
+ return {
317
+ channelId,
318
+ ts: sc.kind === "message" && sc.message ? sc.message.ts : slackTsFromMs(nowMs),
319
+ ...(sc.kind === "message" && sc.message?.threadTs ? { threadTs: sc.message.threadTs } : {}),
320
+ ...(sc.teamId ? { teamId: sc.teamId } : {}),
321
+ // The Grid rung the payload named (OPE-1559), spread conditionally exactly as the wake routes
322
+ // spread it: it reaches `buildSlackAuth` through `authForEvent` and `scheduleDmWake`, so a
323
+ // shortcut in a Grid workspace declared only at the enterprise key now resolves that line, and a
324
+ // non-Grid shortcut stamps nothing.
325
+ ...(sc.enterpriseId ? { enterpriseId: sc.enterpriseId } : {}),
326
+ userId: sc.userId,
327
+ ...(channelType ? { channelType } : {}),
328
+ text,
329
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
330
+ // A global shortcut's body IS what the invoker did — they pressed that named button and typed
331
+ // nothing else, so the trigger line is their own "text". A message shortcut's body is somebody
332
+ // else's message, so the invoker authored nothing.
333
+ authoredText: sc.kind === "global" ? text : "",
334
+ isAppMention: false,
335
+ };
336
+ }
@@ -0,0 +1,53 @@
1
+ // SLACK'S `v0=` SIGNATURE SCHEME, as a leaf primitive — the ONE implementation of Slack's digest in
2
+ // this tree, extracted (OPE-197) so the two callers that need it cannot drift into two schemes:
3
+ //
4
+ // • channels/slack-front.ts `verifySlackSignature` — the SURFACE front door (events, interactivity,
5
+ // `/oe` slash commands), which reads its secret from `SLACK_SIGNING_SECRET` and answers a Slack
6
+ // Request URL.
7
+ // • channels/slack-webhook-verifier.ts — the WEBHOOK VERIFIER REGISTRY entry, which reads its keys
8
+ // from a hook's `secretRef` list and answers the generic ingress.
9
+ //
10
+ // Those two resolve their key differently and discriminate failures differently; what they must
11
+ // share to the byte is the BASESTRING and the DIGEST, because a difference there is a signature that
12
+ // verifies on one route and 401s on the other with nothing saying why. So exactly that much lives
13
+ // here and nothing else — no header reading, no skew window, no key resolution.
14
+ //
15
+ // THE SCHEME (https://api.slack.com/authentication/verifying-requests-from-slack):
16
+ // X-Slack-Request-Timestamp: <unix seconds>
17
+ // X-Slack-Signature: v0=<hex> HMAC-SHA256 of `v0:<timestamp>:<raw body>`, keyed by the
18
+ // app's Signing Secret.
19
+ // The timestamp is INSIDE the signed basestring, which is what makes a skew check meaningful here
20
+ // (contrast GitHub, which signs the body alone) — but enforcing that window is the caller's, since
21
+ // the two callers bound it differently on purpose.
22
+ //
23
+ // Pinned by Slack's OWN PUBLISHED TEST VECTOR in scripts/slack-webhook-verifier-test.ts — the only
24
+ // assertion that proves we implement THEIR scheme rather than a self-consistent idea of it.
25
+
26
+ import { createHmac, timingSafeEqual } from "node:crypto";
27
+
28
+ /** The header carrying the signature. Exported so no caller re-types the string. */
29
+ export const SLACK_SIGNATURE_HEADER = "x-slack-signature";
30
+ /** The header carrying the signed unix-seconds timestamp. */
31
+ export const SLACK_TIMESTAMP_HEADER = "x-slack-request-timestamp";
32
+ /** The version tag that both prefixes the signature and opens the basestring. */
33
+ export const SLACK_SIGNATURE_VERSION = "v0";
34
+
35
+ /**
36
+ * The signature Slack would send for this `(secret, timestamp, rawBody)` — `v0=<hex>` over
37
+ * `v0:<timestamp>:<rawBody>`.
38
+ *
39
+ * `timestamp` is taken as the STRING off the header, never a number, because it is signed
40
+ * character-for-character: re-formatting it (leading zeros, a float round-trip) changes the
41
+ * basestring and breaks a signature that is perfectly good.
42
+ */
43
+ export function slackSignature(secret: string, timestamp: string, rawBody: string): string {
44
+ return `${SLACK_SIGNATURE_VERSION}=${createHmac("sha256", secret).update(`${SLACK_SIGNATURE_VERSION}:${timestamp}:${rawBody}`).digest("hex")}`;
45
+ }
46
+
47
+ /** Length-prechecked constant-time compare of a presented signature against the expected one —
48
+ * the shape every verifier in this tree uses. */
49
+ export function slackSignatureMatches(secret: string, timestamp: string, rawBody: string, presented: string): boolean {
50
+ const a = Buffer.from(slackSignature(secret, timestamp, rawBody));
51
+ const b = Buffer.from(presented);
52
+ return a.length === b.length && timingSafeEqual(a, b);
53
+ }
@@ -0,0 +1,62 @@
1
+ // The Slack ingress SPEND GATE — a THIN BINDING over the shared core gate
2
+ // (@open-everywhere/core/agent/spend/ingress-gate.ts), called at BOTH doors a Slack wake can arrive
3
+ // through (OPE-33).
4
+ //
5
+ // WHY THE GATE IS NOT INLINE IN `slack.ts` (live-diagnosed 2026-07-25). It used to be, inside
6
+ // `onInbound` — eve's `onAppMention`/`onDirectMessage` handler. That covers the MENTION door only.
7
+ // Since eve 0.27.5 the compiled `slackChannel` drops any message it considers self-authored BEFORE
8
+ // the mention hooks run:
9
+ //
10
+ // isSelfAuthoredSlackMessage(e,t){ return e.botUserId!==void 0 && t.author?.userId===e.botUserId
11
+ // ? !0 : e.appId!==void 0 && t.raw.app_id===e.appId }
12
+ //
13
+ // …applied to the `app_mention`/`direct_message` leg AND to eve's own `onMessage` leg — but NOT to
14
+ // the `onEvent` leg. Slack stamps `app_id` on ANY message an app posts through the Web API
15
+ // (including with a user token), so every driver-app-authored mention now bypasses `onInbound`
16
+ // entirely and re-enters through our engagement front route (slack-front.ts → slack-events.ts),
17
+ // which had no spend gate at all. Net effect: a capped surface silently kept spending.
18
+ //
19
+ // WHY THE IMPLEMENTATION NOW LIVES IN CORE. The fix was always structural, not version-specific: the
20
+ // gate is a WAKE-ADMISSION decision, so it belongs at every point a wake is about to dispatch a
21
+ // model turn — and the same defect then recurred on five other surfaces AND on the cross-surface
22
+ // window's own divert. The decision logic, the refusal copy, the admin-bypass semantics and the
23
+ // audit row therefore exist exactly once, in core, and every door — Slack's two, each other
24
+ // surface's native leg, and `divertToUnifiedWindow` — calls that one function. This module keeps
25
+ // only what is genuinely Slack-shaped: the mrkdwn shortcode badges (`:no_entry:` / `:warning:`,
26
+ // which no other surface renders) and the Slack-named type aliases its call sites use.
27
+
28
+ import { ingressSpendGate, SLACK_SPEND_BADGES, spendRefusalCopy as coreSpendRefusalCopy, spendBypassWarnCopy as coreSpendBypassWarnCopy, type IngressSpendGateInput, type SpendGateDecision, type SpendRefusalCopyInput } from "@open-everywhere/core/agent/adapter-api.js";
29
+
30
+ export type { SpendGateDoor, SpendGateDecision, SpendGateAuditRow } from "@open-everywhere/core/agent/adapter-api.js";
31
+
32
+ /** Slack's shape of the shared gate input: `teamId` is the Slack team id (the shared gate's generic
33
+ * `workspaceRef`, emitted as `team` on the audit row), and `mode` stays REQUIRED here because both
34
+ * Slack doors resolve dm/mpim/channel themselves before the gate is reached. */
35
+ export interface SlackSpendGateInput extends Omit<IngressSpendGateInput, "workspaceRef" | "badges" | "mode"> {
36
+ readonly mode: NonNullable<IngressSpendGateInput["mode"]>;
37
+ /** Slack team id, for the audit row only (`null` when the event carried none). */
38
+ readonly teamId?: string | null;
39
+ }
40
+
41
+ /** The refusal copy with Slack's mrkdwn shortcode badge — same sentences as every other surface
42
+ * (they live in core), only the leading emoji differs. The caller passes core's own input, so a
43
+ * Slack refusal names the breached layer and its key exactly as every other surface does
44
+ * (OPE-1860); nothing about the copy is decided here. */
45
+ export function spendRefusalCopy(input: Omit<SpendRefusalCopyInput, "badge">): string {
46
+ return coreSpendRefusalCopy({ ...input, badge: SLACK_SPEND_BADGES.refusal });
47
+ }
48
+
49
+ /** The admin-bypass warning with Slack's mrkdwn shortcode badge. */
50
+ export function spendBypassWarnCopy(reason: string | undefined): string {
51
+ return coreSpendBypassWarnCopy(reason, SLACK_SPEND_BADGES.warning);
52
+ }
53
+
54
+ /** Decide whether a Slack wake may spend — the shared decision table, bound to Slack's badges.
55
+ * See the core module for the full posture (check fails OPEN, a definite OVER fails CLOSED, an
56
+ * admin proceeds as audited sudo). */
57
+ export function slackSpendGate(input: SlackSpendGateInput): Promise<SpendGateDecision> {
58
+ const { teamId, ...rest } = input;
59
+ // `?? null` (never omitted): Slack's row has always carried a `team` key, present-but-null when
60
+ // the event carried no team id, and a live probe reads it that way.
61
+ return ingressSpendGate({ ...rest, workspaceRef: teamId ?? null, badges: SLACK_SPEND_BADGES });
62
+ }
@@ -0,0 +1,99 @@
1
+ // SLACK'S TENANT FENCE WIRING (OPE-309 phase 0c) — one helper, every wake door.
2
+ //
3
+ // The decision itself is core's (access/tenant-admission.ts): pure, unit-tested, platform-neutral.
4
+ // This module is only the Slack-shaped call — "read the admitted set from config, classify this
5
+ // delivery's ids" — named ONCE so the mention door, the four engagement routes, the join door, the
6
+ // `/oe` slash door, the shortcut ingress and the DM window cannot drift apart on what "a tenant we
7
+ // admit" means. Exactly the shape `slackWakeSpendGate` uses for the spend question.
8
+ //
9
+ // WHY SLACK IS THE PLATFORM THAT NEEDS IT. Slack's Events API verifies an APP-LEVEL signing
10
+ // secret, which every workspace that installs the app shares — so `slack-front.ts` cannot tell a
11
+ // second workspace from the first, and there is no team allowlist anywhere else in the tree. The
12
+ // only thing bounding the deployment to one workspace is Slack's own app-distribution setting: a
13
+ // boundary we do not own. Slack is also the one platform in TENANT_SCOPED_PLATFORMS, i.e. the one
14
+ // whose person ids are issued per tenant, so a second workspace is precisely where two humans
15
+ // start sharing one `slack/<id>` principal key.
16
+ //
17
+ // This module lives OUTSIDE slack-events.ts on purpose: slack.ts's mention door needs it too, and
18
+ // a leaf module is how both get it without an import cycle.
19
+
20
+ import { classifyTenantAdmission, admittedTenantsFromConfig, type TenantAdmissionDecision } from "@open-everywhere/core/agent/adapter-api.js";
21
+
22
+ /**
23
+ * Is this delivery's Slack tenant admitted on this deployment? `proceed` also covers every
24
+ * deliberate inert case (nothing declared, operator opted in, no ids on the event at all) — see
25
+ * access/tenant-admission.ts's classifyTenantAdmission for why each one is a choice rather than a
26
+ * gap. `unadmitted` means: drop the wake, write the audit row, run no model turn.
27
+ *
28
+ * 🔴 PASS THE ENTERPRISE ID WHERE THE DOOR CAN READ ONE (OPE-1645). An ORG-WIDE Enterprise Grid
29
+ * install delivers an interactivity payload that names the Grid (`E…`) and NO workspace, so a call
30
+ * that hands over the workspace alone asks the fence to judge a delivery on the one field it is
31
+ * missing — and the classifier's "a delivery naming no tenant proceeds" path then ADMITS a foreign
32
+ * Grid. A door with no enterprise id to read omits the argument and gets the workspace-only
33
+ * decision it had before.
34
+ */
35
+ export function slackTenantAdmission(teamId: string | undefined | null, enterpriseId?: string | undefined | null): TenantAdmissionDecision {
36
+ return classifyTenantAdmission({ platform: "slack", tenant: teamId, enterprise: enterpriseId, admitted: admittedTenantsFromConfig("slack") });
37
+ }
38
+
39
+ /** The workspace and Grid one INTERACTIVITY payload names. Either field is absent where the payload
40
+ * named none — see {@link slackInteractivityTenantIds} for which install shape omits which. */
41
+ export interface SlackInteractivityTenantIds {
42
+ readonly teamId?: string;
43
+ readonly enterpriseId?: string;
44
+ }
45
+
46
+ /**
47
+ * Read the tenant ids off an already-JSON-parsed INTERACTIVITY payload — a `block_actions` click, a
48
+ * `view_submission`, a shortcut. TOTAL AND PURE: any shape that names neither id yields `{}`, which
49
+ * {@link slackTenantAdmission} judges as "names no tenant".
50
+ *
51
+ * 🔴 THE GRID RUNG, IN ALL THREE SPELLINGS AN INTERACTIVITY PAYLOAD USES (OPE-1559). Which one
52
+ * arrives depends on how the app was installed, so reading fewer than three loses the rung on some
53
+ * installs — silently, and in the direction that ADMITS:
54
+ * • a top-level `enterprise` OBJECT (an org-wide install; `team` may then be null);
55
+ * • a top-level `enterprise_id` STRING;
56
+ * • `team: { id, enterprise_id }` — what a PER-WORKSPACE install on a Grid sends.
57
+ * Order is widest-first; the top-level object wins where more than one is present.
58
+ *
59
+ * 🔴 NO SINGLE PLACE IN eve READS ALL THREE, so this union is OURS and cannot be delegated.
60
+ * Re-measured against eve 0.54.3: `parseShortcutPayload` (public/channels/slack/interactions.js) reads
61
+ * NO enterprise at all; `extractInstallationFromInteractive`
62
+ * (compiled/@chat-adapter/slack/index.js) reads `enterprise?.id || enterprise_id`; and the adapter's
63
+ * event path reads the top-level `enterprise_id` alone. Three readers, three different subsets — and
64
+ * none of them reads the `team.enterprise_id` arm, which is why the union stays ours.
65
+ * ⚠️ NOT RE-READ AT 0.54.3: the `enterprise.id || team.enterprise_id` shape this block used to
66
+ * attribute to the block_actions / view_submission parsers. It is not findable in the 0.54.3 bytes, so
67
+ * it is not restated as a measurement. The claim above does not rest on it.
68
+ *
69
+ * 🔴 `context_enterprise_id` IS DELIBERATELY NOT READ, though eve falls back to it. On a Slack
70
+ * Connect payload that field can name ANOTHER organisation, so trusting it would let a foreign Grid
71
+ * declaration vouch our room. `slackEnterpriseIdFromEnvelope` (slack-events.ts) makes the same
72
+ * exclusion on the event side. Every field below names the delivery's OWN workspace or Grid.
73
+ *
74
+ * 🔴 `user.team_id` IS NOT READ EITHER, and that omission is the point. It names the CLICKER's home
75
+ * workspace, which on a Grid is not the workspace the click happened in — so a fence built on it
76
+ * would judge the wrong tenant, in both directions. The question this fence asks is "which tenant
77
+ * DELIVERED this", and `team` / `enterprise` are the only fields that answer it.
78
+ *
79
+ * 🔴 IT LIVES HERE RATHER THAN AT A DOOR because the shortcut ingress and the six CLICK branches
80
+ * (OPE-1680) both need it, and a second copy of the reading rule at a second door is exactly the
81
+ * drift this module exists to prevent — the same argument that keeps {@link slackTenantAdmission}
82
+ * out of `slack-events.ts`.
83
+ */
84
+ export function slackInteractivityTenantIds(raw: unknown): SlackInteractivityTenantIds {
85
+ const p = asObject(raw);
86
+ if (p === undefined) return {};
87
+ const teamId = asNonEmptyString(asObject(p.team)?.id);
88
+ const enterpriseId =
89
+ asNonEmptyString(asObject(p.enterprise)?.id) ?? asNonEmptyString(p.enterprise_id) ?? asNonEmptyString(asObject(p.team)?.enterprise_id);
90
+ return { ...(teamId === undefined ? {} : { teamId }), ...(enterpriseId === undefined ? {} : { enterpriseId }) };
91
+ }
92
+
93
+ function asObject(v: unknown): Record<string, unknown> | undefined {
94
+ return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined;
95
+ }
96
+
97
+ function asNonEmptyString(v: unknown): string | undefined {
98
+ return typeof v === "string" && v.length > 0 ? v : undefined;
99
+ }