diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index e0879797630..2076d6c3503 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -509,6 +509,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 cbb33d9fc5f..3214e430f26 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 @@ -185,7 +185,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..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 @@ -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) @@ -133,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 9925aca7f50..3f7446d15ad 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,32 @@ 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` + 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' ) - return new NextResponse(null, { status: 200 }) + if (failure) return failure.response } - await dispatchSlackWebhooks(webhooks, { 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 f54e18337fd..f26f72b0151 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' }), @@ -509,6 +522,20 @@ 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([ + { + outcome: 'queued', + reason: 'queued', + response: new NextResponse(null, { status: 200 }), + }, + ]) // Set up default workflow for tests testData.workflows.push({ @@ -683,6 +710,207 @@ 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() + }) + + 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', + 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() + }) + + 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', () => { 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 31b37a6edbe..c130915ce01 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -13,10 +13,17 @@ import { handleProviderReachabilityTest, parseWebhookBody, verifyProviderAuth, + type WebhookDispatchResult, } from '@/lib/webhooks/processor' import { acceptsPathWebhookDelivery } 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' @@ -123,18 +130,79 @@ async function handleWebhookPost( return new NextResponse('Not Found', { status: 404 }) } - // Process each webhook matched on this path + 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 authenticatedLegacySlackAlias = false + let firstLegacySlackAuthError: NextResponse | null = null + const legacySlackDispatchResults: WebhookDispatchResult[] = [] + for (const credentialId of legacySlackCredentialIds) { + const authError = await verifySlackCustomBotCredentialRequest({ + credentialId, + request, + rawBody, + requestId, + }) + if (authError) { + firstLegacySlackAuthError ??= authError + continue + } + + const dispatchResults = await dispatchSlackCustomBotCredential({ + credentialId, + body, + request, + requestId, + receivedAt, + }) + authenticatedLegacySlackAlias = true + legacySlackDispatchResults.push(...dispatchResults) + } + + if ( + legacySlackCredentialIds.size > 0 && + !authenticatedLegacySlackAlias && + directWebhooksForPath.length === 0 + ) { + 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[] = [] const failures: NextResponse[] = [] + for (const dispatchResult of legacySlackDispatchResults) { + 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 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` ) @@ -151,7 +219,7 @@ async function handleWebhookPost( requestId ) if (authError) { - if (webhooksForPath.length > 1) { + if (dispatchTargetCount > 1) { logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) continue } @@ -181,7 +249,7 @@ async function handleWebhookPost( } 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 ad59a47e931..797948dbb85 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -292,7 +292,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' @@ -925,6 +925,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 9853a305cd8..200d9ff1575 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -484,11 +484,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 46220afdb91..85a842dfddd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -41,7 +41,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 } @@ -58,10 +58,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, @@ -72,11 +70,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 18b4f97e5f1..816065bdd78 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 fea3362e702..e28735c120e 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -489,6 +489,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 cabdaf0daa5..be13b9759c2 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -65,7 +65,7 @@ afterAll(resetEnvMock) import { GoogleIcon, GoogleVaultIcon } from '@/components/icons' 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' /** @@ -101,6 +101,17 @@ describe('OAuth Provider Branding', () => { }) 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 1414440c3fb..739b5623cd9 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -90,9 +90,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 7882f04d709..12932ba9975 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, @@ -426,11 +428,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 @@ -446,6 +481,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 @@ -528,6 +573,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 06933871349..0b9bd7d61b7 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 @@ -369,6 +370,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}`) @@ -467,6 +475,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..b2ba47723a6 --- /dev/null +++ b/apps/sim/lib/webhooks/slack-custom-ingress.ts @@ -0,0 +1,112 @@ +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, 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' + +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 [] + } + + 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 } 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..b717eb6ef7b --- /dev/null +++ b/packages/db/scripts/migrate-slack-custom-bots.test.ts @@ -0,0 +1,351 @@ +/** + * @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 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()) + ).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..a31a6c78f22 --- /dev/null +++ b/packages/db/scripts/migrate-slack-custom-bots.ts @@ -0,0 +1,1422 @@ +#!/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) { + return { status: 'unresolved', reason: `Source ${source.sourceId} has no bot token` } + } + + const signingSecret = resolveStoredSecret( + source.rawSigningSecret, + source, + lookup, + 'signingSecret' + ) + if (source.kind === 'trigger' && !signingSecret) { + return { + status: 'unresolved', + reason: `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 unresolved secrets', { + 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 5606f0f4319..dd4fe32e36b 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -19235,7 +19235,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", @@ -19416,9 +19416,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 }