diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts index 4cab173c1a2..5e9c47a8ecf 100644 --- a/apps/sim/lib/workflows/application/read-workflow-version.ts +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -5,6 +5,7 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/aut import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth' import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -68,7 +69,10 @@ export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ if (!isWorkflowState(state)) { throw new Error('Deployment version contains invalid workflow state') } - const presentedState = input.includeCredentialValues ? state : sanitizeVersionState(state) + const compatibleState = { ...state, blocks: projectLegacySlackV2Auth(state.blocks ?? {}) } + const presentedState = input.includeCredentialValues + ? compatibleState + : sanitizeVersionState(compatibleState) logger.info('Read workflow version', { workspaceId: context.workspaceId, workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index b2004215b6e..cc7c29cae9a 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -106,6 +106,7 @@ import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow- import { readWorkflow } from '@/lib/workflows/application/read-workflow' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' import { updateWorkflow } from '@/lib/workflows/application/update-workflow' +import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -466,4 +467,31 @@ describe('authorized workflow CRUD and version reads', () => { ).resolves.toMatchObject({ version: { id: 'version-1', version: 1 } }) expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.readVersion) }) + + it('presents historical Slack v2 auth canonically without mutating the stored version', async () => { + const historicalSlack = createHistoricalSlackV2Block('slack') + const state = { + blocks: { slack: historicalSlack }, + edges: [], + loops: {}, + parallels: {}, + } + mocks.readVersion.mockResolvedValue({ + id: 'version-legacy-slack', + version: 1, + state, + }) + + const result = await readWorkflowVersion.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, version: 1, includeCredentialValues: true }, + }) + + expect(result.version.state.blocks.slack.subBlocks.credential.value).toBe( + 'credential-custom-bot' + ) + expect(result.version.state.blocks.slack.subBlocks).not.toHaveProperty('authMethod') + expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token') + expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth') + }) }) diff --git a/apps/sim/lib/workflows/compatibility/slack-v2-auth.fixtures.ts b/apps/sim/lib/workflows/compatibility/slack-v2-auth.fixtures.ts new file mode 100644 index 00000000000..d69a8a785ea --- /dev/null +++ b/apps/sim/lib/workflows/compatibility/slack-v2-auth.fixtures.ts @@ -0,0 +1,41 @@ +import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow' + +function subBlock( + id: string, + type: SubBlockState['type'], + value: SubBlockState['value'] +): SubBlockState { + return { id, type, value } +} + +/** Relevant persisted fields produced by the slack_v2 schema introduced in f4d47ed. */ +export function createHistoricalSlackV2Block(id = 'slack-1'): BlockState { + return { + id, + type: 'slack_v2', + name: 'Slack', + position: { x: 0, y: 0 }, + enabled: true, + triggerMode: false, + subBlocks: { + operation: subBlock('operation', 'dropdown', 'send'), + authMethod: subBlock('authMethod', 'dropdown', 'bot_token'), + credential: subBlock('credential', 'oauth-input', 'dormant-oauth'), + manualCredential: subBlock('manualCredential', 'short-input', null), + customBotCredential: subBlock('customBotCredential', 'oauth-input', 'credential-custom-bot'), + manualCustomBotCredential: subBlock('manualCustomBotCredential', 'short-input', null), + destinationType: subBlock('destinationType', 'dropdown', 'channel'), + channel: subBlock('channel', 'channel-selector', 'C123456789'), + text: subBlock('text', 'long-input', 'Hello'), + messageFormat: subBlock('messageFormat', 'dropdown', 'text'), + }, + data: { + canonicalModes: { + oauthCredential: 'basic', + botCredential: 'basic', + channel: 'basic', + }, + }, + outputs: {}, + } +} diff --git a/apps/sim/lib/workflows/compatibility/slack-v2-auth.test.ts b/apps/sim/lib/workflows/compatibility/slack-v2-auth.test.ts new file mode 100644 index 00000000000..bf97872fcc6 --- /dev/null +++ b/apps/sim/lib/workflows/compatibility/slack-v2-auth.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ + +import { omit } from '@sim/utils/object' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { afterAll, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison/compare' +import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth' +import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' +import * as blocksBarrel from '@/blocks' +import { getBlock as getRealBlock } from '@/blocks/registry' +import { extractBlockParams } from '@/serializer' + +const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock) + +afterAll(() => { + getBlockSpy.mockRestore() +}) + +function workflowWith(block: BlockState): WorkflowState { + return { blocks: { [block.id]: block }, edges: [], loops: {}, parallels: {} } +} + +describe('projectLegacySlackV2Auth', () => { + it('makes the historical custom-bot action behave like its current equivalent', () => { + const historical = createHistoricalSlackV2Block() + const original = structuredClone(historical) + const equivalentCurrent = structuredClone(historical) + equivalentCurrent.subBlocks = omit(equivalentCurrent.subBlocks, [ + 'authMethod', + 'customBotCredential', + 'manualCustomBotCredential', + ]) + equivalentCurrent.subBlocks.credential.value = 'credential-custom-bot' + const blocks = projectLegacySlackV2Auth({ [historical.id]: historical }) + const projected = blocks[historical.id] + + expect(historical).toEqual(original) + expect(projected.subBlocks).not.toHaveProperty('authMethod') + expect(projected.subBlocks).not.toHaveProperty('customBotCredential') + expect(projected.subBlocks.credential.value).toBe('credential-custom-bot') + expect(projected.data?.canonicalModes).toMatchObject({ + oauthCredential: 'basic', + botCredential: 'basic', + }) + + const selectorContext = buildSelectorContextFromBlock(projected.type, projected.subBlocks, { + selectorKey: 'slack.channels', + dependsOn: ['credential'], + canonicalModes: projected.data?.canonicalModes, + }) + expect(selectorContext.oauthCredential).toBe('credential-custom-bot') + + const params = extractBlockParams(projected) + expect(params).toMatchObject({ + oauthCredential: 'credential-custom-bot', + channel: 'C123456789', + }) + expect(params).not.toHaveProperty('botCredential') + + expect( + generateWorkflowDiffSummary(workflowWith(equivalentCurrent), workflowWith(projected)) + .hasChanges + ).toBe(false) + }) + + it('honors the historical custom-bot and OAuth modes', () => { + const historical = createHistoricalSlackV2Block() + historical.data!.canonicalModes!.botCredential = 'advanced' + historical.subBlocks.manualCustomBotCredential.value = 'credential-manual-bot' + + const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id] + + expect(projected.subBlocks.credential.value).toBeNull() + expect(projected.subBlocks.manualCredential.value).toBe('credential-manual-bot') + expect(projected.data?.canonicalModes?.oauthCredential).toBe('advanced') + + const historicalOauth = createHistoricalSlackV2Block() + historicalOauth.subBlocks.authMethod.value = 'oauth' + const projectedOauth = projectLegacySlackV2Auth({ slack: historicalOauth }).slack + expect(projectedOauth.subBlocks.credential.value).toBe('dormant-oauth') + }) + + it('leaves current, trigger, and unidentifiable states untouched', () => { + const cases = [ + createHistoricalSlackV2Block(), + createHistoricalSlackV2Block(), + createHistoricalSlackV2Block(), + ] + cases[0].subBlocks = omit(cases[0].subBlocks, ['authMethod']) + cases[1].triggerMode = true + cases[2].subBlocks.authMethod.value = null + + for (const block of cases) { + const blocks = { [block.id]: block } + expect(projectLegacySlackV2Auth(blocks)).toBe(blocks) + } + }) + + it('does not substitute a dormant OAuth account for a missing historical bot credential', () => { + const historical = createHistoricalSlackV2Block() + historical.subBlocks.customBotCredential.value = null + + const projected = projectLegacySlackV2Auth({ [historical.id]: historical })[historical.id] + + expect(projected.subBlocks.credential.value).toBeNull() + expect( + buildSelectorContextFromBlock(projected.type, projected.subBlocks, { + selectorKey: 'slack.channels', + dependsOn: ['credential'], + canonicalModes: projected.data?.canonicalModes, + }).oauthCredential + ).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/compatibility/slack-v2-auth.ts b/apps/sim/lib/workflows/compatibility/slack-v2-auth.ts new file mode 100644 index 00000000000..ba092e31aa2 --- /dev/null +++ b/apps/sim/lib/workflows/compatibility/slack-v2-auth.ts @@ -0,0 +1,98 @@ +import { omit } from '@sim/utils/object' +import type { BlockState, SubBlockState } from '@sim/workflow-types/workflow' +import { isNonEmptyValue } from '@/lib/workflows/subblocks/visibility' + +type CanonicalMode = 'basic' | 'advanced' + +function resolveLegacyMode( + override: CanonicalMode | undefined, + basicValue: unknown, + advancedValue: unknown +): CanonicalMode { + if (override === 'basic' || override === 'advanced') return override + return !isNonEmptyValue(basicValue) && isNonEmptyValue(advancedValue) ? 'advanced' : 'basic' +} + +function withValue( + subBlock: SubBlockState | undefined, + id: string, + type: SubBlockState['type'], + value: SubBlockState['value'] +): SubBlockState { + return { ...(subBlock ?? { id, type }), value } +} + +/** + * Projects the preview-era slack_v2 action auth shape into the merged credential picker added in + * 5be35b5. The returned view is safe for current readers but is never marked for persistence, so + * frozen deployment snapshots and normalized workflow rows remain unchanged. + */ +export function projectLegacySlackV2Auth( + blocks: Record +): Record { + let projectedBlocks: Record | undefined + + for (const [blockId, block] of Object.entries(blocks)) { + if (block.type !== 'slack_v2' || block.triggerMode) continue + + const authMethod = block.subBlocks.authMethod?.value + if (authMethod !== 'oauth' && authMethod !== 'bot_token') continue + + const canonicalModes = block.data?.canonicalModes ?? {} + const oauthMode = resolveLegacyMode( + canonicalModes.oauthCredential, + block.subBlocks.credential?.value, + block.subBlocks.manualCredential?.value + ) + const botMode = resolveLegacyMode( + canonicalModes.botCredential, + block.subBlocks.customBotCredential?.value, + block.subBlocks.manualCustomBotCredential?.value + ) + const activeMode = authMethod === 'bot_token' ? botMode : oauthMode + const activeValue = + authMethod === 'bot_token' + ? activeMode === 'advanced' + ? block.subBlocks.manualCustomBotCredential?.value + : block.subBlocks.customBotCredential?.value + : activeMode === 'advanced' + ? block.subBlocks.manualCredential?.value + : block.subBlocks.credential?.value + const credentialValue = isNonEmptyValue(activeValue) ? (activeValue ?? null) : null + const currentSubBlocks = omit(block.subBlocks, [ + 'authMethod', + 'customBotCredential', + 'manualCustomBotCredential', + ]) + + projectedBlocks ??= { ...blocks } + projectedBlocks[blockId] = { + ...block, + subBlocks: { + ...currentSubBlocks, + credential: withValue( + block.subBlocks.credential, + 'credential', + 'oauth-input', + activeMode === 'basic' ? credentialValue : null + ), + manualCredential: withValue( + block.subBlocks.manualCredential, + 'manualCredential', + 'short-input', + activeMode === 'advanced' ? credentialValue : null + ), + }, + data: { + ...block.data, + canonicalModes: { + ...canonicalModes, + oauthCredential: activeMode, + botCredential: 'basic', + }, + }, + } + } + + return projectedBlocks ?? blocks +} diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index b4b50ab88a0..e2866667b34 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -23,6 +23,7 @@ import { schemaMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHistoricalSlackV2Block } from '@/lib/workflows/compatibility/slack-v2-auth.fixtures' import type { BlockState as AppBlockState, WorkflowState as AppWorkflowState, @@ -347,6 +348,26 @@ describe('Database Helpers', () => { }) }) + describe('materializeDeploymentState', () => { + it('projects historical Slack v2 auth without changing the frozen snapshot', async () => { + const historicalSlack = createHistoricalSlackV2Block('slack') + const frozenState = createWorkflowState({ + blocks: { slack: historicalSlack }, + }) + + const materialized = await dbHelpers.materializeDeploymentState( + mockWorkflowId, + { id: 'legacy-slack-version', state: frozenState }, + 'test-workspace-id' + ) + + expect(materialized.blocks.slack.subBlocks.credential.value).toBe('credential-custom-bot') + expect(materialized.blocks.slack.subBlocks).not.toHaveProperty('authMethod') + expect(historicalSlack.subBlocks.authMethod.value).toBe('bot_token') + expect(historicalSlack.subBlocks.credential.value).toBe('dormant-oauth') + }) + }) + describe('loadWorkflowFromNormalizedTables', () => { it('should successfully load workflow data from normalized tables', async () => { queueLoadFixtures({ diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 4e21627e27a..d570d939e3f 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -26,6 +26,7 @@ import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm' import { LRUCache } from 'lru-cache' import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' +import { projectLegacySlackV2Auth } from '@/lib/workflows/compatibility/slack-v2-auth' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' import { @@ -206,6 +207,7 @@ export async function materializeDeploymentState( workspaceId, executor ) + const compatibleBlocks = projectLegacySlackV2Auth(migratedBlocks) /* * Read straight out of the version's jsonb blob, so unlike every path that * goes through `loadWorkflowFromNormalizedTables` these handles were never @@ -228,7 +230,7 @@ export async function materializeDeploymentState( */ const errorSourceBlockIds = collectErrorSourceBlockIds(edges) const blocks: DeployedWorkflowData['blocks'] = {} - for (const [blockId, block] of Object.entries(migratedBlocks)) { + for (const [blockId, block] of Object.entries(compatibleBlocks)) { blocks[blockId] = block.errorEnabled || !errorSourceBlockIds.has(blockId) ? block @@ -556,11 +558,12 @@ export async function loadWorkflowFromNormalizedTables( const raw = await loadWorkflowFromNormalizedTablesRaw(workflowId, externalTx) if (!raw) return null - const { blocks: finalBlocks, migrated } = await applyBlockMigrations( + const { blocks: migratedBlocks, migrated } = await applyBlockMigrations( raw.blocks, raw.workspaceId, externalTx ?? db ) + const finalBlocks = projectLegacySlackV2Auth(migratedBlocks) if (migrated) { // Deliberate fire-and-forget persistence on the global pool: it must not @@ -568,7 +571,7 @@ export async function loadWorkflowFromNormalizedTables( // it escapes the transaction context instead of tripping the wire. runOutsideTransactionContext(() => { Promise.resolve().then(() => - persistMigratedBlocks(workflowId, raw.blocks, finalBlocks, raw.blockUpdatedAtById) + persistMigratedBlocks(workflowId, raw.blocks, migratedBlocks, raw.blockUpdatedAtById) ) }) }