From ab14cc993500c323f63a9c8b6e08adb5f68c1592 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 11:01:04 -0700 Subject: [PATCH 1/2] feat(slack): native Sim app trigger mode behind the preview-gated slack_v2 block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the native Sim Slack app mode for the slack_oauth trigger using a single-credential-picker design. The trigger is only reachable through the preview-gated slack_v2 block, so the mode inherits that gate — no separate env flag. - Re-add SIM_SUBSCRIBED_EVENTS / SLACK_SIM_EVENT_OPTIONS derived exports. - Merge the trigger credential picker (credentialKind: 'any') so it lists Sim OAuth accounts and reusable custom bots together, mirroring the slack_v2 block. - Event dropdown narrows to the Sim app's subscribed events when an OAuth account is selected, all events for a custom bot (resolved client-side from the warmed credential list). - Deploy branch discriminates by the RESOLVED credential, not a UI field: a bot credential routes by credential id (custom app); otherwise validate the event against SIM_SUBSCRIBED_EVENTS, resolve the credential owner's token, derive routingKey from Slack team_id (auth.test), and route on the shared Sim app. - The ingest endpoint and team_id fan-out routing already existed on staging. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz --- apps/sim/lib/webhooks/deploy.test.ts | 143 ++++++++++++++++++++++++++- apps/sim/lib/webhooks/deploy.ts | 132 +++++++++++++++++++------ apps/sim/triggers/slack/oauth.ts | 74 +++++++++++--- apps/sim/triggers/slack/shared.ts | 12 ++- 4 files changed, 313 insertions(+), 48 deletions(-) diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 2ffbf360fe7..7010e46fb68 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,112 @@ 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 non-simSubscribed event on the native Sim app (OAuth account)', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + + 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(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') + // 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: '' }) + 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..c6a1c4516d0 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,100 @@ 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 { + // Native Sim app: an OAuth-connected account. The app only subscribes to a + // fixed event set, so 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(slackCredentialId) + 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( + slackCredentialId, + 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 } - 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..74b37a27170 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -1,7 +1,16 @@ import { SlackIcon } from '@/components/icons' -import { getScopesForService } from '@/lib/oauth/utils' +import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { + fetchOAuthCredentials, + OAUTH_CREDENTIAL_LIST_STALE_TIME, + oauthCredentialKeys, +} from '@/hooks/queries/oauth/oauth-credentials' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { SLACK_ALL_EVENT_OPTIONS, + SLACK_SIM_EVENT_OPTIONS, SLACK_SOURCE_OPTIONS, SLACK_THREAD_OPTIONS, SLACK_TRIGGER_OUTPUTS, @@ -9,6 +18,36 @@ import { } from '@/triggers/slack/shared' import type { TriggerConfig } from '@/triggers/types' +const SLACK_PROVIDER_ID = getProviderIdFromServiceId('slack') + +/** + * Event options for the picker, narrowed to what the selected credential can + * actually receive: a native Sim-app OAuth account is limited to the events the + * shared app subscribes to, while a reusable custom bot generates its own + * manifest and can use any event. Resolved from the credential list the picker + * already warmed, so this reads the cache without a refetch in the common case. + */ +async function fetchSlackEventOptions(blockId: string) { + const credentialId = useSubBlockStore.getState().getValue(blockId, 'customBotCredential') + if (typeof credentialId !== 'string' || !credentialId) return [...SLACK_ALL_EVENT_OPTIONS] + + const registry = useWorkflowRegistry.getState() + const workspaceId = registry.hydration.workspaceId ?? undefined + const workflowId = registry.activeWorkflowId ?? undefined + + const credentials = await getQueryClient().fetchQuery({ + queryKey: oauthCredentialKeys.list(SLACK_PROVIDER_ID, workspaceId, workflowId), + queryFn: ({ signal }) => + fetchOAuthCredentials({ providerId: SLACK_PROVIDER_ID, workspaceId, workflowId }, signal), + staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME, + }) + + const selected = credentials.find((cred) => cred.id === credentialId) + return selected && selected.type !== 'service_account' + ? [...SLACK_SIM_EVENT_OPTIONS] + : [...SLACK_ALL_EVENT_OPTIONS] +} + // Filter sub-block gating is derived from the catalog's `filters` field so the // UI conditions and the ingest route share one source of truth. const SOURCE_FILTER_EVENTS = slackEventsSupportingFilter('source') @@ -23,11 +62,17 @@ 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). + * + * 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', @@ -48,19 +93,26 @@ export const slackOAuthTrigger: TriggerConfig = { 'The single Slack event this trigger fires on. Add another trigger block for another event.', required: true, mode: 'trigger', + dependsOn: ['customBotCredential'], + fetchOptions: fetchSlackEventOptions, }, { 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..a5f9f6a6029 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -271,7 +271,17 @@ 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 (Sim-app gating SOT). */ +export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => entry.id) + +/** Dropdown options for the native Sim app — only officially-subscribed events. */ +export const SLACK_SIM_EVENT_OPTIONS = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => ({ label: entry.label, id: entry.id })) + +/** Dropdown options for a custom bot — every event (manifest generated on demand). */ export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({ label: entry.label, id: entry.id, From b59d165afae8bba2bb7061356435f155d90cc1c9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 11:25:41 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(slack):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20set=20credentialId,=20scope=20OAuth=20path=20to=20workspace,?= =?UTF-8?q?=20drop=20event=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (Greptile 4/5 + Cursor Bugbot): - deploy.ts: the native Sim-app (OAuth) branch now sets providerConfig.credentialId (runtime token resolution in the slack provider and credential-disconnect cleanup both key slack_app rows on it — without it, file downloads / reaction text fail and disconnect leaves the webhook active). - deploy.ts: resolve the OAuth credential through resolveTriggerCredentialId (workspace- and oauth-scoped) so a pasted foreign/other-tenant credential id can't bind to the workflow; use the resolved canonical id for token owner lookup + routing. - deploy.ts: a deleted/secretless custom bot credential (getSlackBotCredential → null but a service_account row exists) now returns the "reconnect the bot" error instead of the misleading "connected Slack account" message. - oauth.ts: drop the credential-based event-option narrowing. The shared dropdown framework doesn't revalidate a stored value when its dependency changes, so switching a custom bot → Sim account left an orphaned event that failed deploy with a 400. The event picker now offers all events; the deploy path is the authoritative gate. - tests: add broken-bot and workspace-not-resolvable cases; assert credentialId is set. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz --- apps/sim/lib/webhooks/deploy.test.ts | 33 +++++++++++++++++++ apps/sim/lib/webhooks/deploy.ts | 43 ++++++++++++++++++++++--- apps/sim/triggers/slack/oauth.ts | 48 +++------------------------- apps/sim/triggers/slack/shared.ts | 13 ++++---- 4 files changed, 83 insertions(+), 54 deletions(-) diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 7010e46fb68..da0f6f4a4f0 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -278,8 +278,37 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { 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', @@ -296,6 +325,7 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { 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' }) @@ -308,6 +338,8 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { 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') }) @@ -315,6 +347,7 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { 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' }) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index c6a1c4516d0..7bb32655690 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -469,8 +469,40 @@ export async function resolveWebhookConfigForBlock(input: { providerConfig.credentialId = slackCredentialId if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId } else { - // Native Sim app: an OAuth-connected account. The app only subscribes to a - // fixed event set, so reject anything outside it before deriving routing. + // 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)) { @@ -487,7 +519,7 @@ export async function resolveWebhookConfigForBlock(input: { // shared workspace a teammate can deploy a trigger wired to someone else's // Slack account. let tokenOwnerUserId = input.userId - const resolvedAccount = await resolveOAuthAccountId(slackCredentialId) + const resolvedAccount = await resolveOAuthAccountId(resolvedCredentialId) if (resolvedAccount?.accountId) { const [owner] = await db .select({ userId: account.userId }) @@ -497,7 +529,7 @@ export async function resolveWebhookConfigForBlock(input: { if (owner?.userId) tokenOwnerUserId = owner.userId } const botToken = await refreshAccessTokenIfNeeded( - slackCredentialId, + resolvedCredentialId, tokenOwnerUserId, input.requestId ) @@ -529,6 +561,9 @@ export async function resolveWebhookConfigForBlock(input: { } effectiveProvider = 'slack_app' effectivePath = null + // Runtime token resolution and credential-disconnect cleanup key native + // (`slack_app`) rows on providerConfig.credentialId. + providerConfig.credentialId = resolvedCredentialId } } diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 74b37a27170..fb277ef8594 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -1,16 +1,7 @@ import { SlackIcon } from '@/components/icons' -import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { - fetchOAuthCredentials, - OAUTH_CREDENTIAL_LIST_STALE_TIME, - oauthCredentialKeys, -} from '@/hooks/queries/oauth/oauth-credentials' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { getScopesForService } from '@/lib/oauth/utils' import { SLACK_ALL_EVENT_OPTIONS, - SLACK_SIM_EVENT_OPTIONS, SLACK_SOURCE_OPTIONS, SLACK_THREAD_OPTIONS, SLACK_TRIGGER_OUTPUTS, @@ -18,36 +9,6 @@ import { } from '@/triggers/slack/shared' import type { TriggerConfig } from '@/triggers/types' -const SLACK_PROVIDER_ID = getProviderIdFromServiceId('slack') - -/** - * Event options for the picker, narrowed to what the selected credential can - * actually receive: a native Sim-app OAuth account is limited to the events the - * shared app subscribes to, while a reusable custom bot generates its own - * manifest and can use any event. Resolved from the credential list the picker - * already warmed, so this reads the cache without a refetch in the common case. - */ -async function fetchSlackEventOptions(blockId: string) { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'customBotCredential') - if (typeof credentialId !== 'string' || !credentialId) return [...SLACK_ALL_EVENT_OPTIONS] - - const registry = useWorkflowRegistry.getState() - const workspaceId = registry.hydration.workspaceId ?? undefined - const workflowId = registry.activeWorkflowId ?? undefined - - const credentials = await getQueryClient().fetchQuery({ - queryKey: oauthCredentialKeys.list(SLACK_PROVIDER_ID, workspaceId, workflowId), - queryFn: ({ signal }) => - fetchOAuthCredentials({ providerId: SLACK_PROVIDER_ID, workspaceId, workflowId }, signal), - staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME, - }) - - const selected = credentials.find((cred) => cred.id === credentialId) - return selected && selected.type !== 'service_account' - ? [...SLACK_SIM_EVENT_OPTIONS] - : [...SLACK_ALL_EVENT_OPTIONS] -} - // Filter sub-block gating is derived from the catalog's `filters` field so the // UI conditions and the ingest route share one source of truth. const SOURCE_FILTER_EVENTS = slackEventsSupportingFilter('source') @@ -69,7 +30,10 @@ const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reactio * 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). + * (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. @@ -93,8 +57,6 @@ export const slackOAuthTrigger: TriggerConfig = { 'The single Slack event this trigger fires on. Add another trigger block for another event.', required: true, mode: 'trigger', - dependsOn: ['customBotCredential'], - fetchOptions: fetchSlackEventOptions, }, { id: 'customBotCredential', diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index a5f9f6a6029..eda88da4c08 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -271,17 +271,16 @@ export const slackEventById = new Map( SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry]) ) -/** Event ids the official shared Sim app subscribes to (Sim-app gating SOT). */ +/** + * 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 native Sim app — only officially-subscribed events. */ -export const SLACK_SIM_EVENT_OPTIONS = SLACK_EVENT_CATALOG.filter( - (entry) => entry.simSubscribed -).map((entry) => ({ label: entry.label, id: entry.id })) - -/** Dropdown options for a custom bot — every event (manifest generated on demand). */ +/** 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,