From 604389f9cdcdfb5552f3642a1e0f0262c3c2f4e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 20:25:02 -0700 Subject: [PATCH 1/3] fix(credentials): clear stored refs on credential delete to prevent silent cascade orphaning --- .../app/api/auth/oauth/disconnect/route.ts | 53 ++-- apps/sim/app/api/credentials/[id]/route.ts | 27 +- .../handlers/management/manage-credential.ts | 7 +- .../credentials/__tests__/deletion.test.ts | 164 ++++++++++ apps/sim/lib/credentials/deletion.ts | 280 ++++++++++++++++++ apps/sim/lib/credentials/draft-hooks.ts | 26 +- apps/sim/lib/credentials/draft-processor.ts | 1 + apps/sim/lib/workflows/persistence/utils.ts | 6 +- packages/audit/src/types.ts | 1 + 9 files changed, 524 insertions(+), 41 deletions(-) create mode 100644 apps/sim/lib/credentials/__tests__/deletion.test.ts create mode 100644 apps/sim/lib/credentials/deletion.ts diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 9bef61cf08e..a8fa4cfa2b6 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,14 +1,15 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credentialSet, credentialSetMember } from '@sim/db/schema' +import { account, credential, credentialSet, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, like, or } from 'drizzle-orm' +import { and, eq, inArray, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteCredential } from '@/lib/credentials/deletion' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -52,27 +53,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => { hasProviderId: !!providerId, }) - // If a specific account row ID is provided, delete that exact account - if (accountId) { - await db - .delete(account) - .where(and(eq(account.userId, session.user.id), eq(account.id, accountId))) - } else if (providerId) { - // If a specific providerId is provided, delete accounts for that provider ID - await db - .delete(account) - .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) - } else { - // Otherwise, delete all accounts for this provider - // Handle both exact matches (e.g., 'confluence') and prefixed matches (e.g., 'google-email') - await db - .delete(account) - .where( - and( + // Delete credentials before their accounts so deleteCredential can clear + // stored references first. Otherwise FK CASCADE would orphan them silently. + const accountFilter = accountId + ? and(eq(account.userId, session.user.id), eq(account.id, accountId)) + : providerId + ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) + : and( eq(account.userId, session.user.id), or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`)) ) - ) + + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + + const targetAccountIds = targetAccounts.map((a) => a.id) + + if (targetAccountIds.length > 0) { + const credentialsToDelete = await db + .select({ id: credential.id }) + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + + for (const cred of credentialsToDelete) { + await deleteCredential({ + credentialId: cred.id, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + reason: 'oauth_disconnect', + request, + }) + } + + await db.delete(account).where(inArray(account.id, targetAccountIds)) } // Sync webhooks for all credential sets the user is a member of diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 01502fa904c..0d3939f8d25 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -11,6 +11,7 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' +import { deleteCredential } from '@/lib/credentials/deletion' import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, @@ -333,7 +334,14 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }, { status: 200 }) } - await db.delete(credential).where(eq(credential.id, id)) + await deleteCredential({ + credentialId: id, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + reason: 'user_delete', + request, + }) captureServerEvent( session.user.id, @@ -346,23 +354,6 @@ export const DELETE = withRouteHandler( { groups: { workspace: access.credential.workspaceId } } ) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - providerId: access.credential.providerId, - }, - request, - }) - return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error('Failed to delete credential', error) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index 5f5f4574747..4f77ce3a854 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getCredentialActorContext } from '@/lib/credentials/access' +import { deleteCredential } from '@/lib/credentials/deletion' export function executeManageCredential( rawParams: Record, @@ -71,7 +72,11 @@ export function executeManageCredential( failed.push(id) continue } - await db.delete(credential).where(eq(credential.id, id)) + await deleteCredential({ + credentialId: id, + actorId: context.userId, + reason: 'copilot_delete', + }) deleted.push(id) } diff --git a/apps/sim/lib/credentials/__tests__/deletion.test.ts b/apps/sim/lib/credentials/__tests__/deletion.test.ts new file mode 100644 index 00000000000..b61f4bd8fed --- /dev/null +++ b/apps/sim/lib/credentials/__tests__/deletion.test.ts @@ -0,0 +1,164 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { clearCredentialInValue } from '@/lib/credentials/deletion' + +const TARGET = 'cred_target123' +const OTHER = 'cred_other999' + +describe('clearCredentialInValue', () => { + it('clears matching subBlock value with id="credential"', () => { + const input = { id: 'credential', type: 'oauth-input', value: TARGET } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + expect(result.value).toEqual({ id: 'credential', type: 'oauth-input', value: '' }) + }) + + it('clears matching subBlock value with id="manualCredential"', () => { + const input = { id: 'manualCredential', type: 'short-input', value: TARGET } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + expect(result.value).toEqual({ id: 'manualCredential', type: 'short-input', value: '' }) + }) + + it('clears matching subBlock value with id="triggerCredentials"', () => { + const input = { id: 'triggerCredentials', value: TARGET } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + expect((result.value as { value: string }).value).toBe('') + }) + + it('leaves unrelated subBlock value untouched', () => { + const input = { id: 'someOtherField', value: TARGET } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(false) + expect(result.value).toBe(input) + }) + + it('leaves matching subBlock with non-matching value untouched', () => { + const input = { id: 'credential', value: OTHER } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(false) + expect(result.value).toBe(input) + }) + + it('clears nested tools[].params.credential', () => { + const input = { + id: 'tools', + value: [ + { type: 'gmail_send', params: { credential: TARGET, to: 'a@b.com' } }, + { type: 'slack_message', params: { credential: OTHER, channel: '#x' } }, + ], + } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + const value = result.value as { value: Array<{ params: { credential: string } }> } + expect(value.value[0].params.credential).toBe('') + expect(value.value[1].params.credential).toBe(OTHER) + }) + + it('walks workflow_blocks-style keyed subBlocks structure', () => { + const input = { + credential: { id: 'credential', value: TARGET }, + messages: { id: 'messages', value: 'hello' }, + tools: { + id: 'tools', + value: [{ type: 'x', params: { credential: TARGET } }], + }, + } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + const value = result.value as typeof input + expect(value.credential.value).toBe('') + expect(value.messages.value).toBe('hello') + expect(value.tools.value[0].params.credential).toBe('') + }) + + it('walks deployment-style nested blocks structure', () => { + const input = { + blocks: { + block1: { + subBlocks: { + credential: { id: 'credential', value: TARGET }, + }, + }, + block2: { + subBlocks: { + other: { id: 'other', value: 'unrelated' }, + }, + }, + }, + } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + const value = result.value as typeof input + expect(value.blocks.block1.subBlocks.credential.value).toBe('') + expect(value.blocks.block2.subBlocks.other.value).toBe('unrelated') + }) + + it('returns same reference when no changes are made', () => { + const input = { + blocks: { + block1: { + subBlocks: { credential: { id: 'credential', value: OTHER } }, + }, + }, + } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(false) + expect(result.value).toBe(input) + }) + + it('does not match outer "credential" key whose value is an object wrapper', () => { + const input = { credential: { id: 'credential', value: TARGET } } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + const value = result.value as { credential: { id: string; value: string } } + expect(value.credential).toEqual({ id: 'credential', value: '' }) + }) + + it('clears params.credential string directly even when not nested in tools', () => { + const input = { params: { credential: TARGET, channel: '#x' } } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + expect((result.value as typeof input).params).toEqual({ credential: '', channel: '#x' }) + }) + + it('does not match "credential" key when value is a different string', () => { + const input = { credential: 'cred_unrelated' } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(false) + expect(result.value).toBe(input) + }) + + it('handles primitives and null', () => { + expect(clearCredentialInValue(null, TARGET)).toEqual({ value: null, changed: false }) + expect(clearCredentialInValue('string', TARGET)).toEqual({ value: 'string', changed: false }) + expect(clearCredentialInValue(42, TARGET)).toEqual({ value: 42, changed: false }) + }) + + it('clears multiple references in a single pass', () => { + const input = { + blocks: { + a: { subBlocks: { credential: { id: 'credential', value: TARGET } } }, + b: { subBlocks: { triggerCredentials: { id: 'triggerCredentials', value: TARGET } } }, + c: { + subBlocks: { + tools: { + id: 'tools', + value: [{ params: { credential: TARGET } }, { params: { credential: TARGET } }], + }, + }, + }, + }, + } + const result = clearCredentialInValue(input, TARGET) + expect(result.changed).toBe(true) + const value = result.value as typeof input + expect(value.blocks.a.subBlocks.credential.value).toBe('') + expect(value.blocks.b.subBlocks.triggerCredentials.value).toBe('') + expect(value.blocks.c.subBlocks.tools.value[0].params.credential).toBe('') + expect(value.blocks.c.subBlocks.tools.value[1].params.credential).toBe('') + }) +}) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts new file mode 100644 index 00000000000..26a125b00e8 --- /dev/null +++ b/apps/sim/lib/credentials/deletion.ts @@ -0,0 +1,280 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import * as schema from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('CredentialDeletion') + +export type CredentialDeleteReason = + | 'oauth_disconnect' + | 'user_delete' + | 'copilot_delete' + | 'env_prune' + +interface DeleteCredentialParams { + credentialId: string + actorId: string + actorName?: string | null + actorEmail?: string | null + reason: CredentialDeleteReason + request?: NextRequest +} + +/** + * Clears all stored references to the credential, deletes the row, and + * records an audit entry. Idempotent when the row no longer exists. + */ +export async function deleteCredential(params: DeleteCredentialParams): Promise { + const { credentialId, actorId, actorName, actorEmail, reason, request } = params + + const [row] = await db + .select({ + id: schema.credential.id, + workspaceId: schema.credential.workspaceId, + type: schema.credential.type, + displayName: schema.credential.displayName, + providerId: schema.credential.providerId, + accountId: schema.credential.accountId, + }) + .from(schema.credential) + .where(eq(schema.credential.id, credentialId)) + .limit(1) + + if (!row) return + + await clearCredentialRefs(credentialId, row.workspaceId) + + await db.delete(schema.credential).where(eq(schema.credential.id, credentialId)) + + recordAudit({ + workspaceId: row.workspaceId, + actorId, + actorName: actorName ?? undefined, + actorEmail: actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: row.displayName, + description: `Deleted ${row.type} credential "${row.displayName}" (${reason})`, + metadata: { + reason, + credentialType: row.type, + providerId: row.providerId, + accountId: row.accountId, + }, + request, + }) + + logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) +} + +/** + * Clears stored references to a credential across mutable workspace state + * (editor blocks, copilot checkpoints, knowledge connectors) and frozen + * snapshots (deployed versions, paused executions). Frozen snapshots have + * the reference replaced with an empty string so resumed/redeployed runs + * fail fast at the affected block instead of with "credential not found". + */ +export async function clearCredentialRefs( + credentialId: string, + workspaceId: string +): Promise { + const needle = `%${credentialId}%` + + await clearInWorkflowBlocks(credentialId, workspaceId, needle) + await clearInDeploymentVersions(credentialId, workspaceId, needle) + await clearInPausedExecutions(credentialId, workspaceId, needle) + await clearInWorkflowCheckpoints(credentialId, workspaceId, needle) + await clearInKnowledgeConnectors(credentialId) +} + +async function clearInWorkflowBlocks( + credentialId: string, + workspaceId: string, + needle: string +): Promise { + const rows = await db + .select({ + id: schema.workflowBlocks.id, + subBlocks: schema.workflowBlocks.subBlocks, + }) + .from(schema.workflowBlocks) + .innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowBlocks.workflowId)) + .where( + and( + eq(schema.workflow.workspaceId, workspaceId), + sql`${schema.workflowBlocks.subBlocks}::text LIKE ${needle}` + ) + ) + + let updated = 0 + for (const row of rows) { + const next = clearCredentialInValue(row.subBlocks, credentialId) + if (next.changed) { + await db + .update(schema.workflowBlocks) + .set({ subBlocks: next.value, updatedAt: new Date() }) + .where(eq(schema.workflowBlocks.id, row.id)) + updated += 1 + } + } + if (updated > 0) { + logger.info('Cleared credential refs in workflow_blocks', { + credentialId, + workspaceId, + updated, + }) + } +} + +async function clearInDeploymentVersions( + credentialId: string, + workspaceId: string, + needle: string +): Promise { + const rows = await db + .select({ + id: schema.workflowDeploymentVersion.id, + state: schema.workflowDeploymentVersion.state, + }) + .from(schema.workflowDeploymentVersion) + .innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowDeploymentVersion.workflowId)) + .where( + and( + eq(schema.workflow.workspaceId, workspaceId), + sql`${schema.workflowDeploymentVersion.state}::text LIKE ${needle}` + ) + ) + + for (const row of rows) { + const next = clearCredentialInValue(row.state, credentialId) + if (next.changed) { + await db + .update(schema.workflowDeploymentVersion) + .set({ state: next.value }) + .where(eq(schema.workflowDeploymentVersion.id, row.id)) + } + } +} + +async function clearInPausedExecutions( + credentialId: string, + workspaceId: string, + needle: string +): Promise { + const rows = await db + .select({ + id: schema.pausedExecutions.id, + executionSnapshot: schema.pausedExecutions.executionSnapshot, + }) + .from(schema.pausedExecutions) + .innerJoin(schema.workflow, eq(schema.workflow.id, schema.pausedExecutions.workflowId)) + .where( + and( + eq(schema.workflow.workspaceId, workspaceId), + sql`${schema.pausedExecutions.executionSnapshot}::text LIKE ${needle}` + ) + ) + + for (const row of rows) { + const next = clearCredentialInValue(row.executionSnapshot, credentialId) + if (next.changed) { + await db + .update(schema.pausedExecutions) + .set({ executionSnapshot: next.value, updatedAt: new Date() }) + .where(eq(schema.pausedExecutions.id, row.id)) + } + } +} + +async function clearInWorkflowCheckpoints( + credentialId: string, + workspaceId: string, + needle: string +): Promise { + const rows = await db + .select({ + id: schema.workflowCheckpoints.id, + workflowState: schema.workflowCheckpoints.workflowState, + }) + .from(schema.workflowCheckpoints) + .innerJoin(schema.workflow, eq(schema.workflow.id, schema.workflowCheckpoints.workflowId)) + .where( + and( + eq(schema.workflow.workspaceId, workspaceId), + sql`${schema.workflowCheckpoints.workflowState}::text LIKE ${needle}` + ) + ) + + for (const row of rows) { + const next = clearCredentialInValue(row.workflowState, credentialId) + if (next.changed) { + await db + .update(schema.workflowCheckpoints) + .set({ workflowState: next.value, updatedAt: new Date() }) + .where(eq(schema.workflowCheckpoints.id, row.id)) + } + } +} + +async function clearInKnowledgeConnectors(credentialId: string): Promise { + await db + .update(schema.knowledgeConnector) + .set({ credentialId: null, updatedAt: new Date() }) + .where(eq(schema.knowledgeConnector.credentialId, credentialId)) +} + +interface ClearResult { + value: unknown + changed: boolean +} + +/** + * Recursively walks a JSON value and clears credential references matching + * `credentialId`. Recognizes the two reference shapes used in workflow state: + * subBlock entries (`{id: 'credential'|'manualCredential'|'triggerCredentials', value}`) + * and tool params (`{credential: , ...}` inside a tool's `params` object). + * Returns the original reference when nothing matched so callers can skip writes. + */ +export function clearCredentialInValue(input: unknown, credentialId: string): ClearResult { + if (Array.isArray(input)) { + let changed = false + const next = input.map((item) => { + const result = clearCredentialInValue(item, credentialId) + if (result.changed) changed = true + return result.value + }) + return changed ? { value: next, changed: true } : { value: input, changed: false } + } + + if (input !== null && typeof input === 'object') { + const obj = input as Record + const id = typeof obj.id === 'string' ? obj.id : null + const isCredentialSubBlock = id !== null && CREDENTIAL_SUBBLOCK_IDS.has(id) + let changed = false + const next: Record = {} + + for (const [key, value] of Object.entries(obj)) { + if (isCredentialSubBlock && key === 'value' && value === credentialId) { + next[key] = '' + changed = true + continue + } + if (key === 'credential' && value === credentialId) { + next[key] = '' + changed = true + continue + } + const result = clearCredentialInValue(value, credentialId) + next[key] = result.value + if (result.changed) changed = true + } + + return changed ? { value: next, changed: true } : { value: input, changed: false } + } + + return { value: input, changed: false } +} diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index cacff50418e..c5768c96aee 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -51,6 +52,17 @@ export async function handleCreateCredentialFromDraft(params: { providerId, accountId, }) + + recordAudit({ + workspaceId: draft.workspaceId, + actorId: userId, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: draft.displayName, + description: `Created OAuth credential "${draft.displayName}"`, + metadata: { providerId, accountId }, + }) } catch (insertError: unknown) { const code = insertError && typeof insertError === 'object' && 'code' in insertError @@ -74,9 +86,10 @@ export async function handleReconnectCredential(params: { draft: { credentialId: string | null; workspaceId: string; displayName: string } newAccountId: string workspaceId: string + userId: string now: Date }) { - const { draft, newAccountId, workspaceId, now } = params + const { draft, newAccountId, workspaceId, userId, now } = params if (!draft.credentialId) return const [existingCredential] = await db @@ -134,6 +147,17 @@ export async function handleReconnectCredential(params: { newAccountId, }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CREDENTIAL_RECONNECTED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: draft.credentialId, + resourceName: draft.displayName, + description: `Reconnected OAuth credential "${draft.displayName}" to a new account`, + metadata: { oldAccountId, newAccountId }, + }) + if (oldAccountId) { const [stillReferenced] = await db .select({ id: schema.credential.id }) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index 76cedc66179..b7b9f5cd931 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -44,6 +44,7 @@ export async function processCredentialDraft(params: ProcessCredentialDraftParam draft, newAccountId: accountId, workspaceId: draft.workspaceId, + userId, now, }) } else { diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 80af8e9dad4..cbe1258dd7d 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -235,7 +235,11 @@ export function migrateAgentBlocksToMessagesFormat( ) } -const CREDENTIAL_SUBBLOCK_IDS = new Set(['credential', 'triggerCredentials']) +export const CREDENTIAL_SUBBLOCK_IDS = new Set([ + 'credential', + 'manualCredential', + 'triggerCredentials', +]) async function migrateCredentialIds( blocks: Record, diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 2f9c1f53d96..25679b71596 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -107,6 +107,7 @@ export const AuditAction = { CREDENTIAL_CREATED: 'credential.created', CREDENTIAL_UPDATED: 'credential.updated', CREDENTIAL_RENAMED: 'credential.renamed', + CREDENTIAL_RECONNECTED: 'credential.reconnected', CREDENTIAL_DELETED: 'credential.deleted', // Password From 249286d32ae4b1f922c614d6097eef535a83bdb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 20:28:04 -0700 Subject: [PATCH 2/3] fix(testing): sync auditMock with new CREDENTIAL_RECONNECTED action --- packages/testing/src/mocks/audit.mock.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 58671d6ba46..04bd12d908b 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -40,8 +40,9 @@ export const auditMock = { CHAT_DELETED: 'chat.deleted', CREDENTIAL_CREATED: 'credential.created', CREDENTIAL_UPDATED: 'credential.updated', - CREDENTIAL_DELETED: 'credential.deleted', CREDENTIAL_RENAMED: 'credential.renamed', + CREDENTIAL_RECONNECTED: 'credential.reconnected', + CREDENTIAL_DELETED: 'credential.deleted', CREDIT_PURCHASED: 'credit.purchased', CREDENTIAL_SET_CREATED: 'credential_set.created', CREDENTIAL_SET_UPDATED: 'credential_set.updated', From cb1efebd3427786711e2aad51587548f1c7429ba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 20:31:36 -0700 Subject: [PATCH 3/3] improvement(credentials): parallelize independent ref-clearing scans --- apps/sim/lib/credentials/deletion.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 26a125b00e8..c8912a53765 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -84,11 +84,13 @@ export async function clearCredentialRefs( ): Promise { const needle = `%${credentialId}%` - await clearInWorkflowBlocks(credentialId, workspaceId, needle) - await clearInDeploymentVersions(credentialId, workspaceId, needle) - await clearInPausedExecutions(credentialId, workspaceId, needle) - await clearInWorkflowCheckpoints(credentialId, workspaceId, needle) - await clearInKnowledgeConnectors(credentialId) + await Promise.all([ + clearInWorkflowBlocks(credentialId, workspaceId, needle), + clearInDeploymentVersions(credentialId, workspaceId, needle), + clearInPausedExecutions(credentialId, workspaceId, needle), + clearInWorkflowCheckpoints(credentialId, workspaceId, needle), + clearInKnowledgeConnectors(credentialId), + ]) } async function clearInWorkflowBlocks(