From cb65a3bea1bc6c487ee33563c1ec685948e329c7 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 19 May 2026 21:05:39 +0100 Subject: [PATCH 1/8] feat(slack): add webhook primitives --- packages/adapter-slack/package.json | 4 + packages/adapter-slack/src/webhook-parse.ts | 330 +++++++++++++++++++ packages/adapter-slack/src/webhook-types.ts | 193 +++++++++++ packages/adapter-slack/src/webhook-utils.ts | 123 +++++++ packages/adapter-slack/src/webhook-verify.ts | 99 ++++++ packages/adapter-slack/src/webhook.ts | 12 + packages/adapter-slack/tsup.config.ts | 2 +- 7 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 packages/adapter-slack/src/webhook-parse.ts create mode 100644 packages/adapter-slack/src/webhook-types.ts create mode 100644 packages/adapter-slack/src/webhook-utils.ts create mode 100644 packages/adapter-slack/src/webhook-verify.ts create mode 100644 packages/adapter-slack/src/webhook.ts diff --git a/packages/adapter-slack/package.json b/packages/adapter-slack/package.json index 9d7463e1..645efaab 100644 --- a/packages/adapter-slack/package.json +++ b/packages/adapter-slack/package.json @@ -13,6 +13,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./webhook": { + "types": "./dist/webhook.d.ts", + "import": "./dist/webhook.js" } }, "files": [ diff --git a/packages/adapter-slack/src/webhook-parse.ts b/packages/adapter-slack/src/webhook-parse.ts new file mode 100644 index 00000000..4040f3b2 --- /dev/null +++ b/packages/adapter-slack/src/webhook-parse.ts @@ -0,0 +1,330 @@ +import type { + SlackAction, + SlackAppMentionPayload, + SlackBlockActionsPayload, + SlackBlockSuggestionPayload, + SlackDirectMessagePayload, + SlackParseOptions, + SlackRetry, + SlackSlashCommandPayload, + SlackViewClosedPayload, + SlackViewSubmissionPayload, + SlackWebhookPayload, +} from "./webhook-types"; +import { + getHeader, + getRetry, + isFormBody, + isRecord, + optionalString, + parseJsonBody, + recordValue, + stringValue, +} from "./webhook-utils"; + +export function parseSlackWebhookBody( + body: string, + options: SlackParseOptions = {} +): SlackWebhookPayload { + const headers = options.headers; + const contentType = + options.contentType ?? getHeader(headers, "content-type") ?? ""; + const retry = getRetry(headers); + + if (isFormBody(body, contentType)) { + return parseFormBody(body, retry); + } + + const raw = parseJsonBody(body); + return classifyJsonPayload(raw, retry); +} + +function parseFormBody( + body: string, + retry: SlackRetry | undefined +): SlackWebhookPayload { + const params = new URLSearchParams(body); + const payload = params.get("payload"); + if (payload !== null) { + const raw = parseJsonBody(payload); + return classifyInteractionPayload(raw, retry); + } + if (params.has("command")) { + return parseSlashCommand(params, retry); + } + return { + kind: "unsupported", + raw: Object.fromEntries(params), + retry, + type: "form", + }; +} + +function classifyJsonPayload( + raw: unknown, + retry: SlackRetry | undefined +): SlackWebhookPayload { + if (!isRecord(raw)) { + return { kind: "unsupported", raw, retry, type: "unknown" }; + } + + if (raw.type === "url_verification" && typeof raw.challenge === "string") { + return { challenge: raw.challenge, kind: "url_verification", raw, retry }; + } + + if (raw.type !== "event_callback" || !isRecord(raw.event)) { + return { + kind: "unsupported", + raw, + retry, + type: typeof raw.type === "string" ? raw.type : "unknown", + }; + } + + const event = raw.event; + if (event.type === "app_mention") { + return parseMessageEvent("app_mention", raw, event, retry); + } + + if (event.type === "message" && event.channel_type === "im") { + return parseMessageEvent("direct_message", raw, event, retry); + } + + return { + kind: "unsupported", + raw, + retry, + type: typeof event.type === "string" ? event.type : "event_callback", + }; +} + +function classifyInteractionPayload( + raw: unknown, + retry: SlackRetry | undefined +): SlackWebhookPayload { + if (!isRecord(raw)) { + return { kind: "unsupported", raw, retry, type: "interaction" }; + } + + switch (raw.type) { + case "block_actions": + return parseBlockActions(raw, retry); + case "block_suggestion": + return parseBlockSuggestion(raw, retry); + case "view_submission": + return parseViewSubmission(raw, retry); + case "view_closed": + return parseViewClosed(raw, retry); + default: + return { + kind: "unsupported", + raw, + retry, + type: typeof raw.type === "string" ? raw.type : "interaction", + }; + } +} + +function parseMessageEvent( + kind: "app_mention" | "direct_message", + envelope: Record, + event: Record, + retry: SlackRetry | undefined +): SlackAppMentionPayload | SlackDirectMessagePayload { + const channelId = stringValue(event.channel); + const ts = stringValue(event.ts); + const threadTs = stringValue(event.thread_ts) || ts; + const teamId = + optionalString(event.team_id) || optionalString(envelope.team_id); + const enterpriseId = + optionalString(envelope.enterprise_id) || + optionalString(envelope.context_enterprise_id); + const continuation = channelId + ? { channelId, enterpriseId, teamId, threadTs } + : { channelId: "", enterpriseId, teamId, threadTs }; + const base = { + apiAppId: optionalString(envelope.api_app_id), + channelId, + continuation, + enterpriseId, + eventId: optionalString(envelope.event_id), + eventTime: + typeof envelope.event_time === "number" ? envelope.event_time : undefined, + eventType: event.type, + isExtSharedChannel: + typeof envelope.is_ext_shared_channel === "boolean" + ? envelope.is_ext_shared_channel + : undefined, + raw: event, + retry, + teamId, + text: stringValue(event.text), + threadTs, + ts, + userId: optionalString(event.user), + }; + + if (kind === "app_mention") { + return { ...base, eventType: "app_mention", kind }; + } + + return { + ...base, + botId: optionalString(event.bot_id), + eventType: "message", + kind, + subtype: optionalString(event.subtype), + }; +} + +function parseSlashCommand( + params: URLSearchParams, + retry: SlackRetry | undefined +): SlackSlashCommandPayload { + const enterpriseId = params.get("enterprise_id") || undefined; + const teamId = params.get("team_id") || undefined; + return { + channelId: params.get("channel_id") ?? "", + channelName: params.get("channel_name") || undefined, + command: params.get("command") ?? "", + enterpriseId, + isEnterpriseInstall: params.get("is_enterprise_install") === "true", + kind: "slash_command", + raw: Object.fromEntries(params), + responseUrl: params.get("response_url") || undefined, + retry, + teamId, + text: params.get("text") ?? "", + triggerId: params.get("trigger_id") || undefined, + userId: params.get("user_id") ?? "", + userName: params.get("user_name") || undefined, + }; +} + +function parseBlockActions( + raw: Record, + retry: SlackRetry | undefined +): SlackBlockActionsPayload { + const channel = recordValue(raw.channel); + const container = recordValue(raw.container); + const message = recordValue(raw.message); + const user = recordValue(raw.user); + const team = recordValue(raw.team); + const enterprise = recordValue(raw.enterprise); + const channelId = + optionalString(channel?.id) || optionalString(container?.channel_id); + const messageTs = + optionalString(message?.ts) || optionalString(container?.message_ts); + const threadTs = + optionalString(message?.thread_ts) || + optionalString(container?.thread_ts) || + messageTs; + const teamId = optionalString(team?.id) || optionalString(user?.team_id); + const enterpriseId = + optionalString(enterprise?.id) || optionalString(team?.enterprise_id); + const continuation = + channelId && threadTs + ? { channelId, enterpriseId, teamId, threadTs } + : undefined; + + return { + actions: Array.isArray(raw.actions) ? raw.actions.map(parseAction) : [], + channelId, + continuation, + enterpriseId, + isEnterpriseInstall: + typeof raw.is_enterprise_install === "boolean" + ? raw.is_enterprise_install + : undefined, + kind: "block_actions", + messageTs, + raw, + responseUrl: optionalString(raw.response_url), + retry, + teamId, + threadTs, + triggerId: optionalString(raw.trigger_id), + userId: stringValue(user?.id), + userName: optionalString(user?.username) || optionalString(user?.name), + }; +} + +function parseAction(action: unknown): SlackAction { + const raw = isRecord(action) ? action : {}; + const selectedOption = recordValue(raw.selected_option); + const text = recordValue(raw.text); + return { + actionId: stringValue(raw.action_id), + blockId: optionalString(raw.block_id), + label: optionalString(text?.text), + raw, + selectedOptionValue: optionalString(selectedOption?.value), + type: stringValue(raw.type), + value: optionalString(raw.value), + }; +} + +function parseBlockSuggestion( + raw: Record, + retry: SlackRetry | undefined +): SlackBlockSuggestionPayload { + const channel = recordValue(raw.channel); + const team = recordValue(raw.team); + const enterprise = recordValue(raw.enterprise); + const user = recordValue(raw.user); + return { + actionId: stringValue(raw.action_id), + blockId: stringValue(raw.block_id), + channelId: optionalString(channel?.id), + enterpriseId: + optionalString(enterprise?.id) || optionalString(team?.enterprise_id), + kind: "block_suggestion", + raw, + retry, + teamId: optionalString(team?.id), + userId: stringValue(user?.id), + value: stringValue(raw.value), + }; +} + +function parseViewSubmission( + raw: Record, + retry: SlackRetry | undefined +): SlackViewSubmissionPayload { + const team = recordValue(raw.team); + const enterprise = recordValue(raw.enterprise); + const user = recordValue(raw.user); + const view = recordValue(raw.view) ?? {}; + return { + enterpriseId: + optionalString(enterprise?.id) || optionalString(team?.enterprise_id), + kind: "view_submission", + raw, + responseUrls: Array.isArray(view.response_urls) + ? view.response_urls + : undefined, + retry, + teamId: optionalString(team?.id), + userId: stringValue(user?.id), + view, + }; +} + +function parseViewClosed( + raw: Record, + retry: SlackRetry | undefined +): SlackViewClosedPayload { + const team = recordValue(raw.team); + const enterprise = recordValue(raw.enterprise); + const user = recordValue(raw.user); + return { + enterpriseId: + optionalString(enterprise?.id) || optionalString(team?.enterprise_id), + kind: "view_closed", + raw, + retry, + teamId: optionalString(team?.id), + userId: stringValue(user?.id), + view: recordValue(raw.view) ?? {}, + }; +} diff --git a/packages/adapter-slack/src/webhook-types.ts b/packages/adapter-slack/src/webhook-types.ts new file mode 100644 index 00000000..188e64e0 --- /dev/null +++ b/packages/adapter-slack/src/webhook-types.ts @@ -0,0 +1,193 @@ +export type SlackHeaderValue = readonly string[] | string | null | undefined; + +export type SlackHeaders = + | Headers + | Iterable + | Record; + +export type SlackWebhookVerifier = ( + request: Request, + body: string +) => Promise | unknown; + +export interface SlackVerifyOptions { + maxSkewSeconds?: number; + now?: () => number; + signingSecret?: string; + webhookVerifier?: SlackWebhookVerifier; +} + +export interface SlackParseOptions { + contentType?: string | null; + headers?: SlackHeaders; +} + +export interface SlackReadOptions + extends SlackParseOptions, + SlackVerifyOptions {} + +export interface SlackRetry { + num: number; + reason?: string; +} + +export interface SlackContinuation { + channelId: string; + enterpriseId?: string; + teamId?: string; + threadTs: string; +} + +export type SlackWebhookPayload = + | SlackAppMentionPayload + | SlackBlockActionsPayload + | SlackBlockSuggestionPayload + | SlackDirectMessagePayload + | SlackSlashCommandPayload + | SlackUnsupportedPayload + | SlackUrlVerificationPayload + | SlackViewClosedPayload + | SlackViewSubmissionPayload; + +export interface SlackUrlVerificationPayload { + challenge: string; + kind: "url_verification"; + raw: Record; + retry?: SlackRetry; +} + +export interface SlackEventBasePayload { + apiAppId?: string; + channelId: string; + continuation: SlackContinuation; + enterpriseId?: string; + eventId?: string; + eventTime?: number; + isExtSharedChannel?: boolean; + raw: Record; + retry?: SlackRetry; + teamId?: string; + text: string; + threadTs: string; + ts: string; + userId?: string; +} + +export interface SlackAppMentionPayload extends SlackEventBasePayload { + eventType: "app_mention"; + kind: "app_mention"; +} + +export interface SlackDirectMessagePayload extends SlackEventBasePayload { + botId?: string; + eventType: "message"; + kind: "direct_message"; + subtype?: string; +} + +export interface SlackSlashCommandPayload { + channelId: string; + channelName?: string; + command: string; + enterpriseId?: string; + isEnterpriseInstall: boolean; + kind: "slash_command"; + raw: Record; + responseUrl?: string; + retry?: SlackRetry; + teamId?: string; + text: string; + triggerId?: string; + userId: string; + userName?: string; +} + +export interface SlackAction { + actionId: string; + blockId?: string; + label?: string; + raw: Record; + selectedOptionValue?: string; + type: string; + value?: string; +} + +export interface SlackBlockActionsPayload { + actions: SlackAction[]; + channelId?: string; + continuation?: SlackContinuation; + enterpriseId?: string; + isEnterpriseInstall?: boolean; + kind: "block_actions"; + messageTs?: string; + raw: Record; + responseUrl?: string; + retry?: SlackRetry; + teamId?: string; + threadTs?: string; + triggerId?: string; + userId: string; + userName?: string; +} + +export interface SlackBlockSuggestionPayload { + actionId: string; + blockId: string; + channelId?: string; + enterpriseId?: string; + kind: "block_suggestion"; + raw: Record; + retry?: SlackRetry; + teamId?: string; + userId: string; + value: string; +} + +export interface SlackViewSubmissionPayload { + enterpriseId?: string; + kind: "view_submission"; + raw: Record; + responseUrls?: unknown[]; + retry?: SlackRetry; + teamId?: string; + userId: string; + view: Record; +} + +export interface SlackViewClosedPayload { + enterpriseId?: string; + kind: "view_closed"; + raw: Record; + retry?: SlackRetry; + teamId?: string; + userId: string; + view: Record; +} + +export interface SlackUnsupportedPayload { + kind: "unsupported"; + raw: unknown; + retry?: SlackRetry; + type: string; +} + +export class SlackWebhookError extends Error { + constructor(message: string) { + super(message); + this.name = "SlackWebhookError"; + } +} + +export class SlackWebhookVerificationError extends SlackWebhookError { + constructor(message: string) { + super(message); + this.name = "SlackWebhookVerificationError"; + } +} + +export class SlackWebhookParseError extends SlackWebhookError { + constructor(message: string) { + super(message); + this.name = "SlackWebhookParseError"; + } +} diff --git a/packages/adapter-slack/src/webhook-utils.ts b/packages/adapter-slack/src/webhook-utils.ts new file mode 100644 index 00000000..ab4f1fd8 --- /dev/null +++ b/packages/adapter-slack/src/webhook-utils.ts @@ -0,0 +1,123 @@ +import { + type SlackHeaders, + type SlackRetry, + SlackWebhookParseError, +} from "./webhook-types"; + +export function getHeader( + headers: SlackHeaders | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + if (typeof Headers !== "undefined" && headers instanceof Headers) { + return headers.get(name) ?? undefined; + } + const lower = name.toLowerCase(); + if (isIterableHeaders(headers)) { + for (const [key, value] of headers) { + if (key.toLowerCase() === lower) { + return value; + } + } + return undefined; + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) { + return headerValue(value); + } + } + return undefined; +} + +export function getRetry( + headers: SlackHeaders | undefined +): SlackRetry | undefined { + const retryNum = getHeader(headers, "x-slack-retry-num"); + if (!retryNum) { + return undefined; + } + const num = Number(retryNum); + if (!Number.isFinite(num)) { + return undefined; + } + return { + num, + reason: getHeader(headers, "x-slack-retry-reason"), + }; +} + +export function isFormBody(body: string, contentType: string): boolean { + if (contentType.includes("application/x-www-form-urlencoded")) { + return true; + } + if (contentType.includes("application/json")) { + return false; + } + const trimmed = body.trimStart(); + return !trimmed.startsWith("{") && body.includes("="); +} + +export function parseJsonBody(body: string): unknown { + try { + return JSON.parse(body); + } catch { + throw new SlackWebhookParseError("Slack webhook body is invalid JSON"); + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function recordValue( + value: unknown +): Record | undefined { + return isRecord(value) ? value : undefined; +} + +export function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function optionalString(value: unknown): string | undefined { + const text = stringValue(value); + return text || undefined; +} + +export function constantTimeStringEqual(a: string, b: string): boolean { + const length = Math.max(a.length, b.length); + let diff = Math.abs(a.length - b.length); + for (let i = 0; i < length; i++) { + diff += Math.abs((a.charCodeAt(i) || 0) - (b.charCodeAt(i) || 0)); + } + return diff === 0; +} + +export function toHex(bytes: Uint8Array): string { + let hex = ""; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +} + +function isIterableHeaders( + headers: SlackHeaders +): headers is Iterable { + return ( + typeof (headers as { [Symbol.iterator]?: unknown })[Symbol.iterator] === + "function" + ); +} + +function headerValue(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value[0]; + } + return undefined; +} diff --git a/packages/adapter-slack/src/webhook-verify.ts b/packages/adapter-slack/src/webhook-verify.ts new file mode 100644 index 00000000..98048fa7 --- /dev/null +++ b/packages/adapter-slack/src/webhook-verify.ts @@ -0,0 +1,99 @@ +import { parseSlackWebhookBody } from "./webhook-parse"; +import { + type SlackHeaders, + type SlackReadOptions, + type SlackVerifyOptions, + type SlackWebhookPayload, + SlackWebhookVerificationError, +} from "./webhook-types"; +import { constantTimeStringEqual, getHeader, toHex } from "./webhook-utils"; + +export async function readSlackWebhook( + request: Request, + options: SlackReadOptions +): Promise { + const body = await verifySlackRequest(request, options); + return parseSlackWebhookBody(body, { + contentType: options.contentType, + headers: request.headers, + }); +} + +export async function verifySlackRequest( + request: Request, + options: SlackVerifyOptions +): Promise { + const body = await request.text(); + if (options.webhookVerifier) { + const result = await options.webhookVerifier(request, body); + if (!result) { + throw new SlackWebhookVerificationError( + "Slack webhook verifier rejected the request" + ); + } + return typeof result === "string" ? result : body; + } + + await verifySlackSignature(body, request.headers, options); + return body; +} + +export async function verifySlackSignature( + body: string, + headers: SlackHeaders, + options: SlackVerifyOptions +): Promise { + const signingSecret = options.signingSecret; + if (!signingSecret) { + throw new SlackWebhookVerificationError("Slack signing secret is required"); + } + + const timestamp = getHeader(headers, "x-slack-request-timestamp"); + const signature = getHeader(headers, "x-slack-signature"); + if (!(timestamp && signature)) { + throw new SlackWebhookVerificationError( + "Slack signature headers are required" + ); + } + + const timestampSeconds = Number(timestamp); + if (!Number.isFinite(timestampSeconds)) { + throw new SlackWebhookVerificationError("Slack timestamp is invalid"); + } + + const now = Math.floor((options.now?.() ?? Date.now()) / 1000); + const maxSkewSeconds = options.maxSkewSeconds ?? 300; + if (Math.abs(now - timestampSeconds) > maxSkewSeconds) { + throw new SlackWebhookVerificationError("Slack timestamp is too old"); + } + + const expected = await createSlackSignature(body, signingSecret, timestamp); + if (!constantTimeStringEqual(expected, signature)) { + throw new SlackWebhookVerificationError("Slack signature is invalid"); + } +} + +async function createSlackSignature( + body: string, + signingSecret: string, + timestamp: string +): Promise { + const crypto = globalThis.crypto; + if (!crypto?.subtle) { + throw new SlackWebhookVerificationError("Web Crypto is not available"); + } + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(signingSecret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(`v0:${timestamp}:${body}`) + ); + return `v0=${toHex(new Uint8Array(signature))}`; +} diff --git a/packages/adapter-slack/src/webhook.ts b/packages/adapter-slack/src/webhook.ts new file mode 100644 index 00000000..0fc6c3bd --- /dev/null +++ b/packages/adapter-slack/src/webhook.ts @@ -0,0 +1,12 @@ +export { parseSlackWebhookBody } from "./webhook-parse"; +export type * from "./webhook-types"; +export { + SlackWebhookError, + SlackWebhookParseError, + SlackWebhookVerificationError, +} from "./webhook-types"; +export { + readSlackWebhook, + verifySlackRequest, + verifySlackSignature, +} from "./webhook-verify"; diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index 6a9a22f4..83bf9892 100644 --- a/packages/adapter-slack/tsup.config.ts +++ b/packages/adapter-slack/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/webhook.ts"], format: ["esm"], dts: true, clean: true, From fc5bd95fb77e1efddcf1cd6673e59c5850948d8a Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 19 May 2026 21:07:36 +0100 Subject: [PATCH 2/8] refactor(slack): reuse webhook verification --- packages/adapter-slack/src/index.test.ts | 1 + packages/adapter-slack/src/index.ts | 74 ++++-------------------- packages/adapter-slack/src/types.ts | 6 +- 3 files changed, 15 insertions(+), 66 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 5fd91287..124f2735 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -977,6 +977,7 @@ describe("handleWebhook - interactive payloads", () => { }); const responsePromise = adapter.handleWebhook(request); + await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(2500); const response = await responsePromise; diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 71ca7a77..52654396 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { createHmac, timingSafeEqual } from "node:crypto"; +import { timingSafeEqual } from "node:crypto"; import { AdapterRateLimitError, AuthenticationError, @@ -73,6 +73,7 @@ import { type SlackModalResponse, selectOptionToSlackOption, } from "./modals"; +import { verifySlackRequest } from "./webhook"; const SLACK_USER_ID_PATTERN = /^[A-Z0-9_]+$/; const SLACK_USER_ID_EXACT_PATTERN = /^U[A-Z0-9]+$/; @@ -1133,34 +1134,17 @@ export class SlackAdapter implements Adapter { }); } - let body = await request.text(); - this.logger.debug("Slack webhook raw body", { body }); - - // Verify request using dynamic verifier or signature. - if (this.webhookVerifier) { - try { - const verified = await this.webhookVerifier(request, body); - if (!verified) { - this.logger.warn("Webhook verifier rejected request"); - return new Response("Invalid signature", { status: 401 }); - } - // If the verifier returns a string, use it as the verified body for - // downstream parsing. Other truthy values (boolean, object) are - // treated as a pure verification signal. - if (typeof verified === "string") { - body = verified; - } - } catch (error) { - this.logger.warn("Webhook verifier rejected request", { error }); - return new Response("Invalid signature", { status: 401 }); - } - } else { - const timestamp = request.headers.get("x-slack-request-timestamp"); - const signature = request.headers.get("x-slack-signature"); - if (!this.verifySignature(body, timestamp, signature)) { - return new Response("Invalid signature", { status: 401 }); - } + let body: string; + try { + body = await verifySlackRequest(request, { + signingSecret: this.signingSecret, + webhookVerifier: this.webhookVerifier, + }); + } catch (error) { + this.logger.warn("Webhook verifier rejected request", { error }); + return new Response("Invalid signature", { status: 401 }); } + this.logger.debug("Slack webhook raw body", { body }); // Check if this is a form-urlencoded payload const contentType = request.headers.get("content-type") || ""; @@ -2060,40 +2044,6 @@ export class SlackAdapter implements Adapter { } } - protected verifySignature( - body: string, - timestamp: string | null, - signature: string | null - ): boolean { - if (!(timestamp && signature && this.signingSecret)) { - return false; - } - - // Check timestamp is recent (within 5 minutes) - const now = Math.floor(Date.now() / 1000); - if (Math.abs(now - Number.parseInt(timestamp, 10)) > 300) { - return false; - } - - // Compute expected signature - const sigBasestring = `v0:${timestamp}:${body}`; - const expectedSignature = - "v0=" + - createHmac("sha256", this.signingSecret) - .update(sigBasestring) - .digest("hex"); - - // Compare signatures using timing-safe comparison - try { - return timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature) - ); - } catch { - return false; - } - } - /** * Handle message events from Slack. * Bot message filtering (isMe) is handled centrally by the Chat class. diff --git a/packages/adapter-slack/src/types.ts b/packages/adapter-slack/src/types.ts index 0ec510f5..dd356d7a 100644 --- a/packages/adapter-slack/src/types.ts +++ b/packages/adapter-slack/src/types.ts @@ -3,6 +3,7 @@ */ import type { Logger } from "chat"; +import type { SlackWebhookVerifier } from "./webhook"; export type SlackAdapterMode = "webhook" | "socket"; @@ -90,8 +91,5 @@ export interface SlackAdapterConfig { * equivalent freshness signal) to prevent replay of captured signed * requests. */ - webhookVerifier?: ( - request: Request, - body: string - ) => unknown | Promise; + webhookVerifier?: SlackWebhookVerifier; } From 4faf8c34281d35f1bea6a80de11370f2991305d8 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 19 May 2026 21:07:49 +0100 Subject: [PATCH 3/8] test(slack): cover webhook primitives --- .../src/webhook-boundary.test.ts | 31 ++ packages/adapter-slack/src/webhook.test.ts | 484 ++++++++++++++++++ 2 files changed, 515 insertions(+) create mode 100644 packages/adapter-slack/src/webhook-boundary.test.ts create mode 100644 packages/adapter-slack/src/webhook.test.ts diff --git a/packages/adapter-slack/src/webhook-boundary.test.ts b/packages/adapter-slack/src/webhook-boundary.test.ts new file mode 100644 index 00000000..1d8af7e2 --- /dev/null +++ b/packages/adapter-slack/src/webhook-boundary.test.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("webhook import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const files = [ + "webhook.ts", + "webhook-parse.ts", + "webhook-types.ts", + "webhook-utils.ts", + "webhook-verify.ts", + ]; + const source = ( + await Promise.all( + files.map((file) => + readFile(new URL(`./${file}`, import.meta.url), { + encoding: "utf8", + }) + ) + ) + ).join("\n"); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "@slack/web-api"'); + expect(source).not.toContain('from "@slack/socket-mode"'); + expect(source).not.toContain('from "./index"'); + expect(source).not.toContain("node:crypto"); + }); +}); diff --git a/packages/adapter-slack/src/webhook.test.ts b/packages/adapter-slack/src/webhook.test.ts new file mode 100644 index 00000000..353c47e9 --- /dev/null +++ b/packages/adapter-slack/src/webhook.test.ts @@ -0,0 +1,484 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { + parseSlackWebhookBody, + readSlackWebhook, + SlackWebhookParseError, + SlackWebhookVerificationError, + verifySlackRequest, + verifySlackSignature, +} from "./webhook"; + +const secret = "8f742231b10e8888abcd99yyyzzz85a5"; +const timestamp = 1_531_420_618; +const now = () => timestamp * 1000; + +function sign(body: string, time = timestamp): string { + return `v0=${createHmac("sha256", secret) + .update(`v0:${time}:${body}`) + .digest("hex")}`; +} + +function headers(body: string, time = timestamp): Headers { + return new Headers({ + "content-type": "application/json", + "x-slack-request-timestamp": String(time), + "x-slack-signature": sign(body, time), + }); +} + +function request(body: string, init?: { contentType?: string; time?: number }) { + const time = init?.time ?? timestamp; + return new Request("https://example.com/slack", { + body, + headers: { + "content-type": init?.contentType ?? "application/json", + "x-slack-request-timestamp": String(time), + "x-slack-signature": sign(body, time), + }, + method: "POST", + }); +} + +describe("verifySlackSignature", () => { + it("accepts a valid Slack signature", async () => { + const body = + "token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c"; + + await expect( + verifySlackSignature(body, headers(body), { + now, + signingSecret: secret, + }) + ).resolves.toBeUndefined(); + }); + + it("rejects stale timestamps", async () => { + const body = "payload"; + + await expect( + verifySlackSignature(body, headers(body, timestamp - 301), { + now, + signingSecret: secret, + }) + ).rejects.toBeInstanceOf(SlackWebhookVerificationError); + }); + + it("rejects invalid signatures", async () => { + const body = "payload"; + const signedHeaders = headers(body); + signedHeaders.set("x-slack-signature", "v0=bad"); + + await expect( + verifySlackSignature(body, signedHeaders, { + now, + signingSecret: secret, + }) + ).rejects.toBeInstanceOf(SlackWebhookVerificationError); + }); + + it("accepts plain object headers case-insensitively", async () => { + const body = "payload"; + + await expect( + verifySlackSignature( + body, + { + "Content-Type": "application/json", + "X-Slack-Request-Timestamp": String(timestamp), + "X-Slack-Signature": sign(body), + }, + { now, signingSecret: secret } + ) + ).resolves.toBeUndefined(); + }); +}); + +describe("verifySlackRequest", () => { + it("returns the verified body", async () => { + const body = JSON.stringify({ type: "event_callback" }); + + await expect( + verifySlackRequest(request(body), { now, signingSecret: secret }) + ).resolves.toBe(body); + }); + + it("uses a custom verifier", async () => { + const verifier = vi.fn().mockReturnValue(true); + const body = "payload"; + + await expect( + verifySlackRequest( + new Request("https://example.com", { body, method: "POST" }), + { + webhookVerifier: verifier, + } + ) + ).resolves.toBe(body); + expect(verifier).toHaveBeenCalled(); + }); + + it("allows a custom verifier to replace the body", async () => { + const verifiedBody = JSON.stringify({ + challenge: "challenge-value", + type: "url_verification", + }); + const payload = await readSlackWebhook( + new Request("https://example.com", { + body: "original", + method: "POST", + }), + { webhookVerifier: () => verifiedBody } + ); + + expect(payload).toEqual({ + challenge: "challenge-value", + kind: "url_verification", + raw: { challenge: "challenge-value", type: "url_verification" }, + retry: undefined, + }); + }); +}); + +describe("parseSlackWebhookBody", () => { + it("parses url verification payloads", () => { + const payload = parseSlackWebhookBody( + JSON.stringify({ + challenge: "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + token: "deprecated", + type: "url_verification", + }), + { contentType: "application/json" } + ); + + expect(payload.kind).toBe("url_verification"); + expect(payload).toMatchObject({ + challenge: "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + }); + }); + + it("parses app mentions with provider-native continuation", () => { + const payload = parseSlackWebhookBody( + JSON.stringify({ + api_app_id: "A123", + event: { + channel: "C123", + text: "<@U999> hello", + thread_ts: "1710000000.000001", + ts: "1710000000.000002", + type: "app_mention", + user: "U123", + }, + event_id: "Ev123", + event_time: 1_710_000_000, + is_ext_shared_channel: true, + team_id: "T123", + type: "event_callback", + }), + { + contentType: "application/json", + headers: { + "x-slack-retry-num": "2", + "x-slack-retry-reason": "http_timeout", + }, + } + ); + + expect(payload).toMatchObject({ + apiAppId: "A123", + channelId: "C123", + continuation: { + channelId: "C123", + teamId: "T123", + threadTs: "1710000000.000001", + }, + eventId: "Ev123", + eventTime: 1_710_000_000, + isExtSharedChannel: true, + kind: "app_mention", + retry: { num: 2, reason: "http_timeout" }, + text: "<@U999> hello", + threadTs: "1710000000.000001", + ts: "1710000000.000002", + userId: "U123", + }); + }); + + it("uses ts as threadTs when app mentions are top-level messages", () => { + const payload = parseSlackWebhookBody( + JSON.stringify({ + event: { + channel: "C123", + text: "hello", + ts: "1710000000.000002", + type: "app_mention", + user: "U123", + }, + team_id: "T123", + type: "event_callback", + }), + { contentType: "application/json" } + ); + + expect(payload).toMatchObject({ + continuation: { channelId: "C123", threadTs: "1710000000.000002" }, + kind: "app_mention", + threadTs: "1710000000.000002", + }); + }); + + it("parses direct message events", () => { + const payload = parseSlackWebhookBody( + JSON.stringify({ + event: { + bot_id: "B123", + channel: "D123", + channel_type: "im", + subtype: "bot_message", + text: "hello", + ts: "1710000000.000002", + type: "message", + user: "U123", + }, + team_id: "T123", + type: "event_callback", + }) + ); + + expect(payload).toMatchObject({ + botId: "B123", + channelId: "D123", + kind: "direct_message", + subtype: "bot_message", + }); + }); + + it("parses slash command form posts", () => { + const body = new URLSearchParams({ + channel_id: "C123", + channel_name: "general", + command: "/deploy", + enterprise_id: "E123", + is_enterprise_install: "true", + response_url: "https://hooks.slack.com/commands/T123/1/abc", + team_id: "T123", + text: "prod", + trigger_id: "123.456.abc", + user_id: "U123", + user_name: "josh", + }).toString(); + + const payload = parseSlackWebhookBody(body, { + contentType: "application/x-www-form-urlencoded", + }); + + expect(payload).toEqual({ + channelId: "C123", + channelName: "general", + command: "/deploy", + enterpriseId: "E123", + isEnterpriseInstall: true, + kind: "slash_command", + raw: Object.fromEntries(new URLSearchParams(body)), + responseUrl: "https://hooks.slack.com/commands/T123/1/abc", + retry: undefined, + teamId: "T123", + text: "prod", + triggerId: "123.456.abc", + userId: "U123", + userName: "josh", + }); + }); + + it("parses block action payloads", () => { + const raw = { + actions: [ + { + action_id: "approve", + block_id: "actions", + selected_option: { value: "yes" }, + text: { text: "Approve", type: "plain_text" }, + type: "button", + value: "approve-value", + }, + ], + channel: { id: "C123", name: "general" }, + container: { + channel_id: "C123", + message_ts: "1710000000.000002", + thread_ts: "1710000000.000001", + type: "message", + }, + message: { + thread_ts: "1710000000.000001", + ts: "1710000000.000002", + }, + response_url: "https://hooks.slack.com/actions/T123/1/abc", + team: { enterprise_id: "E123", id: "T123" }, + trigger_id: "123.456.abc", + type: "block_actions", + user: { id: "U123", username: "josh" }, + }; + const body = new URLSearchParams({ + payload: JSON.stringify(raw), + }).toString(); + + const payload = parseSlackWebhookBody(body, { + contentType: "application/x-www-form-urlencoded", + }); + + expect(payload).toMatchObject({ + actions: [ + { + actionId: "approve", + blockId: "actions", + label: "Approve", + selectedOptionValue: "yes", + type: "button", + value: "approve-value", + }, + ], + channelId: "C123", + continuation: { + channelId: "C123", + enterpriseId: "E123", + teamId: "T123", + threadTs: "1710000000.000001", + }, + kind: "block_actions", + messageTs: "1710000000.000002", + responseUrl: "https://hooks.slack.com/actions/T123/1/abc", + teamId: "T123", + threadTs: "1710000000.000001", + triggerId: "123.456.abc", + userId: "U123", + }); + }); + + it("parses block suggestion payloads", () => { + const raw = { + action_id: "external", + block_id: "input", + channel: { id: "C123" }, + enterprise: { id: "E123" }, + team: { id: "T123" }, + type: "block_suggestion", + user: { id: "U123" }, + value: "hel", + }; + const payload = parseSlackWebhookBody( + new URLSearchParams({ payload: JSON.stringify(raw) }).toString(), + { contentType: "application/x-www-form-urlencoded" } + ); + + expect(payload).toMatchObject({ + actionId: "external", + blockId: "input", + channelId: "C123", + enterpriseId: "E123", + kind: "block_suggestion", + teamId: "T123", + userId: "U123", + value: "hel", + }); + }); + + it("parses view submissions", () => { + const raw = { + team: { id: "T123" }, + type: "view_submission", + user: { id: "U123" }, + view: { + callback_id: "feedback", + id: "V123", + response_urls: [ + { + action_id: "target", + channel_id: "C123", + response_url: "https://hooks.slack.com/app/1/2/3", + }, + ], + }, + }; + const payload = parseSlackWebhookBody( + new URLSearchParams({ payload: JSON.stringify(raw) }).toString(), + { contentType: "application/x-www-form-urlencoded" } + ); + + expect(payload).toMatchObject({ + kind: "view_submission", + responseUrls: [ + { + action_id: "target", + channel_id: "C123", + response_url: "https://hooks.slack.com/app/1/2/3", + }, + ], + teamId: "T123", + userId: "U123", + view: { callback_id: "feedback", id: "V123" }, + }); + }); + + it("parses view closed payloads", () => { + const raw = { + enterprise: { id: "E123" }, + team: null, + type: "view_closed", + user: { id: "U123" }, + view: { id: "V123" }, + }; + const payload = parseSlackWebhookBody( + new URLSearchParams({ payload: JSON.stringify(raw) }).toString(), + { contentType: "application/x-www-form-urlencoded" } + ); + + expect(payload).toMatchObject({ + enterpriseId: "E123", + kind: "view_closed", + userId: "U123", + view: { id: "V123" }, + }); + }); + + it("returns unsupported for valid but unsupported payloads", () => { + const payload = parseSlackWebhookBody( + JSON.stringify({ + event: { type: "reaction_added" }, + type: "event_callback", + }) + ); + + expect(payload).toEqual({ + kind: "unsupported", + raw: { + event: { type: "reaction_added" }, + type: "event_callback", + }, + retry: undefined, + type: "reaction_added", + }); + }); + + it("throws a parse error for invalid json", () => { + expect(() => + parseSlackWebhookBody("{", { contentType: "application/json" }) + ).toThrow(SlackWebhookParseError); + }); +}); + +describe("readSlackWebhook", () => { + it("verifies and parses requests", async () => { + const body = JSON.stringify({ + challenge: "challenge-value", + type: "url_verification", + }); + + await expect( + readSlackWebhook(request(body), { now, signingSecret: secret }) + ).resolves.toMatchObject({ + challenge: "challenge-value", + kind: "url_verification", + }); + }); +}); From 9b858cdc459b380a2694f0e5a1e8e886a0dd62b6 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 19 May 2026 21:08:29 +0100 Subject: [PATCH 4/8] chore: add slack webhook changeset --- .changeset/hot-steaks-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-steaks-grin.md diff --git a/.changeset/hot-steaks-grin.md b/.changeset/hot-steaks-grin.md new file mode 100644 index 00000000..94dc5487 --- /dev/null +++ b/.changeset/hot-steaks-grin.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +add lightweight Slack webhook primitives subpath From f6992784ce4c9ab86793d3581b7885c73cd5e48d Mon Sep 17 00:00:00 2001 From: dancer Date: Wed, 20 May 2026 01:26:14 +0100 Subject: [PATCH 5/8] fix(slack): use native hmac verification --- packages/adapter-slack/src/webhook-utils.ts | 17 -------- packages/adapter-slack/src/webhook-verify.ts | 44 ++++++++++++++++---- packages/adapter-slack/src/webhook.test.ts | 13 ++++++ 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/adapter-slack/src/webhook-utils.ts b/packages/adapter-slack/src/webhook-utils.ts index ab4f1fd8..e166bad1 100644 --- a/packages/adapter-slack/src/webhook-utils.ts +++ b/packages/adapter-slack/src/webhook-utils.ts @@ -86,23 +86,6 @@ export function optionalString(value: unknown): string | undefined { return text || undefined; } -export function constantTimeStringEqual(a: string, b: string): boolean { - const length = Math.max(a.length, b.length); - let diff = Math.abs(a.length - b.length); - for (let i = 0; i < length; i++) { - diff += Math.abs((a.charCodeAt(i) || 0) - (b.charCodeAt(i) || 0)); - } - return diff === 0; -} - -export function toHex(bytes: Uint8Array): string { - let hex = ""; - for (const byte of bytes) { - hex += byte.toString(16).padStart(2, "0"); - } - return hex; -} - function isIterableHeaders( headers: SlackHeaders ): headers is Iterable { diff --git a/packages/adapter-slack/src/webhook-verify.ts b/packages/adapter-slack/src/webhook-verify.ts index 98048fa7..c1ce8d30 100644 --- a/packages/adapter-slack/src/webhook-verify.ts +++ b/packages/adapter-slack/src/webhook-verify.ts @@ -6,7 +6,9 @@ import { type SlackWebhookPayload, SlackWebhookVerificationError, } from "./webhook-types"; -import { constantTimeStringEqual, getHeader, toHex } from "./webhook-utils"; +import { getHeader } from "./webhook-utils"; + +const HEX_PATTERN = /^[\da-f]+$/i; export async function readSlackWebhook( request: Request, @@ -67,17 +69,23 @@ export async function verifySlackSignature( throw new SlackWebhookVerificationError("Slack timestamp is too old"); } - const expected = await createSlackSignature(body, signingSecret, timestamp); - if (!constantTimeStringEqual(expected, signature)) { + const verified = await verifySlackSignatureValue( + body, + signingSecret, + timestamp, + signature + ); + if (!verified) { throw new SlackWebhookVerificationError("Slack signature is invalid"); } } -async function createSlackSignature( +async function verifySlackSignatureValue( body: string, signingSecret: string, - timestamp: string -): Promise { + timestamp: string, + signature: string +): Promise { const crypto = globalThis.crypto; if (!crypto?.subtle) { throw new SlackWebhookVerificationError("Web Crypto is not available"); @@ -88,12 +96,30 @@ async function createSlackSignature( encoder.encode(signingSecret), { hash: "SHA-256", name: "HMAC" }, false, - ["sign"] + ["verify"] ); - const signature = await crypto.subtle.sign( + return crypto.subtle.verify( "HMAC", key, + parseSlackSignature(signature), encoder.encode(`v0:${timestamp}:${body}`) ); - return `v0=${toHex(new Uint8Array(signature))}`; +} + +function parseSlackSignature(signature: string): ArrayBuffer { + if (!signature.startsWith("v0=")) { + throw new SlackWebhookVerificationError("Slack signature is invalid"); + } + + const hex = signature.slice(3); + if (hex.length % 2 !== 0 || !HEX_PATTERN.test(hex)) { + throw new SlackWebhookVerificationError("Slack signature is invalid"); + } + + const buffer = new ArrayBuffer(hex.length / 2); + const bytes = new Uint8Array(buffer); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return buffer; } diff --git a/packages/adapter-slack/src/webhook.test.ts b/packages/adapter-slack/src/webhook.test.ts index 353c47e9..c72dc3fc 100644 --- a/packages/adapter-slack/src/webhook.test.ts +++ b/packages/adapter-slack/src/webhook.test.ts @@ -77,6 +77,19 @@ describe("verifySlackSignature", () => { ).rejects.toBeInstanceOf(SlackWebhookVerificationError); }); + it("rejects well-formed signatures with the wrong digest", async () => { + const body = "payload"; + const signedHeaders = headers(body); + signedHeaders.set("x-slack-signature", `v0=${"0".repeat(64)}`); + + await expect( + verifySlackSignature(body, signedHeaders, { + now, + signingSecret: secret, + }) + ).rejects.toBeInstanceOf(SlackWebhookVerificationError); + }); + it("accepts plain object headers case-insensitively", async () => { const body = "payload"; From a69fddec4abc99ef887bd7694ea398c8560ca949 Mon Sep 17 00:00:00 2001 From: dancer Date: Wed, 20 May 2026 02:09:03 +0100 Subject: [PATCH 6/8] test(slack): isolate webhook timer test --- packages/adapter-slack/src/index.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 124f2735..3be72dc5 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -950,6 +950,11 @@ describe("handleWebhook - interactive payloads", () => { try { const state = createMockState(); const chatInstance = createMockChatInstance(state); + const timeoutAdapter = createSlackAdapter({ + botToken: "xoxb-test-token", + logger: mockLogger, + webhookVerifier: () => true, + }); ( chatInstance.processOptionsLoad as ReturnType ).mockImplementation( @@ -961,7 +966,7 @@ describe("handleWebhook - interactive payloads", () => { ); }) ); - await adapter.initialize(chatInstance); + await timeoutAdapter.initialize(chatInstance); const payload = JSON.stringify({ type: "block_suggestion", @@ -976,7 +981,7 @@ describe("handleWebhook - interactive payloads", () => { contentType: "application/x-www-form-urlencoded", }); - const responsePromise = adapter.handleWebhook(request); + const responsePromise = timeoutAdapter.handleWebhook(request); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(2500); const response = await responsePromise; From dfac6a910c1efbe072f0f1c5eaa9dd7b7b09aa34 Mon Sep 17 00:00:00 2001 From: dancer Date: Fri, 22 May 2026 00:56:49 +0100 Subject: [PATCH 7/8] refactor(slack): move webhook primitives into folder --- packages/adapter-slack/src/index.ts | 2 +- packages/adapter-slack/src/types.ts | 2 +- .../boundary.test.ts} | 12 ++++++------ .../src/{webhook.test.ts => webhook/index.test.ts} | 2 +- .../src/{webhook.ts => webhook/index.ts} | 8 ++++---- .../src/{webhook-parse.ts => webhook/parse.ts} | 4 ++-- .../src/{webhook-types.ts => webhook/types.ts} | 0 .../src/{webhook-utils.ts => webhook/utils.ts} | 2 +- .../src/{webhook-verify.ts => webhook/verify.ts} | 6 +++--- packages/adapter-slack/tsup.config.ts | 5 ++++- 10 files changed, 23 insertions(+), 20 deletions(-) rename packages/adapter-slack/src/{webhook-boundary.test.ts => webhook/boundary.test.ts} (82%) rename packages/adapter-slack/src/{webhook.test.ts => webhook/index.test.ts} (99%) rename packages/adapter-slack/src/{webhook.ts => webhook/index.ts} (52%) rename packages/adapter-slack/src/{webhook-parse.ts => webhook/parse.ts} (99%) rename packages/adapter-slack/src/{webhook-types.ts => webhook/types.ts} (100%) rename packages/adapter-slack/src/{webhook-utils.ts => webhook/utils.ts} (98%) rename packages/adapter-slack/src/{webhook-verify.ts => webhook/verify.ts} (96%) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 52654396..eaf8e836 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -73,7 +73,7 @@ import { type SlackModalResponse, selectOptionToSlackOption, } from "./modals"; -import { verifySlackRequest } from "./webhook"; +import { verifySlackRequest } from "./webhook/index"; const SLACK_USER_ID_PATTERN = /^[A-Z0-9_]+$/; const SLACK_USER_ID_EXACT_PATTERN = /^U[A-Z0-9]+$/; diff --git a/packages/adapter-slack/src/types.ts b/packages/adapter-slack/src/types.ts index dd356d7a..7cf8dd4a 100644 --- a/packages/adapter-slack/src/types.ts +++ b/packages/adapter-slack/src/types.ts @@ -3,7 +3,7 @@ */ import type { Logger } from "chat"; -import type { SlackWebhookVerifier } from "./webhook"; +import type { SlackWebhookVerifier } from "./webhook/index"; export type SlackAdapterMode = "webhook" | "socket"; diff --git a/packages/adapter-slack/src/webhook-boundary.test.ts b/packages/adapter-slack/src/webhook/boundary.test.ts similarity index 82% rename from packages/adapter-slack/src/webhook-boundary.test.ts rename to packages/adapter-slack/src/webhook/boundary.test.ts index 1d8af7e2..71ad1a67 100644 --- a/packages/adapter-slack/src/webhook-boundary.test.ts +++ b/packages/adapter-slack/src/webhook/boundary.test.ts @@ -4,11 +4,11 @@ import { describe, expect, it } from "vitest"; describe("webhook import boundary", () => { it("does not import the full adapter or runtime packages", async () => { const files = [ - "webhook.ts", - "webhook-parse.ts", - "webhook-types.ts", - "webhook-utils.ts", - "webhook-verify.ts", + "index.ts", + "parse.ts", + "types.ts", + "utils.ts", + "verify.ts", ]; const source = ( await Promise.all( @@ -25,7 +25,7 @@ describe("webhook import boundary", () => { expect(source).not.toContain('from "@chat-adapter/shared"'); expect(source).not.toContain('from "@slack/web-api"'); expect(source).not.toContain('from "@slack/socket-mode"'); - expect(source).not.toContain('from "./index"'); + expect(source).not.toContain('from "../index"'); expect(source).not.toContain("node:crypto"); }); }); diff --git a/packages/adapter-slack/src/webhook.test.ts b/packages/adapter-slack/src/webhook/index.test.ts similarity index 99% rename from packages/adapter-slack/src/webhook.test.ts rename to packages/adapter-slack/src/webhook/index.test.ts index c72dc3fc..5544617b 100644 --- a/packages/adapter-slack/src/webhook.test.ts +++ b/packages/adapter-slack/src/webhook/index.test.ts @@ -7,7 +7,7 @@ import { SlackWebhookVerificationError, verifySlackRequest, verifySlackSignature, -} from "./webhook"; +} from "./index"; const secret = "8f742231b10e8888abcd99yyyzzz85a5"; const timestamp = 1_531_420_618; diff --git a/packages/adapter-slack/src/webhook.ts b/packages/adapter-slack/src/webhook/index.ts similarity index 52% rename from packages/adapter-slack/src/webhook.ts rename to packages/adapter-slack/src/webhook/index.ts index 0fc6c3bd..a83b3e1c 100644 --- a/packages/adapter-slack/src/webhook.ts +++ b/packages/adapter-slack/src/webhook/index.ts @@ -1,12 +1,12 @@ -export { parseSlackWebhookBody } from "./webhook-parse"; -export type * from "./webhook-types"; +export { parseSlackWebhookBody } from "./parse"; +export type * from "./types"; export { SlackWebhookError, SlackWebhookParseError, SlackWebhookVerificationError, -} from "./webhook-types"; +} from "./types"; export { readSlackWebhook, verifySlackRequest, verifySlackSignature, -} from "./webhook-verify"; +} from "./verify"; diff --git a/packages/adapter-slack/src/webhook-parse.ts b/packages/adapter-slack/src/webhook/parse.ts similarity index 99% rename from packages/adapter-slack/src/webhook-parse.ts rename to packages/adapter-slack/src/webhook/parse.ts index 4040f3b2..a0cf9004 100644 --- a/packages/adapter-slack/src/webhook-parse.ts +++ b/packages/adapter-slack/src/webhook/parse.ts @@ -10,7 +10,7 @@ import type { SlackViewClosedPayload, SlackViewSubmissionPayload, SlackWebhookPayload, -} from "./webhook-types"; +} from "./types"; import { getHeader, getRetry, @@ -20,7 +20,7 @@ import { parseJsonBody, recordValue, stringValue, -} from "./webhook-utils"; +} from "./utils"; export function parseSlackWebhookBody( body: string, diff --git a/packages/adapter-slack/src/webhook-types.ts b/packages/adapter-slack/src/webhook/types.ts similarity index 100% rename from packages/adapter-slack/src/webhook-types.ts rename to packages/adapter-slack/src/webhook/types.ts diff --git a/packages/adapter-slack/src/webhook-utils.ts b/packages/adapter-slack/src/webhook/utils.ts similarity index 98% rename from packages/adapter-slack/src/webhook-utils.ts rename to packages/adapter-slack/src/webhook/utils.ts index e166bad1..7260021c 100644 --- a/packages/adapter-slack/src/webhook-utils.ts +++ b/packages/adapter-slack/src/webhook/utils.ts @@ -2,7 +2,7 @@ import { type SlackHeaders, type SlackRetry, SlackWebhookParseError, -} from "./webhook-types"; +} from "./types"; export function getHeader( headers: SlackHeaders | undefined, diff --git a/packages/adapter-slack/src/webhook-verify.ts b/packages/adapter-slack/src/webhook/verify.ts similarity index 96% rename from packages/adapter-slack/src/webhook-verify.ts rename to packages/adapter-slack/src/webhook/verify.ts index c1ce8d30..c4c15c53 100644 --- a/packages/adapter-slack/src/webhook-verify.ts +++ b/packages/adapter-slack/src/webhook/verify.ts @@ -1,12 +1,12 @@ -import { parseSlackWebhookBody } from "./webhook-parse"; +import { parseSlackWebhookBody } from "./parse"; import { type SlackHeaders, type SlackReadOptions, type SlackVerifyOptions, type SlackWebhookPayload, SlackWebhookVerificationError, -} from "./webhook-types"; -import { getHeader } from "./webhook-utils"; +} from "./types"; +import { getHeader } from "./utils"; const HEX_PATTERN = /^[\da-f]+$/i; diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index 83bf9892..d0a40be3 100644 --- a/packages/adapter-slack/tsup.config.ts +++ b/packages/adapter-slack/tsup.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/webhook.ts"], + entry: { + index: "src/index.ts", + webhook: "src/webhook/index.ts", + }, format: ["esm"], dts: true, clean: true, From 2a9f74231877871a1ef8a46da8cbffcd94741356 Mon Sep 17 00:00:00 2001 From: dancer Date: Fri, 22 May 2026 00:59:46 +0100 Subject: [PATCH 8/8] fix(slack): format webhook boundary test --- packages/adapter-slack/src/webhook/boundary.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/adapter-slack/src/webhook/boundary.test.ts b/packages/adapter-slack/src/webhook/boundary.test.ts index 71ad1a67..5e563d86 100644 --- a/packages/adapter-slack/src/webhook/boundary.test.ts +++ b/packages/adapter-slack/src/webhook/boundary.test.ts @@ -3,13 +3,7 @@ import { describe, expect, it } from "vitest"; describe("webhook import boundary", () => { it("does not import the full adapter or runtime packages", async () => { - const files = [ - "index.ts", - "parse.ts", - "types.ts", - "utils.ts", - "verify.ts", - ]; + const files = ["index.ts", "parse.ts", "types.ts", "utils.ts", "verify.ts"]; const source = ( await Promise.all( files.map((file) =>