@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,178 @@
1
+ // slack-web-api — THE ONE place a Slack Web API call leaves this host (OPE-1211).
2
+ //
3
+ // WHY THIS MODULE REPLACED eve's `callSlackApi`. Every Slack write used to funnel into
4
+ // `callSlackApi` from `eve/channels/slack`. That primitive is a bare `fetch` POST: it reads no
5
+ // status but the 2xx/non-2xx split, and on a non-2xx it throws a `SlackApiError` carrying
6
+ // `{ method, response, status }` — and NOT the response headers. Slack states a rate-limit delay in
7
+ // the `Retry-After` HEADER and nowhere else, so that delay was unreadable from OE code. Honouring it
8
+ // therefore needs OE to own the request. This module is a faithful port of eve's encoding, token
9
+ // resolution and error posture, with the 429 backoff added around the call.
10
+ //
11
+ // 🔴 WHAT THIS SEAM COVERS, EXACTLY. Every Slack Web API call this repo issues DIRECTLY goes through
12
+ // here: `slackForm`, `callSlackRead`, the `auth.test` identity probes and the wake backscroll. It
13
+ // does NOT cover the calls that ride eve's session BINDING, because those never reach OE code:
14
+ // • `ctx.slack.request(...)` — slack-context.ts (`auth.test`, `conversations.history`);
15
+ // • `ctx.thread.postEphemeral(...)` — slack-input-response.ts;
16
+ // • eve's own machinery — `startTyping`, thread `refresh`, `uploadSlackFiles`.
17
+ // Those resolve through eve's `createSlackRequester`/`postSlackEphemeral` to the same bare
18
+ // `callSlackApi`, so they still fail on the first 429. `slack-web-api-seam-test.ts` holds that list
19
+ // as a KNOWN-UNCOVERED inventory and fails when a NEW bypass appears, which is the honest guarantee:
20
+ // the seam is not universal, but the set of things outside it cannot grow unnoticed.
21
+ //
22
+ // SEMANTICS PORTED VERBATIM from eve's primitive, because callers depend on each one:
23
+ // • Form encoding. Slack's JSON support is partial (`conversations.replies`/`history` want form),
24
+ // so form is the only safe default. A null or undefined value is dropped; a string, number or
25
+ // boolean is stringified; anything else is JSON-encoded.
26
+ // • Token resolution. An explicit token wins, otherwise `SLACK_BOT_TOKEN`. Neither ⇒ throw
27
+ // `SLACK_BOT_TOKEN is required.` — the same message and the same fail-loud posture. ONE
28
+ // DELIBERATE NARROWING: eve's `SlackBotToken` also accepts a function (the secret-manager and
29
+ // rotation form). No caller in this repo passes one, so this seam takes a string. Widening it
30
+ // back is a one-line `await` on the resolver if the multi-bot seam ever needs it.
31
+ // • `ok:false` PASSES THROUGH. Slack answers HTTP 200 with `ok:false` when it read the request and
32
+ // refused it. That is a successful transport, and the callers classify it themselves
33
+ // (slack-read.ts marks it not-applied; slack-post-arm.ts reads it as definitive). This module
34
+ // must never turn it into a throw.
35
+ // • A non-2xx THROWS {@link SlackWebApiError}. A request that got no usable reply stays ambiguous,
36
+ // which is what the post spine needs to avoid double-posting.
37
+ //
38
+ // WHAT IS NEW: a 429 is retried after the delay `Retry-After` names, bounded by
39
+ // `channels/rate-limit-retry.js`. When the attempts run out the call throws `RateLimitExhaustedError`
40
+ // — a NAMED failure. Non-429 responses are untouched.
41
+
42
+ import { withRetryAfter, type RateLimitRetryOptions } from "@open-everywhere/core/agent/adapter-api.js";
43
+
44
+ /** Slack's Web API base. A trailing slash is required — `new URL(operation, base)` resolves the
45
+ * operation against the LAST path segment otherwise. */
46
+ const SLACK_API_BASE = "https://slack.com/api/";
47
+
48
+ /** The Slack Web API JSON envelope. `ok` signals Slack's own verdict; callers inspect it. */
49
+ export interface SlackWebApiResponse {
50
+ readonly ok?: boolean;
51
+ readonly error?: string;
52
+ readonly [key: string]: unknown;
53
+ }
54
+
55
+ /** The failure a non-2xx Slack response produces. Mirrors eve's `SlackApiError` field-for-field, so
56
+ * a caller that reads `status` or `response` off a caught error keeps reading the same thing. */
57
+ export class SlackWebApiError extends Error {
58
+ readonly method: string;
59
+ readonly status: number;
60
+ readonly response: unknown;
61
+
62
+ constructor(init: { method: string; status: number; response: unknown }) {
63
+ super(`Slack ${init.method} returned HTTP ${init.status}`);
64
+ this.name = "SlackWebApiError";
65
+ this.method = init.method;
66
+ this.status = init.status;
67
+ this.response = init.response;
68
+ }
69
+ }
70
+
71
+ /** The subset of the global `fetch` this module uses — injectable so tests drive it without Slack. */
72
+ export type SlackFetchLike = (input: string, init: RequestInit) => Promise<Response>;
73
+
74
+ /** Transport and retry seams. A test injects all of them; live code passes none. */
75
+ export interface SlackWebApiDeps {
76
+ readonly fetch?: SlackFetchLike;
77
+ readonly sleep?: RateLimitRetryOptions["sleep"];
78
+ readonly now?: RateLimitRetryOptions["now"];
79
+ readonly maxAttempts?: number;
80
+ readonly capMs?: number;
81
+ readonly totalBudgetMs?: number;
82
+ readonly apiBase?: string;
83
+ }
84
+
85
+ /** Form-encode a Slack request body exactly as eve's `encodeSlackApiBody` does. */
86
+ export function encodeSlackForm(body: unknown): string {
87
+ const params = new URLSearchParams();
88
+ if (body !== null && typeof body === "object" && !Array.isArray(body)) {
89
+ for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
90
+ if (value === null || value === undefined) continue;
91
+ const scalar = typeof value === "string" || typeof value === "number" || typeof value === "boolean";
92
+ params.set(key, scalar ? String(value) : JSON.stringify(value));
93
+ }
94
+ }
95
+ return params.toString();
96
+ }
97
+
98
+ /**
99
+ * Operations that are BEST-EFFORT PROBES on a latency-critical path, and their tightened policy.
100
+ *
101
+ * 🔴 WHY THIS EXISTS (OPE-1211 review finding 2). `auth.test` resolves the bot's own identity, and
102
+ * `slack-front.ts` awaits it BEFORE returning `OK()` on the route Slack posts events to. Slack times
103
+ * that ack out at 3 seconds and re-delivers; an app that keeps missing the ack gets its event
104
+ * subscription disabled. Under the shared defaults a workspace-wide 429 — the exact case this whole
105
+ * change exists for — would block that ack for up to 60 s and turn a rate-limit episode into a
106
+ * delivery outage. So the probe waits at most about 2 s in total, and its callers already degrade
107
+ * gracefully when it answers nothing.
108
+ *
109
+ * `conversations.info` and `users.info` are here for the same reason (deploy #142 batch review):
110
+ * the Slack front route awaits both before `OK()` — `channelFactsFor` resolves through
111
+ * `channelInternalAndMeta` (fails closed to `{internal:false}`) and `authorFactsFor` through
112
+ * `slackAuthorIsMember` (fails closed to `undefined`) — so the shared 60 s budget would hold the
113
+ * 3-second ack exactly like `auth.test`.
114
+ *
115
+ * `conversations.open` joined them for the SAME two reasons (OPE-1376): a GLOBAL shortcut has no
116
+ * conversation, so the front route awaits this call to resolve the invoker's DM before it acks, and
117
+ * `liveOpenDirectMessageChannel` fails closed to `undefined` (the shortcut then wakes nothing and
118
+ * says so). Slack's interactivity budget is 3 seconds, so the shared policy would hold the ack past
119
+ * it on a rate-limited workspace. It is NOT here for the governance DM-delivery arm, which makes
120
+ * the same call off any ack's critical path and is content with the shared budget.
121
+ *
122
+ * Only add an operation here if BOTH hold: its caller treats a failure as non-fatal, AND something
123
+ * time-critical waits on it.
124
+ *
125
+ * Every member is pinned by name in `scripts/slack-rate-limit-test.ts` § 4c (OPE-1242): drop one
126
+ * and it falls back to the shared 4-attempt / 30 s policy, which that suite asserts against.
127
+ */
128
+ const PROBE_OPERATIONS: ReadonlySet<string> = new Set(["auth.test", "conversations.info", "users.info", "conversations.open"]);
129
+ const PROBE_MAX_ATTEMPTS = 2;
130
+ const PROBE_CAP_MS = 2_000;
131
+
132
+ /** An explicit token, else `SLACK_BOT_TOKEN`. Throws eve's exact message when neither is set. */
133
+ function resolveBotToken(botToken: string | undefined): string {
134
+ const token = botToken ?? process.env.SLACK_BOT_TOKEN;
135
+ if (!token) throw new Error("SLACK_BOT_TOKEN is required.");
136
+ return token;
137
+ }
138
+
139
+ /**
140
+ * POST one Slack Web API method, with the 429 backoff applied.
141
+ *
142
+ * Returns Slack's parsed JSON envelope on any 2xx, `ok:false` included. Throws
143
+ * {@link SlackWebApiError} on a non-2xx that is not a 429, and `RateLimitExhaustedError` when a 429
144
+ * outlasts the bounded attempts.
145
+ */
146
+ export async function callSlackWebApi(
147
+ input: { readonly botToken?: string; readonly operation: string; readonly body: unknown },
148
+ deps: SlackWebApiDeps = {},
149
+ ): Promise<SlackWebApiResponse> {
150
+ const token = resolveBotToken(input.botToken);
151
+ const encoded = encodeSlackForm(input.body);
152
+ const url = new URL(input.operation, deps.apiBase ?? SLACK_API_BASE).toString();
153
+ const send = (): Promise<Response> =>
154
+ (deps.fetch ?? (globalThis.fetch as SlackFetchLike))(url, {
155
+ body: encoded,
156
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
157
+ method: "POST",
158
+ });
159
+
160
+ const probe = PROBE_OPERATIONS.has(input.operation);
161
+ const res = await withRetryAfter(send, {
162
+ surface: "slack",
163
+ operation: input.operation,
164
+ // An explicit dep always wins, so a test still drives the policy it wants.
165
+ maxAttempts: deps.maxAttempts ?? (probe ? PROBE_MAX_ATTEMPTS : undefined),
166
+ capMs: deps.capMs ?? (probe ? PROBE_CAP_MS : undefined),
167
+ totalBudgetMs: deps.totalBudgetMs,
168
+ sleep: deps.sleep,
169
+ now: deps.now,
170
+ });
171
+
172
+ // eve parses the body before the status check, so a non-2xx carries Slack's error envelope. Keep
173
+ // that, and keep the parse failure that a non-JSON body produces — callers already treat any throw
174
+ // from this seam as an ambiguous transport failure.
175
+ const json = (await res.json()) as SlackWebApiResponse;
176
+ if (!res.ok) throw new SlackWebApiError({ method: input.operation, status: res.status, response: json });
177
+ return json;
178
+ }
@@ -0,0 +1,116 @@
1
+ // Slack's webhook signature scheme, as a registry entry (OPE-197) — what lets a GENERIC hook
2
+ // (`POST /webhooks/<id>`) declare `source: "slack"` and be verified exactly the way Slack signs,
3
+ // instead of the deployment being able to check that signature in the surface front door and
4
+ // nowhere else.
5
+ //
6
+ // WHY THIS EXISTS AT ALL, in the two terms that survive. (1) `registeredWebhookVerifierSources()` is
7
+ // printed VERBATIM in the gate's 401 (webhooks/gate.ts), so it is a live statement to a caller about
8
+ // what this deployment can authenticate — and it was under-reporting a scheme the tree has
9
+ // implemented and tested for as long as the Slack surface has existed. (2) Registration is the only
10
+ // place `keyOrigin` and `signedTimestamp` get DECLARED, and those two fields are what drive
11
+ // `acceptsMintedSecret` (webhooks/source-vocabulary.ts) and the durable single-use replay dedupe
12
+ // (webhooks/replay-dedupe.ts). Inline in a front door, both facts are true of the code and invisible
13
+ // to every registry that reasons about them.
14
+ //
15
+ // ZERO NEW CRYPTO. The basestring and digest are `channels/slack-signature.ts`'s — the SAME
16
+ // primitive the surface front door (`verifySlackSignature`) uses, extracted for this entry rather
17
+ // than copied, so there is one implementation of Slack's scheme in the tree and not two that can
18
+ // drift. What this file adds is the part a registry entry needs and a boolean-returning front door
19
+ // has no use for: the closed FAILURE vocabulary, and iteration over a rotation's key list.
20
+ //
21
+ // THE SCHEME (https://api.slack.com/authentication/verifying-requests-from-slack):
22
+ // X-Slack-Request-Timestamp: <unix seconds>
23
+ // X-Slack-Signature: v0=<hex> HMAC-SHA256 of `v0:<timestamp>:<raw body>`.
24
+ //
25
+ // 🔴 `keyOrigin: "vendor-issued"` (OPE-186) — the key is the app's **Signing Secret**, which SLACK
26
+ // generates and shows on the app's Basic Information page (docs/deploy/surfaces/slack.md step 1.5:
27
+ // *"copy the Signing Secret"*). It is read, never chosen, so a secret WE minted can never verify a
28
+ // Slack delivery. That is what keeps `slack` OUT of the chat-mintable source vocabulary
29
+ // (`acceptsMintedSecret` is false for it) — only an OPERATOR-DECLARED hook, whose `secretRef` the
30
+ // operator points at the stored signing secret, can use this verifier. Registering it does NOT make
31
+ // it chat-creatable, and that is the intended outcome, not a limitation to be fixed later.
32
+ //
33
+ // ⭐ `signedTimestamp: true` — and this is the substantive gain over leaving it inline. Slack's
34
+ // basestring COVERS the timestamp, so the freshness claim is the sender's own signed one, and this
35
+ // verifier enforces a window against it. Under `webhooks/gate.ts` that promotes the source from
36
+ // "replay bounded by nothing" to the durable single-use 409 (webhooks/replay-dedupe.ts) — the layer
37
+ // `github` and `notion` structurally cannot have, because they sign the body alone.
38
+ //
39
+ // ⚠️ THE SKEW WINDOW HERE IS THE DEPLOYMENT'S, NOT SLACK'S. The front door hard-codes Slack's own
40
+ // documented 5 minutes because it answers a Slack Request URL; this entry reads
41
+ // `webhookMaxSkewSeconds()` (`OE_WEBHOOK_MAX_SKEW_SECONDS`, default 300) because it answers the
42
+ // GENERIC ingress, where an operator's one freshness bound governs every scheme. The two coincide at
43
+ // the default on purpose and are allowed to diverge on purpose.
44
+ //
45
+ // NOTE this entry changes NO existing route. `/eve/v1/slack` and `/eve/v1/slack/commands` keep their
46
+ // own inline gate, unchanged; registering here only makes the scheme available to the generic
47
+ // ingress.
48
+
49
+ import { createHash } from "node:crypto";
50
+ import {
51
+ SLACK_SIGNATURE_HEADER,
52
+ SLACK_SIGNATURE_VERSION,
53
+ SLACK_TIMESTAMP_HEADER,
54
+ slackSignatureMatches,
55
+ } from "./slack-signature.js";
56
+ import { webhookMaxSkewSeconds, type WebhookVerifier } from "@open-everywhere/core/agent/adapter-api.js";
57
+
58
+ /** The registry key — what a generic hook declares as its `source` to be verified as Slack. */
59
+ export const SLACK_WEBHOOK_SOURCE = "slack";
60
+
61
+ /** Unix seconds, as Slack sends them. Checked as TEXT before `Number()` so `"1e9"`, `" 12"` and
62
+ * `"12.0"` are `malformed-timestamp` rather than silently coerced into a passing window. */
63
+ const TIMESTAMP_RE = /^[0-9]{1,15}$/;
64
+
65
+ /**
66
+ * The opaque single-use nonce for an accepted Slack delivery.
67
+ *
68
+ * 🔴 IT IS THE SIGNED CONTENT, HASHED — not a delivery id, because Slack's scheme has none: the
69
+ * basestring is `v0:<timestamp>:<rawBody>` and nothing else, so `(timestamp, rawBody)` IS the
70
+ * complete identity the sender signed. Two presentations agreeing on both are, to this scheme,
71
+ * indistinguishable — which is exactly what "the same delivery, twice" means here.
72
+ *
73
+ * WHAT THAT COSTS, stated rather than assumed: a byte-identical re-send from Slack inside the window
74
+ * is graded a replay. That is deliberate and it is nearly free, because a byte-identical re-send
75
+ * OUTSIDE the window already fails the skew check at 401 — so this dedupe only ever changes the
76
+ * verdict in the interval where the scheme itself cannot tell a legitimate re-send from a captured
77
+ * one. Deriving it from the SIGNATURE instead would have been wrong for a different reason: the
78
+ * signature is key-dependent, so it would change identity across a rotation overlap for a delivery
79
+ * that never changed.
80
+ */
81
+ function slackReplayKey(timestamp: string, rawBody: string): string {
82
+ return createHash("sha256").update(`${SLACK_WEBHOOK_SOURCE}:${timestamp}:${rawBody}`).digest("hex");
83
+ }
84
+
85
+ export const slackWebhookVerifier: WebhookVerifier = {
86
+ source: SLACK_WEBHOOK_SOURCE,
87
+ // Provider-owned ⇒ routed through admitProviderRegistration: de-select the slack bundle and this
88
+ // row never lands, so a `source: "slack"` hook 401s instead of silently using another scheme.
89
+ provider: "slack",
90
+ keyKind: "shared-secret",
91
+ keyOrigin: "vendor-issued",
92
+ signedTimestamp: true,
93
+ verify({ rawBody, headers, keys, nowMs }) {
94
+ const signature = headers.get(SLACK_SIGNATURE_HEADER);
95
+ if (!signature) return { ok: false, failure: "missing-signature" };
96
+ const timestamp = headers.get(SLACK_TIMESTAMP_HEADER);
97
+ // A missing timestamp is its own failure, not `missing-signature`: it is COVERED BY the
98
+ // signature, so without it there is no basestring to compute at all.
99
+ if (timestamp === null) return { ok: false, failure: "missing-timestamp" };
100
+ if (!TIMESTAMP_RE.test(timestamp)) return { ok: false, failure: "malformed-timestamp" };
101
+ // SKEW BEFORE ANY HMAC — a stale replay must not cost a digest per configured key.
102
+ if (Math.abs(nowMs / 1000 - Number(timestamp)) > webhookMaxSkewSeconds()) return { ok: false, failure: "stale-timestamp" };
103
+ // A signature tagged with a version we do not implement is `unsupported-scheme`, never an
104
+ // attempted match: if Slack ever ships a `v1`, guessing at it is the shape of a downgrade.
105
+ if (!signature.startsWith(`${SLACK_SIGNATURE_VERSION}=`)) return { ok: false, failure: "unsupported-scheme" };
106
+ for (const [i, key] of keys.entries()) {
107
+ // Ordered, current-first: a match at i>0 is a rotation overlap and the gate audits it. Slack
108
+ // documents no overlap window of its own (rotating the signing secret is immediate), so
109
+ // `keys[]` is how an operator gets one on our side.
110
+ if (slackSignatureMatches(key, timestamp, rawBody, signature)) {
111
+ return { ok: true, keyIndex: i, replayKey: slackReplayKey(timestamp, rawBody) };
112
+ }
113
+ }
114
+ return { ok: false, failure: "signature-mismatch" };
115
+ },
116
+ };