From aaa94bd472783b142067966e98a0762d0a022652 Mon Sep 17 00:00:00 2001 From: dvoytenko Date: Fri, 24 Apr 2026 17:17:45 -0700 Subject: [PATCH 1/7] feat(slack): dynamic botToken resolver and custom webhookVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow `botToken` to be a function returning `string | Promise` so apps can rotate or lazily fetch tokens; the resolver is invoked per API call. Add `webhookVerifier: (request) => string | Promise` as an alternative to `signingSecret` for custom request verification — returns the verified body text or throws to produce a 401. Co-Authored-By: Claude Opus 4.7 --- ...-dynamic-bot-token-and-webhook-verifier.md | 5 + packages/adapter-slack/src/index.test.ts | 213 ++++++++++++++++- packages/adapter-slack/src/index.ts | 216 ++++++++++++------ 3 files changed, 358 insertions(+), 76 deletions(-) create mode 100644 .changeset/slack-dynamic-bot-token-and-webhook-verifier.md diff --git a/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md b/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md new file mode 100644 index 00000000..3a8977a8 --- /dev/null +++ b/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +Add dynamic `botToken` resolver and custom `webhookVerifier` to Slack adapter config. `botToken` now accepts `string | (() => string | Promise)` so apps can rotate or lazily fetch tokens — the function is invoked per API call. `webhookVerifier: (request: Request) => string | Promise` is used in place of `signingSecret` when set (and `signingSecret` is not provided), letting hosts verify incoming requests with their own logic and return the verified body text; the adapter responds 401 if the verifier throws. diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 98ea4d77..8cd7b752 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -141,7 +141,16 @@ describe("constructor env var resolution", () => { }); it("should throw when signingSecret is missing and env var not set", () => { - expect(() => new SlackAdapter({})).toThrow("signingSecret is required"); + expect(() => new SlackAdapter({})).toThrow( + "signingSecret or webhookVerifier is required" + ); + }); + + it("should not throw when webhookVerifier is provided without signingSecret", () => { + const adapter = new SlackAdapter({ + webhookVerifier: async (req) => await req.text(), + }); + expect(adapter).toBeInstanceOf(SlackAdapter); }); it("should resolve signingSecret from SLACK_SIGNING_SECRET env var", () => { @@ -357,6 +366,71 @@ describe("handleWebhook - signature verification", () => { }); }); +describe("handleWebhook - webhookVerifier", () => { + it("uses webhookVerifier in place of signingSecret", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: async (req) => await req.text(), + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + const json = (await response.json()) as { challenge: string }; + expect(json.challenge).toBe("verifier-challenge"); + }); + + it("returns 401 when verifier throws", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: () => { + throw new Error("bad signature"); + }, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification" }), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("prefers signingSecret over webhookVerifier when both are set", async () => { + const secret = "test-signing-secret"; + const verifier = vi.fn(async (req: Request) => await req.text()); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + webhookVerifier: verifier, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "test-challenge", + }); + const request = createWebhookRequest(body, secret); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).not.toHaveBeenCalled(); + }); +}); + // ============================================================================ // URL Verification Challenge Tests // ============================================================================ @@ -2486,6 +2560,143 @@ function mockClientMethod( obj[parts.at(-1) as string] = mockFn; } +// ============================================================================ +// botToken as function (resolver) Tests +// ============================================================================ + +describe("botToken as function", () => { + const secret = "test-signing-secret"; + + it("accepts a sync resolver and uses its return value on API calls", async () => { + const resolver = vi.fn(() => "xoxb-sync-token"); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "chat.postMessage", + vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.999999" }) + ); + + await adapter.postMessage("slack:C123:1234567890.000000", "hi"); + + const client = getClient(adapter); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-sync-token" }) + ); + expect(resolver).toHaveBeenCalled(); + }); + + it("accepts an async resolver and awaits the returned promise", async () => { + const resolver = vi.fn(async () => { + await Promise.resolve(); + return "xoxb-async-token"; + }); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "chat.postMessage", + vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.999999" }) + ); + + await adapter.postMessage("slack:C123:1234567890.000000", "hi"); + + const client = getClient(adapter); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-async-token" }) + ); + expect(resolver).toHaveBeenCalled(); + }); + + it("invokes the resolver per API call (supports rotation)", async () => { + const tokens = ["xoxb-token-1", "xoxb-token-2", "xoxb-token-3"]; + let i = 0; + const resolver = vi.fn(() => tokens[i++]); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + const postMessage = vi + .fn() + .mockResolvedValue({ ok: true, ts: "1234567890.999999" }); + mockClientMethod(adapter, "chat.postMessage", postMessage); + + await adapter.postMessage("slack:C123:1234567890.000000", "first"); + await adapter.postMessage("slack:C123:1234567890.000000", "second"); + await adapter.postMessage("slack:C123:1234567890.000000", "third"); + + expect(resolver).toHaveBeenCalledTimes(3); + expect(postMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ token: "xoxb-token-1" }) + ); + expect(postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ token: "xoxb-token-2" }) + ); + expect(postMessage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ token: "xoxb-token-3" }) + ); + }); + + it("treats a function botToken as single-workspace mode", async () => { + const adapter = createSlackAdapter({ + botToken: () => "xoxb-fn-token", + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "auth.test", + vi.fn().mockResolvedValue({ + ok: true, + user_id: "U_BOT", + bot_id: "B_BOT", + user: "fnbot", + }) + ); + + // initialize() in single-workspace mode calls auth.test with the resolved token + await adapter.initialize({ + getState: () => ({}) as unknown as StateAdapter, + } as unknown as ChatInstance); + + const client = getClient(adapter); + expect(client.auth.test).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-fn-token" }) + ); + expect(adapter.botUserId).toBe("U_BOT"); + }); + + it("propagates errors thrown by the resolver", async () => { + const adapter = createSlackAdapter({ + botToken: () => { + throw new Error("token fetch failed"); + }, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod(adapter, "chat.postMessage", vi.fn()); + + await expect( + adapter.postMessage("slack:C123:1234567890.000000", "hi") + ).rejects.toThrow("token fetch failed"); + }); +}); + // ============================================================================ // postMessage Tests // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 10f6b596..926a847d 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -104,6 +104,29 @@ const SLACK_MESSAGE_URL_PATTERN = export type SlackAdapterMode = "webhook" | "socket"; +/** + * Bot token configuration. Can be a static string, or a function that returns + * a token (optionally asynchronously). The function is invoked each time a + * token is needed, enabling rotation or lazy retrieval from a secret manager. + */ +export type SlackBotToken = string | (() => string | Promise); + +/** + * Normalize a SlackBotToken config value to a resolver function, or undefined + * if no token is configured. + */ +function normalizeBotTokenProvider( + value: SlackBotToken | undefined +): (() => string | Promise) | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === "function") { + return value; + } + return () => value; +} + /** Envelope for events forwarded from a socket mode listener via HTTP POST */ interface SlackForwardedSocketEvent { body: Record; @@ -117,8 +140,12 @@ export interface SlackAdapterConfig { apiUrl?: string; /** App-level token (xapp-...). Required for socket mode. */ appToken?: string; - /** Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace. */ - botToken?: string; + /** + * Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace. + * May be a string, or a function returning a string or Promise (called + * on each use to support rotation or deferred resolution). + */ + botToken?: SlackBotToken; /** Bot user ID (will be fetched if not provided) */ botUserId?: string; /** Slack app client ID (required for OAuth / multi-workspace) */ @@ -145,6 +172,13 @@ export interface SlackAdapterConfig { socketForwardingSecret?: string; /** Override bot username (optional) */ userName?: string; + /** + * Custom webhook verifier. Used in place of `signingSecret` when set and + * `signingSecret` is not provided. Receives the incoming `Request` and must + * return the verified raw body text (sync or async). Throw/reject to reject + * the request; the adapter will respond with `401 Invalid signature`. + */ + webhookVerifier?: (request: Request) => string | Promise; } export interface SlackOAuthCallbackOptions { @@ -417,7 +451,12 @@ export class SlackAdapter implements Adapter { private readonly client: WebClient; private readonly signingSecret: string | undefined; - private readonly defaultBotToken: string | undefined; + private readonly webhookVerifier: + | ((request: Request) => string | Promise) + | undefined; + private readonly defaultBotTokenProvider: + | (() => string | Promise) + | undefined; private chat: ChatInstance | null = null; private readonly logger: Logger; private _botUserId: string | null = null; @@ -466,10 +505,14 @@ export class SlackAdapter implements Adapter { constructor(config: SlackAdapterConfig = {}) { const signingSecret = config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (!signingSecret && (config.mode ?? "webhook") === "webhook") { + const webhookVerifier = config.webhookVerifier; + if ( + !(signingSecret || webhookVerifier) && + (config.mode ?? "webhook") === "webhook" + ) { throw new ValidationError( "slack", - "signingSecret is required for webhook mode. Set SLACK_SIGNING_SECRET or provide it in config." + "signingSecret or webhookVerifier is required for webhook mode. Set SLACK_SIGNING_SECRET, provide signingSecret in config, or provide a webhookVerifier." ); } @@ -484,15 +527,21 @@ export class SlackAdapter implements Adapter { config.clientSecret ); - const botToken = + const botTokenConfig = config.botToken ?? (zeroConfig ? process.env.SLACK_BOT_TOKEN : undefined); + const botTokenProvider = normalizeBotTokenProvider(botTokenConfig); const slackApiUrl = config.apiUrl ?? process.env.SLACK_API_URL; - this.client = new WebClient(botToken, { + // WebClient token argument is only a fallback; every API call below routes + // through withToken() which resolves the current provider per-call. + this.client = new WebClient(undefined, { ...(slackApiUrl ? { slackApiUrl } : {}), }); + // webhookVerifier takes precedence when signingSecret is not configured; + // if both are provided, signingSecret wins. this.signingSecret = signingSecret; - this.defaultBotToken = botToken; + this.webhookVerifier = signingSecret ? undefined : webhookVerifier; + this.defaultBotTokenProvider = botTokenProvider; this.logger = config.logger ?? new ConsoleLogger("info").child("slack"); this.userName = config.userName || "bot"; this._botUserId = config.botUserId || null; @@ -519,15 +568,16 @@ export class SlackAdapter implements Adapter { /** * Get the current bot token for API calls. - * Checks request context (multi-workspace) → default token (single-workspace) → throws. + * Checks request context (multi-workspace) → default token provider + * (single-workspace) → throws. */ - private getToken(): string { + private async getToken(): Promise { const ctx = this.requestContext.getStore(); if (ctx?.token) { return ctx.token; } - if (this.defaultBotToken) { - return this.defaultBotToken; + if (this.defaultBotTokenProvider) { + return await this.defaultBotTokenProvider(); } throw new AuthenticationError( "slack", @@ -539,20 +589,22 @@ export class SlackAdapter implements Adapter { * Add the current token to API call options. * Workaround for Slack WebClient types not including `token` in per-method args. */ - // biome-ignore lint/suspicious/noExplicitAny: Slack types don't include token in method args - private withToken>( - options: T - ): T & { token: string } { - return { ...options, token: this.getToken() }; + private async withToken< + // biome-ignore lint/suspicious/noExplicitAny: Slack types don't include token in method args + T extends Record, + >(options: T): Promise { + return { ...options, token: await this.getToken() }; } async initialize(chat: ChatInstance): Promise { this.chat = chat; // Only fetch bot user ID in single-workspace mode (when default token is available) - if (this.defaultBotToken && !this._botUserId) { + if (this.defaultBotTokenProvider && !this._botUserId) { try { - const authResult = await this.client.auth.test(this.withToken({})); + const authResult = await this.client.auth.test( + await this.withToken({}) + ); this._botUserId = authResult.user_id as string; this._botId = (authResult.bot_id as string) || null; if (authResult.user) { @@ -567,7 +619,7 @@ export class SlackAdapter implements Adapter { } } - if (!this.defaultBotToken) { + if (!this.defaultBotTokenProvider) { this.logger.info("Slack adapter initialized in multi-workspace mode"); } @@ -796,7 +848,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.users.info( - this.withToken({ user: userId }) + await this.withToken({ user: userId }) ); const user = result.user as { name?: string; @@ -866,7 +918,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.conversations.info( - this.withToken({ channel: channelId }) + await this.withToken({ channel: channelId }) ); const name = (result.channel as { name?: string } | undefined)?.name || channelId; @@ -928,16 +980,23 @@ export class SlackAdapter implements Adapter { }); } - const body = await request.text(); - this.logger.debug("Slack webhook raw body", { body }); - - // Verify request signature - 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; + if (this.webhookVerifier) { + try { + body = await this.webhookVerifier(request); + } catch (error) { + this.logger.warn("Webhook verifier rejected request", { error }); + return new Response("Invalid signature", { status: 401 }); + } + } else { + body = await request.text(); + 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 }); + } } + this.logger.debug("Slack webhook raw body", { body }); // Check if this is a form-urlencoded payload const contentType = request.headers.get("content-type") || ""; @@ -945,7 +1004,7 @@ export class SlackAdapter implements Adapter { const params = new URLSearchParams(body); if (params.has("command") && !params.has("payload")) { const teamId = params.get("team_id"); - if (!this.defaultBotToken && teamId) { + if (!this.defaultBotTokenProvider && teamId) { const ctx = await this.resolveTokenForTeam(teamId); if (ctx) { return this.requestContext.run(ctx, () => @@ -957,7 +1016,7 @@ export class SlackAdapter implements Adapter { return this.handleSlashCommand(params, options); } // In multi-workspace mode, resolve token before processing - if (!this.defaultBotToken) { + if (!this.defaultBotTokenProvider) { const teamId = this.extractTeamIdFromInteractive(body); if (teamId) { const ctx = await this.resolveTokenForTeam(teamId); @@ -986,7 +1045,7 @@ export class SlackAdapter implements Adapter { } // In multi-workspace mode, resolve token from team_id before processing events - if (!this.defaultBotToken && payload.type === "event_callback") { + if (!this.defaultBotTokenProvider && payload.type === "event_callback") { const teamId = payload.team_id; if (teamId) { const ctx = await this.resolveTokenForTeam(teamId); @@ -1905,7 +1964,7 @@ export class SlackAdapter implements Adapter { let parentTs = event.item.ts; try { const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel: event.item.channel, ts: event.item.ts, limit: 1, @@ -2140,7 +2199,7 @@ export class SlackAdapter implements Adapter { ): Promise { await this.client.views.publish( // biome-ignore lint/suspicious/noExplicitAny: view blocks are consumer-defined - this.withToken({ user_id: userId, view }) as any + (await this.withToken({ user_id: userId, view })) as any ); } @@ -2155,7 +2214,7 @@ export class SlackAdapter implements Adapter { title?: string ): Promise { await this.client.assistant.threads.setSuggestedPrompts( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, prompts, @@ -2175,7 +2234,7 @@ export class SlackAdapter implements Adapter { loadingMessages?: string[] ): Promise { await this.client.assistant.threads.setStatus( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, status, @@ -2194,7 +2253,7 @@ export class SlackAdapter implements Adapter { title: string ): Promise { await this.client.assistant.threads.setTitle( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, title, @@ -2365,7 +2424,7 @@ export class SlackAdapter implements Adapter { url, fetchMessage: async () => { const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, latest: ts, inclusive: true, @@ -2475,8 +2534,10 @@ export class SlackAdapter implements Adapter { teamId?: string ): Attachment { const url = file.url_private; - // Capture token at attachment creation time (during webhook processing context) - const botToken = this.getToken(); + // Capture per-request token from the active webhook context so fetchData + // can run later without being inside the AsyncLocalStorage context. + // For single-workspace, the default provider is resolved at fetch time. + const ctxToken = this.requestContext.getStore()?.token; // Determine type based on mimetype let type: Attachment["type"] = "file"; @@ -2505,7 +2566,10 @@ export class SlackAdapter implements Adapter { width: file.original_w, height: file.original_h, fetchMetadata: Object.keys(fetchMeta).length > 0 ? fetchMeta : undefined, - fetchData: url ? () => this.fetchSlackFile(url, botToken) : undefined, + fetchData: url + ? async () => + this.fetchSlackFile(url, ctxToken ?? (await this.getToken())) + : undefined, }; } @@ -2552,7 +2616,7 @@ export class SlackAdapter implements Adapter { } token = installation.botToken; } else { - token = this.getToken(); + token = await this.getToken(); } return this.fetchSlackFile(url, token); }, @@ -2774,7 +2838,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text: fallbackText, // Fallback for notifications @@ -2806,7 +2870,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text: tableResult.text, @@ -2841,7 +2905,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text, @@ -2891,7 +2955,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -2924,7 +2988,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -2960,7 +3024,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -3008,7 +3072,7 @@ export class SlackAdapter implements Adapter { } // Capture token now so cancel() works outside request context - const token = this.getToken(); + const token = await this.getToken(); try { const card = extractCard(message); @@ -3115,7 +3179,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.views.open( - this.withToken({ + await this.withToken({ trigger_id: triggerId, view, }) @@ -3145,7 +3209,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.views.update( - this.withToken({ + await this.withToken({ view_id: viewId, view, }) @@ -3204,7 +3268,7 @@ export class SlackAdapter implements Adapter { if (threadTs) { uploadArgs.thread_ts = threadTs; } - uploadArgs.token = this.getToken(); + uploadArgs.token = await this.getToken(); const result = (await this.client.files.uploadV2(uploadArgs)) as { ok: boolean; files?: Array<{ files?: Array<{ id?: string }> }>; @@ -3264,7 +3328,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text: fallbackText, @@ -3294,7 +3358,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text: tableResult.text, @@ -3327,7 +3391,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text, @@ -3375,7 +3439,7 @@ export class SlackAdapter implements Adapter { blockCount: blocks.length, }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text, @@ -3414,7 +3478,7 @@ export class SlackAdapter implements Adapter { blockCount: blocks.length, }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text, @@ -3531,7 +3595,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: chat.delete", { channel, messageId }); await this.client.chat.delete( - this.withToken({ + await this.withToken({ channel, ts: messageId, }) @@ -3561,7 +3625,7 @@ export class SlackAdapter implements Adapter { }); await this.client.reactions.add( - this.withToken({ + await this.withToken({ channel, timestamp: messageId, name, @@ -3592,7 +3656,7 @@ export class SlackAdapter implements Adapter { }); await this.client.reactions.remove( - this.withToken({ + await this.withToken({ channel, timestamp: messageId, name, @@ -3630,7 +3694,7 @@ export class SlackAdapter implements Adapter { }); try { await this.client.assistant.threads.setStatus( - this.withToken({ + await this.withToken({ channel_id: channel, thread_ts: threadTs, status: status ?? "Typing...", @@ -3682,7 +3746,7 @@ export class SlackAdapter implements Adapter { } this.logger.debug("Slack: starting stream", { channel, threadTs }); - const token = this.getToken(); + const token = await this.getToken(); const streamer = this.client.chatStream({ channel, thread_ts: threadTs, @@ -3812,7 +3876,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.open", { userId }); const result = await this.client.conversations.open( - this.withToken({ users: userId }) + await this.withToken({ users: userId }) ); if (!result.channel?.id) { @@ -3892,7 +3956,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, limit, @@ -3955,7 +4019,7 @@ export class SlackAdapter implements Adapter { const fetchLimit = Math.min(1000, Math.max(limit * 2, 200)); const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, limit: fetchLimit, @@ -4006,7 +4070,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.info", { channel }); const result = await this.client.conversations.info( - this.withToken({ channel }) + await this.withToken({ channel }) ); const channelInfo = result.channel as | { @@ -4062,7 +4126,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, oldest: messageId, @@ -4254,7 +4318,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit, oldest: cursor, @@ -4301,7 +4365,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit, latest: cursor, @@ -4364,7 +4428,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit: Math.min(limit * 3, 200), // Fetch extra since not all have threads cursor: options.cursor, @@ -4429,7 +4493,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.info (channel)", { channel }); const result = await this.client.conversations.info( - this.withToken({ channel }) + await this.withToken({ channel }) ); const info = result.channel as { @@ -4705,10 +4769,11 @@ export function createSlackAdapter( const signingSecret = config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (mode === "webhook" && !signingSecret) { + const webhookVerifier = config?.webhookVerifier; + if (mode === "webhook" && !(signingSecret || webhookVerifier)) { throw new ValidationError( "slack", - "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." + "signingSecret or webhookVerifier is required. Set SLACK_SIGNING_SECRET, provide signingSecret in config, or provide a webhookVerifier." ); } @@ -4739,6 +4804,7 @@ export function createSlackAdapter( process.env.SLACK_SOCKET_FORWARDING_SECRET, userName: config?.userName, botUserId: config?.botUserId, + webhookVerifier, }; return new SlackAdapter(resolved); } From e0e008b12b979f16ad1314ead3e72e27493fb7c9 Mon Sep 17 00:00:00 2001 From: dvoytenko Date: Sat, 25 Apr 2026 15:33:22 -0700 Subject: [PATCH 2/7] change verifier signature to make it compatible with function --- packages/adapter-slack/src/index.test.ts | 47 ++++++++++++++++++++++-- packages/adapter-slack/src/index.ts | 32 +++++++++++----- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 8cd7b752..08c3f60a 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -148,7 +148,7 @@ describe("constructor env var resolution", () => { it("should not throw when webhookVerifier is provided without signingSecret", () => { const adapter = new SlackAdapter({ - webhookVerifier: async (req) => await req.text(), + webhookVerifier: () => true, }); expect(adapter).toBeInstanceOf(SlackAdapter); }); @@ -370,7 +370,7 @@ describe("handleWebhook - webhookVerifier", () => { it("uses webhookVerifier in place of signingSecret", async () => { const adapter = createSlackAdapter({ botToken: "xoxb-test-token", - webhookVerifier: async (req) => await req.text(), + webhookVerifier: () => true, logger: mockLogger, }); @@ -409,9 +409,50 @@ describe("handleWebhook - webhookVerifier", () => { expect(response.status).toBe(401); }); + it("returns 401 when verifier returns a falsy value", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: () => false, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification" }), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("passes the body string to the verifier", async () => { + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const verifier = vi.fn((_req: Request, _body: string) => true); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: verifier, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).toHaveBeenCalledTimes(1); + expect(verifier.mock.calls[0]?.[1]).toBe(body); + }); + it("prefers signingSecret over webhookVerifier when both are set", async () => { const secret = "test-signing-secret"; - const verifier = vi.fn(async (req: Request) => await req.text()); + const verifier = vi.fn(() => true); const adapter = createSlackAdapter({ botToken: "xoxb-test-token", signingSecret: secret, diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 926a847d..4b0c4bf8 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -173,12 +173,19 @@ export interface SlackAdapterConfig { /** Override bot username (optional) */ userName?: string; /** - * Custom webhook verifier. Used in place of `signingSecret` when set and - * `signingSecret` is not provided. Receives the incoming `Request` and must - * return the verified raw body text (sync or async). Throw/reject to reject - * the request; the adapter will respond with `401 Invalid signature`. + * Custom webhook verifier. Used in place of `signingSecret`. + * Receives the incoming `Request` and the raw body text already + * read by the adapter. To reject the request, either + * return a falsy value (sync or async) or throw/reject; the adapter will + * respond with `401 Invalid signature`. Any truthy return value is treated + * as a successful verification. + * When both, `signingSecret` and `webhookVerifier` are specified, the + * `signingSecret` takes precedence. */ - webhookVerifier?: (request: Request) => string | Promise; + webhookVerifier?: ( + request: Request, + body: string + ) => unknown | Promise; } export interface SlackOAuthCallbackOptions { @@ -452,7 +459,7 @@ export class SlackAdapter implements Adapter { private readonly client: WebClient; private readonly signingSecret: string | undefined; private readonly webhookVerifier: - | ((request: Request) => string | Promise) + | ((request: Request, body: string) => unknown | Promise) | undefined; private readonly defaultBotTokenProvider: | (() => string | Promise) @@ -980,23 +987,28 @@ export class SlackAdapter implements Adapter { }); } - let body: string; + const body = await request.text(); + this.logger.debug("Slack webhook raw body", { body }); + + // Verify request using dynamic verifier or signature. if (this.webhookVerifier) { try { - body = await this.webhookVerifier(request); + const verified = await this.webhookVerifier(request, body); + if (!verified) { + this.logger.warn("Webhook verifier rejected request"); + return new Response("Invalid signature", { status: 401 }); + } } catch (error) { this.logger.warn("Webhook verifier rejected request", { error }); return new Response("Invalid signature", { status: 401 }); } } else { - body = await request.text(); 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 }); } } - this.logger.debug("Slack webhook raw body", { body }); // Check if this is a form-urlencoded payload const contentType = request.headers.get("content-type") || ""; From 742076fa3d04607580004c543c1b99a2f22ea418 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 26 Apr 2026 21:08:16 +1000 Subject: [PATCH 3/7] make scheduleMessage cancel() rotation-safe and honor verifier body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduleMessage cancel(): re-resolve token in single-workspace mode so rotation works. Slack rotated tokens have a 12h TTL and scheduled messages can outlive their schedule-time token, leaving cancel() with stale auth. Multi-workspace still snapshots ctx.token since cancel() runs outside the AsyncLocalStorage frame. - webhookVerifier: when it returns a string, use it as the verified body for downstream parsing. JSDoc previously implied this contract; the code only checked truthiness. - webhookVerifier JSDoc: explicit SECURITY note that timestamp/replay protection is the implementer's responsibility when bypassing signingSecret. - Tests: cover Attachment.fetchData snapshot semantics — multi-workspace uses the ctx token captured at attachment creation; single-workspace re-resolves the default provider per fetch (rotation-safe). --- packages/adapter-slack/src/index.test.ts | 114 +++++++++++++++++++++++ packages/adapter-slack/src/index.ts | 36 +++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 08c3f60a..817b47ff 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -2738,6 +2738,120 @@ describe("botToken as function", () => { }); }); +// ============================================================================ +// Attachment fetchData token snapshot Tests +// ============================================================================ + +describe("Attachment.fetchData token resolution", () => { + const fileEvent = { + type: "message", + user: "U123", + channel: "C456", + text: "with file", + ts: "1234567890.123456", + files: [ + { + id: "F123", + mimetype: "application/pdf", + url_private: "https://files.slack.com/file.pdf", + name: "doc.pdf", + size: 100, + }, + ], + }; + + function createMockFetchResponse(): Response { + return new Response(new ArrayBuffer(8), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + } + + it("snapshots ctx token at attachment creation in multi-workspace mode", async () => { + const adapter = createSlackAdapter({ + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + interface AdapterInternals { + requestContext: { + run: (ctx: { token: string }, fn: () => T) => T; + }; + } + const internals = adapter as unknown as AdapterInternals; + + const attachment = internals.requestContext.run( + { token: "xoxb-team-snapshot" }, + () => adapter.parseMessage(fileEvent).attachments?.[0] + ); + expect(attachment).toBeDefined(); + + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(createMockFetchResponse())); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + try { + // Call fetchData OUTSIDE the requestContext frame to confirm the + // captured ctxToken is used (we are no longer inside AsyncLocalStorage). + await attachment?.fetchData?.(); + } finally { + globalThis.fetch = originalFetch; + } + + expect(fetchMock).toHaveBeenCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-team-snapshot" }, + }) + ); + }); + + it("re-resolves the default provider at fetch time in single-workspace mode", async () => { + const tokens = ["xoxb-stale", "xoxb-fresh"]; + let i = 0; + const resolver = vi.fn(() => tokens[i++]); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + + // Attachment created outside any requestContext frame; resolver is NOT + // invoked at creation time because resolution is deferred to fetch. + const attachment = adapter.parseMessage(fileEvent).attachments?.[0]; + expect(attachment).toBeDefined(); + expect(resolver).not.toHaveBeenCalled(); + + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(createMockFetchResponse())); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + try { + // First fetch picks up the first resolver value. + await attachment?.fetchData?.(); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-stale" }, + }) + ); + // A subsequent fetchData() re-invokes the resolver and picks up rotation. + await attachment?.fetchData?.(); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-fresh" }, + }) + ); + } finally { + globalThis.fetch = originalFetch; + } + + expect(resolver).toHaveBeenCalledTimes(2); + }); +}); + // ============================================================================ // postMessage Tests // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 4b0c4bf8..eaeabb2b 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -178,9 +178,18 @@ export interface SlackAdapterConfig { * read by the adapter. To reject the request, either * return a falsy value (sync or async) or throw/reject; the adapter will * respond with `401 Invalid signature`. Any truthy return value is treated - * as a successful verification. - * When both, `signingSecret` and `webhookVerifier` are specified, the + * as a successful verification. If a string is returned, it replaces the + * raw body for downstream parsing — useful when the verifier needs to + * canonicalize or substitute the verified payload. + * + * When both `signingSecret` and `webhookVerifier` are specified, * `signingSecret` takes precedence. + * + * SECURITY: When this is used in place of `signingSecret`, the built-in + * Slack timestamp tolerance check is NOT performed. Implementations are + * responsible for verifying the `x-slack-request-timestamp` header (or an + * equivalent freshness signal) to prevent replay of captured signed + * requests. */ webhookVerifier?: ( request: Request, @@ -987,7 +996,7 @@ export class SlackAdapter implements Adapter { }); } - const body = await request.text(); + let body = await request.text(); this.logger.debug("Slack webhook raw body", { body }); // Verify request using dynamic verifier or signature. @@ -998,6 +1007,12 @@ export class SlackAdapter implements Adapter { 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 }); @@ -3083,8 +3098,15 @@ export class SlackAdapter implements Adapter { ); } - // Capture token now so cancel() works outside request context - const token = await this.getToken(); + // For multi-workspace mode, snapshot the per-team token from the active + // request context — cancel() may run outside the AsyncLocalStorage frame. + // For single-workspace mode, defer resolution so cancel() picks up the + // current token (supporting rotation). + const ctxToken = this.requestContext.getStore()?.token; + const resolveCancelToken = ctxToken + ? () => Promise.resolve(ctxToken) + : () => this.getToken(); + const token = ctxToken ?? (await this.getToken()); try { const card = extractCard(message); @@ -3121,7 +3143,7 @@ export class SlackAdapter implements Adapter { raw: result, async cancel() { await adapter.client.chat.deleteScheduledMessage({ - token, + token: await resolveCancelToken(), channel, scheduled_message_id: scheduledMessageId, }); @@ -3162,7 +3184,7 @@ export class SlackAdapter implements Adapter { raw: result, async cancel() { await adapter.client.chat.deleteScheduledMessage({ - token, + token: await resolveCancelToken(), channel, scheduled_message_id: scheduledMessageId, }); From 563d1d87cfe0ee335f3c919d7d07f0500812a2b7 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 26 Apr 2026 21:12:15 +1000 Subject: [PATCH 4/7] docs(slack): document botToken resolver and webhookVerifier in README --- packages/adapter-slack/README.md | 35 ++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index 455dfe2b..a433b26a 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -31,6 +31,35 @@ bot.onNewMention(async (thread, message) => { }); ``` +### Token rotation + +`botToken` accepts a function returning a string or `Promise` — the resolver is invoked per API call, so it composes with [Slack token rotation](https://docs.slack.dev/authentication/using-token-rotation/) (12-hour TTL) or lazy fetch from a secret manager: + +```typescript +createSlackAdapter({ + botToken: async () => await secrets.get("slack-bot-token"), +}); +``` + +If the resolver is expensive (e.g. a vault round-trip), implement caching inside the resolver itself. + +### Custom webhook verification + +Pass `webhookVerifier` to replace the built-in HMAC check — useful when verification runs in a proxy or signing layer ahead of your handler: + +```typescript +createSlackAdapter({ + webhookVerifier: async (request, body) => { + if (!(await myProxy.verify(request))) { + throw new Error("invalid"); + } + return true; // or return a string to substitute the verified body + }, +}); +``` + +If both `signingSecret` and `webhookVerifier` are set, `signingSecret` wins. When using `webhookVerifier`, you are responsible for replay/timestamp protection — the built-in 5-minute timestamp tolerance only applies to the `signingSecret` path. + ## Multi-workspace mode For apps installed across multiple Slack workspaces via OAuth, omit `botToken` and provide OAuth credentials instead. The adapter resolves tokens dynamically from your state adapter using the `team_id` from incoming webhooks. @@ -266,8 +295,9 @@ All options are auto-detected from environment variables when not provided. You | Option | Required | Description | |--------|----------|-------------| -| `botToken` | No | Bot token (`xoxb-...`). Auto-detected from `SLACK_BOT_TOKEN` | +| `botToken` | No | Bot token (`xoxb-...`) or a function returning one (sync or async) for rotation/lazy fetch. Auto-detected from `SLACK_BOT_TOKEN` | | `signingSecret` | No* | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET` | +| `webhookVerifier` | No* | Custom verifier `(request, body) => unknown \| Promise` used in place of `signingSecret`. Returning a string substitutes the verified body for downstream parsing | | `mode` | No | Connection mode: `"webhook"` (default) or `"socket"` | | `appToken` | No** | App-level token (`xapp-...`) for socket mode. Auto-detected from `SLACK_APP_TOKEN` | | `socketForwardingSecret` | No | Shared secret for authenticating forwarded socket events. Auto-detected from `SLACK_SOCKET_FORWARDING_SECRET`, falls back to `appToken` | @@ -277,7 +307,7 @@ All options are auto-detected from environment variables when not provided. You | `installationKeyPrefix` | No | Prefix for the state key used to store workspace installations. Defaults to `slack:installation`. The full key is `{prefix}:{teamId}` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | -*`signingSecret` is required for webhook mode — either via config or `SLACK_SIGNING_SECRET` env var. +*`signingSecret` is required for webhook mode — either via config, `SLACK_SIGNING_SECRET` env var, or a `webhookVerifier`. **`appToken` is required for socket mode — either via config or `SLACK_APP_TOKEN` env var. ## Environment variables @@ -437,6 +467,7 @@ await slackAdapter.handleOAuthCallback(request); - Verify `SLACK_SIGNING_SECRET` is correct - Check that the request timestamp is within 5 minutes (clock sync issue) +- If using a custom `webhookVerifier`, the error also surfaces when the verifier throws or returns a falsy value ### Bot not responding to messages From 82cb71526a889a344fcdb6bf66c13fd97a370eda Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 26 Apr 2026 21:18:00 +1000 Subject: [PATCH 5/7] opt out of SLACK_SIGNING_SECRET env fallback when webhookVerifier is set A webhookVerifier passed in config was being silently shadowed by SLACK_SIGNING_SECRET in the env (read by both createSlackAdapter and the SlackAdapter constructor). An explicit verifier now opts out of that fallback in both code paths. Added a regression test that stubs the env var via vi.stubEnv. --- packages/adapter-slack/src/index.test.ts | 28 ++++++++++++++++++++++++ packages/adapter-slack/src/index.ts | 16 ++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 817b47ff..738cb8d7 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -470,6 +470,34 @@ describe("handleWebhook - webhookVerifier", () => { expect(response.status).toBe(200); expect(verifier).not.toHaveBeenCalled(); }); + + it("ignores SLACK_SIGNING_SECRET env var when webhookVerifier is configured", async () => { + vi.stubEnv("SLACK_SIGNING_SECRET", "env-signing-secret"); + try { + const verifier = vi.fn(() => true); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: verifier, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllEnvs(); + } + }); }); // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index eaeabb2b..ded1082d 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -519,9 +519,13 @@ export class SlackAdapter implements Adapter { } constructor(config: SlackAdapterConfig = {}) { - const signingSecret = - config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; const webhookVerifier = config.webhookVerifier; + // An explicit webhookVerifier in config opts out of the SLACK_SIGNING_SECRET + // env fallback — otherwise an env-configured deployment would silently + // shadow the verifier the caller intended to use. + const signingSecret = + config.signingSecret ?? + (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET); if ( !(signingSecret || webhookVerifier) && (config.mode ?? "webhook") === "webhook" @@ -4801,9 +4805,13 @@ export function createSlackAdapter( } } - const signingSecret = - config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; const webhookVerifier = config?.webhookVerifier; + // An explicit webhookVerifier in config opts out of the SLACK_SIGNING_SECRET + // env fallback — otherwise an env-configured deployment would silently + // shadow the verifier the caller intended to use. + const signingSecret = + config?.signingSecret ?? + (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET); if (mode === "webhook" && !(signingSecret || webhookVerifier)) { throw new ValidationError( "slack", From 359c26572065ed941e8211b59ffc17146504b8f3 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 26 Apr 2026 21:36:25 +1000 Subject: [PATCH 6/7] register handleReactionEvent's outer promise via waitUntil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleReactionEvent does async work (conversations.replies, users.info) before delegating to chat.processReaction, which is the only point that registers a waitUntil task. The outer prep work was untracked, so callers that drained waitUntil tasks could complete before the reaction handler finished — flaky in CI under tight microtask scheduling. Track the outer promise too so the full handler is awaited. --- packages/adapter-slack/src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ded1082d..b50b2ac3 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1127,7 +1127,14 @@ export class SlackAdapter implements Adapter { event.type === "reaction_added" || event.type === "reaction_removed" ) { - this.handleReactionEvent(event as SlackReactionEvent, options); + // Outer prep work (conversations.replies, users.info) runs before + // processReaction registers its own waitUntil task — register the + // outer promise too so callers can await full completion. + const task = this.handleReactionEvent( + event as SlackReactionEvent, + options + ); + options?.waitUntil?.(task); } else if (event.type === "assistant_thread_started") { this.handleAssistantThreadStarted( event as SlackAssistantThreadStartedEvent, From 6810874fd5cf06d2207275c40c2cc589c83849e8 Mon Sep 17 00:00:00 2001 From: dancer Date: Sun, 26 Apr 2026 23:52:30 +0100 Subject: [PATCH 7/7] fix(integration-tests): drain waitUntil cascade in test tracker --- packages/integration-tests/src/test-scenarios.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/integration-tests/src/test-scenarios.ts b/packages/integration-tests/src/test-scenarios.ts index 72e19986..61677ed9 100644 --- a/packages/integration-tests/src/test-scenarios.ts +++ b/packages/integration-tests/src/test-scenarios.ts @@ -17,8 +17,11 @@ export function createWaitUntilTracker(): WaitUntilTracker { tasks.push(task); }, waitForAll: async () => { - await Promise.all(tasks); - tasks.length = 0; + // drain in a loop: awaited tasks may register more via waitUntil mid-flight + while (tasks.length > 0) { + const pending = tasks.splice(0); + await Promise.all(pending); + } }, }; }