diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 2ffbf360fe7..da0f6f4a4f0 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { credential } from '@sim/db/schema' -import { resetDbChainMock } from '@sim/testing' +import { account, credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { eq } from 'drizzle-orm' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -25,7 +25,33 @@ vi.mock('@/lib/webhooks/pending-verification', () => ({ PendingWebhookVerificationTracker: vi.fn(), })) -import { buildProviderConfig, resolveTriggerCredentialId } from '@/lib/webhooks/deploy' +const { + mockGetSlackBotCredential, + mockResolveOAuthAccountId, + mockRefreshAccessTokenIfNeeded, + mockFetchSlackTeamId, +} = vi.hoisted(() => ({ + mockGetSlackBotCredential: vi.fn(), + mockResolveOAuthAccountId: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), + mockFetchSlackTeamId: vi.fn(), +})) +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getSlackBotCredential: mockGetSlackBotCredential, + resolveOAuthAccountId: mockResolveOAuthAccountId, + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) +vi.mock('@/lib/webhooks/providers/slack', () => ({ + fetchSlackTeamId: mockFetchSlackTeamId, +})) + +import { + buildProviderConfig, + resolveTriggerCredentialId, + resolveWebhookConfigForBlock, +} from '@/lib/webhooks/deploy' +import { getBlock } from '@/blocks' +import { getTrigger } from '@/triggers' afterAll(resetDbChainMock) @@ -191,3 +217,145 @@ describe('resolveTriggerCredentialId', () => { expect(eq).toHaveBeenCalledWith(credential.accountId, 'credential-1') }) }) + +describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { + const slackTriggerDef = { + provider: 'slack_app', + name: 'Slack', + subBlocks: [ + { id: 'eventType', mode: 'trigger', required: true }, + { + id: 'customBotCredential', + mode: 'trigger', + canonicalParamId: 'botCredential', + serviceId: 'slack', + required: true, + }, + { + id: 'manualBotCredential', + mode: 'trigger-advanced', + canonicalParamId: 'botCredential', + required: true, + }, + ], + } + + function resolveSlack( + values: Record, + workflow: Record = { workspaceId: 'ws-1' } + ) { + ;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' }) + ;(getTrigger as unknown as Mock).mockReturnValue(slackTriggerDef) + return resolveWebhookConfigForBlock({ + block: makeBlock('slack_oauth', values), + workflow, + userId: 'deployer-1', + requestId: 'req-1', + }) + } + + it('routes a custom bot credential by credential id on the slack provider', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'ws-1', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack') + expect(result.config.routingKey).toBe('cred_bot_1') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('BUSER') + }) + + it('rejects a custom bot credential from another workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'other-ws', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + }) + + it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_x' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('bot credential is missing or invalid') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects an OAuth credential not resolvable in the workflow workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + // No credential row queued → resolveTriggerCredentialId returns null. + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_foreign' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects a non-simSubscribed event on the native Sim app (OAuth account)', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + + const result = await resolveSlack({ + eventType: 'file_shared', + customBotCredential: 'cred_oauth_1', + }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available on the Sim Slack app') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('routes an OAuth account by team_id on the slack_app provider', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + queueTableRows(account, [{ userId: 'owner-1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('xoxb-token') + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack_app') + expect(result.config.routingKey).toBe('T123') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('UBOT') + // Runtime token resolution + disconnect cleanup key slack_app rows on this. + expect(result.config.providerConfig.credentialId).toBe('cred_oauth_1') + // Owner's token, not the deploying actor's. + expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith('cred_oauth_1', 'owner-1', 'req-1') + }) + + it('fails when the connected Slack account token cannot be resolved', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: '' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue(null) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('Could not access the connected Slack account') + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1678159e2f7..7bb32655690 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' +import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -15,6 +15,7 @@ import { projectDesiredWebhookProviderConfig, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' +import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' import { prepareStableWebhookRegistrations, type StableDesiredWebhookRegistration, @@ -26,12 +27,17 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { getSlackBotCredential } from '@/app/api/auth/oauth/utils' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' +import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared' const logger = createLogger('DeployWebhookSync') @@ -355,7 +361,12 @@ export async function resolveTriggerCredentialId( return resolvedCredential?.id ?? null } -async function resolveWebhookConfigForBlock(input: { +/** + * Resolves a trigger block to its persisted webhook config, including the + * Slack-specific routing branch. Exported for unit testing that branch; not part + * of the public deploy API. + */ +export async function resolveWebhookConfigForBlock(input: { block: BlockState workflow: Record userId: string @@ -425,43 +436,135 @@ async function resolveWebhookConfigForBlock(input: { let effectivePath: string | null = triggerPath let routingKey: string | null = null if (triggerId === 'slack_oauth') { - // Custom-bot only: events route by the bot credential to one shared ingest - // URL verified with the bot's own signing secret. The native Sim-app path - // (team_id routing via the official app) is intentionally not supported yet. - const botCredentialId = + // One credential picker feeds two backends. The credential's resolved kind — + // not a UI field — picks the branch: a Slack bot credential routes by the + // credential id (custom bring-your-own app); an OAuth account routes by Slack + // team_id on the native shared Sim app. + const slackCredentialId = typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined - if (!botCredentialId) { + if (!slackCredentialId) { return { success: false, - error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, + error: { message: 'Select a Slack account or bot for the trigger.', status: 400 }, } } - const botCredential = await getSlackBotCredential(botCredentialId) - if (!botCredential) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', - status: 400, - }, + const botCredential = await getSlackBotCredential(slackCredentialId) + if (botCredential) { + // Custom bring-your-own bot: events route by the bot credential to one + // shared ingest URL verified with the bot's own signing secret. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is not available in this workspace.', + status: 400, + }, + } } - } - const workflowWorkspace = - typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined - if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is not available in this workspace.', - status: 400, - }, + effectiveProvider = 'slack' + effectivePath = null + routingKey = slackCredentialId + providerConfig.credentialId = slackCredentialId + if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId + } else { + // getSlackBotCredential also returns null for a custom bot credential that + // was deleted or lost its stored secrets. Name that case so the error + // directs the user to reconnect the bot rather than mislabeling it an OAuth + // account below. + const resolvedKind = await resolveOAuthAccountId(slackCredentialId) + if (resolvedKind?.credentialType === 'service_account') { + return { + success: false, + error: { + message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', + status: 400, + }, + } + } + // Native Sim app: a workspace OAuth Slack credential. Resolve it through the + // same workspace/provider-scoped lookup the generic credential path uses, so + // a pasted foreign or other-tenant credential id can't bind here and the + // canonical id is what routing and runtime token resolution key on. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + const resolvedCredentialId = workflowWorkspace + ? await resolveTriggerCredentialId(slackCredentialId, workflowWorkspace, 'slack') + : null + if (!resolvedCredentialId) { + return { + success: false, + error: { + message: 'The selected Slack credential is not available in this workspace.', + status: 400, + }, + } + } + // The shared app only subscribes to a fixed event set; reject anything + // outside it before deriving routing. + const eventType = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + if (!eventType || !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { + return { + success: false, + error: { + message: + 'This event is not available on the Sim Slack app. Use a custom bot or choose a supported event.', + status: 400, + }, + } + } + // Resolve the credential OWNER's token (not the deploying actor's) — in a + // shared workspace a teammate can deploy a trigger wired to someone else's + // Slack account. + let tokenOwnerUserId = input.userId + const resolvedAccount = await resolveOAuthAccountId(resolvedCredentialId) + if (resolvedAccount?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolvedAccount.accountId)) + .limit(1) + if (owner?.userId) tokenOwnerUserId = owner.userId + } + const botToken = await refreshAccessTokenIfNeeded( + resolvedCredentialId, + tokenOwnerUserId, + input.requestId + ) + if (!botToken) { + return { + success: false, + error: { + message: 'Could not access the connected Slack account. Reconnect it and try again.', + status: 400, + }, + } + } + try { + const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) + routingKey = teamId + if (botUserId) providerConfig.bot_user_id = botUserId + } catch (error: unknown) { + logger.error( + `[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`, + error + ) + return { + success: false, + error: { + message: 'Could not verify the connected Slack workspace. Reconnect it and try again.', + status: 400, + }, + } } + effectiveProvider = 'slack_app' + effectivePath = null + // Runtime token resolution and credential-disconnect cleanup key native + // (`slack_app`) rows on providerConfig.credentialId. + providerConfig.credentialId = resolvedCredentialId } - effectiveProvider = 'slack' - effectivePath = null - routingKey = botCredentialId - providerConfig.credentialId = botCredentialId - if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId } return { diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 368b4128dc5..fb277ef8594 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -23,11 +23,20 @@ const BOT_FILTER_EVENTS = ['message', 'app_mention'] const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reaction_removed'] /** - * Slack trigger backed by a reusable custom-bot credential (set up once, reused - * across triggers and actions). Events route by that credential - * (`webhook.routingKey = credentialId`) to one shared ingest URL, so many - * triggers on the same bot share a single Request URL, verified with the bot's - * own signing secret. + * Unified Slack trigger. A single credential picker lists both the native Sim + * Slack app (an OAuth-connected account) and reusable custom bots; the deploy + * path resolves the credential's kind server-side to pick the backend: + * - Custom bot: events route by that credential (`webhook.routingKey = + * credentialId`) to one shared ingest URL verified with the bot's own signing + * secret, so many triggers on the same bot share a single Request URL. + * - Native Sim app: events route by Slack `team_id` on the official shared app + * (derived at deploy time via `auth.test`, no path or app setup). Only the + * events the shared app subscribes to are usable; the deploy path enforces + * that (`SIM_SUBSCRIBED_EVENTS`), so the event picker offers every event + * rather than mutating its option set with the selected credential. + * + * The trigger is only reachable through the preview-gated `slack_v2` block, so + * the native Sim-app mode inherits that gate — no separate env flag. */ export const slackOAuthTrigger: TriggerConfig = { id: 'slack_oauth', @@ -51,16 +60,21 @@ export const slackOAuthTrigger: TriggerConfig = { }, { id: 'customBotCredential', - title: 'Slack Bot', + title: 'Slack Account', type: 'oauth-input', canonicalParamId: 'botCredential', serviceId: 'slack', - credentialKind: 'service-account', - credentialLabels: { serviceAccountConnect: 'Set up a custom bot' }, + credentialKind: 'any', + credentialLabels: { + oauthGroup: 'Sim app', + oauthConnect: 'Connect the Sim app', + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }, requiredScopes: getScopesForService('slack'), - placeholder: 'Select a connected bot', + placeholder: 'Select Slack account or bot', description: - 'Choose a custom Slack bot you set up once and reuse across triggers and actions.', + 'Connect the native Sim Slack app, or choose a custom Slack bot you set up once and reuse across triggers and actions.', required: true, mode: 'trigger', }, diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index 4372f7ba68b..eda88da4c08 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -271,7 +271,16 @@ export const slackEventById = new Map( SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry]) ) -/** Dropdown options for the event picker — every event (manifest generated on demand). */ +/** + * Event ids the official shared Sim app subscribes to. Single source of truth + * for the deploy-time gate that rejects a native Sim-app trigger configured with + * an event the shared app can't deliver. + */ +export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => entry.id) + +/** Dropdown options for the event picker — every selectable event. */ export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({ label: entry.label, id: entry.id,