feat(slack): native Sim app trigger mode via the preview-gated slack_v2 block (#5892)

* feat(slack): native Sim app trigger mode behind the preview-gated slack_v2 block

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz

* fix(slack): address review — set credentialId, scope OAuth path to workspace, drop event narrowing

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018asmKsWQ5Vi7T7wD9uHofz

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-07-23 14:51:19 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent efe1de315e
commit 5adf68f332
4 changed files with 341 additions and 47 deletions
+172 -4
View File
@@ -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<string, unknown>,
workflow: Record<string, unknown> = { 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()
})
})
+135 -32
View File
@@ -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<string, unknown>
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 {
+24 -10
View File
@@ -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',
},
+10 -1
View File
@@ -271,7 +271,16 @@ export const slackEventById = new Map<string, SlackEventCatalogEntry>(
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,