From d7e51c8d98499b4ef96c64771fe592c0c2288a08 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 19 Aug 2026 16:58:10 -0700 Subject: [PATCH 1/6] feat(slack): launch v2 triggers and backfill custom bots --- apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/slack.mdx | 23 +- .../self-hosting/integrations-oauth.mdx | 2 +- apps/sim/app/api/auth/oauth/utils.test.ts | 17 + .../slack/custom/[credentialId]/route.test.ts | 13 + .../slack/custom/[credentialId]/route.ts | 37 +- .../api/webhooks/trigger/[path]/route.test.ts | 79 + .../app/api/webhooks/trigger/[path]/route.ts | 69 +- .../use-service-account-connect.ts | 7 +- .../credential-selector.tsx | 10 +- apps/sim/blocks/blocks/slack.test.ts | 47 + apps/sim/blocks/blocks/slack.ts | 89 +- apps/sim/blocks/registry-maps.ts | 3 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 5 - apps/sim/lib/copilot/vfs/serializers.ts | 13 +- .../copilot/vfs/service-account-gate.test.ts | 10 +- .../service-account-provider-ids.test.ts | 2 +- .../service-account-provider-ids.ts | 9 +- .../integrations/availability.server.test.ts | 65 +- .../credential-visibility.server.test.ts | 45 +- apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/oauth/credential-service.ts | 24 +- apps/sim/lib/oauth/oauth.test.ts | 13 +- apps/sim/lib/oauth/oauth.ts | 8 +- apps/sim/lib/webhooks/deploy.test.ts | 164 +- apps/sim/lib/webhooks/deploy.ts | 87 +- apps/sim/lib/webhooks/processor.ts | 15 + .../slack-custom-ingress-constants.ts | 1 + .../lib/webhooks/slack-custom-ingress.test.ts | 39 + apps/sim/lib/webhooks/slack-custom-ingress.ts | 113 ++ apps/sim/triggers/slack/oauth.test.ts | 32 + apps/sim/triggers/slack/oauth.ts | 70 +- apps/sim/triggers/slack/webhook.ts | 9 + .../scripts/migrate-slack-custom-bots.test.ts | 318 ++++ .../db/scripts/migrate-slack-custom-bots.ts | 1417 +++++++++++++++++ .../deployment-config/src/integrations.json | 8 +- .../src/service-account-metadata.ts | 1 - packages/sim-setup/src/twins.test.ts | 11 + packages/sim-setup/src/twins.ts | 1 + scripts/generate-docs.ts | 13 +- 40 files changed, 2667 insertions(+), 226 deletions(-) create mode 100644 apps/sim/blocks/blocks/slack.test.ts create mode 100644 apps/sim/lib/webhooks/slack-custom-ingress-constants.ts create mode 100644 apps/sim/lib/webhooks/slack-custom-ingress.test.ts create mode 100644 apps/sim/lib/webhooks/slack-custom-ingress.ts create mode 100644 apps/sim/triggers/slack/oauth.test.ts create mode 100644 packages/db/scripts/migrate-slack-custom-bots.test.ts create mode 100644 packages/db/scripts/migrate-slack-custom-bots.ts create mode 100644 packages/sim-setup/src/twins.test.ts diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 926f37afdfb..b49b37ac044 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -521,6 +521,8 @@ export const blockTypeToIconMap: Record = { similarweb: SimilarwebIcon, sixtyfour: SixtyfourIcon, slack: SlackIcon, + slack_app: SlackIcon, + slack_v2: SlackIcon, smartlead: SmartleadIcon, smtp: SmtpIcon, snowflake: SnowflakeIcon, diff --git a/apps/docs/content/docs/en/integrations/slack.mdx b/apps/docs/content/docs/en/integrations/slack.mdx index 6dae35d9ada..c2ff9abf2af 100644 --- a/apps/docs/content/docs/en/integrations/slack.mdx +++ b/apps/docs/content/docs/en/integrations/slack.mdx @@ -6,7 +6,7 @@ description: Send, update, delete messages, manage views and modals, add or remo import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -1872,18 +1872,27 @@ Set the purpose (description) for a Slack channel (max 250 characters). A **Trigger** is a block that starts a workflow when an event happens in this service. -### Slack Webhook +### Slack -Trigger workflow from Slack events like mentions, messages, and reactions +Trigger from Slack events (mentions, messages, reactions) #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `signingSecret` | string | Yes | The signing secret from your Slack app to validate request authenticity. | -| `botToken` | string | No | The bot token from your Slack app. Required for downloading files attached to messages. | -| `includeFiles` | boolean | No | Download and include file attachments from messages. Requires a bot token with files:read scope. | -| `setupWizard` | modal | No | Walk through manifest creation, app install, and pasting credentials. | +| `eventType` | string | Yes | The single Slack event this trigger fires on. Add another trigger block for another event. | +| `customBotCredential` | string | Yes | Choose a custom Slack bot you set up once and reuse across triggers. | +| `manualBotCredential` | string | Yes | Set the custom bot credential ID directly. | +| `source` | string | No | Restrict to direct messages, public channels, or private channels. Leave empty to match any. | +| `channelFilter` | channel-selector | No | Restrict to specific channels. Leave empty to trigger on any channel the bot has been added to. | +| `manualChannelFilter` | string | No | Comma-separated channel IDs to restrict to. Set IDs directly here. | +| `threads` | string | No | Include thread replies, exclude them \(top-level only\), or fire only on thread replies. | +| `emoji` | string | No | Comma-separated emoji names to restrict to. Leave empty to match any emoji. | +| `nameContains` | string | No | Only fire when the created channel name contains this text. | +| `interactionFilter` | string | No | Comma-separated action_ids \(buttons/selects\) or callback_ids \(modals\) to restrict to. Leave empty to fire on any interaction. | +| `filterBotMessages` | boolean | No | Ignore messages sent by other bots. This app's own output is always ignored. | +| `includeOwnMessages` | boolean | No | Also fire on this app's own messages and reactions. Can cause loops — use with care. | +| `includeFiles` | boolean | No | Download and include file attachments from messages. Requires files:read. | #### Output diff --git a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx index 761e59b2e7a..d545871ec55 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx @@ -194,7 +194,7 @@ Webhook triggers receive callbacks from the provider and must be able to verify | Variable | Needed for | |---|---| | `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures | -| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Requesting the broader Slack scope set | +| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Enabling the native Sim-app trigger and its broader Slack scope set; set both to the same value | Your deployment must also be reachable from the provider's servers for webhook triggers to fire — a Sim instance on a private network can use polling triggers but not webhook triggers. Polling triggers additionally require the scheduler; see [Background Jobs](/platform/self-hosting/background-jobs). diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index a5d76c1bc5a..05782849e6d 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -445,6 +445,23 @@ describe('OAuth Utils', () => { expect(result.accessToken).toBe('xoxb-tok') }) + it('returns the bot token for an action-only Slack bot without a signing secret', async () => { + mockSelectChain([ + { + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + encryptedServiceAccountKey: 'enc', + }, + ]) + mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ botToken: 'xoxb-action' }), + }) + + const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID) + + expect(result.accessToken).toBe('xoxb-action') + }) + it('throws when the Slack bot credential is missing', async () => { mockSelectChain([]) await expect( diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts index b1d182cdbd1..022c8619aac 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts @@ -99,6 +99,19 @@ describe('Slack custom-bot webhook route', () => { expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) + it('404s an action-only bot credential without a signing secret', async () => { + mockGetSlackBotCredential.mockResolvedValue({ + botToken: 'xoxb-x', + teamId: 'T1', + }) + + const res = await POST(makeRequest(), context) + + expect(res.status).toBe(404) + expect(mockVerifySignature).not.toHaveBeenCalled() + expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() + }) + it('verifies with the credential signing secret and rejects a bad signature', async () => { mockVerifySignature.mockReturnValue(new Response(null, { status: 401 })) const res = await POST(makeRequest(), context) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts index 9925aca7f50..4ece7692b19 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts @@ -1,14 +1,13 @@ -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getSlackBotCredential } from '@/lib/oauth/credential-service' -import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' -import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' -import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' - -const logger = createLogger('SlackCustomBotWebhookAPI') +import { parseWebhookBody } from '@/lib/webhooks/processor' +import { handleSlackChallenge } from '@/lib/webhooks/providers/slack' +import { + dispatchSlackCustomBotCredential, + verifySlackCustomBotCredentialRequest, +} from '@/lib/webhooks/slack-custom-ingress' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -58,31 +57,17 @@ async function handleSlackCustomBotWebhook( return challenge } - const botCredential = await getSlackBotCredential(credentialId) - if (!botCredential) { - logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`) - return new NextResponse(null, { status: 404 }) - } - - const authError = verifySlackRequestSignature( - botCredential.signingSecret, + const authError = await verifySlackCustomBotCredentialRequest({ + credentialId, request, rawBody, - requestId - ) + requestId, + }) if (authError) { return authError } - const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack') - if (webhooks.length === 0) { - logger.info( - `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run` - ) - return new NextResponse(null, { status: 200 }) - } - - await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) + await dispatchSlackCustomBotCredential({ credentialId, body, request, requestId, receivedAt }) return new NextResponse(null, { status: 200 }) } diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 0aec54129cd..5cc70714a28 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -79,6 +79,7 @@ interface TestWebhook { path: string isActive: boolean providerConfig?: Record + routingKey?: string | null workflowId: string blockId?: string rateLimitCount?: number @@ -120,6 +121,9 @@ const { shouldSkipWebhookEventMock, admissionRejectedResponseMock, tryAdmitMock, + getLegacySlackCustomBotCredentialIdMock, + verifySlackCustomBotCredentialRequestMock, + dispatchSlackCustomBotCredentialMock, } = vi.hoisted(() => ({ generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'), validateSlackSignatureMock: vi.fn().mockResolvedValue(true), @@ -179,6 +183,9 @@ const { shouldSkipWebhookEventMock: vi.fn().mockReturnValue(false), admissionRejectedResponseMock: vi.fn(), tryAdmitMock: vi.fn<() => { release: () => void } | null>(() => ({ release: vi.fn() })), + getLegacySlackCustomBotCredentialIdMock: vi.fn(), + verifySlackCustomBotCredentialRequestMock: vi.fn(), + dispatchSlackCustomBotCredentialMock: vi.fn(), })) vi.mock('@/lib/core/admission/gate', () => ({ @@ -186,6 +193,12 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: tryAdmitMock, })) +vi.mock('@/lib/webhooks/slack-custom-ingress', () => ({ + getLegacySlackCustomBotCredentialId: getLegacySlackCustomBotCredentialIdMock, + verifySlackCustomBotCredentialRequest: verifySlackCustomBotCredentialRequestMock, + dispatchSlackCustomBotCredential: dispatchSlackCustomBotCredentialMock, +})) + vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), @@ -513,6 +526,14 @@ describe('Webhook Trigger API Route', () => { workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true) handleWebhookEventFilterMock.mockResolvedValue(null) shouldSkipWebhookEventMock.mockReturnValue(false) + getLegacySlackCustomBotCredentialIdMock.mockImplementation((foundWebhook: TestWebhook) => { + const providerConfig = foundWebhook.providerConfig ?? {} + return providerConfig.ingressMode === 'legacy_custom_bot' + ? (providerConfig.credentialId as string) + : null + }) + verifySlackCustomBotCredentialRequestMock.mockResolvedValue(null) + dispatchSlackCustomBotCredentialMock.mockResolvedValue(1) // Set up default workflow for tests testData.workflows.push({ @@ -965,6 +986,64 @@ describe('Webhook Trigger API Route', () => { }) }) + describe('Migrated legacy Slack paths', () => { + it('authenticates by custom-bot credential and replaces direct dispatch with fan-out', async () => { + testData.webhooks.push({ + id: 'legacy-slack-webhook', + provider: 'slack', + path: 'legacy-slack-path', + routingKey: 'credential-1', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'legacy-slack-path' }), + }) + + expect(response.status).toBe(200) + expect(verifySlackCustomBotCredentialRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'credential-1' }) + ) + expect(dispatchSlackCustomBotCredentialMock).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'credential-1' }) + ) + expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() + }) + + it('rejects a legacy alias when its credential signature is invalid', async () => { + testData.webhooks.push({ + id: 'legacy-slack-webhook', + provider: 'slack', + path: 'legacy-slack-path', + routingKey: 'credential-1', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }) + verifySlackCustomBotCredentialRequestMock.mockResolvedValueOnce( + new NextResponse('Unauthorized', { status: 401 }) + ) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'legacy-slack-path' }), + }) + + expect(response.status).toBe(401) + expect(dispatchSlackCustomBotCredentialMock).not.toHaveBeenCalled() + expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() + }) + }) + describe('Reservation-free filtering', () => { it('skips filtered webhook events before preprocessing reserves a slot', async () => { testData.webhooks.push({ diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 0a8a90ca774..975f90f9c04 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -21,8 +21,14 @@ import { verifyProviderAuth, } from '@/lib/webhooks/processor' import { acceptsPathWebhookDelivery, acceptsWebhookDeliveryMethod } from '@/lib/webhooks/providers' +import { + dispatchSlackCustomBotCredential, + getLegacySlackCustomBotCredentialId, + verifySlackCustomBotCredentialRequest, +} from '@/lib/webhooks/slack-custom-ingress' const logger = createLogger('WebhookTriggerAPI') +const MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH = 25 export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -197,18 +203,69 @@ async function handleWebhookDelivery( return notDeliverableResponse(request.method) } - // Process each webhook matched on this path - const responses: NextResponse[] = [] + const legacySlackCredentialIds = new Set() + const directWebhooksForPath = webhooksForPath.filter(({ webhook: foundWebhook }) => { + const credentialId = getLegacySlackCustomBotCredentialId(foundWebhook) + if (!credentialId) return true + legacySlackCredentialIds.add(credentialId) + return false + }) + if (legacySlackCredentialIds.size > MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH) { + throw new Error( + `Webhook path resolves more than ${MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH} legacy Slack credentials` + ) + } + + let dispatchedLegacySlackAlias = false + let firstLegacySlackAuthError: NextResponse | null = null + for (const credentialId of legacySlackCredentialIds) { + const authError = await verifySlackCustomBotCredentialRequest({ + credentialId, + request, + rawBody, + requestId, + }) + if (authError) { + if (authError.status === 404) return authError + firstLegacySlackAuthError ??= authError + continue + } + + await dispatchSlackCustomBotCredential({ + credentialId, + body, + request, + requestId, + receivedAt, + }) + dispatchedLegacySlackAlias = true + } + + if (legacySlackCredentialIds.size > 0 && !dispatchedLegacySlackAlias) { + return ( + firstLegacySlackAuthError ?? + new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) + ) + } + + /** + * Process each unmarked webhook matched on this path. Marked Slack rows were + * already included in the routing-key fan-out and must not run twice. + */ + const responses: NextResponse[] = dispatchedLegacySlackAlias + ? [new NextResponse(null, { status: 200 })] + : [] const failures: NextResponse[] = [] + const dispatchTargetCount = directWebhooksForPath.length + (dispatchedLegacySlackAlias ? 1 : 0) - for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { + for (const { webhook: foundWebhook, workflow: foundWorkflow } of directWebhooksForPath) { const provider = foundWebhook.provider if (!provider) { const missingProviderResponse = NextResponse.json( { error: 'Webhook provider is missing' }, { status: 500 } ) - if (webhooksForPath.length > 1) { + if (dispatchTargetCount > 1) { logger.error( `[${requestId}] Webhook ${foundWebhook.id} has no provider, continuing to next` ) @@ -225,7 +282,7 @@ async function handleWebhookDelivery( requestId ) if (authError) { - if (webhooksForPath.length > 1) { + if (dispatchTargetCount > 1) { logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) continue } @@ -255,7 +312,7 @@ async function handleWebhookDelivery( } if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') { - if (webhooksForPath.length > 1) { + if (dispatchTargetCount > 1) { logger.warn( `[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`, { reason: dispatchResult.reason, status: dispatchResult.response.status } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts index 7303ff84a33..043933dc882 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -41,9 +41,8 @@ export interface ServiceAccountConnectTarget { label: string /** * True when the provider's setup surface must stay hidden for this viewer. - * Custom Slack bots ride the `slack_v2` preview flag, so any surface that - * offers one — the integrations page or the chat — has to honour it or the - * flag is trivially bypassed. + * Custom Slack bots follow the released `slack_v2` block's visibility, so a + * hosted kill switch applies consistently across integrations and chat. */ hidden: boolean } @@ -55,7 +54,7 @@ interface UseServiceAccountConnectTargetArgs { } /** - * Derives the connect-control label and preview gating for a service-account + * Derives the connect-control label and block visibility for a service-account * provider. Shared by the integrations detail page and the chat's inline * connect button so the two can't drift on either the wording or the gate. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 0ab6e2fab39..6276166c298 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -131,11 +131,11 @@ export function CredentialSelector({ [credentialKind, isMergedKinds, serviceId] ) - // Canonical resolver for the service-account connect control: the vendor- - // accurate label and — critically — the per-viewer preview gate (a custom - // Slack bot rides `slack_v2`). Shared with the integrations page and chat so - // the gate can't be bypassed here. When `hidden`, the setup action is - // suppressed; existing service-account credentials stay selectable. + /** + * Canonical resolver for the service-account connect control: the vendor- + * accurate label and the owning block's per-viewer visibility. When hidden, + * the setup action is suppressed while existing credentials stay selectable. + */ const serviceAccountTarget = useServiceAccountConnectTarget({ serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId as | ServiceAccountProviderId diff --git a/apps/sim/blocks/blocks/slack.test.ts b/apps/sim/blocks/blocks/slack.test.ts new file mode 100644 index 00000000000..f9ad12ac823 --- /dev/null +++ b/apps/sim/blocks/blocks/slack.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getSlackV2ActionSubBlocks, + getSlackV2OperationSentences, + getSlackV2ToolAccess, + SlackBlock, + SlackV2Block, +} from '@/blocks/blocks/slack' + +const EXTENDED_OPERATION_IDS = ['set_status', 'set_title', 'set_suggested_prompts'] +const EXTENDED_TOOL_IDS = ['slack_set_status', 'slack_set_title', 'slack_set_suggested_prompts'] + +function operationIds(extendedScopesEnabled: boolean): string[] { + const operation = getSlackV2ActionSubBlocks(extendedScopesEnabled).find( + (subBlock) => subBlock.id === 'operation' + ) + return operation?.options?.map((option) => option.id) ?? [] +} + +describe('Slack block extended-scope capability', () => { + it('releases slack_v2 and keeps the legacy block executable but hidden', () => { + expect(SlackBlock.hideFromToolbar).toBe(true) + expect(SlackBlock.sunset).toEqual({ status: 'legacy', replacedBy: 'slack_v2' }) + expect(SlackV2Block.hideFromToolbar).toBe(false) + expect(SlackV2Block.preview).toBeUndefined() + expect(SlackV2Block.sunset).toBeUndefined() + }) + + it('removes extended-scope operations and tools when the capability is disabled', () => { + expect(operationIds(false)).not.toEqual(expect.arrayContaining(EXTENDED_OPERATION_IDS)) + expect(getSlackV2ToolAccess(false)).not.toEqual(expect.arrayContaining(EXTENDED_TOOL_IDS)) + expect(Object.keys(getSlackV2OperationSentences(false))).not.toEqual( + expect.arrayContaining(EXTENDED_OPERATION_IDS) + ) + }) + + it('restores extended-scope operations and tools when the capability is enabled', () => { + expect(operationIds(true)).toEqual(expect.arrayContaining(EXTENDED_OPERATION_IDS)) + expect(getSlackV2ToolAccess(true)).toEqual(expect.arrayContaining(EXTENDED_TOOL_IDS)) + expect(Object.keys(getSlackV2OperationSentences(true))).toEqual( + expect.arrayContaining(EXTENDED_OPERATION_IDS) + ) + }) +}) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index aac1288181a..f498ed18dcc 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,5 +1,6 @@ import { BookOpen, ClipboardList, File, Table, Users } from '@sim/emcn/icons' import { GoogleTranslateIcon, GreptileIcon, SlackIcon } from '@/components/icons' +import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' @@ -15,6 +16,18 @@ import { getTrigger } from '@/triggers' /** The operations that offer a channel/DM switch, and so honour it. */ const DESTINATION_SWITCH_OPERATIONS = ['send', 'read', 'schedule_message'] as const +const SLACK_EXTENDED_SCOPE_OPERATION_IDS = new Set([ + 'set_status', + 'set_title', + 'set_suggested_prompts', +]) + +const SLACK_EXTENDED_SCOPE_TOOL_IDS = new Set([ + 'slack_set_status', + 'slack_set_title', + 'slack_set_suggested_prompts', +]) + const CHANNEL_FIELD = ['channel', 'manualChannel'] as const /** @@ -295,9 +308,9 @@ export const SlackBlock: BlockConfig = { }, }, }, - // Superseded by slack_v2, but stays discoverable until v2 GAs — hiding both - // would leave no Slack block in the toolbar while v2 is preview-gated. At v2 - // GA this becomes `hideFromToolbar: true` (superseded-version paradigm). + /** Existing workflows keep resolving v1 while discovery uses the released successor. */ + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'slack_v2' }, subBlocks: [ { id: 'operation', @@ -2748,9 +2761,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te team_id: { type: 'string', description: 'Slack workspace/team ID' }, event_id: { type: 'string', description: 'Unique event identifier for the trigger' }, }, - // Trigger capabilities moved to slack_v2 so the trigger surfaces once. - // Legacy webhook trigger stays available while slack_v2 (which hosts the - // redesigned slack_oauth trigger) is preview-gated; drops at v2 GA. + /** Keeps saved v1 webhook-trigger workflows executable after slack_v2 is released. */ triggers: { enabled: true, available: ['slack_webhook'], @@ -2910,6 +2921,11 @@ export const SlackBlockMeta = { ], } as const satisfies BlockMeta +export const SlackV2BlockMeta = { + tags: ['messaging', 'webhooks', 'automation'], + url: 'https://slack.com', +} as const satisfies BlockMeta + const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set( getTrigger('slack_webhook').subBlocks.map((sb) => sb.id) ) @@ -2918,8 +2934,16 @@ const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set( * Adapts a v1 subblock for slack_v2's merged credential picker: fields gated on * the removed `authMethod` dropdown now depend on the single `credential` field. */ -function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { +function adaptSubBlockForV2(sb: SubBlockConfig, extendedScopesEnabled: boolean): SubBlockConfig { const { dependsOn, condition, ...rest } = sb + if (sb.id === 'operation' && !extendedScopesEnabled) { + const options = typeof sb.options === 'function' ? sb.options() : sb.options + if (!options) throw new Error('Slack operation subblock must define options') + return { + ...sb, + options: options.filter((option) => !SLACK_EXTENDED_SCOPE_OPERATION_IDS.has(option.id)), + } + } if (sb.id === 'credential') { return { ...rest, @@ -2942,6 +2966,33 @@ function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { return sb } +export function getSlackV2ActionSubBlocks(extendedScopesEnabled: boolean): SubBlockConfig[] { + return SlackBlock.subBlocks.flatMap((sb) => { + if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return [] + if (sb.id === 'authMethod') return [] + return [adaptSubBlockForV2(sb, extendedScopesEnabled)] + }) +} + +export function getSlackV2ToolAccess(extendedScopesEnabled: boolean): string[] { + if (extendedScopesEnabled) return [...SlackBlock.tools.access] + return SlackBlock.tools.access.filter((toolId) => !SLACK_EXTENDED_SCOPE_TOOL_IDS.has(toolId)) +} + +export function getSlackV2OperationSentences(extendedScopesEnabled: boolean) { + const operationSentences = SlackBlock.canvasPresentation?.sentences?.byOperation + if (!operationSentences) { + throw new Error('Slack action sentences must be defined before building slack_v2') + } + if (extendedScopesEnabled) return { ...operationSentences } + + const scopedSentences = { ...operationSentences } + for (const operationId of SLACK_EXTENDED_SCOPE_OPERATION_IDS) { + delete scopedSentences[operationId] + } + return scopedSentences +} + const { authMethod: _authMethod, botToken: _botToken, @@ -2960,14 +3011,14 @@ export const SlackV2Block: BlockConfig = { ...SlackBlock, type: 'slack_v2', hideFromToolbar: false, - // Preview-gated: hidden from every discovery surface until revealed via the - // block-visibility AppConfig (hosted) or PREVIEW_BLOCKS=slack_v2 (dev / - // self-host). At GA: drop this flag, add SlackV2BlockMeta + docs, and set - // hideFromToolbar on v1. - preview: true, + sunset: undefined, canvasPresentation: { ...SlackBlock.canvasPresentation, defaultTitle: 'Slack', + sentences: { + ...SlackBlock.canvasPresentation?.sentences, + byOperation: getSlackV2OperationSentences(isSlackExtendedScopesEnabled), + }, /* * Unlike v1, this trigger picks one event and scopes it, so the card names * both. Each filter clause is gated on the events that expose it — @@ -2988,17 +3039,13 @@ export const SlackV2Block: BlockConfig = { }, }, subBlocks: [ - ...SlackBlock.subBlocks.flatMap((sb) => { - // Drop the legacy paste-secret trigger config (v1 hosts slack_webhook) - // and v1's raw bot-token auth field — the trigger set includes an - // id-colliding 'botToken', so the set check covers both. The authMethod - // dropdown is gone: the merged credential picker covers both auth kinds. - if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return [] - if (sb.id === 'authMethod') return [] - return [adaptSubBlockForV2(sb)] - }), + ...getSlackV2ActionSubBlocks(isSlackExtendedScopesEnabled), ...getTrigger('slack_oauth').subBlocks, ], + tools: { + ...SlackBlock.tools, + access: getSlackV2ToolAccess(isSlackExtendedScopesEnabled), + }, inputs: { ...slackV2Inputs, oauthCredential: { type: 'string', description: 'Slack credential (OAuth account or bot)' }, diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 23532e9e394..fe44f895c3f 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -298,7 +298,7 @@ import { ShopifyBlock, ShopifyBlockMeta } from '@/blocks/blocks/shopify' import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' import { SimilarwebBlock, SimilarwebBlockMeta } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour' -import { SlackBlock, SlackBlockMeta, SlackV2Block } from '@/blocks/blocks/slack' +import { SlackBlock, SlackBlockMeta, SlackV2Block, SlackV2BlockMeta } from '@/blocks/blocks/slack' import { SmartleadBlock, SmartleadBlockMeta } from '@/blocks/blocks/smartlead' import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp' import { SnowflakeBlock, SnowflakeBlockMeta } from '@/blocks/blocks/snowflake' @@ -943,6 +943,7 @@ export const BLOCK_META_REGISTRY: Record = { similarweb: SimilarwebBlockMeta, sixtyfour: SixtyfourBlockMeta, slack: SlackBlockMeta, + slack_v2: SlackV2BlockMeta, smartlead: SmartleadBlockMeta, smtp: SmtpBlockMeta, snowflake: SnowflakeBlockMeta, diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 236c3524edd..370485eb66f 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -498,11 +498,6 @@ describe('serializeIntegrationSchema — service-account auth', () => { expect(schema.auth.serviceAccount).toEqual({ connectNoun: 'integration secret' }) expect(schema.oauth).toBeUndefined() }) - - // The preview-gate behavior (slack custom bot ↔ slack_v2) is covered in - // service-account-gate.test.ts, which mocks getBlock — the block registry is - // globally stubbed here, so slack_v2's real `preview: true` isn't observable - // through serializeIntegrationSchema. }) describe('serializeCredentials — type distinguishes reconnect flow', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 02a33625af4..c6022940147 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -45,7 +45,7 @@ export type VfsToolAuth = * credential (connect AS AN APPLICATION, not as the user). The agent emits * a `service_account` credential tag with this entry's OAuth `provider` to * open the in-chat setup form. Omitted when the service has no - * service-account flow, or its flow is gated by a preview block. + * service-account flow or its owning block is hidden. */ serviceAccount?: VfsServiceAccountAuth } @@ -62,10 +62,8 @@ export type VfsToolAuth = * noun for the secret it collects. The single composition point behind both the * per-tool `auth.serviceAccount` field and the `oauth-integrations.json` * roll-up, so the two never disagree. Returns `undefined` when the service has - * no service-account flow, or its flow is gated by a preview block (a custom - * Slack bot needs slack_v2) that is not the visible owner being serialized. - * This keeps the default projection GA-only while allowing a revealed preview - * block's own schema to describe the credential flow it enables. + * no service-account flow, or its owning block is hidden and is not the owner + * currently being serialized. */ export function describeServiceAccountForOAuthProvider( oauthProvider: string, @@ -76,11 +74,6 @@ export function describeServiceAccountForOAuthProvider( const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId) if (gatingBlockType) { const gatingBlock = getBlock(gatingBlockType) - // Omit when the gating block is missing (fail-closed) or hidden by the - // canonical predicate. Passing `null` vis reduces `isHiddenUnder` to the - // static preview check — so once the block GAs and drops `preview`, it is - // no longer hidden and discovery includes it again, matching the renderer. - // Hand-rolling `?.preview ?? true` would keep it omitted forever after GA. if (!gatingBlock || (ownerBlockType !== gatingBlockType && isHiddenUnder(null, gatingBlock))) { return undefined } diff --git a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts index 958ba995d77..500d9bddfe8 100644 --- a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts +++ b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts @@ -8,7 +8,7 @@ vi.mock('@/blocks', () => ({ getBlock: mockGetBlock })) import { describeServiceAccountForOAuthProvider } from '@/lib/copilot/vfs/serializers' -describe('describeServiceAccountForOAuthProvider — preview gate', () => { +describe('describeServiceAccountForOAuthProvider — owning block visibility', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -18,16 +18,12 @@ describe('describeServiceAccountForOAuthProvider — preview gate', () => { expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() }) - it('includes it once the gating block GAs and drops preview', () => { - // slack_v2's documented GA migration removes `preview`. Discovery must then - // surface the custom bot, matching what the UI shows. A hand-rolled - // `?.preview ?? true` would keep it omitted forever — the "sticks after GA" - // regression; reusing isHiddenUnder(null, block) fixes it. + it('includes it for the released owning block', () => { mockGetBlock.mockReturnValue({ type: 'slack_v2' }) expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' }) }) - it('includes it for the revealed preview block that owns the serialized tool', () => { + it('includes it when a preview block owns the serialized tool', () => { mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) expect(describeServiceAccountForOAuthProvider('slack', 'slack_v2')).toEqual({ diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index cbcdac95056..19fa62966df 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -34,7 +34,7 @@ describe('isServiceAccountProviderId', () => { }) describe('getServiceAccountGatingBlockType', () => { - it('maps the custom Slack bot to slack_v2 and leaves everything else ungated', () => { + it('maps the custom Slack bot to its owning block and leaves everything else independent', () => { expect(getServiceAccountGatingBlockType('slack-custom-bot')).toBe('slack_v2') expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 9e838b0a313..b77be5d8bd9 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -51,11 +51,10 @@ export function isServiceAccountProviderId(value: string): boolean { } /** - * The block type whose preview gate governs a service-account provider's setup - * surface, or `null` when the provider is ungated. A custom Slack bot is only - * usable through `slack_v2`, so its setup form must stay hidden wherever that - * block is preview-hidden — both the in-chat connect button and the tool that - * offers it read this so they can't disagree on availability. + * The block type that owns a service-account provider's setup surface, or + * `null` when it is independent of block visibility. Custom Slack bots belong + * to `slack_v2`, so its kill switch is honored without making the released bot + * flow depend on a preview reveal. */ export function getServiceAccountGatingBlockType(providerId: string): string | null { return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts index e7ec5bc9cad..4230532dc94 100644 --- a/apps/sim/lib/integrations/availability.server.test.ts +++ b/apps/sim/lib/integrations/availability.server.test.ts @@ -39,7 +39,7 @@ function availabilityFor( describe('integration availability', () => { it('marks a configured OAuth integration ready', () => { expect( - availabilityFor('slack', { + availabilityFor('slack_v2', { SLACK_CLIENT_ID: 'client', SLACK_CLIENT_SECRET: 'secret', }) @@ -48,7 +48,7 @@ describe('integration availability', () => { slug: 'slack', state: 'ready', oauthAvailable: true, - serviceAccountAvailable: false, + serviceAccountAvailable: true, missingFields: [], setupCommand: 'npx sim-setup add integration slack', }) @@ -81,47 +81,26 @@ describe('integration availability', () => { }) }) - it('reports a partially configured OAuth client and its missing fields', () => { - expect(availabilityFor('slack', { SLACK_CLIENT_ID: 'client' })).toMatchObject({ - state: 'misconfigured', + it('keeps custom bots available when the Slack OAuth client is partial', () => { + expect(availabilityFor('slack_v2', { SLACK_CLIENT_ID: 'client' })).toMatchObject({ + state: 'limited', oauthAvailable: false, - serviceAccountAvailable: false, + serviceAccountAvailable: true, missingFields: ['SLACK_CLIENT_SECRET'], setupCommand: 'npx sim-setup add integration slack', }) }) - it('projects a revealed preview service-account path as limited', () => { - const unavailableSlack = availabilityFor('slack') - const misconfiguredSlack = availabilityFor('slack', { SLACK_CLIENT_ID: 'client' }) - const revealed = { - revealed: new Set(['slack_v2']), - disabled: new Set(), - previewTagged: new Set(['slack_v2']), - } - - expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, null)).toBe( - 'unavailable' - ) - expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, revealed)).toBe( - 'limited' - ) - expect(resolveIntegrationAvailabilityStateForVisibility(misconfiguredSlack, revealed)).toBe( - 'limited' - ) - }) - - it('keeps preview service accounts unavailable when their block is kill-switched', () => { - const unavailableSlack = availabilityFor('slack') + it('keeps the released custom-bot path independent of preview visibility', () => { + const limitedSlack = availabilityFor('slack_v2') const disabled = { revealed: new Set(['slack_v2']), disabled: new Set(['slack_v2']), previewTagged: new Set(['slack_v2']), } - expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, disabled)).toBe( - 'unavailable' - ) + expect(resolveIntegrationAvailabilityStateForVisibility(limitedSlack, null)).toBe('limited') + expect(resolveIntegrationAvailabilityStateForVisibility(limitedSlack, disabled)).toBe('limited') expect(resolveIntegrationAvailabilityStateForVisibility(availabilityFor('x'), disabled)).toBe( 'unavailable' ) @@ -138,19 +117,19 @@ describe('integration availability', () => { disabled: new Set(['slack_v2']), } - expect(isIntegrationDeploymentAvailable('slack')).toBe(false) - expect(isIntegrationDeploymentAvailable('slack_v2')).toBe(false) - expect(isIntegrationDeploymentAvailable('slack-v2')).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack', null)).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', null)).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', null)).toBe(false) + expect(isIntegrationDeploymentAvailable('slack')).toBe(true) + expect(isIntegrationDeploymentAvailable('slack_v2')).toBe(true) + expect(isIntegrationDeploymentAvailable('slack-v2')).toBe(true) + expect(isIntegrationDeploymentAvailableForVisibility('slack', null)).toBe(true) + expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', null)).toBe(true) + expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', null)).toBe(true) expect(isIntegrationDeploymentAvailableForVisibility('slack', revealed)).toBe(true) expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', revealed)).toBe(true) expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', revealed)).toBe(true) expect(isIntegrationDeploymentAvailableForVisibility('x', revealed)).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack', disabled)).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', disabled)).toBe(false) - expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', disabled)).toBe(false) + expect(isIntegrationDeploymentAvailableForVisibility('slack', disabled)).toBe(true) + expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', disabled)).toBe(true) + expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', disabled)).toBe(true) }) it('requires the deployment Trello API key for OAuth and pasted member tokens', () => { @@ -171,7 +150,7 @@ describe('integration availability', () => { it('maps OAuth service ids to the integration allowlist without loading registries', () => { expect(getIntegrationTypesForOAuthServiceId('gmail')).toContain('gmail_v2') expect(isOAuthServiceAllowedByIntegrationTypes('gmail', new Set(['slack']))).toBe(false) - expect(isOAuthServiceAllowedByIntegrationTypes('slack', new Set(['slack']))).toBe(true) + expect(isOAuthServiceAllowedByIntegrationTypes('slack', new Set(['slack_v2']))).toBe(true) expect(isOAuthServiceAllowedByIntegrationTypes('spotify', null)).toBe(true) }) @@ -215,9 +194,7 @@ describe('integration availability', () => { ) ) ).toEqual(expectedServiceAccountIds) - expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.slack.deploymentRequirement).toBe( - 'preview-gated' - ) + expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.slack.deploymentRequirement).toBeUndefined() expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.trello.deploymentRequirement).toBe( 'oauth-client' ) diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts index 708d9833fc5..f2b63c6b275 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.test.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts @@ -58,25 +58,22 @@ function availability( describe('integration credential visibility', () => { beforeEach(() => { vi.clearAllMocks() - getBlockMock.mockImplementation((type: string) => ({ - type, - ...(type === 'slack_v2' ? { preview: true } : {}), - })) + getBlockMock.mockImplementation((type: string) => ({ type })) getIntegrationAvailabilityMock.mockReturnValue([ availability('notion_v2', 'limited', { oauthAvailable: false, serviceAccountAvailable: true, }), - availability('slack', 'unavailable', { + availability('slack_v2', 'limited', { oauthAvailable: false, - serviceAccountAvailable: false, + serviceAccountAvailable: true, }), ]) }) it('applies the integration allowlist to OAuth and service-account credentials', () => { const visibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: new Set(['slack']), + allowedIntegrationTypes: new Set(['slack_v2']), blockVisibility: null, oauthServices: SERVICES, }) @@ -106,45 +103,31 @@ describe('integration credential visibility', () => { ).toBe(true) }) - it('requires the Slack preview reveal for custom-bot credentials', () => { - const hidden = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: new Set(['slack']), + it('exposes released Slack custom-bot credentials without a preview reveal', () => { + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: new Set(['slack_v2']), blockVisibility: null, oauthServices: SERVICES, }) - const revealed = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: new Set(['slack']), - blockVisibility: { - revealed: new Set(['slack_v2']), - disabled: new Set(), - previewTagged: new Set(['slack_v2']), - }, - oauthServices: SERVICES, - }) const credential = { providerId: 'slack-custom-bot', type: 'service_account' } as const - expect(hidden.isCredentialVisible(credential)).toBe(false) - expect(revealed.isCredentialVisible(credential)).toBe(true) + expect(visibility.isCredentialVisible(credential)).toBe(true) }) - it('projects partial OAuth state through a revealed service-account preview', () => { + it('keeps custom bots available with partial OAuth configuration unless Slack is disabled', () => { getIntegrationAvailabilityMock.mockReturnValue([ - availability('slack', 'misconfigured', { + availability('slack_v2', 'limited', { oauthAvailable: false, - serviceAccountAvailable: false, + serviceAccountAvailable: true, }), ]) const visibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: new Set(['slack']), - blockVisibility: { - revealed: new Set(['slack_v2']), - disabled: new Set(), - previewTagged: new Set(['slack_v2']), - }, + allowedIntegrationTypes: new Set(['slack_v2']), + blockVisibility: null, oauthServices: SERVICES, }) const disabled = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: new Set(['slack']), + allowedIntegrationTypes: new Set(['slack_v2']), blockVisibility: { revealed: new Set(['slack_v2']), disabled: new Set(['slack_v2']), diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index e18191e4c7b..0f88fe7fdd3 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -501,6 +501,8 @@ export const blockTypeToIconMap: Record = { similarweb: SimilarwebIcon, sixtyfour: SixtyfourIcon, slack: SlackIcon, + slack_app: SlackIcon, + slack_v2: SlackIcon, smartlead: SmartleadIcon, smtp: SmtpIcon, snowflake: SnowflakeIcon, diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 84eaf0fc674..a92468715e6 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -257,9 +257,11 @@ export async function getServiceAccountToken( } export interface SlackBotCredentialSecrets { - signingSecret: string + /** Required only when the bot receives Slack events; action-only bots may omit it. */ + signingSecret?: string botToken: string - teamId: string + /** Present on newly connected bots; legacy backfills resolve it only when needed. */ + teamId?: string botUserId?: string teamName?: string /** Owning workspace — callers with a user/workflow context must verify it. */ @@ -269,8 +271,10 @@ export interface SlackBotCredentialSecrets { /** * Decrypt a reusable custom Slack bot credential — a `service_account` credential * with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own - * app's signing secret + bot token + derived team_id/bot_user_id. Returns null if - * the id is not such a credential (or its blob is incomplete). + * app's bot token and, when configured for event ingestion, its signing secret. + * Newly connected bots also hold derived team identity; legacy backfills may not. + * Returns null if the id is not such a credential or the action-capable portion + * of its blob is incomplete. * * @remarks Server-internal. The native custom ingest route authenticates each * request via the app's signing secret (not a user session), so this reader does @@ -301,15 +305,17 @@ export async function getSlackBotCredential( const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) const blob = JSON.parse(decrypted) as Partial - if (!blob.signingSecret || !blob.botToken || !blob.teamId) { + if (!blob.botToken) { return null } return { - signingSecret: blob.signingSecret, + ...(typeof blob.signingSecret === 'string' && blob.signingSecret + ? { signingSecret: blob.signingSecret } + : {}), botToken: blob.botToken, - teamId: blob.teamId, - botUserId: blob.botUserId, - teamName: blob.teamName, + ...(typeof blob.teamId === 'string' && blob.teamId ? { teamId: blob.teamId } : {}), + ...(typeof blob.botUserId === 'string' && blob.botUserId ? { botUserId: blob.botUserId } : {}), + ...(typeof blob.teamName === 'string' && blob.teamName ? { teamName: blob.teamName } : {}), workspaceId: row.workspaceId ?? null, } } diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 582ac55ff01..501be394f33 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -70,7 +70,7 @@ afterAll(resetEnvMock) import { GoogleIcon, GoogleVaultIcon } from '@/components/icons' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' -import { OAUTH_PROVIDERS, refreshOAuthToken } from '@/lib/oauth' +import { getSlackApprovalGatedScopes, OAUTH_PROVIDERS, refreshOAuthToken } from '@/lib/oauth' import { REDDIT_USER_AGENT } from '@/tools/reddit/constants' /** @@ -267,6 +267,17 @@ describe('Bitbucket OAuth Connector', () => { }) describe('OAuth Token Refresh', () => { + describe('Slack approval-gated scopes', () => { + it('adds the extended scope set only when the deployment capability is enabled', () => { + expect(getSlackApprovalGatedScopes(false)).toEqual([]) + expect(getSlackApprovalGatedScopes(true)).toEqual([ + 'assistant:write', + 'app_mentions:read', + 'im:history', + ]) + }) + }) + describe('Basic Auth Providers', () => { const basicAuthProviders = [ { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2dd43e6999d..714aa4e97ee 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -93,9 +93,11 @@ const logger = createLogger('OAuth') * with "unapproved permissions requested" when any requested scope is not on the * app's approved list, so these stay out of the default grant. */ -const SLACK_APPROVAL_GATED_SCOPES = isSlackExtendedScopesEnabled - ? (['assistant:write', 'app_mentions:read', 'im:history'] as const) - : ([] as const) +export function getSlackApprovalGatedScopes(enabled: boolean): readonly string[] { + return enabled ? ['assistant:write', 'app_mentions:read', 'im:history'] : [] +} + +const SLACK_APPROVAL_GATED_SCOPES = getSlackApprovalGatedScopes(isSlackExtendedScopesEnabled) export const OAUTH_PROVIDERS: Record = { 'claude-platform': { diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 5733adc045d..b5463c81d52 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { account, credential } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { eq } from 'drizzle-orm' import { afterAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' @@ -58,7 +58,10 @@ import { import { getBlock } from '@/blocks' import { getTrigger } from '@/triggers' -afterAll(resetDbChainMock) +afterAll(() => { + resetDbChainMock() + resetEnvFlagsMock() +}) const trigger = (subBlocks: Partial[]): { subBlocks: SubBlockConfig[] } => ({ subBlocks: subBlocks as SubBlockConfig[], @@ -124,6 +127,7 @@ function makeBlock( beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnvFlags({ isSlackExtendedScopesEnabled: true }) }) describe('buildProviderConfig canonical collapse', () => { @@ -269,7 +273,14 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { } it('routes a custom bot credential by credential id on the slack provider', async () => { - mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'ws-1', botUserId: 'BUSER' }) + setEnvFlags({ isSlackExtendedScopesEnabled: false }) + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'ws-1', + botToken: 'xoxb-token', + teamId: 'T123', + botUserId: 'BUSER', + signingSecret: 'secret', + }) const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) @@ -279,10 +290,72 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { expect(result.config.routingKey).toBe('cred_bot_1') expect(result.config.triggerPath).toBeNull() expect(result.config.providerConfig.bot_user_id).toBe('BUSER') + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) + + it('does not validate an identity-less migrated bot for ordinary triggers', async () => { + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'ws-1', + botToken: 'xoxb-migrated', + signingSecret: 'secret', + }) + + 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.providerConfig.bot_user_id).toBeUndefined() + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) + + it('resolves identity when a migrated bot is deployed for reaction events', async () => { + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'ws-1', + botToken: 'xoxb-migrated', + signingSecret: 'secret', + }) + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' }) + + const result = await resolveSlack({ + eventType: 'reaction_added', + 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.providerConfig.bot_user_id).toBe('UBOT') + expect(mockFetchSlackTeamId).toHaveBeenCalledWith('xoxb-migrated') + }) + + it('rejects a Sim-app credential when extended scopes are disabled', async () => { + setEnvFlags({ isSlackExtendedScopesEnabled: false }) + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + + 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).toEqual({ + message: 'The Sim Slack app trigger is disabled for this deployment. Select a custom bot.', + status: 400, + }) + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() }) it('rejects a custom bot credential from another workspace', async () => { - mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'other-ws', botUserId: 'BUSER' }) + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'other-ws', + botToken: 'xoxb-token', + teamId: 'T123', + botUserId: 'BUSER', + signingSecret: 'secret', + }) const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) @@ -292,6 +365,24 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { expect(result?.error?.message).toContain('not available in this workspace') }) + it('rejects an action-only custom bot that has no signing secret', async () => { + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'ws-1', + botUserId: 'BUSER', + botToken: 'xoxb-token', + }) + + 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).toEqual({ + message: + 'The selected Slack bot can run actions but cannot receive events because it has no signing secret. Reconnect it with a signing secret.', + status: 400, + }) + }) + it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => { mockGetSlackBotCredential.mockResolvedValue(null) mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' }) @@ -374,6 +465,71 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { }) }) +describe('resolveWebhookConfigForBlock — migrated slack_webhook routing', () => { + const legacySlackTriggerDef = { + provider: 'slack', + name: 'Slack Webhook', + subBlocks: [ + { id: 'signingSecret', mode: 'trigger', required: true }, + { id: 'botToken', mode: 'trigger' }, + { id: 'botCredential', mode: 'trigger' }, + ], + } + + function resolveLegacySlack(values: Record) { + ;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' }) + ;(getTrigger as unknown as Mock).mockReturnValue(legacySlackTriggerDef) + return resolveWebhookConfigForBlock({ + block: makeBlock('slack_webhook', values), + workflow: { workspaceId: 'ws-1' }, + userId: 'deployer-1', + requestId: 'req-1', + }) + } + + it('keeps the legacy path while routing the webhook by its migrated bot credential', async () => { + mockGetSlackBotCredential.mockResolvedValue({ + workspaceId: 'ws-1', + botToken: 'xoxb-token', + signingSecret: 'secret', + }) + + const result = await resolveLegacySlack({ + signingSecret: 'legacy-secret', + botToken: 'legacy-token', + botCredential: 'cred_bot_1', + triggerPath: 'legacy-path', + }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack') + expect(result.config.triggerPath).toBe('legacy-path') + expect(result.config.routingKey).toBe('cred_bot_1') + expect(result.config.providerConfig).toMatchObject({ + botCredential: 'cred_bot_1', + credentialId: 'cred_bot_1', + ingressMode: 'legacy_custom_bot', + }) + }) + + it('leaves an unmigrated legacy trigger on direct path dispatch', async () => { + const result = await resolveLegacySlack({ + signingSecret: 'legacy-secret', + botToken: 'legacy-token', + triggerPath: 'legacy-path', + }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.triggerPath).toBe('legacy-path') + expect(result.config.routingKey).toBeNull() + expect(result.config.providerConfig.credentialId).toBeUndefined() + expect(result.config.providerConfig.ingressMode).toBeUndefined() + expect(mockGetSlackBotCredential).not.toHaveBeenCalled() + }) +}) + describe('resolveWebhookConfigForBlock — TikTok routing', () => { const tiktokTriggerDef = { provider: 'tiktok', diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 16843b7ce44..6526cf9afc8 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { getProviderIdFromServiceId } from '@/lib/oauth' import { getSlackBotCredential, @@ -25,6 +26,7 @@ import { prepareStableWebhookRegistrations, type StableDesiredWebhookRegistration, } from '@/lib/webhooks/registration-service' +import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants' import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { buildCanonicalIndex, @@ -428,11 +430,44 @@ export async function resolveWebhookConfigForBlock(input: { }, } } + if (!botCredential.signingSecret) { + return { + success: false, + error: { + message: + 'The selected Slack bot can run actions but cannot receive events because it has no signing secret. Reconnect it with a signing secret.', + status: 400, + }, + } + } effectiveProvider = 'slack' effectivePath = null routingKey = slackCredentialId providerConfig.credentialId = slackCredentialId - if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId + if (botCredential.botUserId) { + providerConfig.bot_user_id = botCredential.botUserId + } else if ( + !botCredential.teamId && + (providerConfig.eventType === 'reaction_added' || + providerConfig.eventType === 'reaction_removed') + ) { + try { + const { userId: botUserId } = await fetchSlackTeamId(botCredential.botToken) + if (botUserId) providerConfig.bot_user_id = botUserId + } catch (error: unknown) { + logger.error( + `[${input.requestId}] Slack custom bot identity resolution failed for ${input.block.id}`, + error + ) + return { + success: false, + error: { + message: 'Could not verify the selected Slack bot. Reconnect it and try again.', + status: 400, + }, + } + } + } } else { // getSlackBotCredential also returns null for a custom bot credential that // was deleted or lost its stored secrets. Name that case so the error @@ -448,6 +483,16 @@ export async function resolveWebhookConfigForBlock(input: { }, } } + if (!isSlackExtendedScopesEnabled) { + return { + success: false, + error: { + message: + 'The Sim Slack app trigger is disabled for this deployment. Select a custom bot.', + 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 @@ -530,6 +575,46 @@ export async function resolveWebhookConfigForBlock(input: { // (`slack_app`) rows on providerConfig.credentialId. providerConfig.credentialId = resolvedCredentialId } + } else if (triggerId === 'slack_webhook') { + const slackCredentialId = + typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined + + if (slackCredentialId) { + const botCredential = await getSlackBotCredential(slackCredentialId) + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!botCredential || !workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The migrated Slack bot credential is not available in this workspace.', + status: 400, + }, + } + } + if (!botCredential.signingSecret) { + return { + success: false, + error: { + message: + 'The migrated Slack bot cannot receive events because it has no signing secret.', + status: 400, + }, + } + } + + routingKey = slackCredentialId + providerConfig.credentialId = slackCredentialId + providerConfig.ingressMode = LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE + } else if (providerConfig.credentialId || providerConfig.ingressMode) { + return { + success: false, + error: { + message: 'The migrated Slack webhook credential association is incomplete.', + status: 400, + }, + } + } } else if (triggerDef.provider === 'tiktok') { if (!credentialId) { return { diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index ca8c6694404..17e5985b191 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -76,6 +76,7 @@ export interface WebhookPreprocessingResult { } const WEBHOOK_BODY_LABEL = 'Webhook request body' +const MAX_WEBHOOK_TARGETS_PER_LOOKUP = 1_000 /** * Flattens a `multipart/form-data` body into the plain object shape provider handlers @@ -379,6 +380,13 @@ export async function findAllWebhooksForPath( ) ) ) + .limit(MAX_WEBHOOK_TARGETS_PER_LOOKUP + 1) + + if (results.length > MAX_WEBHOOK_TARGETS_PER_LOOKUP) { + throw new Error( + `Webhook path resolves more than ${MAX_WEBHOOK_TARGETS_PER_LOOKUP} active webhooks` + ) + } if (results.length === 0) { logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`) @@ -477,6 +485,13 @@ export async function findWebhooksByRoutingKey( ) ) ) + .limit(MAX_WEBHOOK_TARGETS_PER_LOOKUP + 1) + + if (results.length > MAX_WEBHOOK_TARGETS_PER_LOOKUP) { + throw new Error( + `Routing key resolves more than ${MAX_WEBHOOK_TARGETS_PER_LOOKUP} active ${provider} webhooks` + ) + } if (results.length === 0) { logger.warn(`[${requestId}] No active ${provider} webhooks for routing key`) diff --git a/apps/sim/lib/webhooks/slack-custom-ingress-constants.ts b/apps/sim/lib/webhooks/slack-custom-ingress-constants.ts new file mode 100644 index 00000000000..d6ece32e669 --- /dev/null +++ b/apps/sim/lib/webhooks/slack-custom-ingress-constants.ts @@ -0,0 +1 @@ +export const LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE = 'legacy_custom_bot' diff --git a/apps/sim/lib/webhooks/slack-custom-ingress.test.ts b/apps/sim/lib/webhooks/slack-custom-ingress.test.ts new file mode 100644 index 00000000000..23a11c59b6f --- /dev/null +++ b/apps/sim/lib/webhooks/slack-custom-ingress.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getLegacySlackCustomBotCredentialId } from '@/lib/webhooks/slack-custom-ingress' + +function webhook(overrides: Record = {}) { + return { + id: 'webhook-1', + provider: 'slack', + routingKey: 'credential-1', + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + ...overrides, + } +} + +describe('getLegacySlackCustomBotCredentialId', () => { + it('returns the credential for a fully marked legacy webhook', () => { + expect(getLegacySlackCustomBotCredentialId(webhook())).toBe('credential-1') + }) + + it('ignores ordinary path webhooks', () => { + expect( + getLegacySlackCustomBotCredentialId( + webhook({ providerConfig: { triggerId: 'slack_webhook' }, routingKey: null }) + ) + ).toBeNull() + }) + + it('fails fast on a partial marker', () => { + expect(() => + getLegacySlackCustomBotCredentialId(webhook({ routingKey: 'credential-2' })) + ).toThrow(/routing key does not match/) + }) +}) diff --git a/apps/sim/lib/webhooks/slack-custom-ingress.ts b/apps/sim/lib/webhooks/slack-custom-ingress.ts new file mode 100644 index 00000000000..591f01e1bb3 --- /dev/null +++ b/apps/sim/lib/webhooks/slack-custom-ingress.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getSlackBotCredential } from '@/lib/oauth/credential-service' +import { findWebhooksByRoutingKey } from '@/lib/webhooks/processor' +import { verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' +import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants' +import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' + +const logger = createLogger('SlackCustomBotIngress') + +interface LegacySlackPathWebhook { + id: string + provider: string | null + routingKey: string | null + providerConfig: unknown +} + +interface SlackCustomBotRequestOptions { + credentialId: string + request: NextRequest + rawBody: string + requestId: string +} + +interface DispatchSlackCustomBotOptions { + credentialId: string + body: unknown + request: NextRequest + requestId: string + receivedAt: number +} + +/** + * Returns the custom-bot credential attached to a migrated legacy Slack path. + * A persisted marker is treated as a strict contract so partial migrations fail + * instead of silently falling back to the duplicated legacy secrets. + */ +export function getLegacySlackCustomBotCredentialId( + foundWebhook: LegacySlackPathWebhook +): string | null { + const providerConfig = + foundWebhook.providerConfig !== null && + typeof foundWebhook.providerConfig === 'object' && + !Array.isArray(foundWebhook.providerConfig) + ? (foundWebhook.providerConfig as Record) + : {} + + if (providerConfig.ingressMode !== LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE) { + return null + } + if (foundWebhook.provider !== 'slack') { + throw new Error(`Legacy Slack custom-bot webhook ${foundWebhook.id} must use provider slack`) + } + if (providerConfig.triggerId !== 'slack_webhook') { + throw new Error( + `Legacy Slack custom-bot webhook ${foundWebhook.id} must use trigger slack_webhook` + ) + } + + const credentialId = + typeof providerConfig.credentialId === 'string' && providerConfig.credentialId.length > 0 + ? providerConfig.credentialId + : null + if (!credentialId) { + throw new Error(`Legacy Slack custom-bot webhook ${foundWebhook.id} has no credentialId`) + } + if (foundWebhook.routingKey !== credentialId) { + throw new Error( + `Legacy Slack custom-bot webhook ${foundWebhook.id} routing key does not match its credential` + ) + } + + return credentialId +} + +export async function verifySlackCustomBotCredentialRequest({ + credentialId, + request, + rawBody, + requestId, +}: SlackCustomBotRequestOptions): Promise { + const botCredential = await getSlackBotCredential(credentialId) + if (!botCredential) { + logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`) + return new NextResponse(null, { status: 404 }) + } + if (!botCredential.signingSecret) { + logger.warn(`[${requestId}] Slack bot credential ${credentialId} has no signing secret`) + return new NextResponse(null, { status: 404 }) + } + + return verifySlackRequestSignature(botCredential.signingSecret, request, rawBody, requestId) +} + +export async function dispatchSlackCustomBotCredential({ + credentialId, + body, + request, + requestId, + receivedAt, +}: DispatchSlackCustomBotOptions): Promise { + const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack') + if (webhooks.length === 0) { + logger.info( + `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run` + ) + return 0 + } + + await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) + return webhooks.length +} diff --git a/apps/sim/triggers/slack/oauth.test.ts b/apps/sim/triggers/slack/oauth.test.ts new file mode 100644 index 00000000000..1bb88c81036 --- /dev/null +++ b/apps/sim/triggers/slack/oauth.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getSlackTriggerCredentialSubBlock } from '@/triggers/slack/oauth' + +describe('Slack trigger extended-scope capability', () => { + it('offers only custom bots when the capability is disabled', () => { + const credential = getSlackTriggerCredentialSubBlock(false) + + expect(credential.credentialKind).toBe('service-account') + expect(credential.credentialLabels).toEqual({ + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }) + expect(credential.credentialLabels?.oauthGroup).toBeUndefined() + expect(credential.placeholder).toBe('Select custom bot') + }) + + it('offers the Sim app and custom bots when the capability is enabled', () => { + const credential = getSlackTriggerCredentialSubBlock(true) + + expect(credential.credentialKind).toBe('any') + expect(credential.credentialLabels).toMatchObject({ + oauthGroup: 'Sim app', + oauthConnect: 'Connect the Sim app', + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }) + expect(credential.placeholder).toBe('Select Slack account or bot') + }) +}) diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index fb277ef8594..96efa05cbda 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -1,5 +1,7 @@ import { SlackIcon } from '@/components/icons' +import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' +import type { SubBlockConfig } from '@/blocks/types' import { SLACK_ALL_EVENT_OPTIONS, SLACK_SOURCE_OPTIONS, @@ -35,9 +37,52 @@ const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reactio * 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. + * Native Sim-app mode is a deployment capability controlled by the existing + * Slack extended-scopes env pair. Custom bots stay available independently. */ +export function getSlackTriggerCredentialSubBlock(extendedScopesEnabled: boolean): SubBlockConfig { + if (!extendedScopesEnabled) { + return { + id: 'customBotCredential', + title: 'Custom Bot', + type: 'oauth-input', + canonicalParamId: 'botCredential', + serviceId: 'slack', + credentialKind: 'service-account', + credentialLabels: { + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }, + requiredScopes: getScopesForService('slack'), + placeholder: 'Select custom bot', + description: 'Choose a custom Slack bot you set up once and reuse across triggers.', + required: true, + mode: 'trigger', + } + } + + return { + id: 'customBotCredential', + title: 'Slack Account', + type: 'oauth-input', + canonicalParamId: 'botCredential', + serviceId: 'slack', + 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 Slack account or bot', + description: + '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', + } +} + export const slackOAuthTrigger: TriggerConfig = { id: 'slack_oauth', name: 'Slack', @@ -58,26 +103,7 @@ export const slackOAuthTrigger: TriggerConfig = { required: true, mode: 'trigger', }, - { - id: 'customBotCredential', - title: 'Slack Account', - type: 'oauth-input', - canonicalParamId: 'botCredential', - serviceId: 'slack', - 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 Slack account or bot', - description: - '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', - }, + getSlackTriggerCredentialSubBlock(isSlackExtendedScopesEnabled), { id: 'manualBotCredential', title: 'Bot Credential ID', diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index 6f41b7ee7ca..9154aa954d9 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -52,6 +52,15 @@ export const slackWebhookTrigger: TriggerConfig = { required: false, mode: 'trigger', }, + { + id: 'botCredential', + title: 'Migrated Slack Bot Credential', + type: 'short-input', + mode: 'trigger', + hidden: true, + hideFromPreview: true, + hideFromCopilot: true, + }, { id: 'setupWizard', title: 'Slack app setup', diff --git a/packages/db/scripts/migrate-slack-custom-bots.test.ts b/packages/db/scripts/migrate-slack-custom-bots.test.ts new file mode 100644 index 00000000000..f2bf2897afa --- /dev/null +++ b/packages/db/scripts/migrate-slack-custom-bots.test.ts @@ -0,0 +1,318 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + buildSlackBotDisplayName, + buildSlackCustomBotSecretBlob, + type EnvironmentLookup, + extractSlackBotSources, + planLegacySlackTriggerLink, + resolveSlackSourceSecrets, + type SlackBotSource, + type SlackMigrationBlock, +} from './migrate-slack-custom-bots' + +function storedSubBlocks(values: Record): Record { + return Object.fromEntries(Object.entries(values).map(([id, value]) => [id, { value }])) +} + +function migrationBlock(overrides: Partial = {}): SlackMigrationBlock { + return { + blockId: 'block-1', + blockName: 'Notify Support', + blockType: 'slack', + triggerMode: false, + subBlocks: {}, + workflowId: 'workflow-1', + workflowName: 'Escalations', + workflowUserId: 'user-1', + ...overrides, + } +} + +function source(overrides: Partial = {}): SlackBotSource { + return { + sourceId: 'workflow-1:block-1:action', + kind: 'action', + blockId: 'block-1', + blockName: 'Notify Support', + workflowId: 'workflow-1', + workflowName: 'Escalations', + workflowUserId: 'user-1', + rawBotToken: 'xoxb-token', + ...overrides, + } +} + +function environmentLookup(overrides: Partial = {}): EnvironmentLookup { + return { + workspaceVariables: {}, + personalVariablesByUserId: new Map(), + workspaceOwnerId: 'user-1', + encryptionKey: '0'.repeat(64), + ...overrides, + } +} + +describe('extractSlackBotSources', () => { + it('extracts direct Slack trigger secrets before triggerConfig fallbacks', () => { + const result = extractSlackBotSources( + migrationBlock({ + triggerMode: true, + subBlocks: storedSubBlocks({ + signingSecret: 'direct-signing-secret', + botToken: 'direct-token', + botCredential: 'credential-1', + triggerConfig: { + signingSecret: 'fallback-signing-secret', + botToken: 'fallback-token', + }, + }), + }) + ) + + expect(result).toEqual([ + expect.objectContaining({ + sourceId: 'workflow-1:block-1:trigger', + kind: 'trigger', + rawSigningSecret: 'direct-signing-secret', + rawBotToken: 'direct-token', + existingBotCredentialId: 'credential-1', + }), + ]) + }) + + it('extracts legacy triggerConfig secrets when direct fields are absent', () => { + const result = extractSlackBotSources( + migrationBlock({ + triggerMode: true, + subBlocks: storedSubBlocks({ + triggerConfig: { signingSecret: '{{SLACK_SIGNING}}', botToken: '{{SLACK_TOKEN}}' }, + }), + }) + ) + + expect(result[0]).toMatchObject({ + rawSigningSecret: '{{SLACK_SIGNING}}', + rawBotToken: '{{SLACK_TOKEN}}', + }) + }) + + it('extracts standalone custom-bot actions and ignores stale OAuth tokens', () => { + const customBot = extractSlackBotSources( + migrationBlock({ + subBlocks: storedSubBlocks({ authMethod: 'bot_token', botToken: 'xoxb-action' }), + }) + ) + const oauth = extractSlackBotSources( + migrationBlock({ + subBlocks: storedSubBlocks({ authMethod: 'oauth', botToken: 'stale-token' }), + }) + ) + + expect(customBot).toEqual([ + expect.objectContaining({ kind: 'action', rawBotToken: 'xoxb-action' }), + ]) + expect(oauth).toEqual([]) + }) + + it('extracts Slack tools from serialized tools and notification inputs', () => { + const toolsResult = extractSlackBotSources( + migrationBlock({ + blockType: 'agent', + subBlocks: storedSubBlocks({ + tools: JSON.stringify([ + { + type: 'slack', + title: 'Send to incidents', + params: { authMethod: 'bot_token', botToken: 'xoxb-tool' }, + }, + { + type: 'slack', + title: 'Old OAuth selection', + params: { authMethod: 'oauth', botToken: 'stale-token' }, + }, + ]), + }), + }) + ) + const notificationResult = extractSlackBotSources( + migrationBlock({ + blockType: 'human_in_the_loop', + subBlocks: storedSubBlocks({ + notification: [ + { type: 'slack', title: 'Approval alert', params: { accessToken: 'xoxb-legacy' } }, + ], + }), + }) + ) + + expect(toolsResult).toEqual([ + expect.objectContaining({ + sourceId: 'workflow-1:block-1:tools:0', + kind: 'embedded_tool', + toolTitle: 'Send to incidents', + rawBotToken: 'xoxb-tool', + }), + ]) + expect(notificationResult).toEqual([ + expect.objectContaining({ + sourceId: 'workflow-1:block-1:notification:0', + toolTitle: 'Approval alert', + rawBotToken: 'xoxb-legacy', + }), + ]) + }) + + it('fails fast on malformed tool-input storage', () => { + expect(() => + extractSlackBotSources( + migrationBlock({ + blockType: 'agent', + subBlocks: storedSubBlocks({ tools: '{not-json' }), + }) + ) + ).toThrow() + }) + + it('fails before iterating an oversized tool-input list', () => { + const tools = Array.from({ length: 1_001 }, () => ({ + type: 'slack', + params: { authMethod: 'bot_token', botToken: 'xoxb-tool' }, + })) + + expect(() => + extractSlackBotSources( + migrationBlock({ + blockType: 'agent', + subBlocks: storedSubBlocks({ tools }), + }) + ) + ).toThrow(/1000-tool migration limit/) + }) +}) + +describe('buildSlackBotDisplayName', () => { + it('uses workflow, block, and optional tool names', () => { + expect(buildSlackBotDisplayName(source(), new Set())).toBe('Escalations — Notify Support') + expect( + buildSlackBotDisplayName( + source({ kind: 'embedded_tool', toolTitle: 'Send to incidents' }), + new Set() + ) + ).toBe('Escalations — Notify Support — Send to incidents') + }) + + it('allocates a normalized suffix while keeping names within 255 characters', () => { + const longSource = source({ workflowName: 'W'.repeat(250), blockName: 'Block' }) + const first = buildSlackBotDisplayName(longSource, new Set()) + const second = buildSlackBotDisplayName(longSource, new Set([first.toLowerCase()])) + + expect(first).toHaveLength(255) + expect(second).toHaveLength(255) + expect(second.endsWith(' (2)')).toBe(true) + }) +}) + +describe('buildSlackCustomBotSecretBlob', () => { + it('builds a trigger-capable credential without calling Slack for identity', () => { + expect( + buildSlackCustomBotSecretBlob('workflow-1:block-1:trigger', 'xoxb-token', 'secret') + ).toEqual({ + type: 'slack_custom_bot', + signingSecret: 'secret', + botToken: 'xoxb-token', + metadata: { migrationSourceId: 'workflow-1:block-1:trigger' }, + }) + }) + + it('builds an action-only credential without inventing a signing secret', () => { + expect( + buildSlackCustomBotSecretBlob('workflow-1:block-1:action', 'xoxb-token', undefined) + ).toEqual({ + type: 'slack_custom_bot', + botToken: 'xoxb-token', + metadata: { migrationSourceId: 'workflow-1:block-1:action' }, + }) + }) +}) + +describe('planLegacySlackTriggerLink', () => { + const triggerSource = source({ + sourceId: 'workflow-1:block-1:trigger', + kind: 'trigger', + rawSigningSecret: 'secret', + }) + const existingCredential = { credentialId: 'credential-1', hasSigningSecret: true } + + it('links the trigger block and marks its existing webhook', () => { + expect( + planLegacySlackTriggerLink(triggerSource, existingCredential, [ + { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + routingKey: null, + providerConfig: { triggerId: 'slack_webhook' }, + }, + ]) + ).toEqual({ updateTriggerBlock: true, webhookIdsToUpdate: ['webhook-1'] }) + }) + + it('is idempotent after the block and webhook are linked', () => { + expect( + planLegacySlackTriggerLink( + { ...triggerSource, existingBotCredentialId: 'credential-1' }, + existingCredential, + [ + { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + routingKey: 'credential-1', + providerConfig: { + triggerId: 'slack_webhook', + botCredential: 'credential-1', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + }, + ] + ) + ).toEqual({ updateTriggerBlock: false, webhookIdsToUpdate: [] }) + }) + + it('fails fast instead of overwriting a different credential association', () => { + expect(() => + planLegacySlackTriggerLink( + { ...triggerSource, existingBotCredentialId: 'credential-2' }, + existingCredential, + [] + ) + ).toThrow(/different Slack bot credential/) + }) +}) + +describe('resolveSlackSourceSecrets', () => { + it('marks a missing environment variable as an unresolved source', () => { + expect( + resolveSlackSourceSecrets(source({ rawBotToken: '{{SLACK_BOT_TOKEN}}' }), environmentLookup()) + ).toEqual({ + status: 'unresolved', + reason: 'botToken references missing environment variable SLACK_BOT_TOKEN', + }) + }) + + it('still fails fast when a personal variable cannot be promoted safely', () => { + expect(() => + resolveSlackSourceSecrets( + source({ workflowUserId: 'user-2', rawBotToken: '{{SLACK_BOT_TOKEN}}' }), + environmentLookup({ + personalVariablesByUserId: new Map([['user-2', { SLACK_BOT_TOKEN: 'encrypted-value' }]]), + }) + ) + ).toThrow(/non-owner personal environment variable/) + }) +}) diff --git a/packages/db/scripts/migrate-slack-custom-bots.ts b/packages/db/scripts/migrate-slack-custom-bots.ts new file mode 100644 index 00000000000..b90878a64b0 --- /dev/null +++ b/packages/db/scripts/migrate-slack-custom-bots.ts @@ -0,0 +1,1417 @@ +#!/usr/bin/env bun + +/** + * Materializes raw Slack bot tokens from saved workflows as reusable + * `slack-custom-bot` credentials. + * + * This intentionally mirrors the block-key-to-BYOK migration: + * - dry-run discovery and validation must happen first; + * - the dry run writes the workspace allowlist consumed by the live run; + * - migrated legacy triggers keep their URL and gain a durable credential link; + * - deployment snapshots are never changed. + * - Slack is never called; token validation remains deferred to normal credential use. + * + * Sources covered: + * - legacy Slack triggers (`signingSecret` + `botToken`), including `triggerConfig` fallback; + * - legacy Slack action blocks using `authMethod = bot_token`; + * - Slack tools embedded in `tools` or `notification` tool-input subblocks. + * + * Usage: + * bun run packages/db/scripts/migrate-slack-custom-bots.ts --dry-run + * bun run packages/db/scripts/migrate-slack-custom-bots.ts \ + * --from-file migrate-slack-custom-bot-workspace-ids.txt + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' +import { + appendFileSync, + existsSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'fs' +import { resolve } from 'path' +import { + credential, + credentialMember, + environment, + permissions, + webhook, + workflow, + workflowBlocks, + workspace, + workspaceEnvironment, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { chunkArray } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { and, asc, eq, gt, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +const logger = createLogger('MigrateSlackCustomBots', { logLevel: 'INFO', enabled: true }) + +const SLACK_CUSTOM_BOT_PROVIDER_ID = 'slack-custom-bot' +const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' +const LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE = 'legacy_custom_bot' +const DISPLAY_NAME_MAX_LENGTH = 255 +const OUTPUT_FILE = 'migrate-slack-custom-bot-workspace-ids.txt' +const WORKSPACE_CONCURRENCY = 3 +const WORKSPACE_DISCOVERY_PAGE_SIZE = 250 +const MEMBERSHIP_INSERT_CHUNK_SIZE = 500 +const WEBHOOK_UPDATE_CHUNK_SIZE = 500 +const ENVIRONMENT_USER_QUERY_CHUNK_SIZE = 500 +const MAX_BLOCKS_PER_WORKSPACE = 10_000 +const MAX_MEMBERS_PER_WORKSPACE = 10_000 +const MAX_SLACK_CREDENTIALS_PER_WORKSPACE = 10_000 +const MAX_SLACK_WEBHOOKS_PER_WORKSPACE = 10_000 +const MAX_SOURCES_PER_WORKSPACE = 20_000 +const MAX_TOOLS_PER_SUBBLOCK = 1_000 +const MAX_SUBBLOCK_BYTES = 2 * 1024 * 1024 +const MAX_WORKSPACE_SUBBLOCK_BYTES = 32 * 1024 * 1024 +const MAX_ENVIRONMENT_BYTES = 5 * 1024 * 1024 +const MAX_PERSONAL_ENVIRONMENT_BYTES_PER_WORKSPACE = 16 * 1024 * 1024 +const MAX_ENCRYPTED_CREDENTIAL_BYTES = 256 * 1024 +const MAX_ENCRYPTED_CREDENTIAL_BYTES_PER_WORKSPACE = 16 * 1024 * 1024 +const MAX_WEBHOOK_PROVIDER_CONFIG_BYTES = 512 * 1024 +const MAX_WEBHOOK_PROVIDER_CONFIG_BYTES_PER_WORKSPACE = 16 * 1024 * 1024 +const MAX_ALLOWLIST_BYTES = 10 * 1024 * 1024 +const MAX_ALLOWLIST_WORKSPACES = 250_000 +const TRANSACTION_LOCK_TIMEOUT_MS = 2_000 +const TRANSACTION_STATEMENT_TIMEOUT_MS = 10_000 +const LIVE_LOCK_NAMESPACE = 834_217 +const LIVE_LOCK_ID = 20_260_819 + +const CANDIDATE_BLOCK_TYPES = ['slack', 'agent', 'human_in_the_loop', 'mothership', 'pi'] as const + +const TOOL_INPUT_SUBBLOCK_IDS = ['tools', 'notification'] as const +const ENV_VAR_PATTERN = /^\{\{([^}]+)\}\}$/ + +type MigrationDb = PostgresJsDatabase +type SlackSourceKind = 'trigger' | 'action' | 'embedded_tool' + +export interface SlackMigrationBlock { + blockId: string + blockName: string + blockType: string + triggerMode: boolean + subBlocks: unknown + workflowId: string + workflowName: string + workflowUserId: string +} + +export interface SlackBotSource { + sourceId: string + kind: SlackSourceKind + blockId: string + blockName: string + workflowId: string + workflowName: string + workflowUserId: string + toolTitle?: string + rawBotToken?: string + rawSigningSecret?: string + existingBotCredentialId?: string +} + +interface CliOptions { + dryRun: boolean + fromFile: string | null +} + +export interface EnvironmentLookup { + workspaceVariables: Record + personalVariablesByUserId: Map> + workspaceOwnerId: string + encryptionKey: string +} + +export type SlackSourceSecretResolution = + | { status: 'ready'; botToken: string; signingSecret?: string } + | { status: 'unresolved'; reason: string } + +interface PreparedCredential { + source: SlackBotSource + credentialId: string + insertCredential: boolean + displayName?: string + description?: string + botToken?: string + signingSecret?: string + updateTriggerBlock: boolean + webhookIdsToUpdate: string[] +} + +export interface ExistingMigrationCredential { + credentialId: string + hasSigningSecret: boolean +} + +export interface LegacySlackWebhookRow { + id: string + workflowId: string + blockId: string | null + routingKey: string | null + providerConfig: unknown +} + +interface MigrationStats { + workspacesProcessed: number + workspacesSkipped: number + sourcesFound: number + inserted: number + skippedExisting: number + skippedUnresolved: number + triggersLinked: number + webhooksMarked: number + actionOnly: number + errors: number +} + +interface WorkspaceResult { + stats: MigrationStats + approvedForLiveRun: boolean +} + +function emptyStats(): MigrationStats { + return { + workspacesProcessed: 0, + workspacesSkipped: 0, + sourcesFound: 0, + inserted: 0, + skippedExisting: 0, + skippedUnresolved: 0, + triggersLinked: 0, + webhooksMarked: 0, + actionOnly: 0, + errors: 0, + } +} + +function mergeStats(target: MigrationStats, source: MigrationStats): void { + target.workspacesProcessed += source.workspacesProcessed + target.workspacesSkipped += source.workspacesSkipped + target.sourcesFound += source.sourcesFound + target.inserted += source.inserted + target.skippedExisting += source.skippedExisting + target.skippedUnresolved += source.skippedUnresolved + target.triggersLinked += source.triggersLinked + target.webhooksMarked += source.webhooksMarked + target.actionOnly += source.actionOnly + target.errors += source.errors +} + +function parseCliOptions(args: string[]): CliOptions { + const dryRun = args.includes('--dry-run') + const fromFileIndex = args.indexOf('--from-file') + const fromFile = fromFileIndex >= 0 ? args[fromFileIndex + 1] : null + + if (fromFileIndex >= 0 && !fromFile) { + throw new Error('--from-file requires a path') + } + if (dryRun && fromFile) { + throw new Error('--dry-run and --from-file cannot be used together') + } + if (!dryRun && !fromFile) { + throw new Error(`Live runs require --from-file ${OUTPUT_FILE}; run --dry-run first`) + } + + return { dryRun, fromFile } +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function subBlockValues(subBlocks: unknown): Record { + if (!isRecordLike(subBlocks)) { + throw new Error('workflow_blocks.sub_blocks must be an object') + } + + const values: Record = {} + for (const [id, stored] of Object.entries(subBlocks)) { + if (!isRecordLike(stored)) { + throw new Error(`Subblock "${id}" must be an object`) + } + values[id] = stored.value + } + return values +} + +function parseToolInputValue(value: unknown, subBlockId: string): Record[] { + if (value === null || value === undefined || value === '') return [] + + const parsed = typeof value === 'string' ? JSON.parse(value) : value + if (!Array.isArray(parsed)) { + throw new Error(`Tool-input subblock "${subBlockId}" must contain an array`) + } + if (parsed.length > MAX_TOOLS_PER_SUBBLOCK) { + throw new Error( + `Tool-input subblock "${subBlockId}" exceeds the ${MAX_TOOLS_PER_SUBBLOCK}-tool migration limit` + ) + } + return parsed.map((tool, index) => { + if (!isRecordLike(tool)) { + throw new Error(`Tool ${index} in "${subBlockId}" must be an object`) + } + return tool + }) +} + +function preferredTriggerValue( + values: Record, + triggerConfig: Record, + field: 'signingSecret' | 'botToken' +): string | undefined { + return nonEmptyString(values[field]) ?? nonEmptyString(triggerConfig[field]) +} + +export function extractSlackBotSources(block: SlackMigrationBlock): SlackBotSource[] { + const values = subBlockValues(block.subBlocks) + const sources: SlackBotSource[] = [] + + if (block.blockType === 'slack') { + if (block.triggerMode) { + const rawTriggerConfig = values.triggerConfig + if ( + rawTriggerConfig !== null && + rawTriggerConfig !== undefined && + !isRecordLike(rawTriggerConfig) + ) { + throw new Error('Slack triggerConfig must be an object when present') + } + const triggerConfig = isRecordLike(rawTriggerConfig) ? rawTriggerConfig : {} + const rawSigningSecret = preferredTriggerValue(values, triggerConfig, 'signingSecret') + const rawBotToken = preferredTriggerValue(values, triggerConfig, 'botToken') + const existingBotCredentialId = nonEmptyString(values.botCredential) + + if (rawSigningSecret || rawBotToken) { + sources.push({ + sourceId: `${block.workflowId}:${block.blockId}:trigger`, + kind: 'trigger', + blockId: block.blockId, + blockName: block.blockName, + workflowId: block.workflowId, + workflowName: block.workflowName, + workflowUserId: block.workflowUserId, + rawSigningSecret, + rawBotToken, + existingBotCredentialId, + }) + } + } else { + const authMethod = nonEmptyString(values.authMethod) + const rawBotToken = nonEmptyString(values.botToken) + const usesCustomBot = authMethod === 'bot_token' || (!authMethod && Boolean(rawBotToken)) + + if (usesCustomBot) { + sources.push({ + sourceId: `${block.workflowId}:${block.blockId}:action`, + kind: 'action', + blockId: block.blockId, + blockName: block.blockName, + workflowId: block.workflowId, + workflowName: block.workflowName, + workflowUserId: block.workflowUserId, + rawBotToken, + }) + } + } + } + + for (const subBlockId of TOOL_INPUT_SUBBLOCK_IDS) { + const tools = parseToolInputValue(values[subBlockId], subBlockId) + for (const [index, tool] of tools.entries()) { + if (tool.type !== 'slack') continue + + if (!isRecordLike(tool.params)) { + throw new Error(`Slack tool ${index} in "${subBlockId}" must have params`) + } + const authMethod = nonEmptyString(tool.params.authMethod) + const rawBotToken = + nonEmptyString(tool.params.botToken) ?? nonEmptyString(tool.params.accessToken) + const usesCustomBot = authMethod === 'bot_token' || (!authMethod && Boolean(rawBotToken)) + if (!usesCustomBot) continue + + sources.push({ + sourceId: `${block.workflowId}:${block.blockId}:${subBlockId}:${index}`, + kind: 'embedded_tool', + blockId: block.blockId, + blockName: block.blockName, + workflowId: block.workflowId, + workflowName: block.workflowName, + workflowUserId: block.workflowUserId, + toolTitle: nonEmptyString(tool.title) ?? `Slack tool ${index + 1}`, + rawBotToken, + }) + } + } + + return sources +} + +export function buildSlackBotDisplayName( + source: SlackBotSource, + takenNames: ReadonlySet +): string { + const parts = [source.workflowName.trim(), source.blockName.trim()] + if (source.toolTitle) parts.push(source.toolTitle.trim()) + const base = parts.join(' — ') + + const first = truncate(base, DISPLAY_NAME_MAX_LENGTH, '') + if (!takenNames.has(first.toLowerCase())) return first + + for (let index = 2; index < 10_000; index++) { + const suffix = ` (${index})` + const candidate = `${truncate(base, DISPLAY_NAME_MAX_LENGTH - suffix.length, '')}${suffix}` + if (!takenNames.has(candidate.toLowerCase())) return candidate + } + throw new Error(`Could not allocate a unique credential name for source ${source.sourceId}`) +} + +/** Creates the exact plaintext payload consumed by `getSlackBotCredential`. */ +export function buildSlackCustomBotSecretBlob( + sourceId: string, + botToken: string, + signingSecret: string | undefined +): Record { + return { + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + ...(signingSecret ? { signingSecret } : {}), + botToken, + metadata: { migrationSourceId: sourceId }, + } +} + +function getEncryptionKeyBuffer(encryptionKey: string): Buffer { + return Buffer.from(encryptionKey, 'hex') +} + +function encryptSecret(secret: string, encryptionKey: string): string { + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', getEncryptionKeyBuffer(encryptionKey), iv, { + authTagLength: 16, + }) + let encrypted = cipher.update(secret, 'utf8', 'hex') + encrypted += cipher.final('hex') + return `${iv.toString('hex')}:${encrypted}:${cipher.getAuthTag().toString('hex')}` +} + +function decryptSecret(encryptedValue: string, encryptionKey: string): string { + const parts = encryptedValue.split(':') + if (parts.length !== 3) { + throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"') + } + const [ivHex, encrypted, authTagHex] = parts + if (!ivHex || !encrypted || !authTagHex) { + throw new Error('Encrypted value is missing an AES-GCM component') + } + + const decipher = createDecipheriv( + 'aes-256-gcm', + getEncryptionKeyBuffer(encryptionKey), + Buffer.from(ivHex, 'hex'), + { authTagLength: 16 } + ) + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted +} + +function assertCryptoRoundTrip(encryptionKey: string): void { + const sample = 'slack-custom-bot-migration-self-test' + const roundTrip = decryptSecret(encryptSecret(sample, encryptionKey), encryptionKey) + if (roundTrip !== sample) { + throw new Error('Crypto self-test failed; refusing to run migration') + } +} + +function isEnvVariableReference(value: string): boolean { + return ENV_VAR_PATTERN.test(value) +} + +function envVariableName(value: string): string { + const match = ENV_VAR_PATTERN.exec(value) + const name = match?.[1]?.trim() + if (!name) throw new Error(`Invalid environment variable reference: ${value}`) + return name +} + +class MissingEnvironmentVariableError extends Error { + constructor(fieldName: 'botToken' | 'signingSecret', variableName: string) { + super(`${fieldName} references missing environment variable ${variableName}`) + this.name = 'MissingEnvironmentVariableError' + } +} + +function resolveStoredSecret( + rawValue: string | undefined, + source: SlackBotSource, + lookup: EnvironmentLookup, + fieldName: 'botToken' | 'signingSecret' +): string | undefined { + if (!rawValue) return undefined + if (!isEnvVariableReference(rawValue)) return rawValue.trim() + + const variableName = envVariableName(rawValue) + const workspaceValue = lookup.workspaceVariables[variableName] + if (workspaceValue) return decryptSecret(workspaceValue, lookup.encryptionKey).trim() + + const personalValue = lookup.personalVariablesByUserId.get(source.workflowUserId)?.[variableName] + if (!personalValue) { + throw new MissingEnvironmentVariableError(fieldName, variableName) + } + if (source.workflowUserId !== lookup.workspaceOwnerId) { + throw new Error( + `${fieldName} uses non-owner personal environment variable ${variableName}; refusing to promote it to a workspace credential` + ) + } + return decryptSecret(personalValue, lookup.encryptionKey).trim() +} + +export function resolveSlackSourceSecrets( + source: SlackBotSource, + lookup: EnvironmentLookup +): SlackSourceSecretResolution { + try { + const botToken = resolveStoredSecret(source.rawBotToken, source, lookup, 'botToken') + if (!botToken) throw new Error(`Source ${source.sourceId} has no bot token`) + + const signingSecret = resolveStoredSecret( + source.rawSigningSecret, + source, + lookup, + 'signingSecret' + ) + if (source.kind === 'trigger' && !signingSecret) { + throw new Error(`Trigger source ${source.sourceId} has no signing secret`) + } + + return { status: 'ready', botToken, signingSecret } + } catch (error) { + if (error instanceof MissingEnvironmentVariableError) { + return { status: 'unresolved', reason: error.message } + } + throw error + } +} + +function readExistingMigrationCredential( + credentialId: string, + encryptedValue: string, + encryptionKey: string +): { sourceId: string; credential: ExistingMigrationCredential } | null { + const parsed: unknown = JSON.parse(decryptSecret(encryptedValue, encryptionKey)) + if (!isRecordLike(parsed) || !isRecordLike(parsed.metadata)) return null + const sourceId = nonEmptyString(parsed.metadata.migrationSourceId) + if (!sourceId) return null + + return { + sourceId, + credential: { + credentialId, + hasSigningSecret: Boolean(nonEmptyString(parsed.signingSecret)), + }, + } +} + +async function loadEnvironmentLookup( + db: MigrationDb, + workspaceId: string, + workspaceOwnerId: string, + sources: SlackBotSource[], + encryptionKey: string +): Promise { + const needsEnvironmentLookup = sources.some( + (source) => + (source.rawBotToken && isEnvVariableReference(source.rawBotToken)) || + (source.rawSigningSecret && isEnvVariableReference(source.rawSigningSecret)) + ) + if (!needsEnvironmentLookup) { + return { + workspaceVariables: {}, + personalVariablesByUserId: new Map(), + workspaceOwnerId, + encryptionKey, + } + } + + const userIds = [...new Set(sources.map((source) => source.workflowUserId))] + const workspaceRows = await db + .select({ + variables: sql`case when octet_length(${workspaceEnvironment.variables}::text) <= ${MAX_ENVIRONMENT_BYTES} then ${workspaceEnvironment.variables} else null end`, + variablesBytes: sql`octet_length(${workspaceEnvironment.variables}::text)`, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + if ((workspaceRows[0]?.variablesBytes ?? 0) > MAX_ENVIRONMENT_BYTES) { + throw new Error( + `Workspace environment exceeds the ${MAX_ENVIRONMENT_BYTES}-byte migration limit` + ) + } + + const personalRows: { userId: string; variables: unknown; variablesBytes: number }[] = [] + let personalEnvironmentBytes = 0 + for (const userIdChunk of chunkArray(userIds, ENVIRONMENT_USER_QUERY_CHUNK_SIZE)) { + const remainingEnvironmentBytes = + MAX_PERSONAL_ENVIRONMENT_BYTES_PER_WORKSPACE - personalEnvironmentBytes + const rows = await db + .select({ + userId: environment.userId, + variables: sql`case when octet_length(${environment.variables}::text) <= ${MAX_ENVIRONMENT_BYTES} and sum(coalesce(octet_length(${environment.variables}::text), 0)) over () <= ${remainingEnvironmentBytes} then ${environment.variables} else null end`, + variablesBytes: sql`octet_length(${environment.variables}::text)`, + chunkVariablesBytes: sql`sum(coalesce(octet_length(${environment.variables}::text), 0)) over ()`, + }) + .from(environment) + .where(inArray(environment.userId, userIdChunk)) + .limit(userIdChunk.length) + const chunkVariablesBytes = Number(rows[0]?.chunkVariablesBytes ?? 0) + if (chunkVariablesBytes > remainingEnvironmentBytes) { + throw new Error( + `Personal environments exceed the ${MAX_PERSONAL_ENVIRONMENT_BYTES_PER_WORKSPACE}-byte workspace migration limit` + ) + } + personalEnvironmentBytes += chunkVariablesBytes + personalRows.push(...rows) + } + + const workspaceVariables = isRecordLike(workspaceRows[0]?.variables) + ? (workspaceRows[0].variables as Record) + : {} + const personalVariablesByUserId = new Map>() + for (const row of personalRows) { + if (row.variablesBytes > MAX_ENVIRONMENT_BYTES) { + throw new Error( + `Personal environment for user ${row.userId} exceeds the ${MAX_ENVIRONMENT_BYTES}-byte migration limit` + ) + } + if (!isRecordLike(row.variables)) { + throw new Error(`Personal environment for user ${row.userId} must be an object`) + } + personalVariablesByUserId.set(row.userId, row.variables as Record) + } + + return { + workspaceVariables, + personalVariablesByUserId, + workspaceOwnerId, + encryptionKey, + } +} + +export function planLegacySlackTriggerLink( + source: SlackBotSource, + credential: ExistingMigrationCredential, + webhookRows: LegacySlackWebhookRow[] +): { updateTriggerBlock: boolean; webhookIdsToUpdate: string[] } { + if (source.kind !== 'trigger') { + return { updateTriggerBlock: false, webhookIdsToUpdate: [] } + } + if (!credential.hasSigningSecret) { + throw new Error( + `Existing credential for trigger source ${source.sourceId} has no signing secret` + ) + } + if ( + source.existingBotCredentialId && + source.existingBotCredentialId !== credential.credentialId + ) { + throw new Error( + `Trigger source ${source.sourceId} is already linked to a different Slack bot credential` + ) + } + + const webhookIdsToUpdate: string[] = [] + for (const row of webhookRows) { + if (!isRecordLike(row.providerConfig)) { + throw new Error(`Legacy Slack webhook ${row.id} providerConfig must be an object`) + } + if (row.providerConfig.triggerId !== 'slack_webhook') { + throw new Error(`Legacy Slack webhook ${row.id} does not use trigger slack_webhook`) + } + + const persistedCredentialId = nonEmptyString(row.providerConfig.credentialId) + const persistedBotCredential = nonEmptyString(row.providerConfig.botCredential) + const persistedIngressMode = nonEmptyString(row.providerConfig.ingressMode) + if (row.routingKey !== null && row.routingKey !== credential.credentialId) { + throw new Error(`Legacy Slack webhook ${row.id} already has a different routing key`) + } + if (persistedCredentialId && persistedCredentialId !== credential.credentialId) { + throw new Error(`Legacy Slack webhook ${row.id} already has a different credentialId`) + } + if (persistedBotCredential && persistedBotCredential !== credential.credentialId) { + throw new Error(`Legacy Slack webhook ${row.id} already has a different botCredential`) + } + if (persistedIngressMode && persistedIngressMode !== LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE) { + throw new Error(`Legacy Slack webhook ${row.id} already has a different ingress mode`) + } + + const alreadyMarked = + row.routingKey === credential.credentialId && + persistedCredentialId === credential.credentialId && + persistedBotCredential === credential.credentialId && + persistedIngressMode === LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE + if (!alreadyMarked) webhookIdsToUpdate.push(row.id) + } + + return { + updateTriggerBlock: source.existingBotCredentialId !== credential.credentialId, + webhookIdsToUpdate, + } +} + +async function prepareWorkspaceCredentials(params: { + db: MigrationDb + workspaceId: string + workspaceOwnerId: string + sources: SlackBotSource[] + encryptionKey: string + takenNames: Set + existingCredentialsBySourceId: Map + webhookRowsBySourceId: Map + stats: MigrationStats +}): Promise { + const sourcesNeedingCredentials = params.sources.filter( + (source) => !params.existingCredentialsBySourceId.has(source.sourceId) + ) + const environmentLookup = await loadEnvironmentLookup( + params.db, + params.workspaceId, + params.workspaceOwnerId, + sourcesNeedingCredentials, + params.encryptionKey + ) + const prepared: PreparedCredential[] = [] + + for (const source of params.sources) { + const existingCredential = params.existingCredentialsBySourceId.get(source.sourceId) + if (existingCredential) { + params.stats.skippedExisting++ + const link = planLegacySlackTriggerLink( + source, + existingCredential, + params.webhookRowsBySourceId.get(source.sourceId) ?? [] + ) + if (link.updateTriggerBlock || link.webhookIdsToUpdate.length > 0) { + prepared.push({ + source, + credentialId: existingCredential.credentialId, + insertCredential: false, + updateTriggerBlock: link.updateTriggerBlock, + webhookIdsToUpdate: link.webhookIdsToUpdate, + }) + } + continue + } + + const resolution = resolveSlackSourceSecrets(source, environmentLookup) + if (resolution.status === 'unresolved') { + params.stats.skippedUnresolved++ + logger.warn('Skipping Slack bot credential source with an unresolved environment variable', { + workspaceId: params.workspaceId, + workflowId: source.workflowId, + workflowName: source.workflowName, + blockId: source.blockId, + blockName: source.blockName, + sourceKind: source.kind, + reason: resolution.reason, + }) + continue + } + + const { botToken, signingSecret } = resolution + + const displayName = buildSlackBotDisplayName(source, params.takenNames) + params.takenNames.add(displayName.toLowerCase()) + if (!signingSecret) params.stats.actionOnly++ + const credentialId = generateId() + const link = planLegacySlackTriggerLink( + source, + { credentialId, hasSigningSecret: Boolean(signingSecret) }, + params.webhookRowsBySourceId.get(source.sourceId) ?? [] + ) + + prepared.push({ + source, + credentialId, + insertCredential: true, + displayName, + description: `Backfilled from Slack credentials in workflow "${source.workflowName}", block "${source.blockName}".`, + botToken, + signingSecret, + updateTriggerBlock: link.updateTriggerBlock, + webhookIdsToUpdate: link.webhookIdsToUpdate, + }) + } + + return prepared +} + +async function applyPreparedCredential(params: { + db: MigrationDb + workspaceId: string + workspaceOwnerId: string + memberUserIds: string[] + encryptionKey: string + prepared: PreparedCredential +}): Promise<{ triggerLinked: boolean; webhooksMarked: number }> { + const now = new Date() + const { credentialId } = params.prepared + const encryptedServiceAccountKey = params.prepared.insertCredential + ? (() => { + if (!params.prepared.botToken) { + throw new Error(`Prepared source ${params.prepared.source.sourceId} has no bot token`) + } + return encryptSecret( + JSON.stringify( + buildSlackCustomBotSecretBlob( + params.prepared.source.sourceId, + params.prepared.botToken, + params.prepared.signingSecret + ) + ), + params.encryptionKey + ) + })() + : null + + await params.db.transaction(async (tx) => { + await tx.execute( + sql`select set_config('lock_timeout', ${`${TRANSACTION_LOCK_TIMEOUT_MS}ms`}, true), set_config('statement_timeout', ${`${TRANSACTION_STATEMENT_TIMEOUT_MS}ms`}, true)` + ) + if (params.prepared.insertCredential) { + if ( + !params.prepared.displayName || + !params.prepared.description || + !encryptedServiceAccountKey + ) { + throw new Error(`Prepared source ${params.prepared.source.sourceId} is incomplete`) + } + await tx.insert(credential).values({ + id: credentialId, + workspaceId: params.workspaceId, + type: 'service_account', + displayName: params.prepared.displayName, + description: params.prepared.description, + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + encryptedServiceAccountKey, + createdBy: params.workspaceOwnerId, + createdAt: now, + updatedAt: now, + }) + + const memberships = params.memberUserIds.map((userId) => ({ + id: generateId(), + credentialId, + userId, + role: userId === params.workspaceOwnerId ? ('admin' as const) : ('member' as const), + status: 'active' as const, + joinedAt: now, + invitedBy: params.workspaceOwnerId, + createdAt: now, + updatedAt: now, + })) + for (const chunk of chunkArray(memberships, MEMBERSHIP_INSERT_CHUNK_SIZE)) { + await tx.insert(credentialMember).values(chunk) + } + } + + if (params.prepared.updateTriggerBlock) { + const updatedBlocks = await tx + .update(workflowBlocks) + .set({ + subBlocks: sql`jsonb_set(${workflowBlocks.subBlocks}, '{botCredential}', jsonb_build_object('value', ${credentialId}::text), true)`, + updatedAt: now, + }) + .where( + and( + eq(workflowBlocks.id, params.prepared.source.blockId), + eq(workflowBlocks.workflowId, params.prepared.source.workflowId), + sql`(${workflowBlocks.subBlocks}->'botCredential'->>'value' IS NULL OR ${workflowBlocks.subBlocks}->'botCredential'->>'value' = ${credentialId})` + ) + ) + .returning({ id: workflowBlocks.id }) + if (updatedBlocks.length !== 1) { + throw new Error( + `Trigger source ${params.prepared.source.sourceId} changed while the migration was running` + ) + } + } + + for (const webhookIdChunk of chunkArray( + params.prepared.webhookIdsToUpdate, + WEBHOOK_UPDATE_CHUNK_SIZE + )) { + const updatedWebhooks = await tx + .update(webhook) + .set({ + routingKey: credentialId, + providerConfig: sql`jsonb_set(jsonb_set(jsonb_set(coalesce(${webhook.providerConfig}::jsonb, '{}'::jsonb), '{botCredential}', to_jsonb(${credentialId}::text), true), '{credentialId}', to_jsonb(${credentialId}::text), true), '{ingressMode}', to_jsonb(${LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE}::text), true)::json`, + updatedAt: now, + }) + .where( + and( + inArray(webhook.id, webhookIdChunk), + eq(webhook.provider, 'slack'), + isNull(webhook.archivedAt), + sql`(${webhook.routingKey} IS NULL OR ${webhook.routingKey} = ${credentialId})`, + sql`(${webhook.providerConfig}->>'credentialId' IS NULL OR ${webhook.providerConfig}->>'credentialId' = ${credentialId})`, + sql`(${webhook.providerConfig}->>'botCredential' IS NULL OR ${webhook.providerConfig}->>'botCredential' = ${credentialId})`, + sql`(${webhook.providerConfig}->>'ingressMode' IS NULL OR ${webhook.providerConfig}->>'ingressMode' = ${LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE})`, + sql`${webhook.providerConfig}->>'triggerId' = 'slack_webhook'` + ) + ) + .returning({ id: webhook.id }) + if (updatedWebhooks.length !== webhookIdChunk.length) { + throw new Error( + `A legacy Slack webhook for source ${params.prepared.source.sourceId} changed while the migration was running` + ) + } + } + }) + + return { + triggerLinked: params.prepared.updateTriggerBlock, + webhooksMarked: params.prepared.webhookIdsToUpdate.length, + } +} + +async function processWorkspace(params: { + db: MigrationDb + workspaceId: string + encryptionKey: string + dryRun: boolean + index: number + total: number | null +}): Promise { + const stats = emptyStats() + stats.workspacesProcessed = 1 + + try { + const [ + blockRows, + workspaceRows, + permissionRows, + existingCredentialRows, + legacySlackWebhookRows, + ] = await Promise.all([ + params.db + .select({ + blockId: workflowBlocks.id, + blockName: workflowBlocks.name, + blockType: workflowBlocks.type, + triggerMode: workflowBlocks.triggerMode, + subBlocks: sql`case when octet_length(${workflowBlocks.subBlocks}::text) <= ${MAX_SUBBLOCK_BYTES} and sum(octet_length(${workflowBlocks.subBlocks}::text)) over () <= ${MAX_WORKSPACE_SUBBLOCK_BYTES} then ${workflowBlocks.subBlocks} else null end`, + subBlocksBytes: sql`octet_length(${workflowBlocks.subBlocks}::text)`, + workspaceSubBlocksBytes: sql`sum(octet_length(${workflowBlocks.subBlocks}::text)) over ()`, + workflowId: workflow.id, + workflowName: workflow.name, + workflowUserId: workflow.userId, + }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflowBlocks.workflowId, workflow.id)) + .where( + and( + eq(workflow.workspaceId, params.workspaceId), + inArray(workflowBlocks.type, [...CANDIDATE_BLOCK_TYPES]) + ) + ) + .limit(MAX_BLOCKS_PER_WORKSPACE + 1), + params.db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, params.workspaceId)) + .limit(1), + params.db + .select({ userId: permissions.userId }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, params.workspaceId)) + ) + .limit(MAX_MEMBERS_PER_WORKSPACE + 1), + params.db + .select({ + id: credential.id, + displayName: credential.displayName, + encryptedServiceAccountKey: sql< + string | null + >`case when octet_length(${credential.encryptedServiceAccountKey}) <= ${MAX_ENCRYPTED_CREDENTIAL_BYTES} and sum(coalesce(octet_length(${credential.encryptedServiceAccountKey}), 0)) over () <= ${MAX_ENCRYPTED_CREDENTIAL_BYTES_PER_WORKSPACE} then ${credential.encryptedServiceAccountKey} else null end`, + encryptedServiceAccountKeyBytes: sql< + number | null + >`octet_length(${credential.encryptedServiceAccountKey})`, + workspaceEncryptedCredentialBytes: sql`sum(coalesce(octet_length(${credential.encryptedServiceAccountKey}), 0)) over ()`, + }) + .from(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) + ) + .limit(MAX_SLACK_CREDENTIALS_PER_WORKSPACE + 1), + params.db + .select({ + id: webhook.id, + workflowId: webhook.workflowId, + blockId: webhook.blockId, + routingKey: webhook.routingKey, + providerConfig: sql`case when octet_length(${webhook.providerConfig}::text) <= ${MAX_WEBHOOK_PROVIDER_CONFIG_BYTES} and sum(coalesce(octet_length(${webhook.providerConfig}::text), 0)) over () <= ${MAX_WEBHOOK_PROVIDER_CONFIG_BYTES_PER_WORKSPACE} then ${webhook.providerConfig} else null end`, + providerConfigBytes: sql`octet_length(${webhook.providerConfig}::text)`, + workspaceProviderConfigBytes: sql`sum(coalesce(octet_length(${webhook.providerConfig}::text), 0)) over ()`, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where( + and( + eq(workflow.workspaceId, params.workspaceId), + eq(webhook.provider, 'slack'), + isNull(webhook.archivedAt) + ) + ) + .limit(MAX_SLACK_WEBHOOKS_PER_WORKSPACE + 1), + ]) + + const workspaceOwnerId = workspaceRows[0]?.ownerId + if (!workspaceOwnerId) throw new Error(`Workspace ${params.workspaceId} has no owner`) + + if (blockRows.length > MAX_BLOCKS_PER_WORKSPACE) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_BLOCKS_PER_WORKSPACE}-candidate-block migration limit` + ) + } + if (Number(blockRows[0]?.workspaceSubBlocksBytes ?? 0) > MAX_WORKSPACE_SUBBLOCK_BYTES) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_WORKSPACE_SUBBLOCK_BYTES}-byte candidate-block migration limit` + ) + } + if (permissionRows.length > MAX_MEMBERS_PER_WORKSPACE) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_MEMBERS_PER_WORKSPACE}-member migration limit` + ) + } + if (existingCredentialRows.length > MAX_SLACK_CREDENTIALS_PER_WORKSPACE) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_SLACK_CREDENTIALS_PER_WORKSPACE}-Slack-credential migration limit` + ) + } + if ( + Number(existingCredentialRows[0]?.workspaceEncryptedCredentialBytes ?? 0) > + MAX_ENCRYPTED_CREDENTIAL_BYTES_PER_WORKSPACE + ) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_ENCRYPTED_CREDENTIAL_BYTES_PER_WORKSPACE}-byte Slack-credential migration limit` + ) + } + if (legacySlackWebhookRows.length > MAX_SLACK_WEBHOOKS_PER_WORKSPACE) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_SLACK_WEBHOOKS_PER_WORKSPACE}-Slack-webhook migration limit` + ) + } + if ( + Number(legacySlackWebhookRows[0]?.workspaceProviderConfigBytes ?? 0) > + MAX_WEBHOOK_PROVIDER_CONFIG_BYTES_PER_WORKSPACE + ) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_WEBHOOK_PROVIDER_CONFIG_BYTES_PER_WORKSPACE}-byte Slack-webhook-config migration limit` + ) + } + + const sources: SlackBotSource[] = [] + for (const row of blockRows) { + if (row.subBlocksBytes > MAX_SUBBLOCK_BYTES) { + throw new Error( + `Workflow block ${row.blockId} exceeds the ${MAX_SUBBLOCK_BYTES}-byte subblock migration limit` + ) + } + const blockSources = extractSlackBotSources(row) + if (sources.length + blockSources.length > MAX_SOURCES_PER_WORKSPACE) { + throw new Error( + `Workspace ${params.workspaceId} exceeds the ${MAX_SOURCES_PER_WORKSPACE}-Slack-source migration limit` + ) + } + sources.push(...blockSources) + } + stats.sourcesFound = sources.length + if (sources.length === 0) { + stats.workspacesSkipped = 1 + return { stats, approvedForLiveRun: false } + } + + const existingCredentialsBySourceId = new Map() + const takenNames = new Set() + for (const row of existingCredentialRows) { + takenNames.add(row.displayName.toLowerCase()) + if ((row.encryptedServiceAccountKeyBytes ?? 0) > MAX_ENCRYPTED_CREDENTIAL_BYTES) { + throw new Error( + `Slack credential "${row.displayName}" exceeds the ${MAX_ENCRYPTED_CREDENTIAL_BYTES}-byte encrypted-secret migration limit` + ) + } + if (!row.encryptedServiceAccountKey) { + throw new Error(`Slack credential "${row.displayName}" has no encrypted secret`) + } + const existing = readExistingMigrationCredential( + row.id, + row.encryptedServiceAccountKey, + params.encryptionKey + ) + if (!existing) continue + if (existingCredentialsBySourceId.has(existing.sourceId)) { + throw new Error(`Multiple Slack credentials claim migration source ${existing.sourceId}`) + } + existingCredentialsBySourceId.set(existing.sourceId, existing.credential) + } + + const webhookRowsBySourceId = new Map() + for (const row of legacySlackWebhookRows) { + if ((row.providerConfigBytes ?? 0) > MAX_WEBHOOK_PROVIDER_CONFIG_BYTES) { + throw new Error( + `Legacy Slack webhook ${row.id} exceeds the ${MAX_WEBHOOK_PROVIDER_CONFIG_BYTES}-byte provider-config migration limit` + ) + } + if (!row.blockId) continue + const sourceId = `${row.workflowId}:${row.blockId}:trigger` + const rows = webhookRowsBySourceId.get(sourceId) ?? [] + rows.push(row) + webhookRowsBySourceId.set(sourceId, rows) + } + + const prepared = await prepareWorkspaceCredentials({ + db: params.db, + workspaceId: params.workspaceId, + workspaceOwnerId, + sources, + encryptionKey: params.encryptionKey, + takenNames, + existingCredentialsBySourceId, + webhookRowsBySourceId, + stats, + }) + + if (prepared.length === 0) { + stats.workspacesSkipped = 1 + return { stats, approvedForLiveRun: false } + } + + logger.info( + `${params.dryRun ? '[DRY RUN] ' : ''}Workspace ${params.index}${params.total ? `/${params.total}` : ''} ready`, + { + workspaceId: params.workspaceId, + sources: sources.length, + credentialsToInsert: prepared.filter((candidate) => candidate.insertCredential).length, + triggersToLink: prepared.filter((candidate) => candidate.updateTriggerBlock).length, + webhooksToMark: prepared.reduce( + (total, candidate) => total + candidate.webhookIdsToUpdate.length, + 0 + ), + } + ) + + if (params.dryRun) { + for (const candidate of prepared) { + logger.info('[DRY RUN] Would migrate Slack bot source', { + workspaceId: params.workspaceId, + workflowId: candidate.source.workflowId, + blockId: candidate.source.blockId, + sourceKind: candidate.source.kind, + displayName: candidate.displayName, + createCredential: candidate.insertCredential, + linkTrigger: candidate.updateTriggerBlock, + webhooksToMark: candidate.webhookIdsToUpdate.length, + actionOnly: candidate.insertCredential && !candidate.signingSecret, + }) + if (candidate.insertCredential) stats.inserted++ + if (candidate.updateTriggerBlock) stats.triggersLinked++ + stats.webhooksMarked += candidate.webhookIdsToUpdate.length + } + return { stats, approvedForLiveRun: true } + } + + const memberUserIds = [ + ...new Set([workspaceOwnerId, ...permissionRows.map((row) => row.userId)]), + ] + for (const candidate of prepared) { + const applied = await applyPreparedCredential({ + db: params.db, + workspaceId: params.workspaceId, + workspaceOwnerId, + memberUserIds, + encryptionKey: params.encryptionKey, + prepared: candidate, + }) + if (candidate.insertCredential) stats.inserted++ + if (applied.triggerLinked) stats.triggersLinked++ + stats.webhooksMarked += applied.webhooksMarked + logger.info('Migrated Slack bot source', { + workspaceId: params.workspaceId, + workflowId: candidate.source.workflowId, + blockId: candidate.source.blockId, + sourceKind: candidate.source.kind, + displayName: candidate.displayName, + createdCredential: candidate.insertCredential, + linkedTrigger: applied.triggerLinked, + webhooksMarked: applied.webhooksMarked, + actionOnly: candidate.insertCredential && !candidate.signingSecret, + }) + } + + return { stats, approvedForLiveRun: false } + } catch (error) { + stats.errors++ + logger.error('Failed to process Slack bot credentials for workspace', { + workspaceId: params.workspaceId, + error: getErrorMessage(error), + }) + return { stats, approvedForLiveRun: false } + } +} + +async function discoverWorkspaceIdPage( + db: MigrationDb, + afterWorkspaceId: string | null +): Promise { + const rows = await db + .selectDistinct({ workspaceId: workflow.workspaceId }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflowBlocks.workflowId, workflow.id)) + .where( + and( + isNotNull(workflow.workspaceId), + inArray(workflowBlocks.type, [...CANDIDATE_BLOCK_TYPES]), + afterWorkspaceId ? gt(workflow.workspaceId, afterWorkspaceId) : undefined + ) + ) + .orderBy(asc(workflow.workspaceId)) + .limit(WORKSPACE_DISCOVERY_PAGE_SIZE) + + return rows.map((row) => { + if (!row.workspaceId) throw new Error('Workspace discovery returned a null workspace ID') + return row.workspaceId + }) +} + +function readWorkspaceAllowlist(path: string): string[] { + const resolvedPath = resolve(path) + const size = statSync(resolvedPath).size + if (size > MAX_ALLOWLIST_BYTES) { + throw new Error(`Workspace allowlist exceeds the ${MAX_ALLOWLIST_BYTES}-byte migration limit`) + } + + const workspaceIds = [ + ...new Set( + readFileSync(resolvedPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + ), + ] + if (workspaceIds.length > MAX_ALLOWLIST_WORKSPACES) { + throw new Error( + `Workspace allowlist exceeds the ${MAX_ALLOWLIST_WORKSPACES}-workspace migration limit` + ) + } + return workspaceIds +} + +async function processWorkspaceIds(params: { + db: MigrationDb + workspaceIds: string[] + encryptionKey: string + dryRun: boolean + startIndex: number + total: number | null + stats: MigrationStats +}): Promise { + const approvedWorkspaceIds: string[] = [] + + for ( + let chunkStart = 0; + chunkStart < params.workspaceIds.length; + chunkStart += WORKSPACE_CONCURRENCY + ) { + const workspaceChunk = params.workspaceIds.slice(chunkStart, chunkStart + WORKSPACE_CONCURRENCY) + const results = await Promise.all( + workspaceChunk.map((workspaceId, chunkIndex) => + processWorkspace({ + db: params.db, + workspaceId, + encryptionKey: params.encryptionKey, + dryRun: params.dryRun, + index: params.startIndex + chunkStart + chunkIndex + 1, + total: params.total, + }) + ) + ) + for (const [resultIndex, result] of results.entries()) { + mergeStats(params.stats, result.stats) + if (result.approvedForLiveRun) approvedWorkspaceIds.push(workspaceChunk[resultIndex]) + } + if (params.stats.errors > 0) { + throw new Error('Aborting after a workspace failed validation or insertion') + } + } + + return approvedWorkspaceIds +} + +async function acquireLiveRunLock(lockClient: ReturnType): Promise { + const [result] = await lockClient<[{ locked: boolean }]>` + SELECT pg_try_advisory_lock(${LIVE_LOCK_NAMESPACE}, ${LIVE_LOCK_ID}) AS locked + ` + if (!result?.locked) { + throw new Error('Another Slack custom-bot migration live run already holds the advisory lock') + } +} + +async function releaseLiveRunLock(lockClient: ReturnType): Promise { + const [result] = await lockClient<[{ unlocked: boolean }]>` + SELECT pg_advisory_unlock(${LIVE_LOCK_NAMESPACE}, ${LIVE_LOCK_ID}) AS unlocked + ` + if (!result?.unlocked) throw new Error('Slack custom-bot migration advisory lock was not held') +} + +export async function runSlackCustomBotMigration(args = process.argv.slice(2)): Promise { + const options = parseCliOptions(args) + const connectionString = process.env.POSTGRES_URL ?? process.env.DATABASE_URL + if (!connectionString) throw new Error('Missing POSTGRES_URL or DATABASE_URL') + + const encryptionKey = process.env.ENCRYPTION_KEY + if (!encryptionKey || !/^[0-9a-f]{64}$/i.test(encryptionKey)) { + throw new Error('ENCRYPTION_KEY must be a 64-character hex string') + } + assertCryptoRoundTrip(encryptionKey) + + const postgresClient = postgres(connectionString, { + prepare: false, + idle_timeout: 20, + connect_timeout: 30, + max: WORKSPACE_CONCURRENCY, + onnotice: () => {}, + connection: { application_name: 'sim-slack-custom-bot-migration' }, + }) + const db = drizzle(postgresClient) + const lockClient = options.dryRun + ? null + : postgres(connectionString, { + prepare: false, + idle_timeout: 0, + connect_timeout: 30, + max: 1, + max_lifetime: null, + onnotice: () => {}, + connection: { application_name: 'sim-slack-custom-bot-migration-lock' }, + }) + let lockHeld = false + let dryRunPartialPath: string | null = null + + try { + if (lockClient) { + await acquireLiveRunLock(lockClient) + lockHeld = true + } + + logger.info('Starting Slack custom-bot migration', { + mode: options.dryRun ? 'dry-run' : 'live', + concurrency: WORKSPACE_CONCURRENCY, + }) + + const stats = emptyStats() + if (options.dryRun) { + const outputPath = resolve(OUTPUT_FILE) + dryRunPartialPath = `${outputPath}.${process.pid}.partial` + writeFileSync(outputPath, '') + writeFileSync(dryRunPartialPath, '') + + let afterWorkspaceId: string | null = null + let discoveredWorkspaces = 0 + let approvedWorkspaces = 0 + while (true) { + const workspaceIds = await discoverWorkspaceIdPage(db, afterWorkspaceId) + if (workspaceIds.length === 0) break + + discoveredWorkspaces += workspaceIds.length + if (discoveredWorkspaces > MAX_ALLOWLIST_WORKSPACES) { + throw new Error( + `Dry-run discovery exceeds the ${MAX_ALLOWLIST_WORKSPACES}-workspace migration limit` + ) + } + + const approvedWorkspaceIds = await processWorkspaceIds({ + db, + workspaceIds, + encryptionKey, + dryRun: true, + startIndex: discoveredWorkspaces - workspaceIds.length, + total: null, + stats, + }) + if (approvedWorkspaceIds.length > 0) { + appendFileSync(dryRunPartialPath, `${approvedWorkspaceIds.join('\n')}\n`) + if (statSync(dryRunPartialPath).size > MAX_ALLOWLIST_BYTES) { + throw new Error( + `Dry-run workspace allowlist exceeds the ${MAX_ALLOWLIST_BYTES}-byte migration limit` + ) + } + approvedWorkspaces += approvedWorkspaceIds.length + } + + afterWorkspaceId = workspaceIds.at(-1) ?? null + if (workspaceIds.length < WORKSPACE_DISCOVERY_PAGE_SIZE) break + } + + if (discoveredWorkspaces === 0) throw new Error('No candidate workspaces found to process') + renameSync(dryRunPartialPath, outputPath) + dryRunPartialPath = null + logger.info('Dry-run workspace allowlist written', { + outputPath, + discoveredWorkspaces, + approvedWorkspaces, + }) + } else { + if (!options.fromFile) throw new Error('Live run is missing its workspace allowlist path') + const workspaceIds = readWorkspaceAllowlist(options.fromFile) + if (workspaceIds.length === 0) throw new Error('No workspace IDs found to process') + + logger.info('Loaded live-run workspace allowlist', { workspaces: workspaceIds.length }) + await processWorkspaceIds({ + db, + workspaceIds, + encryptionKey, + dryRun: false, + startIndex: 0, + total: workspaceIds.length, + stats, + }) + } + + logger.info('Slack custom-bot migration finished', stats) + if (stats.errors > 0) { + throw new Error(`Migration completed with ${stats.errors} workspace error(s)`) + } + } finally { + if (lockClient) { + if (lockHeld) await releaseLiveRunLock(lockClient) + await lockClient.end({ timeout: 5 }) + } + await postgresClient.end({ timeout: 5 }) + if (dryRunPartialPath && existsSync(dryRunPartialPath)) unlinkSync(dryRunPartialPath) + } +} + +if ((import.meta as { main?: boolean }).main) { + try { + await runSlackCustomBotMigration() + } catch (error) { + logger.error('Slack custom-bot migration aborted', { error: getErrorMessage(error) }) + process.exitCode = 1 + } +} diff --git a/packages/deployment-config/src/integrations.json b/packages/deployment-config/src/integrations.json index 3f7b9e96b43..b0d603b5c48 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -20419,7 +20419,7 @@ "tags": ["enrichment", "sales-engagement"] }, { - "type": "slack", + "type": "slack_v2", "slug": "slack", "name": "Slack", "description": "Send, update, delete messages, manage views and modals, add or remove reactions, manage canvases, get channel info and user presence in Slack", @@ -20600,9 +20600,9 @@ "operationCount": 42, "triggers": [ { - "id": "slack_webhook", - "name": "Slack Webhook", - "description": "Trigger workflow from Slack events like mentions, messages, and reactions" + "id": "slack_oauth", + "name": "Slack", + "description": "Trigger from Slack events (mentions, messages, reactions)" } ], "triggerCount": 1, diff --git a/packages/deployment-config/src/service-account-metadata.ts b/packages/deployment-config/src/service-account-metadata.ts index 859aff2475c..c60f2f75ced 100644 --- a/packages/deployment-config/src/service-account-metadata.ts +++ b/packages/deployment-config/src/service-account-metadata.ts @@ -10,7 +10,6 @@ export interface ServiceAccountMetadata { /** Handwritten deployment policy layered over generated OAuth registry facts. */ const DEPLOYMENT_REQUIREMENT_BY_OAUTH_SERVICE_ID = { - slack: 'preview-gated', trello: 'oauth-client', } as const satisfies Partial> diff --git a/packages/sim-setup/src/twins.test.ts b/packages/sim-setup/src/twins.test.ts new file mode 100644 index 00000000000..9a834c4a73b --- /dev/null +++ b/packages/sim-setup/src/twins.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { FLAG_TWINS } from './twins' + +describe('setup environment twins', () => { + it('keeps the Slack server and browser capability values coherent', () => { + expect(FLAG_TWINS).toContainEqual({ + server: 'SLACK_EXTENDED_SCOPES', + client: 'NEXT_PUBLIC_SLACK_EXTENDED_SCOPES', + }) + }) +}) diff --git a/packages/sim-setup/src/twins.ts b/packages/sim-setup/src/twins.ts index 4ef7207cfd1..9e39bd73ef0 100644 --- a/packages/sim-setup/src/twins.ts +++ b/packages/sim-setup/src/twins.ts @@ -24,6 +24,7 @@ export const FLAG_TWINS: ReadonlyArray<{ server: string; client: string }> = [ { server: 'EMAIL_PASSWORD_SIGNUP_ENABLED', client: 'NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED' }, { server: 'E2B_ENABLED', client: 'NEXT_PUBLIC_E2B_ENABLED' }, { server: 'DEPLOY_AS_BLOCK', client: 'NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED' }, + { server: 'SLACK_EXTENDED_SCOPES', client: 'NEXT_PUBLIC_SLACK_EXTENDED_SCOPES' }, ] /** Self-host feature unlocks offered by the wizard's Custom flow. */ diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 20f19cd3c1e..1ce7f542e51 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -106,8 +106,8 @@ const HANDWRITTEN_TRIGGER_DOCS = new Set([ 'sim', ]) -/** Providers whose docs are already covered by hand-written pages. */ -const SKIP_TRIGGER_PROVIDERS = new Set(['generic', 'rss', 'table', 'sim']) +/** Omits hand-written providers and Slack's superseded legacy webhook trigger. */ +const SKIP_TRIGGER_PROVIDERS = new Set(['generic', 'rss', 'table', 'sim', 'slack']) /** * Maps trigger provider names (from TriggerConfig.provider) to their @@ -120,6 +120,7 @@ const PROVIDER_TO_BLOCK_TYPE: Record = { 'google-drive': 'google_drive', 'google-sheets': 'google_sheets', jsm: 'jira_service_management', + slack_app: 'slack', } /** Human-readable display names for trigger providers. */ @@ -3895,10 +3896,8 @@ async function buildProviderColorMap(): Promise> { * Trigger ids that every hosting block gates behind `preview: true`. * * Blocks declare the triggers they expose via `triggers.available`. A trigger - * listed only by preview blocks inherits their gate — `slack_oauth` is reachable - * solely through the preview-gated `slack_v2` block, so documenting it would - * publish an unreleased surface under its own `slack_app` page. Triggers no - * block claims are left alone: standalone webhook providers are legitimately + * listed only by preview blocks inherits their gate, while triggers no block + * claims are left alone because standalone webhook providers are legitimately * unlisted and must keep their pages. */ async function collectPreviewOnlyTriggerIds(): Promise> { @@ -3955,7 +3954,7 @@ async function generateAllTriggerDocs(): Promise { for (const [provider, triggers] of grouped) { if (SKIP_TRIGGER_PROVIDERS.has(provider)) { - console.log(`Skipping trigger provider: ${provider} (covered by hand-written docs)`) + console.log(`Skipping trigger provider: ${provider}`) continue } From 300a190429a47570ed2c5b79b626f9a046b11fd6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 19 Aug 2026 17:09:18 -0700 Subject: [PATCH 2/6] fix(slack): propagate legacy webhook dispatch failures --- .../api/webhooks/trigger/[path]/route.test.ts | 38 ++++++++++++++++++- .../app/api/webhooks/trigger/[path]/route.ts | 25 ++++++++---- apps/sim/lib/webhooks/slack-custom-ingress.ts | 9 ++--- apps/sim/lib/webhooks/slack-dispatch.ts | 7 +++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 5cc70714a28..a36d0458b14 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -533,7 +533,13 @@ describe('Webhook Trigger API Route', () => { : null }) verifySlackCustomBotCredentialRequestMock.mockResolvedValue(null) - dispatchSlackCustomBotCredentialMock.mockResolvedValue(1) + dispatchSlackCustomBotCredentialMock.mockResolvedValue([ + { + outcome: 'queued', + reason: 'queued', + response: new NextResponse(null, { status: 200 }), + }, + ]) // Set up default workflow for tests testData.workflows.push({ @@ -1042,6 +1048,36 @@ describe('Webhook Trigger API Route', () => { expect(dispatchSlackCustomBotCredentialMock).not.toHaveBeenCalled() expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() }) + + it('propagates a legacy fan-out failure when no target queues successfully', async () => { + testData.webhooks.push({ + id: 'legacy-slack-webhook', + provider: 'slack', + path: 'legacy-slack-path', + routingKey: 'credential-1', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }) + dispatchSlackCustomBotCredentialMock.mockResolvedValueOnce([ + { + outcome: 'failed', + reason: 'preprocessing', + response: NextResponse.json({ error: 'Preprocessing failed' }, { status: 500 }), + }, + ]) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'legacy-slack-path' }), + }) + + expect(response.status).toBe(500) + expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() + }) }) describe('Reservation-free filtering', () => { diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 975f90f9c04..09f9cd9e282 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -19,6 +19,7 @@ import { handleProviderReachabilityTest, parseWebhookBody, verifyProviderAuth, + type WebhookDispatchResult, } from '@/lib/webhooks/processor' import { acceptsPathWebhookDelivery, acceptsWebhookDeliveryMethod } from '@/lib/webhooks/providers' import { @@ -216,8 +217,9 @@ async function handleWebhookDelivery( ) } - let dispatchedLegacySlackAlias = false + let authenticatedLegacySlackAlias = false let firstLegacySlackAuthError: NextResponse | null = null + const legacySlackDispatchResults: WebhookDispatchResult[] = [] for (const credentialId of legacySlackCredentialIds) { const authError = await verifySlackCustomBotCredentialRequest({ credentialId, @@ -231,17 +233,18 @@ async function handleWebhookDelivery( continue } - await dispatchSlackCustomBotCredential({ + const dispatchResults = await dispatchSlackCustomBotCredential({ credentialId, body, request, requestId, receivedAt, }) - dispatchedLegacySlackAlias = true + authenticatedLegacySlackAlias = true + legacySlackDispatchResults.push(...dispatchResults) } - if (legacySlackCredentialIds.size > 0 && !dispatchedLegacySlackAlias) { + if (legacySlackCredentialIds.size > 0 && !authenticatedLegacySlackAlias) { return ( firstLegacySlackAuthError ?? new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) @@ -252,11 +255,17 @@ async function handleWebhookDelivery( * Process each unmarked webhook matched on this path. Marked Slack rows were * already included in the routing-key fan-out and must not run twice. */ - const responses: NextResponse[] = dispatchedLegacySlackAlias - ? [new NextResponse(null, { status: 200 })] - : [] + const responses: NextResponse[] = [] const failures: NextResponse[] = [] - const dispatchTargetCount = directWebhooksForPath.length + (dispatchedLegacySlackAlias ? 1 : 0) + for (const dispatchResult of legacySlackDispatchResults) { + if (dispatchResult.reason === 'filtered') continue + if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') { + failures.push(dispatchResult.response) + continue + } + responses.push(dispatchResult.response) + } + const dispatchTargetCount = directWebhooksForPath.length + legacySlackDispatchResults.length for (const { webhook: foundWebhook, workflow: foundWorkflow } of directWebhooksForPath) { const provider = foundWebhook.provider diff --git a/apps/sim/lib/webhooks/slack-custom-ingress.ts b/apps/sim/lib/webhooks/slack-custom-ingress.ts index 591f01e1bb3..b2ba47723a6 100644 --- a/apps/sim/lib/webhooks/slack-custom-ingress.ts +++ b/apps/sim/lib/webhooks/slack-custom-ingress.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getSlackBotCredential } from '@/lib/oauth/credential-service' -import { findWebhooksByRoutingKey } from '@/lib/webhooks/processor' +import { findWebhooksByRoutingKey, type WebhookDispatchResult } from '@/lib/webhooks/processor' import { verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants' import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' @@ -99,15 +99,14 @@ export async function dispatchSlackCustomBotCredential({ request, requestId, receivedAt, -}: DispatchSlackCustomBotOptions): Promise { +}: DispatchSlackCustomBotOptions): Promise { const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack') if (webhooks.length === 0) { logger.info( `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run` ) - return 0 + return [] } - await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) - return webhooks.length + return dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) } diff --git a/apps/sim/lib/webhooks/slack-dispatch.ts b/apps/sim/lib/webhooks/slack-dispatch.ts index 8d1b15e704f..0757cb3b3d9 100644 --- a/apps/sim/lib/webhooks/slack-dispatch.ts +++ b/apps/sim/lib/webhooks/slack-dispatch.ts @@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server' import { dispatchResolvedWebhookTarget, type findWebhooksByRoutingKey, + type WebhookDispatchResult, } from '@/lib/webhooks/processor' import { resolveSlackEventKey } from '@/lib/webhooks/providers/slack' @@ -25,11 +26,12 @@ interface DispatchSlackWebhooksOptions { export async function dispatchSlackWebhooks( webhooks: Awaited>, { body, request, requestId, receivedAt }: DispatchSlackWebhooksOptions -): Promise { +): Promise { const payload = body as Record const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const parsedTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 : undefined const triggerTimestampMs = Number.isFinite(parsedTimestampMs) ? parsedTimestampMs : undefined + const results: WebhookDispatchResult[] = [] for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) { const result = await dispatchResolvedWebhookTarget(foundWebhook, foundWorkflow, body, request, { @@ -52,5 +54,8 @@ export async function dispatchSlackWebhooks( botId: rawEvent?.bot_id, }) } + results.push(result) } + + return results } From 02cfdd50becb4362d7e5660f6da7439ff9f586da Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 19 Aug 2026 17:14:29 -0700 Subject: [PATCH 3/6] fix(slack): continue shared legacy webhook fanout --- .../api/webhooks/trigger/[path]/route.test.ts | 82 +++++++++++++++++++ .../app/api/webhooks/trigger/[path]/route.ts | 7 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index a36d0458b14..1a380f67825 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -1049,6 +1049,88 @@ describe('Webhook Trigger API Route', () => { expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() }) + it('continues past a missing credential to another valid legacy credential', async () => { + testData.webhooks.push( + { + id: 'missing-legacy-slack-webhook', + provider: 'slack', + path: 'shared-legacy-slack-path', + routingKey: 'missing-credential', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'missing-credential', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }, + { + id: 'valid-legacy-slack-webhook', + provider: 'slack', + path: 'shared-legacy-slack-path', + routingKey: 'valid-credential', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'valid-credential', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + } + ) + verifySlackCustomBotCredentialRequestMock.mockImplementation( + async ({ credentialId }: { credentialId: string }) => + credentialId === 'missing-credential' ? new NextResponse(null, { status: 404 }) : null + ) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'shared-legacy-slack-path' }), + }) + + expect(response.status).toBe(200) + expect(dispatchSlackCustomBotCredentialMock).toHaveBeenCalledOnce() + expect(dispatchSlackCustomBotCredentialMock).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'valid-credential' }) + ) + }) + + it('continues to a direct webhook when every legacy credential is unavailable', async () => { + testData.webhooks.push( + { + id: 'missing-legacy-slack-webhook', + provider: 'slack', + path: 'shared-direct-path', + routingKey: 'missing-credential', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'missing-credential', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }, + { + id: 'direct-webhook', + provider: 'generic', + path: 'shared-direct-path', + isActive: true, + providerConfig: { requireAuth: false }, + workflowId: 'test-workflow-id', + } + ) + verifySlackCustomBotCredentialRequestMock.mockResolvedValueOnce( + new NextResponse(null, { status: 404 }) + ) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'shared-direct-path' }), + }) + + expect(response.status).toBe(200) + expect(dispatchSlackCustomBotCredentialMock).not.toHaveBeenCalled() + expect(dispatchResolvedWebhookTargetMock).toHaveBeenCalledOnce() + }) + it('propagates a legacy fan-out failure when no target queues successfully', async () => { testData.webhooks.push({ id: 'legacy-slack-webhook', diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 09f9cd9e282..faaa83998a3 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -228,7 +228,6 @@ async function handleWebhookDelivery( requestId, }) if (authError) { - if (authError.status === 404) return authError firstLegacySlackAuthError ??= authError continue } @@ -244,7 +243,11 @@ async function handleWebhookDelivery( legacySlackDispatchResults.push(...dispatchResults) } - if (legacySlackCredentialIds.size > 0 && !authenticatedLegacySlackAlias) { + if ( + legacySlackCredentialIds.size > 0 && + !authenticatedLegacySlackAlias && + directWebhooksForPath.length === 0 + ) { return ( firstLegacySlackAuthError ?? new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) From 59fa7da46423612a0655d55a3ea1db80f374bef6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 19 Aug 2026 17:25:31 -0700 Subject: [PATCH 4/6] fix(slack): acknowledge filtered webhook deliveries --- .../slack/custom/[credentialId]/route.test.ts | 14 ++++++++ .../slack/custom/[credentialId]/route.ts | 17 +++++++++- .../api/webhooks/trigger/[path]/route.test.ts | 31 +++++++++++++++++ .../app/api/webhooks/trigger/[path]/route.ts | 1 - .../scripts/migrate-slack-custom-bots.test.ts | 33 +++++++++++++++++++ .../db/scripts/migrate-slack-custom-bots.ts | 11 +++++-- 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts index 022c8619aac..b572d10b2e1 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts @@ -146,4 +146,18 @@ describe('Slack custom-bot webhook route', () => { expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) expect(res.status).toBe(200) }) + + it('returns the dispatch failure when no target is acknowledged', async () => { + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'failed', + response: new Response('Preprocessing failed', { status: 500 }), + reason: 'preprocessing', + }) + + const res = await POST(makeRequest(), context) + + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) + expect(res.status).toBe(500) + await expect(res.text()).resolves.toBe('Preprocessing failed') + }) }) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts index 4ece7692b19..3f7446d15ad 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts @@ -67,7 +67,22 @@ async function handleSlackCustomBotWebhook( return authError } - await dispatchSlackCustomBotCredential({ credentialId, body, request, requestId, receivedAt }) + const dispatchResults = await dispatchSlackCustomBotCredential({ + credentialId, + body, + request, + requestId, + receivedAt, + }) + const acknowledged = dispatchResults.some( + (result) => result.outcome !== 'failed' && result.reason !== 'block-missing' + ) + if (!acknowledged) { + const failure = dispatchResults.find( + (result) => result.outcome === 'failed' || result.reason === 'block-missing' + ) + if (failure) return failure.response + } return new NextResponse(null, { status: 200 }) } diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 1a380f67825..9e9d463e325 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -1160,6 +1160,37 @@ describe('Webhook Trigger API Route', () => { expect(response.status).toBe(500) expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() }) + + it('acknowledges a legacy fan-out when every target filters the event', async () => { + testData.webhooks.push({ + id: 'legacy-slack-webhook', + provider: 'slack', + path: 'legacy-slack-path', + routingKey: 'credential-1', + isActive: true, + providerConfig: { + triggerId: 'slack_webhook', + credentialId: 'credential-1', + ingressMode: 'legacy_custom_bot', + }, + workflowId: 'test-workflow-id', + }) + dispatchSlackCustomBotCredentialMock.mockResolvedValueOnce([ + { + outcome: 'ignored', + reason: 'filtered', + response: NextResponse.json({ message: 'Webhook event ignored' }), + }, + ]) + + const response = await POST(createMockRequest('POST', { type: 'event_callback' }), { + params: Promise.resolve({ path: 'legacy-slack-path' }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: 'Webhook event ignored' }) + expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled() + }) }) describe('Reservation-free filtering', () => { diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index faaa83998a3..c7be8aea8ab 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -261,7 +261,6 @@ async function handleWebhookDelivery( const responses: NextResponse[] = [] const failures: NextResponse[] = [] for (const dispatchResult of legacySlackDispatchResults) { - if (dispatchResult.reason === 'filtered') continue if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') { failures.push(dispatchResult.response) continue diff --git a/packages/db/scripts/migrate-slack-custom-bots.test.ts b/packages/db/scripts/migrate-slack-custom-bots.test.ts index f2bf2897afa..b717eb6ef7b 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.test.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.test.ts @@ -296,6 +296,39 @@ describe('planLegacySlackTriggerLink', () => { }) describe('resolveSlackSourceSecrets', () => { + it('marks a trigger without a bot token as unresolved', () => { + expect( + resolveSlackSourceSecrets( + source({ + sourceId: 'workflow-1:block-1:trigger', + kind: 'trigger', + rawBotToken: undefined, + rawSigningSecret: 'signing-secret', + }), + environmentLookup() + ) + ).toEqual({ + status: 'unresolved', + reason: 'Source workflow-1:block-1:trigger has no bot token', + }) + }) + + it('marks a trigger without a signing secret as unresolved', () => { + expect( + resolveSlackSourceSecrets( + source({ + sourceId: 'workflow-1:block-1:trigger', + kind: 'trigger', + rawSigningSecret: undefined, + }), + environmentLookup() + ) + ).toEqual({ + status: 'unresolved', + reason: 'Trigger source workflow-1:block-1:trigger has no signing secret', + }) + }) + it('marks a missing environment variable as an unresolved source', () => { expect( resolveSlackSourceSecrets(source({ rawBotToken: '{{SLACK_BOT_TOKEN}}' }), environmentLookup()) diff --git a/packages/db/scripts/migrate-slack-custom-bots.ts b/packages/db/scripts/migrate-slack-custom-bots.ts index b90878a64b0..a31a6c78f22 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.ts @@ -482,7 +482,9 @@ export function resolveSlackSourceSecrets( ): SlackSourceSecretResolution { try { const botToken = resolveStoredSecret(source.rawBotToken, source, lookup, 'botToken') - if (!botToken) throw new Error(`Source ${source.sourceId} has no bot token`) + if (!botToken) { + return { status: 'unresolved', reason: `Source ${source.sourceId} has no bot token` } + } const signingSecret = resolveStoredSecret( source.rawSigningSecret, @@ -491,7 +493,10 @@ export function resolveSlackSourceSecrets( 'signingSecret' ) if (source.kind === 'trigger' && !signingSecret) { - throw new Error(`Trigger source ${source.sourceId} has no signing secret`) + return { + status: 'unresolved', + reason: `Trigger source ${source.sourceId} has no signing secret`, + } } return { status: 'ready', botToken, signingSecret } @@ -716,7 +721,7 @@ async function prepareWorkspaceCredentials(params: { const resolution = resolveSlackSourceSecrets(source, environmentLookup) if (resolution.status === 'unresolved') { params.stats.skippedUnresolved++ - logger.warn('Skipping Slack bot credential source with an unresolved environment variable', { + logger.warn('Skipping Slack bot credential source with unresolved secrets', { workspaceId: params.workspaceId, workflowId: source.workflowId, workflowName: source.workflowName, From ffef9b2def9a49f30d7154ce84a9dd4f0ebef9e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 20 Aug 2026 12:19:18 -0700 Subject: [PATCH 5/6] fix(slack): finalize custom bot migration rollout --- apps/sim/blocks/blocks/slack.test.ts | 24 ++----- apps/sim/blocks/blocks/slack.ts | 51 +++---------- apps/sim/lib/webhooks/slack-dispatch.test.ts | 71 +++++++++++++++++++ apps/sim/lib/webhooks/slack-dispatch.ts | 60 +++++++++------- .../scripts/migrate-slack-custom-bots.test.ts | 7 ++ .../db/scripts/migrate-slack-custom-bots.ts | 30 ++++++-- 6 files changed, 155 insertions(+), 88 deletions(-) create mode 100644 apps/sim/lib/webhooks/slack-dispatch.test.ts diff --git a/apps/sim/blocks/blocks/slack.test.ts b/apps/sim/blocks/blocks/slack.test.ts index f9ad12ac823..540211ce985 100644 --- a/apps/sim/blocks/blocks/slack.test.ts +++ b/apps/sim/blocks/blocks/slack.test.ts @@ -13,14 +13,12 @@ import { const EXTENDED_OPERATION_IDS = ['set_status', 'set_title', 'set_suggested_prompts'] const EXTENDED_TOOL_IDS = ['slack_set_status', 'slack_set_title', 'slack_set_suggested_prompts'] -function operationIds(extendedScopesEnabled: boolean): string[] { - const operation = getSlackV2ActionSubBlocks(extendedScopesEnabled).find( - (subBlock) => subBlock.id === 'operation' - ) +function operationIds(): string[] { + const operation = getSlackV2ActionSubBlocks().find((subBlock) => subBlock.id === 'operation') return operation?.options?.map((option) => option.id) ?? [] } -describe('Slack block extended-scope capability', () => { +describe('Slack block release', () => { it('releases slack_v2 and keeps the legacy block executable but hidden', () => { expect(SlackBlock.hideFromToolbar).toBe(true) expect(SlackBlock.sunset).toEqual({ status: 'legacy', replacedBy: 'slack_v2' }) @@ -29,18 +27,10 @@ describe('Slack block extended-scope capability', () => { expect(SlackV2Block.sunset).toBeUndefined() }) - it('removes extended-scope operations and tools when the capability is disabled', () => { - expect(operationIds(false)).not.toEqual(expect.arrayContaining(EXTENDED_OPERATION_IDS)) - expect(getSlackV2ToolAccess(false)).not.toEqual(expect.arrayContaining(EXTENDED_TOOL_IDS)) - expect(Object.keys(getSlackV2OperationSentences(false))).not.toEqual( - expect.arrayContaining(EXTENDED_OPERATION_IDS) - ) - }) - - it('restores extended-scope operations and tools when the capability is enabled', () => { - expect(operationIds(true)).toEqual(expect.arrayContaining(EXTENDED_OPERATION_IDS)) - expect(getSlackV2ToolAccess(true)).toEqual(expect.arrayContaining(EXTENDED_TOOL_IDS)) - expect(Object.keys(getSlackV2OperationSentences(true))).toEqual( + it('keeps custom-bot operations available independently of native OAuth scopes', () => { + expect(operationIds()).toEqual(expect.arrayContaining(EXTENDED_OPERATION_IDS)) + expect(getSlackV2ToolAccess()).toEqual(expect.arrayContaining(EXTENDED_TOOL_IDS)) + expect(Object.keys(getSlackV2OperationSentences())).toEqual( expect.arrayContaining(EXTENDED_OPERATION_IDS) ) }) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index f498ed18dcc..8239ba275e3 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,6 +1,5 @@ import { BookOpen, ClipboardList, File, Table, Users } from '@sim/emcn/icons' import { GoogleTranslateIcon, GreptileIcon, SlackIcon } from '@/components/icons' -import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' @@ -16,18 +15,6 @@ import { getTrigger } from '@/triggers' /** The operations that offer a channel/DM switch, and so honour it. */ const DESTINATION_SWITCH_OPERATIONS = ['send', 'read', 'schedule_message'] as const -const SLACK_EXTENDED_SCOPE_OPERATION_IDS = new Set([ - 'set_status', - 'set_title', - 'set_suggested_prompts', -]) - -const SLACK_EXTENDED_SCOPE_TOOL_IDS = new Set([ - 'slack_set_status', - 'slack_set_title', - 'slack_set_suggested_prompts', -]) - const CHANNEL_FIELD = ['channel', 'manualChannel'] as const /** @@ -2934,16 +2921,8 @@ const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set( * Adapts a v1 subblock for slack_v2's merged credential picker: fields gated on * the removed `authMethod` dropdown now depend on the single `credential` field. */ -function adaptSubBlockForV2(sb: SubBlockConfig, extendedScopesEnabled: boolean): SubBlockConfig { +function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { const { dependsOn, condition, ...rest } = sb - if (sb.id === 'operation' && !extendedScopesEnabled) { - const options = typeof sb.options === 'function' ? sb.options() : sb.options - if (!options) throw new Error('Slack operation subblock must define options') - return { - ...sb, - options: options.filter((option) => !SLACK_EXTENDED_SCOPE_OPERATION_IDS.has(option.id)), - } - } if (sb.id === 'credential') { return { ...rest, @@ -2966,31 +2945,24 @@ function adaptSubBlockForV2(sb: SubBlockConfig, extendedScopesEnabled: boolean): return sb } -export function getSlackV2ActionSubBlocks(extendedScopesEnabled: boolean): SubBlockConfig[] { +export function getSlackV2ActionSubBlocks(): SubBlockConfig[] { return SlackBlock.subBlocks.flatMap((sb) => { if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return [] if (sb.id === 'authMethod') return [] - return [adaptSubBlockForV2(sb, extendedScopesEnabled)] + return [adaptSubBlockForV2(sb)] }) } -export function getSlackV2ToolAccess(extendedScopesEnabled: boolean): string[] { - if (extendedScopesEnabled) return [...SlackBlock.tools.access] - return SlackBlock.tools.access.filter((toolId) => !SLACK_EXTENDED_SCOPE_TOOL_IDS.has(toolId)) +export function getSlackV2ToolAccess(): string[] { + return [...SlackBlock.tools.access] } -export function getSlackV2OperationSentences(extendedScopesEnabled: boolean) { +export function getSlackV2OperationSentences() { const operationSentences = SlackBlock.canvasPresentation?.sentences?.byOperation if (!operationSentences) { throw new Error('Slack action sentences must be defined before building slack_v2') } - if (extendedScopesEnabled) return { ...operationSentences } - - const scopedSentences = { ...operationSentences } - for (const operationId of SLACK_EXTENDED_SCOPE_OPERATION_IDS) { - delete scopedSentences[operationId] - } - return scopedSentences + return { ...operationSentences } } const { @@ -3017,7 +2989,7 @@ export const SlackV2Block: BlockConfig = { defaultTitle: 'Slack', sentences: { ...SlackBlock.canvasPresentation?.sentences, - byOperation: getSlackV2OperationSentences(isSlackExtendedScopesEnabled), + byOperation: getSlackV2OperationSentences(), }, /* * Unlike v1, this trigger picks one event and scopes it, so the card names @@ -3038,13 +3010,10 @@ export const SlackV2Block: BlockConfig = { ], }, }, - subBlocks: [ - ...getSlackV2ActionSubBlocks(isSlackExtendedScopesEnabled), - ...getTrigger('slack_oauth').subBlocks, - ], + subBlocks: [...getSlackV2ActionSubBlocks(), ...getTrigger('slack_oauth').subBlocks], tools: { ...SlackBlock.tools, - access: getSlackV2ToolAccess(isSlackExtendedScopesEnabled), + access: getSlackV2ToolAccess(), }, inputs: { ...slackV2Inputs, diff --git a/apps/sim/lib/webhooks/slack-dispatch.test.ts b/apps/sim/lib/webhooks/slack-dispatch.test.ts new file mode 100644 index 00000000000..b7dce4d07a8 --- /dev/null +++ b/apps/sim/lib/webhooks/slack-dispatch.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +const { mockDispatchResolvedWebhookTarget } = vi.hoisted(() => ({ + mockDispatchResolvedWebhookTarget: vi.fn(), +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget, +})) + +vi.mock('@/lib/webhooks/providers/slack', () => ({ + resolveSlackEventKey: vi.fn(), +})) + +import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor' +import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' + +describe('dispatchSlackWebhooks', () => { + it('dispatches at most ten targets concurrently and preserves result order', async () => { + let active = 0 + let peak = 0 + const releases: Array<() => void> = [] + + vi.mocked(dispatchResolvedWebhookTarget).mockImplementation(async (foundWebhook) => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => releases.push(resolve)) + active -= 1 + + const index = Number(foundWebhook.id.replace('webhook-', '')) + return { + outcome: 'queued', + response: new NextResponse(null, { status: 200 + index }), + reason: 'queued', + } + }) + + const webhooks = Array.from({ length: 12 }, (_, index) => ({ + webhook: { id: `webhook-${index}`, providerConfig: {} }, + workflow: { id: `workflow-${index}` }, + })) + const dispatchPromise = dispatchSlackWebhooks(webhooks as never, { + body: { event: { type: 'message' } }, + request: new NextRequest('http://localhost/api/webhooks/slack'), + requestId: 'request-1', + receivedAt: Date.now(), + }) + + await vi.waitFor(() => expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(10)) + expect(active).toBe(10) + + for (let index = 0; index < 10; index += 1) { + releases.shift()?.() + } + + await vi.waitFor(() => expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(12)) + while (releases.length > 0) { + releases.shift()?.() + } + + const results = await dispatchPromise + expect(peak).toBe(10) + expect(results.map(({ response }) => response.status)).toEqual( + Array.from({ length: 12 }, (_, index) => 200 + index) + ) + }) +}) diff --git a/apps/sim/lib/webhooks/slack-dispatch.ts b/apps/sim/lib/webhooks/slack-dispatch.ts index 0757cb3b3d9..5530dc8b2d2 100644 --- a/apps/sim/lib/webhooks/slack-dispatch.ts +++ b/apps/sim/lib/webhooks/slack-dispatch.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { dispatchResolvedWebhookTarget, type findWebhooksByRoutingKey, @@ -8,6 +9,7 @@ import { import { resolveSlackEventKey } from '@/lib/webhooks/providers/slack' const logger = createLogger('SlackWebhookDispatch') +const SLACK_WEBHOOK_DISPATCH_CONCURRENCY = 10 interface DispatchSlackWebhooksOptions { body: unknown @@ -31,31 +33,41 @@ export async function dispatchSlackWebhooks( const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const parsedTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 : undefined const triggerTimestampMs = Number.isFinite(parsedTimestampMs) ? parsedTimestampMs : undefined - const results: WebhookDispatchResult[] = [] + return mapWithConcurrency( + webhooks, + SLACK_WEBHOOK_DISPATCH_CONCURRENCY, + async ({ webhook: foundWebhook, workflow: foundWorkflow }) => { + const result = await dispatchResolvedWebhookTarget( + foundWebhook, + foundWorkflow, + body, + request, + { + requestId, + receivedAt, + triggerTimestampMs, + } + ) - for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) { - const result = await dispatchResolvedWebhookTarget(foundWebhook, foundWorkflow, body, request, { - requestId, - receivedAt, - triggerTimestampMs, - }) + if (result.outcome === 'ignored' && result.reason === 'filtered') { + const rawEvent = payload.event as Record | undefined + const providerConfig = (foundWebhook.providerConfig as Record) || {} + logger.info( + `[${requestId}] Event skipped by trigger filter for webhook ${foundWebhook.id}`, + { + eventKey: resolveSlackEventKey(payload), + configuredEvent: providerConfig.eventType, + channelType: rawEvent?.channel_type, + subtype: rawEvent?.subtype, + isThreadReply: + typeof rawEvent?.thread_ts === 'string' && rawEvent.thread_ts !== rawEvent.ts, + threadsSetting: providerConfig.threads, + botId: rawEvent?.bot_id, + } + ) + } - if (result.outcome === 'ignored' && result.reason === 'filtered') { - const rawEvent = payload.event as Record | undefined - const providerConfig = (foundWebhook.providerConfig as Record) || {} - logger.info(`[${requestId}] Event skipped by trigger filter for webhook ${foundWebhook.id}`, { - eventKey: resolveSlackEventKey(payload), - configuredEvent: providerConfig.eventType, - channelType: rawEvent?.channel_type, - subtype: rawEvent?.subtype, - isThreadReply: - typeof rawEvent?.thread_ts === 'string' && rawEvent.thread_ts !== rawEvent.ts, - threadsSetting: providerConfig.threads, - botId: rawEvent?.bot_id, - }) + return result } - results.push(result) - } - - return results + ) } diff --git a/packages/db/scripts/migrate-slack-custom-bots.test.ts b/packages/db/scripts/migrate-slack-custom-bots.test.ts index b717eb6ef7b..35c9152b226 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.test.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.test.ts @@ -261,6 +261,13 @@ describe('planLegacySlackTriggerLink', () => { ).toEqual({ updateTriggerBlock: true, webhookIdsToUpdate: ['webhook-1'] }) }) + it('links an undeployed trigger even when there is no webhook to mark', () => { + expect(planLegacySlackTriggerLink(triggerSource, existingCredential, [])).toEqual({ + updateTriggerBlock: true, + webhookIdsToUpdate: [], + }) + }) + it('is idempotent after the block and webhook are linked', () => { expect( planLegacySlackTriggerLink( diff --git a/packages/db/scripts/migrate-slack-custom-bots.ts b/packages/db/scripts/migrate-slack-custom-bots.ts index a31a6c78f22..eab94737a11 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.ts @@ -145,6 +145,7 @@ interface PreparedCredential { botToken?: string signingSecret?: string updateTriggerBlock: boolean + legacyWebhookCount: number webhookIdsToUpdate: string[] } @@ -698,20 +699,18 @@ async function prepareWorkspaceCredentials(params: { const prepared: PreparedCredential[] = [] for (const source of params.sources) { + const legacyWebhookRows = params.webhookRowsBySourceId.get(source.sourceId) ?? [] const existingCredential = params.existingCredentialsBySourceId.get(source.sourceId) if (existingCredential) { params.stats.skippedExisting++ - const link = planLegacySlackTriggerLink( - source, - existingCredential, - params.webhookRowsBySourceId.get(source.sourceId) ?? [] - ) + const link = planLegacySlackTriggerLink(source, existingCredential, legacyWebhookRows) if (link.updateTriggerBlock || link.webhookIdsToUpdate.length > 0) { prepared.push({ source, credentialId: existingCredential.credentialId, insertCredential: false, updateTriggerBlock: link.updateTriggerBlock, + legacyWebhookCount: legacyWebhookRows.length, webhookIdsToUpdate: link.webhookIdsToUpdate, }) } @@ -742,7 +741,7 @@ async function prepareWorkspaceCredentials(params: { const link = planLegacySlackTriggerLink( source, { credentialId, hasSigningSecret: Boolean(signingSecret) }, - params.webhookRowsBySourceId.get(source.sourceId) ?? [] + legacyWebhookRows ) prepared.push({ @@ -754,6 +753,7 @@ async function prepareWorkspaceCredentials(params: { botToken, signingSecret, updateTriggerBlock: link.updateTriggerBlock, + legacyWebhookCount: legacyWebhookRows.length, webhookIdsToUpdate: link.webhookIdsToUpdate, }) } @@ -1114,6 +1114,12 @@ async function processWorkspace(params: { sources: sources.length, credentialsToInsert: prepared.filter((candidate) => candidate.insertCredential).length, triggersToLink: prepared.filter((candidate) => candidate.updateTriggerBlock).length, + undeployedTriggersToLink: prepared.filter( + (candidate) => + candidate.source.kind === 'trigger' && + candidate.updateTriggerBlock && + candidate.legacyWebhookCount === 0 + ).length, webhooksToMark: prepared.reduce( (total, candidate) => total + candidate.webhookIdsToUpdate.length, 0 @@ -1131,6 +1137,12 @@ async function processWorkspace(params: { displayName: candidate.displayName, createCredential: candidate.insertCredential, linkTrigger: candidate.updateTriggerBlock, + triggerDeployment: + candidate.source.kind === 'trigger' + ? candidate.legacyWebhookCount > 0 + ? 'deployed' + : 'undeployed' + : undefined, webhooksToMark: candidate.webhookIdsToUpdate.length, actionOnly: candidate.insertCredential && !candidate.signingSecret, }) @@ -1164,6 +1176,12 @@ async function processWorkspace(params: { displayName: candidate.displayName, createdCredential: candidate.insertCredential, linkedTrigger: applied.triggerLinked, + triggerDeployment: + candidate.source.kind === 'trigger' + ? candidate.legacyWebhookCount > 0 + ? 'deployed' + : 'undeployed' + : undefined, webhooksMarked: applied.webhooksMarked, actionOnly: candidate.insertCredential && !candidate.signingSecret, }) From 5f2a0bb96bdb95fad039a7aa819f22f294d97218 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 22 Aug 2026 17:06:11 -0700 Subject: [PATCH 6/6] fix(slack): dedupe migrated bots per workflow --- .../scripts/migrate-slack-custom-bots.test.ts | 144 +++++- .../db/scripts/migrate-slack-custom-bots.ts | 413 +++++++++++++----- 2 files changed, 421 insertions(+), 136 deletions(-) diff --git a/packages/db/scripts/migrate-slack-custom-bots.test.ts b/packages/db/scripts/migrate-slack-custom-bots.test.ts index 35c9152b226..5f5800402ef 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.test.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest' import { + buildSlackBotDescription, buildSlackBotDisplayName, buildSlackCustomBotSecretBlob, type EnvironmentLookup, extractSlackBotSources, + groupSlackSourcesByWorkflowCredentials, planLegacySlackTriggerLink, resolveSlackSourceSecrets, type SlackBotSource, @@ -195,20 +197,14 @@ describe('extractSlackBotSources', () => { }) describe('buildSlackBotDisplayName', () => { - it('uses workflow, block, and optional tool names', () => { - expect(buildSlackBotDisplayName(source(), new Set())).toBe('Escalations — Notify Support') - expect( - buildSlackBotDisplayName( - source({ kind: 'embedded_tool', toolTitle: 'Send to incidents' }), - new Set() - ) - ).toBe('Escalations — Notify Support — Send to incidents') + it('uses only the workflow name', () => { + expect(buildSlackBotDisplayName('Escalations', new Set())).toBe('Escalations') }) it('allocates a normalized suffix while keeping names within 255 characters', () => { - const longSource = source({ workflowName: 'W'.repeat(250), blockName: 'Block' }) - const first = buildSlackBotDisplayName(longSource, new Set()) - const second = buildSlackBotDisplayName(longSource, new Set([first.toLowerCase()])) + const workflowName = 'W'.repeat(300) + const first = buildSlackBotDisplayName(workflowName, new Set()) + const second = buildSlackBotDisplayName(workflowName, new Set([first.toLowerCase()])) expect(first).toHaveLength(255) expect(second).toHaveLength(255) @@ -216,25 +212,135 @@ describe('buildSlackBotDisplayName', () => { }) }) +describe('buildSlackBotDescription', () => { + it('identifies blocks without migration terminology', () => { + expect( + buildSlackBotDescription('Escalations', [ + source(), + source({ + sourceId: 'workflow-1:block-2:tools:0', + blockId: 'block-2', + blockName: 'Incident Agent', + kind: 'embedded_tool', + toolTitle: 'Notify channel', + }), + ]) + ).toBe( + 'Used by workflow "Escalations". Blocks: "Incident Agent" (Notify channel), "Notify Support".' + ) + }) +}) + +describe('groupSlackSourcesByWorkflowCredentials', () => { + it('groups matching credentials within a workflow and keeps different credentials separate', () => { + const groups = groupSlackSourcesByWorkflowCredentials([ + { source: source(), botToken: 'xoxb-one', signingSecret: 'secret-one' }, + { + source: source({ + sourceId: 'workflow-1:block-2:trigger', + blockId: 'block-2', + blockName: 'Handle Reply', + kind: 'trigger', + }), + botToken: 'xoxb-one', + signingSecret: 'secret-one', + }, + { + source: source({ + sourceId: 'workflow-1:block-3:trigger', + blockId: 'block-3', + blockName: 'Handle Mention', + kind: 'trigger', + }), + botToken: 'xoxb-two', + signingSecret: 'secret-two', + }, + ]) + + expect(groups).toHaveLength(2) + expect(groups[0].sources.map((candidate) => candidate.blockName)).toEqual([ + 'Notify Support', + 'Handle Reply', + ]) + expect(groups[1].sources.map((candidate) => candidate.blockName)).toEqual(['Handle Mention']) + }) + + it('does not combine matching credentials across workflows', () => { + const groups = groupSlackSourcesByWorkflowCredentials([ + { source: source(), botToken: 'xoxb-one', signingSecret: 'secret-one' }, + { + source: source({ + sourceId: 'workflow-2:block-2:trigger', + workflowId: 'workflow-2', + workflowName: 'Onboarding', + blockId: 'block-2', + kind: 'trigger', + }), + botToken: 'xoxb-one', + signingSecret: 'secret-one', + }, + ]) + + expect(groups).toHaveLength(2) + }) + + it('keeps different signing secrets separate when bot tokens match', () => { + const groups = groupSlackSourcesByWorkflowCredentials([ + { source: source(), botToken: 'xoxb-one', signingSecret: 'secret-one' }, + { + source: source({ + sourceId: 'workflow-1:block-2:trigger', + blockId: 'block-2', + blockName: 'Slack Trigger', + kind: 'trigger', + }), + botToken: 'xoxb-one', + signingSecret: 'secret-two', + }, + ]) + + expect(groups).toHaveLength(2) + }) + + it('joins an action-only source to the unique matching trigger credential', () => { + const groups = groupSlackSourcesByWorkflowCredentials([ + { source: source(), botToken: 'xoxb-one' }, + { + source: source({ + sourceId: 'workflow-1:block-2:trigger', + blockId: 'block-2', + blockName: 'Slack Trigger', + kind: 'trigger', + }), + botToken: 'xoxb-one', + signingSecret: 'secret-one', + }, + ]) + + expect(groups).toHaveLength(1) + expect(groups[0].signingSecret).toBe('secret-one') + expect(groups[0].sources.map((candidate) => candidate.blockName)).toEqual([ + 'Slack Trigger', + 'Notify Support', + ]) + }) +}) + describe('buildSlackCustomBotSecretBlob', () => { it('builds a trigger-capable credential without calling Slack for identity', () => { - expect( - buildSlackCustomBotSecretBlob('workflow-1:block-1:trigger', 'xoxb-token', 'secret') - ).toEqual({ + expect(buildSlackCustomBotSecretBlob('workflow-1', 'xoxb-token', 'secret')).toEqual({ type: 'slack_custom_bot', signingSecret: 'secret', botToken: 'xoxb-token', - metadata: { migrationSourceId: 'workflow-1:block-1:trigger' }, + metadata: { migrationWorkflowId: 'workflow-1' }, }) }) it('builds an action-only credential without inventing a signing secret', () => { - expect( - buildSlackCustomBotSecretBlob('workflow-1:block-1:action', 'xoxb-token', undefined) - ).toEqual({ + expect(buildSlackCustomBotSecretBlob('workflow-1', 'xoxb-token', undefined)).toEqual({ type: 'slack_custom_bot', botToken: 'xoxb-token', - metadata: { migrationSourceId: 'workflow-1:block-1:action' }, + metadata: { migrationWorkflowId: 'workflow-1' }, }) }) }) diff --git a/packages/db/scripts/migrate-slack-custom-bots.ts b/packages/db/scripts/migrate-slack-custom-bots.ts index eab94737a11..6032e4b5e40 100644 --- a/packages/db/scripts/migrate-slack-custom-bots.ts +++ b/packages/db/scripts/migrate-slack-custom-bots.ts @@ -60,10 +60,12 @@ const SLACK_CUSTOM_BOT_PROVIDER_ID = 'slack-custom-bot' const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' const LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE = 'legacy_custom_bot' const DISPLAY_NAME_MAX_LENGTH = 255 +const DESCRIPTION_MAX_LENGTH = 500 const OUTPUT_FILE = 'migrate-slack-custom-bot-workspace-ids.txt' const WORKSPACE_CONCURRENCY = 3 const WORKSPACE_DISCOVERY_PAGE_SIZE = 250 const MEMBERSHIP_INSERT_CHUNK_SIZE = 500 +const TRIGGER_BLOCK_UPDATE_CHUNK_SIZE = 500 const WEBHOOK_UPDATE_CHUNK_SIZE = 500 const ENVIRONMENT_USER_QUERY_CHUNK_SIZE = 500 const MAX_BLOCKS_PER_WORKSPACE = 10_000 @@ -136,17 +138,38 @@ export type SlackSourceSecretResolution = | { status: 'ready'; botToken: string; signingSecret?: string } | { status: 'unresolved'; reason: string } -interface PreparedCredential { +export interface ResolvedSlackBotSource { source: SlackBotSource + botToken: string + signingSecret?: string +} + +export interface SlackCredentialGroup { + workflowId: string + workflowName: string + botToken: string + signingSecret?: string + sources: SlackBotSource[] +} + +interface PreparedSourceLink { + source: SlackBotSource + updateTriggerBlock: boolean + legacyWebhookCount: number + webhookIdsToUpdate: string[] +} + +interface PreparedCredential { + workflowId: string + workflowName: string + sources: SlackBotSource[] + sourceLinks: PreparedSourceLink[] credentialId: string insertCredential: boolean displayName?: string description?: string botToken?: string signingSecret?: string - updateTriggerBlock: boolean - legacyWebhookCount: number - webhookIdsToUpdate: string[] } export interface ExistingMigrationCredential { @@ -154,6 +177,12 @@ export interface ExistingMigrationCredential { hasSigningSecret: boolean } +interface StoredMigrationCredential extends ExistingMigrationCredential { + workflowId: string + botToken: string + signingSecret?: string +} + export interface LegacySlackWebhookRow { id: string workflowId: string @@ -358,12 +387,11 @@ export function extractSlackBotSources(block: SlackMigrationBlock): SlackBotSour } export function buildSlackBotDisplayName( - source: SlackBotSource, + workflowName: string, takenNames: ReadonlySet ): string { - const parts = [source.workflowName.trim(), source.blockName.trim()] - if (source.toolTitle) parts.push(source.toolTitle.trim()) - const base = parts.join(' — ') + const base = workflowName.trim() + if (!base) throw new Error('Slack credential workflow name cannot be empty') const first = truncate(base, DISPLAY_NAME_MAX_LENGTH, '') if (!takenNames.has(first.toLowerCase())) return first @@ -373,12 +401,105 @@ export function buildSlackBotDisplayName( const candidate = `${truncate(base, DISPLAY_NAME_MAX_LENGTH - suffix.length, '')}${suffix}` if (!takenNames.has(candidate.toLowerCase())) return candidate } - throw new Error(`Could not allocate a unique credential name for source ${source.sourceId}`) + throw new Error(`Could not allocate a unique credential name for workflow "${workflowName}"`) +} + +export function buildSlackBotDescription(workflowName: string, sources: SlackBotSource[]): string { + const sourceLabels = [ + ...new Set( + sources.map((source) => + source.toolTitle + ? `"${source.blockName.trim()}" (${source.toolTitle.trim()})` + : `"${source.blockName.trim()}"` + ) + ), + ].sort((left, right) => left.localeCompare(right)) + const description = `Used by workflow "${workflowName.trim()}". Blocks: ${sourceLabels.join(', ')}.` + return truncate(description, DESCRIPTION_MAX_LENGTH) +} + +function slackCredentialGroupKey( + workflowId: string, + botToken: string, + signingSecret: string | undefined +): string { + return JSON.stringify([workflowId, botToken, signingSecret ?? null]) +} + +function slackBotTokenGroupKey(workflowId: string, botToken: string): string { + return JSON.stringify([workflowId, botToken]) +} + +export function groupSlackSourcesByWorkflowCredentials( + resolvedSources: ResolvedSlackBotSource[] +): SlackCredentialGroup[] { + const groups = new Map() + const signedGroupKeysByBotToken = new Map() + const orderedSources = [...resolvedSources].sort((left, right) => + left.source.sourceId.localeCompare(right.source.sourceId) + ) + + for (const resolved of orderedSources.filter((candidate) => candidate.signingSecret)) { + const key = slackCredentialGroupKey( + resolved.source.workflowId, + resolved.botToken, + resolved.signingSecret + ) + const existing = groups.get(key) + if (existing) { + if (existing.workflowName !== resolved.source.workflowName) { + throw new Error(`Workflow ${resolved.source.workflowId} has inconsistent names`) + } + existing.sources.push(resolved.source) + continue + } + + groups.set(key, { + workflowId: resolved.source.workflowId, + workflowName: resolved.source.workflowName, + botToken: resolved.botToken, + signingSecret: resolved.signingSecret, + sources: [resolved.source], + }) + + const botTokenKey = slackBotTokenGroupKey(resolved.source.workflowId, resolved.botToken) + const signedGroupKeys = signedGroupKeysByBotToken.get(botTokenKey) ?? [] + signedGroupKeys.push(key) + signedGroupKeysByBotToken.set(botTokenKey, signedGroupKeys) + } + + for (const resolved of orderedSources.filter((candidate) => !candidate.signingSecret)) { + const botTokenKey = slackBotTokenGroupKey(resolved.source.workflowId, resolved.botToken) + const signedGroupKeys = signedGroupKeysByBotToken.get(botTokenKey) ?? [] + const key = + signedGroupKeys.length === 1 + ? signedGroupKeys[0] + : slackCredentialGroupKey(resolved.source.workflowId, resolved.botToken, undefined) + if (!key) throw new Error(`Could not group Slack source ${resolved.source.sourceId}`) + + const existing = groups.get(key) + if (existing) { + if (existing.workflowName !== resolved.source.workflowName) { + throw new Error(`Workflow ${resolved.source.workflowId} has inconsistent names`) + } + existing.sources.push(resolved.source) + continue + } + + groups.set(key, { + workflowId: resolved.source.workflowId, + workflowName: resolved.source.workflowName, + botToken: resolved.botToken, + sources: [resolved.source], + }) + } + + return [...groups.values()] } /** Creates the exact plaintext payload consumed by `getSlackBotCredential`. */ export function buildSlackCustomBotSecretBlob( - sourceId: string, + workflowId: string, botToken: string, signingSecret: string | undefined ): Record { @@ -386,7 +507,7 @@ export function buildSlackCustomBotSecretBlob( type: SLACK_CUSTOM_BOT_SECRET_TYPE, ...(signingSecret ? { signingSecret } : {}), botToken, - metadata: { migrationSourceId: sourceId }, + metadata: { migrationWorkflowId: workflowId }, } } @@ -513,18 +634,28 @@ function readExistingMigrationCredential( credentialId: string, encryptedValue: string, encryptionKey: string -): { sourceId: string; credential: ExistingMigrationCredential } | null { +): StoredMigrationCredential | null { const parsed: unknown = JSON.parse(decryptSecret(encryptedValue, encryptionKey)) if (!isRecordLike(parsed) || !isRecordLike(parsed.metadata)) return null - const sourceId = nonEmptyString(parsed.metadata.migrationSourceId) - if (!sourceId) return null + const workflowId = nonEmptyString(parsed.metadata.migrationWorkflowId) + const legacySourceId = nonEmptyString(parsed.metadata.migrationSourceId) + if (!workflowId && !legacySourceId) return null + + const resolvedWorkflowId = workflowId ?? legacySourceId?.split(':')[0] + if (!resolvedWorkflowId) { + throw new Error(`Slack credential ${credentialId} has invalid migration metadata`) + } + + const botToken = nonEmptyString(parsed.botToken) + if (!botToken) throw new Error(`Slack credential ${credentialId} has no bot token`) + const signingSecret = nonEmptyString(parsed.signingSecret) return { - sourceId, - credential: { - credentialId, - hasSigningSecret: Boolean(nonEmptyString(parsed.signingSecret)), - }, + credentialId, + workflowId: resolvedWorkflowId, + botToken, + signingSecret, + hasSigningSecret: Boolean(signingSecret), } } @@ -682,41 +813,20 @@ async function prepareWorkspaceCredentials(params: { sources: SlackBotSource[] encryptionKey: string takenNames: Set - existingCredentialsBySourceId: Map + existingCredentialsByGroupKey: Map webhookRowsBySourceId: Map stats: MigrationStats }): Promise { - const sourcesNeedingCredentials = params.sources.filter( - (source) => !params.existingCredentialsBySourceId.has(source.sourceId) - ) const environmentLookup = await loadEnvironmentLookup( params.db, params.workspaceId, params.workspaceOwnerId, - sourcesNeedingCredentials, + params.sources, params.encryptionKey ) - const prepared: PreparedCredential[] = [] + const resolvedSources: ResolvedSlackBotSource[] = [] for (const source of params.sources) { - const legacyWebhookRows = params.webhookRowsBySourceId.get(source.sourceId) ?? [] - const existingCredential = params.existingCredentialsBySourceId.get(source.sourceId) - if (existingCredential) { - params.stats.skippedExisting++ - const link = planLegacySlackTriggerLink(source, existingCredential, legacyWebhookRows) - if (link.updateTriggerBlock || link.webhookIdsToUpdate.length > 0) { - prepared.push({ - source, - credentialId: existingCredential.credentialId, - insertCredential: false, - updateTriggerBlock: link.updateTriggerBlock, - legacyWebhookCount: legacyWebhookRows.length, - webhookIdsToUpdate: link.webhookIdsToUpdate, - }) - } - continue - } - const resolution = resolveSlackSourceSecrets(source, environmentLookup) if (resolution.status === 'unresolved') { params.stats.skippedUnresolved++ @@ -732,29 +842,66 @@ async function prepareWorkspaceCredentials(params: { continue } - const { botToken, signingSecret } = resolution + resolvedSources.push({ + source, + botToken: resolution.botToken, + signingSecret: resolution.signingSecret, + }) + } + + const prepared: PreparedCredential[] = [] + + for (const group of groupSlackSourcesByWorkflowCredentials(resolvedSources)) { + const groupKey = slackCredentialGroupKey(group.workflowId, group.botToken, group.signingSecret) + const existingCredential = params.existingCredentialsByGroupKey.get(groupKey) + const credentialId = existingCredential?.credentialId ?? generateId() + const credential = { + credentialId, + hasSigningSecret: Boolean(group.signingSecret), + } + const sourceLinks = group.sources.map((source): PreparedSourceLink => { + const legacyWebhookRows = params.webhookRowsBySourceId.get(source.sourceId) ?? [] + const link = planLegacySlackTriggerLink(source, credential, legacyWebhookRows) + return { + source, + updateTriggerBlock: link.updateTriggerBlock, + legacyWebhookCount: legacyWebhookRows.length, + webhookIdsToUpdate: link.webhookIdsToUpdate, + } + }) + + if (existingCredential) { + params.stats.skippedExisting++ + if ( + sourceLinks.some((link) => link.updateTriggerBlock || link.webhookIdsToUpdate.length > 0) + ) { + prepared.push({ + workflowId: group.workflowId, + workflowName: group.workflowName, + sources: group.sources, + sourceLinks, + credentialId: existingCredential.credentialId, + insertCredential: false, + }) + } + continue + } - const displayName = buildSlackBotDisplayName(source, params.takenNames) + const displayName = buildSlackBotDisplayName(group.workflowName, params.takenNames) params.takenNames.add(displayName.toLowerCase()) - if (!signingSecret) params.stats.actionOnly++ - const credentialId = generateId() - const link = planLegacySlackTriggerLink( - source, - { credentialId, hasSigningSecret: Boolean(signingSecret) }, - legacyWebhookRows - ) + if (!group.signingSecret) params.stats.actionOnly++ prepared.push({ - source, + workflowId: group.workflowId, + workflowName: group.workflowName, + sources: group.sources, + sourceLinks, credentialId, insertCredential: true, displayName, - description: `Backfilled from Slack credentials in workflow "${source.workflowName}", block "${source.blockName}".`, - botToken, - signingSecret, - updateTriggerBlock: link.updateTriggerBlock, - legacyWebhookCount: legacyWebhookRows.length, - webhookIdsToUpdate: link.webhookIdsToUpdate, + description: buildSlackBotDescription(group.workflowName, group.sources), + botToken: group.botToken, + signingSecret: group.signingSecret, }) } @@ -768,18 +915,18 @@ async function applyPreparedCredential(params: { memberUserIds: string[] encryptionKey: string prepared: PreparedCredential -}): Promise<{ triggerLinked: boolean; webhooksMarked: number }> { +}): Promise<{ triggersLinked: number; webhooksMarked: number }> { const now = new Date() const { credentialId } = params.prepared const encryptedServiceAccountKey = params.prepared.insertCredential ? (() => { if (!params.prepared.botToken) { - throw new Error(`Prepared source ${params.prepared.source.sourceId} has no bot token`) + throw new Error(`Prepared workflow ${params.prepared.workflowId} has no bot token`) } return encryptSecret( JSON.stringify( buildSlackCustomBotSecretBlob( - params.prepared.source.sourceId, + params.prepared.workflowId, params.prepared.botToken, params.prepared.signingSecret ) @@ -799,7 +946,7 @@ async function applyPreparedCredential(params: { !params.prepared.description || !encryptedServiceAccountKey ) { - throw new Error(`Prepared source ${params.prepared.source.sourceId} is incomplete`) + throw new Error(`Prepared workflow ${params.prepared.workflowId} is incomplete`) } await tx.insert(credential).values({ id: credentialId, @@ -830,7 +977,8 @@ async function applyPreparedCredential(params: { } } - if (params.prepared.updateTriggerBlock) { + const triggerLinks = params.prepared.sourceLinks.filter((link) => link.updateTriggerBlock) + for (const triggerLinkChunk of chunkArray(triggerLinks, TRIGGER_BLOCK_UPDATE_CHUNK_SIZE)) { const updatedBlocks = await tx .update(workflowBlocks) .set({ @@ -839,23 +987,26 @@ async function applyPreparedCredential(params: { }) .where( and( - eq(workflowBlocks.id, params.prepared.source.blockId), - eq(workflowBlocks.workflowId, params.prepared.source.workflowId), + inArray( + workflowBlocks.id, + triggerLinkChunk.map((link) => link.source.blockId) + ), + eq(workflowBlocks.workflowId, params.prepared.workflowId), sql`(${workflowBlocks.subBlocks}->'botCredential'->>'value' IS NULL OR ${workflowBlocks.subBlocks}->'botCredential'->>'value' = ${credentialId})` ) ) .returning({ id: workflowBlocks.id }) - if (updatedBlocks.length !== 1) { + if (updatedBlocks.length !== triggerLinkChunk.length) { throw new Error( - `Trigger source ${params.prepared.source.sourceId} changed while the migration was running` + `A Slack trigger in workflow ${params.prepared.workflowId} changed while the migration was running` ) } } - for (const webhookIdChunk of chunkArray( - params.prepared.webhookIdsToUpdate, - WEBHOOK_UPDATE_CHUNK_SIZE - )) { + const webhookIdsToUpdate = params.prepared.sourceLinks.flatMap( + (link) => link.webhookIdsToUpdate + ) + for (const webhookIdChunk of chunkArray(webhookIdsToUpdate, WEBHOOK_UPDATE_CHUNK_SIZE)) { const updatedWebhooks = await tx .update(webhook) .set({ @@ -878,15 +1029,18 @@ async function applyPreparedCredential(params: { .returning({ id: webhook.id }) if (updatedWebhooks.length !== webhookIdChunk.length) { throw new Error( - `A legacy Slack webhook for source ${params.prepared.source.sourceId} changed while the migration was running` + `A legacy Slack webhook for workflow ${params.prepared.workflowId} changed while the migration was running` ) } } }) return { - triggerLinked: params.prepared.updateTriggerBlock, - webhooksMarked: params.prepared.webhookIdsToUpdate.length, + triggersLinked: params.prepared.sourceLinks.filter((link) => link.updateTriggerBlock).length, + webhooksMarked: params.prepared.sourceLinks.reduce( + (count, link) => count + link.webhookIdsToUpdate.length, + 0 + ), } } @@ -1052,7 +1206,7 @@ async function processWorkspace(params: { return { stats, approvedForLiveRun: false } } - const existingCredentialsBySourceId = new Map() + const existingCredentialsByGroupKey = new Map() const takenNames = new Set() for (const row of existingCredentialRows) { takenNames.add(row.displayName.toLowerCase()) @@ -1070,10 +1224,17 @@ async function processWorkspace(params: { params.encryptionKey ) if (!existing) continue - if (existingCredentialsBySourceId.has(existing.sourceId)) { - throw new Error(`Multiple Slack credentials claim migration source ${existing.sourceId}`) + const groupKey = slackCredentialGroupKey( + existing.workflowId, + existing.botToken, + existing.signingSecret + ) + if (existingCredentialsByGroupKey.has(groupKey)) { + throw new Error( + `Multiple Slack credentials match the same credentials in workflow ${existing.workflowId}` + ) } - existingCredentialsBySourceId.set(existing.sourceId, existing.credential) + existingCredentialsByGroupKey.set(groupKey, existing) } const webhookRowsBySourceId = new Map() @@ -1097,7 +1258,7 @@ async function processWorkspace(params: { sources, encryptionKey: params.encryptionKey, takenNames, - existingCredentialsBySourceId, + existingCredentialsByGroupKey, webhookRowsBySourceId, stats, }) @@ -1107,48 +1268,71 @@ async function processWorkspace(params: { return { stats, approvedForLiveRun: false } } + const triggersToLink = prepared.reduce( + (count, candidate) => + count + candidate.sourceLinks.filter((link) => link.updateTriggerBlock).length, + 0 + ) + const undeployedTriggersToLink = prepared.reduce( + (count, candidate) => + count + + candidate.sourceLinks.filter( + (link) => link.updateTriggerBlock && link.legacyWebhookCount === 0 + ).length, + 0 + ) + const webhooksToMark = prepared.reduce( + (count, candidate) => + count + + candidate.sourceLinks.reduce( + (sourceCount, link) => sourceCount + link.webhookIdsToUpdate.length, + 0 + ), + 0 + ) + logger.info( `${params.dryRun ? '[DRY RUN] ' : ''}Workspace ${params.index}${params.total ? `/${params.total}` : ''} ready`, { workspaceId: params.workspaceId, sources: sources.length, credentialsToInsert: prepared.filter((candidate) => candidate.insertCredential).length, - triggersToLink: prepared.filter((candidate) => candidate.updateTriggerBlock).length, - undeployedTriggersToLink: prepared.filter( - (candidate) => - candidate.source.kind === 'trigger' && - candidate.updateTriggerBlock && - candidate.legacyWebhookCount === 0 - ).length, - webhooksToMark: prepared.reduce( - (total, candidate) => total + candidate.webhookIdsToUpdate.length, - 0 - ), + triggersToLink, + undeployedTriggersToLink, + webhooksToMark, } ) if (params.dryRun) { for (const candidate of prepared) { - logger.info('[DRY RUN] Would migrate Slack bot source', { + const candidateTriggersToLink = candidate.sourceLinks.filter( + (link) => link.updateTriggerBlock + ).length + const candidateWebhooksToMark = candidate.sourceLinks.reduce( + (count, link) => count + link.webhookIdsToUpdate.length, + 0 + ) + logger.info('[DRY RUN] Would migrate Slack credential', { workspaceId: params.workspaceId, - workflowId: candidate.source.workflowId, - blockId: candidate.source.blockId, - sourceKind: candidate.source.kind, + workflowId: candidate.workflowId, + blockIds: candidate.sources.map((source) => source.blockId), + blockNames: [...new Set(candidate.sources.map((source) => source.blockName))], + sourceKinds: [...new Set(candidate.sources.map((source) => source.kind))], displayName: candidate.displayName, createCredential: candidate.insertCredential, - linkTrigger: candidate.updateTriggerBlock, - triggerDeployment: - candidate.source.kind === 'trigger' - ? candidate.legacyWebhookCount > 0 - ? 'deployed' - : 'undeployed' - : undefined, - webhooksToMark: candidate.webhookIdsToUpdate.length, + triggersToLink: candidateTriggersToLink, + deployedTriggersToLink: candidate.sourceLinks.filter( + (link) => link.updateTriggerBlock && link.legacyWebhookCount > 0 + ).length, + undeployedTriggersToLink: candidate.sourceLinks.filter( + (link) => link.updateTriggerBlock && link.legacyWebhookCount === 0 + ).length, + webhooksToMark: candidateWebhooksToMark, actionOnly: candidate.insertCredential && !candidate.signingSecret, }) if (candidate.insertCredential) stats.inserted++ - if (candidate.updateTriggerBlock) stats.triggersLinked++ - stats.webhooksMarked += candidate.webhookIdsToUpdate.length + stats.triggersLinked += candidateTriggersToLink + stats.webhooksMarked += candidateWebhooksToMark } return { stats, approvedForLiveRun: true } } @@ -1166,22 +1350,17 @@ async function processWorkspace(params: { prepared: candidate, }) if (candidate.insertCredential) stats.inserted++ - if (applied.triggerLinked) stats.triggersLinked++ + stats.triggersLinked += applied.triggersLinked stats.webhooksMarked += applied.webhooksMarked - logger.info('Migrated Slack bot source', { + logger.info('Migrated Slack credential', { workspaceId: params.workspaceId, - workflowId: candidate.source.workflowId, - blockId: candidate.source.blockId, - sourceKind: candidate.source.kind, + workflowId: candidate.workflowId, + blockIds: candidate.sources.map((source) => source.blockId), + blockNames: [...new Set(candidate.sources.map((source) => source.blockName))], + sourceKinds: [...new Set(candidate.sources.map((source) => source.kind))], displayName: candidate.displayName, createdCredential: candidate.insertCredential, - linkedTrigger: applied.triggerLinked, - triggerDeployment: - candidate.source.kind === 'trigger' - ? candidate.legacyWebhookCount > 0 - ? 'deployed' - : 'undeployed' - : undefined, + triggersLinked: applied.triggersLinked, webhooksMarked: applied.webhooksMarked, actionOnly: candidate.insertCredential && !candidate.signingSecret, })