Files
simstudioai--sim/apps/sim/lib/webhooks/provider-subscriptions.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

175 lines
5.4 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'
const logger = createLogger('WebhookProviderSubscriptions')
type ExternalSubscriptionResult = {
updatedProviderConfig: Record<string, unknown>
externalSubscriptionCreated: boolean
}
type RecreateCheckInput = {
previousProvider: string
nextProvider: string
previousConfig: Record<string, unknown>
nextConfig: Record<string, unknown>
}
/** System-managed fields that should not trigger recreation. */
const SYSTEM_MANAGED_FIELDS = new Set([
'externalId',
'externalSubscriptionId',
'eventTypes',
'webhookTag',
'webhookSecret',
'signingSecret',
'secretToken',
'historyId',
'lastCheckedTimestamp',
'setupCompleted',
'userId',
])
/** Returns true when user-controlled persisted webhook configuration changed. */
export function hasWebhookConfigChanged(
previousConfig: Record<string, unknown>,
nextConfig: Record<string, unknown>
): boolean {
const allKeys = new Set([...Object.keys(previousConfig), ...Object.keys(nextConfig)])
for (const key of allKeys) {
if (SYSTEM_MANAGED_FIELDS.has(key)) continue
const previousValue = previousConfig[key]
const nextValue = nextConfig[key]
const previousComparable =
typeof previousValue === 'object' ? JSON.stringify(previousValue ?? null) : previousValue
const nextComparable =
typeof nextValue === 'object' ? JSON.stringify(nextValue ?? null) : nextValue
if (previousComparable !== nextComparable) return true
}
return false
}
/**
* Determine whether a webhook with provider-managed registration should be
* recreated after its persisted provider config changes.
*
* Only user-controlled fields are considered; provider-managed fields such as
* external IDs and generated secrets are ignored.
*/
export function shouldRecreateExternalWebhookSubscription({
previousProvider,
nextProvider,
previousConfig,
nextConfig,
}: RecreateCheckInput): boolean {
const hasSubscription = (provider: string) => {
const handler = getProviderHandler(provider)
return Boolean(handler.createSubscription)
}
if (previousProvider !== nextProvider) {
return hasSubscription(previousProvider) || hasSubscription(nextProvider)
}
if (!hasSubscription(nextProvider)) {
return false
}
return hasWebhookConfigChanged(previousConfig, nextConfig)
}
/**
* Ask the provider handler to create an external webhook subscription, if that
* provider supports automatic registration.
*
* `providerConfig` may contain unresolved `{{ENV_VAR}}` references (e.g. an
* API key field backed by an environment variable) — these are resolved here
* before the provider call so deploy-triggered registration (this function is
* also called from the async deployment outbox, not just the interactive
* webhook-save route) behaves the same as a manual save. The persisted
* `providerConfig` returned to the caller stays unresolved; only the
* provider-managed fields from `result.providerConfigUpdates` get merged in.
*
* The returned provider-managed fields are merged back into `providerConfig`
* by the caller.
*/
export async function createExternalWebhookSubscription(
request: NextRequest,
webhookData: Record<string, unknown>,
workflow: Record<string, unknown>,
userId: string,
requestId: string
): Promise<ExternalSubscriptionResult> {
const provider = webhookData.provider as string
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const handler = getProviderHandler(provider)
if (!handler.createSubscription) {
return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false }
}
const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
const resolvedProviderConfig = await resolveWebhookProviderConfig(
providerConfig,
userId,
workspaceId
)
const result = await handler.createSubscription({
webhook: { ...webhookData, providerConfig: resolvedProviderConfig },
workflow,
userId,
requestId,
request,
})
if (!result) {
return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false }
}
return {
updatedProviderConfig: { ...providerConfig, ...result.providerConfigUpdates },
externalSubscriptionCreated: true,
}
}
/**
* Clean up external webhook subscriptions for a webhook.
* By default, cleanup failure is logged but non-fatal for legacy best-effort callers.
* Deployment outbox cleanup passes `throwOnError` so provider failures stay retryable.
*/
export async function cleanupExternalWebhook(
webhook: Record<string, unknown>,
workflow: Record<string, unknown>,
requestId: string,
options: { throwOnError?: boolean } = {}
): Promise<void> {
const provider = webhook.provider as string
const handler = getProviderHandler(provider)
if (!handler.deleteSubscription) {
return
}
try {
await handler.deleteSubscription({ webhook, workflow, requestId, strict: options.throwOnError })
} catch (error) {
logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, {
provider,
webhookId: webhook.id,
error: toError(error).message,
})
if (options.throwOnError) {
throw error
}
}
}