Files
simstudioai--sim/apps/sim/lib/webhooks/deploy.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1130 lines
37 KiB
TypeScript

import { db } from '@sim/db'
import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
hasWebhookConfigChanged,
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack'
import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server'
import {
buildCanonicalIndex,
buildSubBlockValues,
isCanonicalPair,
resolveActiveCanonicalValue,
} from '@/lib/workflows/subblocks/visibility'
import {
getSlackBotCredential,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared'
const logger = createLogger('DeployWebhookSync')
interface TriggerSaveError {
message: string
status: number
}
interface TriggerSaveResult {
success: boolean
error?: TriggerSaveError
}
interface BuiltProviderConfig {
providerConfig: Record<string, unknown>
missingFields: string[]
credentialReference?: string
credentialServiceId?: string
triggerPath: string
}
export async function validateTriggerWebhookConfigForDeploy(
blocks: Record<string, BlockState>
): Promise<TriggerSaveResult> {
const triggerBlocks = Object.values(blocks || {}).filter((b) => b && b.enabled !== false)
for (const block of triggerBlocks) {
const triggerId = resolveTriggerId(block)
if (!triggerId || !isTriggerValid(triggerId)) continue
const triggerDef = getTrigger(triggerId)
const { providerConfig, missingFields } = buildProviderConfig(block, triggerId, triggerDef)
if (missingFields.length > 0) {
return {
success: false,
error: {
message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`,
status: 400,
},
}
}
if (providerConfig.requireAuth && !providerConfig.token) {
return {
success: false,
error: {
message:
'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.',
status: 400,
},
}
}
}
return { success: true }
}
interface SaveTriggerWebhooksInput {
request: NextRequest
workflowId: string
workflow: Record<string, unknown>
userId: string
blocks: Record<string, BlockState>
requestId: string
deploymentVersionId?: string
/**
* When true, forces recreation of external subscriptions even if webhook config is unchanged.
* Used when activating a previous deployment version whose subscriptions were cleaned up.
*/
forceRecreateSubscriptions?: boolean
strictExternalCleanup?: boolean
}
function getSubBlockValue(block: BlockState, subBlockId: string): unknown {
return block.subBlocks?.[subBlockId]?.value
}
function isFieldRequired(
config: SubBlockConfig,
subBlockValues: Record<string, { value?: unknown }>
): boolean {
if (!config.required) return false
if (typeof config.required === 'boolean') return config.required
const evalCond = (
cond: {
field: string
value: string | number | boolean | Array<string | number | boolean>
not?: boolean
and?: {
field: string
value: string | number | boolean | Array<string | number | boolean> | undefined
not?: boolean
}
},
values: Record<string, { value?: unknown }>
): boolean => {
const fieldValue = values[cond.field]?.value
const condValue = cond.value
let match = Array.isArray(condValue)
? condValue.includes(fieldValue as string | number | boolean)
: fieldValue === condValue
if (cond.not) match = !match
if (cond.and) {
const andFieldValue = values[cond.and.field]?.value
const andCondValue = cond.and.value
let andMatch = Array.isArray(andCondValue)
? (andCondValue || []).includes(andFieldValue as string | number | boolean)
: andFieldValue === andCondValue
if (cond.and.not) andMatch = !andMatch
match = match && andMatch
}
return match
}
const condition = typeof config.required === 'function' ? config.required() : config.required
return evalCond(condition, subBlockValues)
}
function resolveTriggerId(block: BlockState): string | undefined {
const blockConfig = getBlock(block.type)
if (blockConfig?.category === 'triggers' && isTriggerValid(block.type)) {
return block.type
}
if (!block.triggerMode) {
return undefined
}
const selectedTriggerId = getSubBlockValue(block, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
return selectedTriggerId
}
const storedTriggerId = getSubBlockValue(block, 'triggerId')
if (typeof storedTriggerId === 'string' && isTriggerValid(storedTriggerId)) {
return storedTriggerId
}
if (blockConfig?.triggers?.enabled) {
const configuredTriggerId =
typeof selectedTriggerId === 'string' ? selectedTriggerId : undefined
if (configuredTriggerId && isTriggerValid(configuredTriggerId)) {
return configuredTriggerId
}
const available = blockConfig.triggers?.available?.[0]
if (available && isTriggerValid(available)) {
return available
}
}
return undefined
}
function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown {
const fieldValue = getSubBlockValue(block, subBlock.id)
if (
(fieldValue === null || fieldValue === undefined || fieldValue === '') &&
subBlock.defaultValue !== undefined
) {
return subBlock.defaultValue
}
return fieldValue
}
/**
* Build the persisted `webhook.providerConfig` for a trigger block at deploy time.
*
* Exported for unit testing the canonical-collapse pass; not part of the public
* deploy API.
*/
export function buildProviderConfig(
block: BlockState,
triggerId: string,
triggerDef: { subBlocks: SubBlockConfig[] }
): BuiltProviderConfig {
const triggerConfigValue = getSubBlockValue(block, 'triggerConfig')
const baseConfig =
triggerConfigValue && typeof triggerConfigValue === 'object'
? (triggerConfigValue as Record<string, unknown>)
: {}
const providerConfig: Record<string, unknown> = { ...baseConfig }
const missingFields: string[] = []
const subBlockValues = Object.fromEntries(
Object.entries(block.subBlocks || {}).map(([key, value]) => [key, { value: value.value }])
)
const canonicalIndex = buildCanonicalIndex(triggerDef.subBlocks)
const satisfiedCanonicalIds = new Set<string>()
const filledSubBlockIds = new Set<string>()
const relevantSubBlocks = triggerDef.subBlocks.filter(
(subBlock) =>
(subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') &&
!SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)
)
// First pass: populate providerConfig, clear stale baseConfig entries, and track which
// subblocks and canonical groups have a value.
for (const subBlock of relevantSubBlocks) {
const valueToUse = getConfigValue(block, subBlock)
if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') {
providerConfig[subBlock.id] = valueToUse
filledSubBlockIds.add(subBlock.id)
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
if (canonicalId) satisfiedCanonicalIds.add(canonicalId)
} else {
delete providerConfig[subBlock.id]
}
}
// Second pass: validate required fields. Skip subblocks that are filled or whose canonical
// group is satisfied by another member.
for (const subBlock of relevantSubBlocks) {
if (filledSubBlockIds.has(subBlock.id)) continue
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
if (canonicalId && satisfiedCanonicalIds.has(canonicalId)) continue
if (isFieldRequired(subBlock, subBlockValues)) {
missingFields.push(subBlock.title || subBlock.id)
}
}
// Collapse each canonical pair (basic + advanced swap) to its ACTIVE value under the
// canonical key, so pollers read one authoritative key instead of guessing basic-first.
// resolveActiveCanonicalValue is the shared SOT: an explicit block.data.canonicalModes
// override, else the value heuristic. The raw subblock keys written in the first pass are
// kept for transitional readers (removable in a follow-up contract phase). This only runs on
// a (re)deploy, so any drift collapse is scoped to the new deployment version — already
// deployed rows are migrated separately and keep their current resource.
const canonicalModes = block.data?.canonicalModes
const flatSubBlockValues = buildSubBlockValues(block.subBlocks || {})
for (const group of Object.values(canonicalIndex.groupsById)) {
if (!isCanonicalPair(group)) continue
const activeValue = resolveActiveCanonicalValue(group, flatSubBlockValues, canonicalModes)
if (activeValue !== null && activeValue !== undefined && activeValue !== '') {
providerConfig[group.canonicalId] = activeValue
} else {
delete providerConfig[group.canonicalId]
}
}
const credentialConfig = triggerDef.subBlocks.find(
(subBlock) => subBlock.id === 'triggerCredentials'
)
const triggerCredentials = getSubBlockValue(block, 'triggerCredentials')
if (
credentialConfig &&
isFieldRequired(credentialConfig, subBlockValues) &&
!triggerCredentials
) {
missingFields.push(credentialConfig.title || 'Credentials')
}
const credentialReference =
typeof triggerCredentials === 'string' && triggerCredentials.length > 0
? triggerCredentials
: undefined
providerConfig.triggerId = triggerId
const triggerPathValue = getSubBlockValue(block, 'triggerPath')
const triggerPath =
typeof triggerPathValue === 'string' && triggerPathValue.length > 0
? triggerPathValue
: block.id
return {
providerConfig,
missingFields,
credentialReference,
credentialServiceId: credentialConfig?.serviceId,
triggerPath,
}
}
/**
* Resolves a trigger credential reference to its canonical platform credential ID while enforcing
* that the credential belongs to the deployed workflow's workspace and OAuth service.
*/
async function resolveTriggerCredentialId(
credentialReference: string,
workspaceId: string,
serviceId: string
): Promise<string | null> {
const [resolvedCredential] = await db
.select({ id: credential.id })
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.providerId, serviceId),
or(eq(credential.id, credentialReference), eq(credential.accountId, credentialReference))
)
)
.limit(1)
return resolvedCredential?.id ?? null
}
async function configurePollingIfNeeded(
provider: string,
savedWebhook: Record<string, unknown>,
requestId: string
): Promise<TriggerSaveError | null> {
const handler = getProviderHandler(provider)
if (!handler.configurePolling) {
return null
}
const success = await handler.configurePolling({ webhook: savedWebhook, requestId })
if (!success) {
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id as string))
return {
message: `Failed to configure ${provider} polling. Please check your account permissions.`,
status: 500,
}
}
return null
}
/**
* Saves trigger webhook configurations as part of workflow deployment.
* Uses delete + create approach for changed/deleted webhooks.
*/
export async function saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow,
userId,
blocks,
requestId,
deploymentVersionId,
forceRecreateSubscriptions = false,
strictExternalCleanup = false,
}: SaveTriggerWebhooksInput): Promise<TriggerSaveResult> {
const validationResult = await validateTriggerWebhookConfigForDeploy(blocks)
if (!validationResult.success) return validationResult
const triggerBlocks = Object.values(blocks || {}).filter((b) => b && b.enabled !== false)
const currentBlockIds = new Set(triggerBlocks.map((b) => b.id))
// 1. Get ALL webhooks for this workflow (all versions including draft)
const allWorkflowWebhooks = await db
.select()
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt)))
// Separate webhooks by version: current deployment vs others
const existingWebhooks: typeof allWorkflowWebhooks = []
for (const wh of allWorkflowWebhooks) {
if (deploymentVersionId && wh.deploymentVersionId === deploymentVersionId) {
existingWebhooks.push(wh)
}
}
const webhooksByBlockId = new Map<string, typeof existingWebhooks>()
for (const wh of existingWebhooks) {
if (!wh.blockId) continue
const existingForBlock = webhooksByBlockId.get(wh.blockId) ?? []
existingForBlock.push(wh)
webhooksByBlockId.set(wh.blockId, existingForBlock)
}
logger.info(`[${requestId}] Starting webhook sync`, {
workflowId,
currentBlockIds: Array.from(currentBlockIds),
existingWebhookBlockIds: Array.from(webhooksByBlockId.keys()),
})
type WebhookConfig = {
provider: string
providerConfig: Record<string, unknown>
triggerPath: string | null
routingKey: string | null
triggerDef: ReturnType<typeof getTrigger>
}
const webhookConfigs = new Map<string, WebhookConfig>()
const webhooksToDelete: typeof existingWebhooks = []
const blocksNeedingWebhook: BlockState[] = []
for (const block of triggerBlocks) {
const triggerId = resolveTriggerId(block)
if (!triggerId || !isTriggerValid(triggerId)) continue
const triggerDef = getTrigger(triggerId)
const provider = triggerDef.provider
const { providerConfig, missingFields, credentialReference, credentialServiceId, triggerPath } =
buildProviderConfig(block, triggerId, triggerDef)
if (missingFields.length > 0) {
return {
success: false,
error: {
message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`,
status: 400,
},
}
}
if (providerConfig.requireAuth && !providerConfig.token) {
return {
success: false,
error: {
message:
'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.',
status: 400,
},
}
}
let credentialId: string | undefined
if (credentialReference && credentialServiceId) {
const workflowWorkspaceId =
typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
if (!workflowWorkspaceId) {
return {
success: false,
error: {
message: `Cannot validate credentials for ${triggerDef.name || triggerId} without a workflow workspace`,
status: 400,
},
}
}
credentialId =
(await resolveTriggerCredentialId(
credentialReference,
workflowWorkspaceId,
credentialServiceId
)) ?? undefined
if (!credentialId) {
return {
success: false,
error: {
message: `The selected ${credentialServiceId} credential is not available in this workspace`,
status: 400,
},
}
}
providerConfig.credentialId = credentialId
}
/**
* The unified Slack trigger (`slack_oauth`) resolves to one of two backends
* by App Type: `sim` routes inbound events on the official Sim app by Slack
* `team_id` (routingKey, no path); `custom` routes by the reusable bot
* credential id. The team_id is derived here from the connected account via
* `auth.test` — never from user input.
*/
let effectiveProvider = provider
let effectivePath: string | null = triggerPath
let routingKey: string | null = null
if (triggerId === 'slack_oauth') {
// Absent appType means custom: it's the only mode this ship exposes (the
// hidden selector seeds/persists 'custom'), and defaulting to sim would
// send credential-less configs down the OAuth/team-id branch.
const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom'
if (appType === 'sim') {
const eventType =
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) {
return {
success: false,
error: {
message:
'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.',
status: 400,
},
}
}
if (!credentialId) {
return {
success: false,
error: { message: 'Select a Slack account for the trigger.', status: 400 },
}
}
// Resolve the credential OWNER's token (not the deploying actor's) —
// in a shared workspace a teammate can deploy a trigger wired to
// someone else's Slack credential. Mirrors the runtime formatInput path.
let tokenOwnerUserId = userId
const resolvedAccount = await resolveOAuthAccountId(credentialId)
if (resolvedAccount?.accountId) {
const [owner] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolvedAccount.accountId))
.limit(1)
if (owner?.userId) tokenOwnerUserId = owner.userId
}
const botToken = await refreshAccessTokenIfNeeded(credentialId, tokenOwnerUserId, requestId)
if (!botToken) {
return {
success: false,
error: {
message: 'Could not access the connected Slack account. Reconnect it and try again.',
status: 400,
},
}
}
try {
const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken)
routingKey = teamId
if (botUserId) providerConfig.bot_user_id = botUserId
} catch (error: unknown) {
logger.error(`[${requestId}] Slack team_id resolution failed for ${block.id}`, error)
return {
success: false,
error: {
message:
'Could not verify the connected Slack workspace. Reconnect it and try again.',
status: 400,
},
}
}
effectiveProvider = 'slack_app'
effectivePath = null
} else {
// Custom: a reusable bring-your-own bot credential. Route by the
// credential id (one shared ingest URL per bot) instead of a per-workflow
// path, so multiple triggers on the same bot share one Request URL.
const botCredentialId =
typeof providerConfig.botCredential === 'string'
? providerConfig.botCredential
: undefined
if (!botCredentialId) {
return {
success: false,
error: { message: 'Select a Slack bot credential for the trigger.', status: 400 },
}
}
const botCredential = await getSlackBotCredential(botCredentialId)
if (!botCredential) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
status: 400,
},
}
}
// The credential must belong to the workflow's workspace: bot credential
// ids are semi-public (they're embedded in Slack Request URLs), so a
// pasted foreign id must never bind another tenant's bot to this
// workflow.
const workflowWorkspace =
typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is not available in this workspace.',
status: 400,
},
}
}
effectiveProvider = 'slack'
effectivePath = null
routingKey = botCredentialId
providerConfig.credentialId = botCredentialId
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
}
}
if (effectivePath) {
const pathConflict = await findConflictingWebhookPathOwner({
path: effectivePath,
workflowId,
})
if (pathConflict) {
logger.warn(
`[${requestId}] Webhook path conflict for "${effectivePath}": already owned by workflow ${pathConflict}`
)
return {
success: false,
error: {
message: `Webhook path "${effectivePath}" is already in use. Choose a different path.`,
status: 409,
},
}
}
}
webhookConfigs.set(block.id, {
provider: effectiveProvider,
providerConfig,
triggerPath: effectivePath,
routingKey,
triggerDef,
})
const existingForBlock = webhooksByBlockId.get(block.id) ?? []
if (existingForBlock.length === 0) {
// No existing webhook - needs creation
blocksNeedingWebhook.push(block)
} else {
const [existingWh, ...extraWebhooks] = existingForBlock
if (extraWebhooks.length > 0) {
webhooksToDelete.push(...extraWebhooks)
logger.info(
`[${requestId}] Found ${extraWebhooks.length} extra webhook(s) for block ${block.id}`
)
}
// Check if config changed or if we're forcing recreation (e.g., activating old version)
const existingConfig = (existingWh.providerConfig as Record<string, unknown>) || {}
const needsRecreation =
forceRecreateSubscriptions ||
existingWh.provider !== effectiveProvider ||
// Routing transitions (path-based <-> routing-key, or a changed key)
// must recreate the row even when the provider config compares equal —
// otherwise a stale delivery surface stays active on the old route.
(existingWh.path ?? null) !== effectivePath ||
((existingWh.routingKey as string | null) ?? null) !== routingKey ||
hasWebhookConfigChanged(existingConfig, providerConfig)
if (needsRecreation) {
webhooksToDelete.push(existingWh)
blocksNeedingWebhook.push(block)
if (forceRecreateSubscriptions) {
logger.info(
`[${requestId}] Forcing webhook recreation for block ${block.id} (reactivating version)`
)
} else {
logger.info(`[${requestId}] Webhook config changed for block ${block.id}, will recreate`)
}
}
// else: config unchanged and not forcing recreation, keep existing webhook
}
}
// Add orphaned webhooks (block no longer exists)
for (const wh of existingWebhooks) {
if (wh.blockId && !currentBlockIds.has(wh.blockId)) {
webhooksToDelete.push(wh)
logger.info(`[${requestId}] Webhook orphaned (block deleted): ${wh.blockId}`)
}
}
// 3. Delete webhooks that need deletion
if (webhooksToDelete.length > 0) {
logger.info(`[${requestId}] Deleting ${webhooksToDelete.length} webhook(s)`, {
webhookIds: webhooksToDelete.map((wh) => wh.id),
})
for (const wh of webhooksToDelete) {
let cleanupSucceeded = false
try {
await cleanupExternalWebhook(wh, workflow, requestId, {
throwOnError: strictExternalCleanup,
})
cleanupSucceeded = true
} catch (cleanupError) {
logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError)
if (strictExternalCleanup) throw cleanupError
}
if (!strictExternalCleanup || cleanupSucceeded) {
await db.delete(webhook).where(eq(webhook.id, wh.id))
}
}
}
// 5. Create webhooks for blocks that need them (two-phase approach for atomicity)
const createdSubscriptions: Array<{
webhookId: string
block: BlockState
provider: string
triggerPath: string | null
routingKey: string | null
updatedProviderConfig: Record<string, unknown>
externalSubscriptionCreated: boolean
}> = []
const pendingVerificationTracker = new PendingWebhookVerificationTracker()
for (const block of blocksNeedingWebhook) {
const config = webhookConfigs.get(block.id)
if (!config) continue
const { provider, providerConfig, triggerPath, routingKey } = config
const webhookId = generateShortId()
const createPayload = {
id: webhookId,
path: triggerPath,
provider,
providerConfig,
}
try {
if (triggerPath) {
await pendingVerificationTracker.register({
path: triggerPath,
provider,
workflowId,
blockId: block.id,
metadata: providerConfig,
})
}
const result = await createExternalWebhookSubscription(
request,
createPayload,
workflow,
userId,
requestId
)
createdSubscriptions.push({
webhookId,
block,
provider,
triggerPath,
routingKey,
updatedProviderConfig: result.updatedProviderConfig as Record<string, unknown>,
externalSubscriptionCreated: result.externalSubscriptionCreated,
})
} catch (error: unknown) {
logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error)
await pendingVerificationTracker.clearAll()
let cleanupFailure: unknown
for (const sub of createdSubscriptions) {
if (sub.externalSubscriptionCreated) {
try {
await cleanupExternalWebhook(
{
id: sub.webhookId,
path: sub.triggerPath,
provider: sub.provider,
providerConfig: sub.updatedProviderConfig,
},
workflow,
requestId,
{ throwOnError: strictExternalCleanup }
)
} catch (cleanupError) {
cleanupFailure = cleanupError
logger.warn(
`[${requestId}] Failed to cleanup external subscription for ${sub.block.id}`,
cleanupError
)
await persistCreatedWebhookRecordAfterCleanupFailure({
workflowId,
deploymentVersionId,
sub,
requestId,
})
}
}
}
return {
success: false,
error: {
message:
(cleanupFailure as Error)?.message ||
(error as Error)?.message ||
'Failed to create external subscription',
status: 500,
},
}
}
}
// Phase 2: Insert all DB records in a transaction
try {
await db.transaction(async (tx) => {
for (const sub of createdSubscriptions) {
await tx.insert(webhook).values({
id: sub.webhookId,
workflowId,
deploymentVersionId: deploymentVersionId || null,
blockId: sub.block.id,
path: sub.triggerPath,
routingKey: sub.routingKey,
provider: sub.provider,
providerConfig: sub.updatedProviderConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
}
})
await pendingVerificationTracker.clearAll()
for (const sub of createdSubscriptions) {
const pollingError = await configurePollingIfNeeded(
sub.provider,
{ id: sub.webhookId, path: sub.triggerPath, providerConfig: sub.updatedProviderConfig },
requestId
)
if (pollingError) {
logger.error(
`[${requestId}] Polling configuration failed for ${sub.block.id}`,
pollingError
)
const cleanedWebhookIds: string[] = []
for (const otherSub of createdSubscriptions) {
if (otherSub.webhookId === sub.webhookId) continue
if (otherSub.externalSubscriptionCreated) {
try {
await cleanupExternalWebhook(
{
id: otherSub.webhookId,
path: otherSub.triggerPath,
provider: otherSub.provider,
providerConfig: otherSub.updatedProviderConfig,
},
workflow,
requestId,
{ throwOnError: strictExternalCleanup }
)
cleanedWebhookIds.push(otherSub.webhookId)
} catch (cleanupError) {
logger.warn(
`[${requestId}] Failed to cleanup external subscription for ${otherSub.block.id}`,
cleanupError
)
}
} else {
cleanedWebhookIds.push(otherSub.webhookId)
}
}
if (cleanedWebhookIds.length > 0) {
await db.delete(webhook).where(inArray(webhook.id, cleanedWebhookIds))
}
return { success: false, error: pollingError }
}
}
} catch (error: unknown) {
await pendingVerificationTracker.clearAll()
logger.error(`[${requestId}] Failed to insert webhook records`, error)
let cleanupFailure: unknown
for (const sub of createdSubscriptions) {
if (sub.externalSubscriptionCreated) {
try {
await cleanupExternalWebhook(
{
id: sub.webhookId,
path: sub.triggerPath,
provider: sub.provider,
providerConfig: sub.updatedProviderConfig,
},
workflow,
requestId,
{ throwOnError: strictExternalCleanup }
)
} catch (cleanupError) {
cleanupFailure = cleanupError
logger.warn(
`[${requestId}] Failed to cleanup external subscription for ${sub.block.id}`,
cleanupError
)
await persistCreatedWebhookRecordAfterCleanupFailure({
workflowId,
deploymentVersionId,
sub,
requestId,
})
}
}
}
return {
success: false,
error: {
message:
(cleanupFailure as Error)?.message ||
(error as Error)?.message ||
'Failed to save webhook records',
status: 500,
},
}
}
return { success: true }
}
async function persistCreatedWebhookRecordAfterCleanupFailure({
workflowId,
deploymentVersionId,
sub,
requestId,
}: {
workflowId: string
deploymentVersionId?: string
sub: {
webhookId: string
block: BlockState
provider: string
triggerPath: string | null
routingKey: string | null
updatedProviderConfig: Record<string, unknown>
}
requestId: string
}): Promise<void> {
try {
await db.insert(webhook).values({
id: sub.webhookId,
workflowId,
deploymentVersionId: deploymentVersionId || null,
blockId: sub.block.id,
path: sub.triggerPath,
routingKey: sub.routingKey,
provider: sub.provider,
providerConfig: sub.updatedProviderConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
} catch (persistError) {
logger.error(
`[${requestId}] Failed to persist webhook record after external cleanup failure`,
persistError
)
}
}
/**
* Clean up all webhooks for a workflow during undeploy.
* Removes external subscriptions and deletes webhook records from the database.
*
* @param skipExternalCleanup - If true, skip external subscription cleanup (already done elsewhere)
* @param shouldDeleteWebhook - Best-effort early-exit probe. Its implementations
* query the global pool, so it MUST only be awaited while no transaction is open.
* See {@link deleteWebhookRecordAfterCleanup} for the in-transaction recheck that
* makes this probe non-authoritative.
*/
export async function cleanupWebhooksForWorkflow(
workflowId: string,
workflow: Record<string, unknown>,
requestId: string,
deploymentVersionId?: string | null,
skipExternalCleanup = false,
strictExternalCleanup = false,
shouldDeleteWebhook?: () => Promise<boolean>
): Promise<void> {
const existingWebhooks = await db
.select()
.from(webhook)
.where(
deploymentVersionId
? and(
eq(webhook.workflowId, workflowId),
eq(webhook.deploymentVersionId, deploymentVersionId),
isNull(webhook.archivedAt)
)
: deploymentVersionId === null
? and(
eq(webhook.workflowId, workflowId),
isNull(webhook.deploymentVersionId),
isNull(webhook.archivedAt)
)
: and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))
)
if (existingWebhooks.length === 0) {
return
}
logger.info(
`[${requestId}] Cleaning up ${existingWebhooks.length} webhook(s) for ${skipExternalCleanup ? 'DB records only' : 'undeploy'}`,
{
workflowId,
deploymentVersionId,
webhookIds: existingWebhooks.map((wh) => wh.id),
}
)
if (!skipExternalCleanup) {
for (const wh of existingWebhooks) {
if (shouldDeleteWebhook && !(await shouldDeleteWebhook())) {
logger.info(`[${requestId}] Stopping webhook cleanup because deployment became active`, {
workflowId,
deploymentVersionId,
webhookId: wh.id,
})
return
}
try {
await cleanupExternalWebhook(wh, workflow, requestId, {
throwOnError: strictExternalCleanup,
})
} catch (cleanupError) {
logger.warn(`[${requestId}] Failed to cleanup external webhook ${wh.id}`, cleanupError)
if (strictExternalCleanup) throw cleanupError
// Continue with other webhooks even if one fails
}
const deleted = await deleteWebhookRecordAfterCleanup({
workflowId,
deploymentVersionId,
webhookId: wh.id,
shouldDeleteWebhook,
})
if (!deleted) {
logger.info(`[${requestId}] Stopping webhook DB cleanup because deployment became active`, {
workflowId,
deploymentVersionId,
webhookId: wh.id,
})
return
}
}
} else {
for (const wh of existingWebhooks) {
const deleted = await deleteWebhookRecordAfterCleanup({
workflowId,
deploymentVersionId,
webhookId: wh.id,
shouldDeleteWebhook,
})
if (!deleted) {
logger.info(`[${requestId}] Stopping webhook DB cleanup because deployment became active`, {
workflowId,
deploymentVersionId,
webhookId: wh.id,
})
return
}
}
}
logger.info(
deploymentVersionId
? `[${requestId}] Cleaned up webhooks for workflow ${workflowId} deployment ${deploymentVersionId}`
: `[${requestId}] Cleaned up all webhooks for workflow ${workflowId}`
)
}
/**
* Deletes a webhook record unless the deployment became active again.
*
* `shouldDeleteWebhook` is awaited BEFORE the transaction opens — its
* implementations query the global pool, so running it inside the
* transaction would nest a second pooled checkout under the held
* connection. The transaction does not need it: the `FOR UPDATE` select
* on the deployment version row is the authoritative recheck, and it
* aborts the delete if the version was reactivated.
*/
async function deleteWebhookRecordAfterCleanup(params: {
workflowId: string
deploymentVersionId?: string | null
webhookId: string
shouldDeleteWebhook?: () => Promise<boolean>
}): Promise<boolean> {
if (params.shouldDeleteWebhook && !(await params.shouldDeleteWebhook())) {
return false
}
if (!params.shouldDeleteWebhook || typeof params.deploymentVersionId !== 'string') {
await db
.delete(webhook)
.where(and(eq(webhook.workflowId, params.workflowId), eq(webhook.id, params.webhookId)))
return true
}
const deploymentVersionId = params.deploymentVersionId
return db.transaction(async (tx) => {
const [inactiveVersion] = await tx
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, params.workflowId),
eq(workflowDeploymentVersion.id, deploymentVersionId),
eq(workflowDeploymentVersion.isActive, false)
)
)
.limit(1)
.for('update')
if (!inactiveVersion) return false
await tx
.delete(webhook)
.where(
and(
eq(webhook.workflowId, params.workflowId),
eq(webhook.id, params.webhookId),
eq(webhook.deploymentVersionId, deploymentVersionId)
)
)
return true
})
}