From cc6b80c722c161dd90815a7cc64eb1a82e44fce6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 4 Apr 2026 20:33:39 -0700 Subject: [PATCH 01/18] refactor(webhooks): extract provider-specific logic into handler registry --- .claude/commands/add-trigger.md | 117 +++ .../api/webhooks/trigger/[path]/route.test.ts | 1 - .../app/api/webhooks/trigger/[path]/route.ts | 2 +- apps/sim/background/webhook-execution.ts | 374 +++---- apps/sim/lib/core/idempotency/service.ts | 2 +- apps/sim/lib/webhooks/processor.test.ts | 15 +- apps/sim/lib/webhooks/processor.ts | 965 ++---------------- apps/sim/lib/webhooks/provider-utils.ts | 93 -- apps/sim/lib/webhooks/providers/airtable.ts | 44 + apps/sim/lib/webhooks/providers/ashby.ts | 12 + apps/sim/lib/webhooks/providers/attio.ts | 74 ++ apps/sim/lib/webhooks/providers/calcom.ts | 12 + apps/sim/lib/webhooks/providers/circleback.ts | 12 + apps/sim/lib/webhooks/providers/confluence.ts | 41 + apps/sim/lib/webhooks/providers/fireflies.ts | 12 + apps/sim/lib/webhooks/providers/generic.ts | 139 +++ apps/sim/lib/webhooks/providers/github.ts | 71 ++ .../lib/webhooks/providers/google-forms.ts | 23 + apps/sim/lib/webhooks/providers/grain.ts | 36 + apps/sim/lib/webhooks/providers/hubspot.ts | 52 + apps/sim/lib/webhooks/providers/index.ts | 24 + apps/sim/lib/webhooks/providers/jira.ts | 51 + apps/sim/lib/webhooks/providers/linear.ts | 21 + .../lib/webhooks/providers/microsoft-teams.ts | 85 ++ apps/sim/lib/webhooks/providers/registry.ts | 77 ++ apps/sim/lib/webhooks/providers/slack.ts | 26 + apps/sim/lib/webhooks/providers/stripe.ts | 19 + apps/sim/lib/webhooks/providers/telegram.ts | 16 + .../lib/webhooks/providers/twilio-voice.ts | 117 +++ apps/sim/lib/webhooks/providers/twilio.ts | 8 + apps/sim/lib/webhooks/providers/typeform.ts | 12 + apps/sim/lib/webhooks/providers/types.ts | 98 ++ apps/sim/lib/webhooks/providers/utils.ts | 102 ++ apps/sim/lib/webhooks/providers/webflow.ts | 23 + apps/sim/lib/webhooks/providers/whatsapp.ts | 11 + apps/sim/lib/webhooks/utils.server.ts | 91 -- 36 files changed, 1572 insertions(+), 1306 deletions(-) delete mode 100644 apps/sim/lib/webhooks/provider-utils.ts create mode 100644 apps/sim/lib/webhooks/providers/airtable.ts create mode 100644 apps/sim/lib/webhooks/providers/ashby.ts create mode 100644 apps/sim/lib/webhooks/providers/attio.ts create mode 100644 apps/sim/lib/webhooks/providers/calcom.ts create mode 100644 apps/sim/lib/webhooks/providers/circleback.ts create mode 100644 apps/sim/lib/webhooks/providers/confluence.ts create mode 100644 apps/sim/lib/webhooks/providers/fireflies.ts create mode 100644 apps/sim/lib/webhooks/providers/generic.ts create mode 100644 apps/sim/lib/webhooks/providers/github.ts create mode 100644 apps/sim/lib/webhooks/providers/google-forms.ts create mode 100644 apps/sim/lib/webhooks/providers/grain.ts create mode 100644 apps/sim/lib/webhooks/providers/hubspot.ts create mode 100644 apps/sim/lib/webhooks/providers/index.ts create mode 100644 apps/sim/lib/webhooks/providers/jira.ts create mode 100644 apps/sim/lib/webhooks/providers/linear.ts create mode 100644 apps/sim/lib/webhooks/providers/microsoft-teams.ts create mode 100644 apps/sim/lib/webhooks/providers/registry.ts create mode 100644 apps/sim/lib/webhooks/providers/slack.ts create mode 100644 apps/sim/lib/webhooks/providers/stripe.ts create mode 100644 apps/sim/lib/webhooks/providers/telegram.ts create mode 100644 apps/sim/lib/webhooks/providers/twilio-voice.ts create mode 100644 apps/sim/lib/webhooks/providers/twilio.ts create mode 100644 apps/sim/lib/webhooks/providers/typeform.ts create mode 100644 apps/sim/lib/webhooks/providers/types.ts create mode 100644 apps/sim/lib/webhooks/providers/utils.ts create mode 100644 apps/sim/lib/webhooks/providers/webflow.ts create mode 100644 apps/sim/lib/webhooks/providers/whatsapp.ts diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index d252bf61666..8f10752d39e 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -552,6 +552,115 @@ All fields automatically have: - `mode: 'trigger'` - Only shown in trigger mode - `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +## Webhook Provider Handler (Optional) + +If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), or **idempotency dedup**, create a provider handler in the webhook provider registry. + +### Directory + +``` +apps/sim/lib/webhooks/providers/ +├── types.ts # WebhookProviderHandler interface +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── registry.ts # Handler map + default handler +├── index.ts # Barrel export +└── {service}.ts # Your provider handler +``` + +### When to Create a Handler + +| Behavior | Method to implement | Example providers | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event type filtering | `matchEvent` | GitHub, Jira, Confluence, Attio, HubSpot | +| Event skip by type list | `shouldSkipEvent` via `skipByEventTypes` | Stripe, Grain | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Microsoft Teams | +| Custom error format | `formatErrorResponse` | Microsoft Teams | + +If none of these apply, you do NOT need a handler file. The default handler provides bearer token auth for providers that set `providerConfig.token`. + +### Simple Example: HMAC Auth Only + +```typescript +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validate{Service}Signature } from '@/lib/webhooks/utils.server' + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), +} +``` + +### Example: Auth + Event Matching + Idempotency + +```typescript +import { createLogger } from '@sim/logger' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validate{Service}Signature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:{Service}') + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] {Service} event mismatch for trigger ${triggerId}. Skipping.`, + { webhookId: webhook.id, workflowId: workflow.id, triggerId } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.type) { + return `${obj.type}:${obj.id}` + } + return null + }, +} +``` + +### Registering the Handler + +In `apps/sim/lib/webhooks/providers/registry.ts`: + +```typescript +import { {service}Handler } from '@/lib/webhooks/providers/{service}' + +const PROVIDER_HANDLERS: Record = { + // ... existing providers (alphabetical) ... + {service}: {service}Handler, +} +``` + +### Adding a Signature Validator + +If the service uses HMAC signatures, add a `validate{Service}Signature` function in `apps/sim/lib/webhooks/utils.server.ts` alongside the existing validators. Then reference it from your handler via `createHmacVerifier`. + ## Trigger Outputs & Webhook Input Formatting ### Important: Two Sources of Truth @@ -696,6 +805,14 @@ export const {service}WebhookTrigger: TriggerConfig = { - [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` - [ ] Added provider to `cleanupExternalWebhook` function +### Webhook Provider Handler (if needed) +- [ ] Created `apps/sim/lib/webhooks/providers/{service}.ts` handler file +- [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) +- [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth +- [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth +- [ ] Added `validate{Service}Signature` in `utils.server.ts` (if HMAC auth needed) +- [ ] Event matching uses dynamic `await import()` for trigger utils + ### Webhook Input Formatting - [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) - [ ] Handler returns fields matching trigger `outputs` exactly 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 7ee5d198f3f..2c4b65facf2 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -156,7 +156,6 @@ vi.mock('@/background/logs-webhook-delivery', () => ({ vi.mock('@/lib/webhooks/utils', () => ({ handleWhatsAppVerification: handleWhatsAppVerificationMock, handleSlackChallenge: handleSlackChallengeMock, - verifyProviderWebhook: vi.fn().mockReturnValue(null), processWhatsAppDeduplication: processWhatsAppDeduplicationMock, processGenericDeduplication: processGenericDeduplicationMock, fetchAndProcessAirtablePayloads: fetchAndProcessAirtablePayloadsMock, diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 2c283b72fdb..a04c749af50 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -87,7 +87,7 @@ async function handleWebhookPost( if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( request.method, - body, + body as Record | undefined, requestId, path ) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index f9e01c6300c..83cea7a7216 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -12,7 +12,8 @@ import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' -import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' +import { formatWebhookInput } from '@/lib/webhooks/utils.server' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -23,6 +24,7 @@ import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +import type { ExecutionResult } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' @@ -48,12 +50,12 @@ export function buildWebhookCorrelation( } /** - * Process trigger outputs based on their schema definitions - * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage + * Process trigger outputs based on their schema definitions. + * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage. */ async function processTriggerFileOutputs( - input: any, - triggerOutputs: Record, + input: unknown, + triggerOutputs: Record, context: { workspaceId: string workflowId: string @@ -62,29 +64,31 @@ async function processTriggerFileOutputs( userId?: string }, path = '' -): Promise { +): Promise { if (!input || typeof input !== 'object') { return input } - const processed: any = Array.isArray(input) ? [] : {} + const processed: Record = {} for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key - const outputDef = triggerOutputs[key] - const val: any = value + const outputDef = triggerOutputs[key] as Record | undefined + const val = value as Record - // If this field is marked as file or file[], process it if (outputDef?.type === 'file[]' && Array.isArray(val)) { try { - processed[key] = await WebhookAttachmentProcessor.processAttachments(val as any, context) + processed[key] = await WebhookAttachmentProcessor.processAttachments( + val as unknown as Parameters[0], + context + ) } catch (error) { processed[key] = [] } } else if (outputDef?.type === 'file' && val) { try { const [processedFile] = await WebhookAttachmentProcessor.processAttachments( - [val as any], + [val] as unknown as Parameters[0], context ) processed[key] = processedFile @@ -98,18 +102,20 @@ async function processTriggerFileOutputs( (outputDef.type === 'object' || outputDef.type === 'json') && outputDef.properties ) { - // Explicit object schema with properties - recurse into properties processed[key] = await processTriggerFileOutputs( val, - outputDef.properties, + outputDef.properties as Record, context, currentPath ) } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { - // Nested object in schema (flat pattern) - recurse with the nested schema - processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath) + processed[key] = await processTriggerFileOutputs( + val, + outputDef as Record, + context, + currentPath + ) } else { - // Not a file output - keep as is processed[key] = val } } @@ -125,7 +131,7 @@ export type WebhookExecutionPayload = { requestId?: string correlation?: AsyncExecutionCorrelation provider: string - body: any + body: unknown headers: Record path: string blockId?: string @@ -164,9 +170,6 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { ) } -/** - * Resolve the account userId for a credential - */ async function resolveCredentialAccountUserId(credentialId: string): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -180,6 +183,62 @@ async function resolveCredentialAccountUserId(credentialId: string): Promise + requestId: string + executionId: string + workflowId: string + } +) { + if ( + executionResult.status === 'cancelled' && + ctx.timeoutController.isTimedOut() && + ctx.timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, ctx.timeoutController.timeoutMs) + logger.info(`[${ctx.requestId}] Webhook execution timed out`, { + timeoutMs: ctx.timeoutController.timeoutMs, + }) + await ctx.loggingSession.markAsFailed(timeoutErrorMessage) + } else if (executionResult.status === 'paused') { + if (!executionResult.snapshotSeed) { + logger.error(`[${ctx.requestId}] Missing snapshot seed for paused execution`, { + executionId: ctx.executionId, + }) + await ctx.loggingSession.markAsFailed('Missing snapshot seed for paused execution') + } else { + try { + await PauseResumeManager.persistPauseResult({ + workflowId: ctx.workflowId, + executionId: ctx.executionId, + pausePoints: executionResult.pausePoints || [], + snapshotSeed: executionResult.snapshotSeed, + executorUserId: executionResult.metadata?.userId, + }) + } catch (pauseError) { + logger.error(`[${ctx.requestId}] Failed to persist pause result`, { + executionId: ctx.executionId, + error: pauseError instanceof Error ? pauseError.message : String(pauseError), + }) + await ctx.loggingSession.markAsFailed( + `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` + ) + } + } + } else { + await PauseResumeManager.processQueuedResumes(ctx.executionId) + } + + await ctx.loggingSession.waitForPostExecution() +} + async function executeWebhookJobInternal( payload: WebhookExecutionPayload, correlation: AsyncExecutionCorrelation @@ -192,7 +251,6 @@ async function executeWebhookJobInternal( requestId ) - // Resolve workflow record, billing actor, subscription, and timeout const preprocessResult = await preprocessExecution({ workflowId: payload.workflowId, userId: payload.userId, @@ -221,14 +279,13 @@ async function executeWebhookJobInternal( throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) } - const workflowVariables = (workflowRecord.variables as Record) || {} + const workflowVariables = (workflowRecord.variables as Record) || {} const asyncTimeout = executionTimeout?.async ?? 120_000 const timeoutController = createTimeoutAbortController(asyncTimeout) let deploymentVersionId: string | undefined try { - // Parallelize workflow state, webhook record, and credential resolution const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([ loadDeployedWorkflowState(payload.workflowId, workspaceId), db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1), @@ -255,184 +312,60 @@ async function executeWebhookJobInternal( ? (workflowData.deploymentVersionId as string) : undefined - // Handle special Airtable case - if (payload.provider === 'airtable') { - logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) + const handler = getProviderHandler(payload.provider) + + let input: Record | null = null + let skipMessage: string | undefined + if (handler.formatInput) { const webhookRecord = webhookRows[0] if (!webhookRecord) { throw new Error(`Webhook record not found: ${payload.webhookId}`) } - const webhookData = { - id: payload.webhookId, - provider: payload.provider, - providerConfig: webhookRecord.providerConfig, - } + const result = await handler.formatInput({ + webhook: webhookRecord, + workflow: { id: payload.workflowId, userId: payload.userId }, + body: payload.body, + headers: payload.headers, + requestId, + }) + input = result.input as Record | null + skipMessage = result.skip?.message + } else { + const actualWebhook = + webhookRows.length > 0 + ? webhookRows[0] + : { + provider: payload.provider, + blockId: payload.blockId, + providerConfig: {}, + } const mockWorkflow = { id: payload.workflowId, userId: payload.userId, } + const mockRequest = { + headers: new Map(Object.entries(payload.headers)), + } as unknown as Parameters[3] - const airtableInput = await fetchAndProcessAirtablePayloads( - webhookData, + input = (await formatWebhookInput( + actualWebhook, mockWorkflow, - requestId - ) - - if (airtableInput) { - logger.info(`[${requestId}] Executing workflow with Airtable changes`) - - const metadata: ExecutionMetadata = { - requestId, - executionId, - workflowId: payload.workflowId, - workspaceId, - userId: payload.userId, - sessionUserId: undefined, - workflowUserId: workflowRecord.userId, - triggerType: payload.provider || 'webhook', - triggerBlockId: payload.blockId, - useDraftState: false, - startTime: new Date().toISOString(), - isClientSession: false, - credentialAccountUserId, - correlation, - workflowStateOverride: { - blocks, - edges, - loops: loops || {}, - parallels: parallels || {}, - deploymentVersionId, - }, - } - - const snapshot = new ExecutionSnapshot( - metadata, - workflowRecord, - airtableInput, - workflowVariables, - [] - ) - - const executionResult = await executeWorkflowCore({ - snapshot, - callbacks: {}, - loggingSession, - includeFileBase64: true, - base64MaxBytes: undefined, - abortSignal: timeoutController.signal, - }) - - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Airtable webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) + payload.body, + mockRequest + )) as Record | null + + if (!input && handler.handleEmptyInput) { + const skipResult = handler.handleEmptyInput(requestId) + if (skipResult) { + skipMessage = skipResult.message } - - await loggingSession.waitForPostExecution() - - logger.info(`[${requestId}] Airtable webhook execution completed`, { - success: executionResult.success, - workflowId: payload.workflowId, - }) - - return { - success: executionResult.success, - workflowId: payload.workflowId, - executionId, - output: executionResult.output, - executedAt: new Date().toISOString(), - provider: payload.provider, - } - } - // No changes to process - logger.info(`[${requestId}] No Airtable changes to process`) - - await loggingSession.safeStart({ - userId: payload.userId, - workspaceId, - variables: {}, - triggerData: { - isTest: false, - correlation, - }, - deploymentVersionId, - }) - - await loggingSession.safeComplete({ - endedAt: new Date().toISOString(), - totalDurationMs: 0, - finalOutput: { message: 'No Airtable changes to process' }, - traceSpans: [], - }) - - return { - success: true, - workflowId: payload.workflowId, - executionId, - output: { message: 'No Airtable changes to process' }, - executedAt: new Date().toISOString(), } } - // Format input for standard webhooks - const actualWebhook = - webhookRows.length > 0 - ? webhookRows[0] - : { - provider: payload.provider, - blockId: payload.blockId, - providerConfig: {}, - } - - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, - } - const mockRequest = { - headers: new Map(Object.entries(payload.headers)), - } as any - - const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest) - - if (!input && payload.provider === 'whatsapp') { - logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) - + if (skipMessage) { await loggingSession.safeStart({ userId: payload.userId, workspaceId, @@ -447,19 +380,19 @@ async function executeWebhookJobInternal( await loggingSession.safeComplete({ endedAt: new Date().toISOString(), totalDurationMs: 0, - finalOutput: { message: 'No messages in WhatsApp payload' }, + finalOutput: { message: skipMessage }, traceSpans: [], }) + return { success: true, workflowId: payload.workflowId, executionId, - output: { message: 'No messages in WhatsApp payload' }, + output: { message: skipMessage }, executedAt: new Date().toISOString(), } } - // Process trigger file outputs based on schema if (input && payload.blockId && blocks[payload.blockId]) { try { const triggerBlock = blocks[payload.blockId] @@ -502,6 +435,23 @@ async function executeWebhookJobInternal( } } + if (input && handler.processInputFiles && payload.blockId && blocks[payload.blockId]) { + try { + await handler.processInputFiles({ + input, + blocks, + blockId: payload.blockId, + workspaceId, + workflowId: payload.workflowId, + executionId, + requestId, + userId: payload.userId, + }) + } catch (error) { + logger.error(`[${requestId}] Error processing provider-specific files:`, error) + } + } + // Process generic webhook files based on inputFormat if (input && payload.provider === 'generic' && payload.blockId && blocks[payload.blockId]) { try { @@ -589,49 +539,17 @@ async function executeWebhookJobInternal( callbacks: {}, loggingSession, includeFileBase64: true, + base64MaxBytes: undefined, abortSignal: timeoutController.signal, }) - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } - - await loggingSession.waitForPostExecution() + await handleExecutionResult(executionResult, { + loggingSession, + timeoutController, + requestId, + executionId, + workflowId: payload.workflowId, + }) logger.info(`[${requestId}] Webhook execution completed`, { success: executionResult.success, diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index d9adf4f504f..27d0746e2a9 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -6,7 +6,7 @@ import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getStorageMethod, type StorageMethod } from '@/lib/core/storage' import { generateId } from '@/lib/core/utils/uuid' -import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils' +import { extractProviderIdentifierFromBody } from '@/lib/webhooks/providers' const logger = createLogger('IdempotencyService') diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index e3cb8dcde41..3f543780f71 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -106,17 +106,10 @@ vi.mock('@/lib/webhooks/utils', () => ({ vi.mock('@/lib/webhooks/utils.server', () => ({ handleSlackChallenge: vi.fn().mockReturnValue(null), handleWhatsAppVerification: vi.fn().mockResolvedValue(null), - validateAttioSignature: vi.fn().mockReturnValue(true), - validateCalcomSignature: vi.fn().mockReturnValue(true), - validateCirclebackSignature: vi.fn().mockReturnValue(true), - validateFirefliesSignature: vi.fn().mockReturnValue(true), - validateGitHubSignature: vi.fn().mockReturnValue(true), - validateJiraSignature: vi.fn().mockReturnValue(true), - validateLinearSignature: vi.fn().mockReturnValue(true), - validateMicrosoftTeamsSignature: vi.fn().mockReturnValue(true), - validateTwilioSignature: vi.fn().mockResolvedValue(true), - validateTypeformSignature: vi.fn().mockReturnValue(true), - verifyProviderWebhook: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: vi.fn().mockReturnValue({}), })) vi.mock('@/background/webhook-execution', () => ({ diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 34de5f54ba6..38f6cc81bbc 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -8,7 +8,6 @@ import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq' import { isProd } from '@/lib/core/config/feature-flags' -import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -18,30 +17,10 @@ import { matchesPendingWebhookVerificationProbe, requiresPendingWebhookVerification, } from '@/lib/webhooks/pending-verification' -import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' -import { - handleSlackChallenge, - handleWhatsAppVerification, - validateAshbySignature, - validateAttioSignature, - validateCalcomSignature, - validateCirclebackSignature, - validateFirefliesSignature, - validateGitHubSignature, - validateJiraSignature, - validateLinearSignature, - validateMicrosoftTeamsSignature, - validateTwilioSignature, - validateTypeformSignature, - verifyProviderWebhook, -} from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' -import { isConfluencePayloadMatch } from '@/triggers/confluence/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -import { isGitHubEventMatch } from '@/triggers/github/utils' -import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils' -import { isJiraEventMatch } from '@/triggers/jira/utils' const logger = createLogger('WebhookProcessor') @@ -61,19 +40,6 @@ export interface WebhookPreprocessingResult { correlation?: AsyncExecutionCorrelation } -function getExternalUrl(request: NextRequest): string { - const proto = request.headers.get('x-forwarded-proto') || 'https' - const host = request.headers.get('x-forwarded-host') || request.headers.get('host') - - if (host) { - const url = new URL(request.url) - const reconstructed = `${proto}://${host}${url.pathname}${url.search}` - return reconstructed - } - - return request.url -} - async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ valid: boolean error?: string @@ -106,13 +72,12 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ export async function parseWebhookBody( request: NextRequest, requestId: string -): Promise<{ body: any; rawBody: string } | NextResponse> { +): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { const requestClone = request.clone() rawBody = await requestClone.text() - // Allow empty body - some webhooks send empty payloads if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } @@ -123,7 +88,7 @@ export async function parseWebhookBody( return new NextResponse('Failed to read request body', { status: 400 }) } - let body: any + let body: unknown try { const contentType = request.headers.get('content-type') || '' @@ -139,10 +104,6 @@ export async function parseWebhookBody( } else { body = JSON.parse(rawBody) } - - // Allow empty JSON objects - some webhooks send empty payloads - if (Object.keys(body).length === 0) { - } } catch (parseError) { logger.error(`[${requestId}] Failed to parse webhook body`, { error: parseError instanceof Error ? parseError.message : String(parseError), @@ -155,38 +116,24 @@ export async function parseWebhookBody( return { body, rawBody } } +/** Providers that implement challenge/verification handling, checked before webhook lookup. */ +const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const + export async function handleProviderChallenges( - body: any, + body: unknown, request: NextRequest, requestId: string, path: string ): Promise { - const slackResponse = handleSlackChallenge(body) - if (slackResponse) { - return slackResponse - } - - const url = new URL(request.url) - - // Microsoft Graph subscription validation (can come as GET or POST) - const validationToken = url.searchParams.get('validationToken') - if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) - return new NextResponse(validationToken, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }) - } - - const mode = url.searchParams.get('hub.mode') - const token = url.searchParams.get('hub.verify_token') - const challenge = url.searchParams.get('hub.challenge') - - const whatsAppResponse = await handleWhatsAppVerification(requestId, path, mode, token, challenge) - if (whatsAppResponse) { - return whatsAppResponse + for (const provider of CHALLENGE_PROVIDERS) { + const handler = getProviderHandler(provider) + if (handler.handleChallenge) { + const response = await handler.handleChallenge(body, request, requestId, path) + if (response) { + return response + } + } } - return null } @@ -218,108 +165,54 @@ export async function handlePreLookupWebhookVerification( /** * Handle provider-specific reachability tests that occur AFTER webhook lookup. - * - * @param webhook - The webhook record from the database - * @param body - The parsed request body - * @param requestId - Request ID for logging - * @returns NextResponse if this is a verification request, null to continue normal flow + * Delegates to the provider handler registry. */ export function handleProviderReachabilityTest( - webhook: any, - body: any, + webhookRecord: { provider: string }, + body: unknown, requestId: string ): NextResponse | null { - const provider = webhook?.provider - - if (provider === 'grain') { - const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type - if (isVerificationRequest) { - logger.info( - `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` - ) - return NextResponse.json({ - status: 'ok', - message: 'Webhook endpoint verified', - }) - } - } - - return null + const handler = getProviderHandler(webhookRecord?.provider) + return handler.handleReachabilityTest?.(body, requestId) ?? null } /** * Format error response based on provider requirements. - * Some providers (like Microsoft Teams) require specific response formats. + * Delegates to the provider handler registry. */ export function formatProviderErrorResponse( - webhook: any, + webhookRecord: { provider: string }, error: string, status: number ): NextResponse { - if (webhook.provider === 'microsoft-teams') { - return NextResponse.json({ type: 'message', text: error }, { status }) - } - return NextResponse.json({ error }, { status }) + const handler = getProviderHandler(webhookRecord.provider) + return handler.formatErrorResponse?.(error, status) ?? NextResponse.json({ error }, { status }) } /** * Check if a webhook event should be skipped based on provider-specific filtering. - * Returns true if the event should be skipped, false if it should be processed. + * Delegates to the provider handler registry. */ -export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean { - const providerConfig = (webhook.providerConfig as Record) || {} - - if (webhook.provider === 'stripe') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - if (webhook.provider === 'grain') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Grain event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - // Webflow collection filtering - filter by collectionId if configured - if (webhook.provider === 'webflow') { - const configuredCollectionId = providerConfig.collectionId - if (configuredCollectionId) { - const payloadCollectionId = body?.payload?.collectionId || body?.collectionId - if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { - logger.info( - `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - return false +export function shouldSkipWebhookEvent( + webhookRecord: { provider: string; providerConfig?: Record }, + body: unknown, + requestId: string +): boolean { + const handler = getProviderHandler(webhookRecord.provider) + const providerConfig = webhookRecord.providerConfig ?? {} + return ( + handler.shouldSkipEvent?.({ webhook: webhookRecord, body, requestId, providerConfig }) ?? false + ) } /** Returns 200 OK for providers that validate URLs before the workflow is deployed */ export function handlePreDeploymentVerification( - webhook: any, + webhookRecord: { provider: string }, requestId: string ): NextResponse | null { - if (requiresPendingWebhookVerification(webhook.provider)) { + if (requiresPendingWebhookVerification(webhookRecord.provider)) { logger.info( - `[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation` + `[${requestId}] ${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation` ) return NextResponse.json({ status: 'ok', @@ -458,27 +351,15 @@ export async function findAllWebhooksForPath( return results } -/** - * Resolve {{VARIABLE}} references in a string value - * @param value - String that may contain {{VARIABLE}} references - * @param envVars - Already decrypted environment variables - * @returns String with all {{VARIABLE}} references replaced - */ function resolveEnvVars(value: string, envVars: Record): string { return resolveEnvVarReferences(value, envVars) as string } -/** - * Resolve environment variables in webhook providerConfig - * @param config - Raw providerConfig from database (may contain {{VARIABLE}} refs) - * @param envVars - Already decrypted environment variables - * @returns New object with resolved values (original config is unchanged) - */ function resolveProviderConfigEnvVars( - config: Record, + config: Record, envVars: Record -): Record { - const resolved: Record = {} +): Record { + const resolved: Record = {} for (const [key, value] of Object.entries(config)) { if (typeof value === 'string') { resolved[key] = resolveEnvVars(value, envVars) @@ -490,8 +371,8 @@ function resolveProviderConfigEnvVars( } /** - * Verify webhook provider authentication and signatures - * @returns NextResponse with 401 if auth fails, null if auth passes + * Verify webhook provider authentication and signatures. + * Delegates to the provider handler registry. */ export async function verifyProviderAuth( foundWebhook: any, @@ -500,7 +381,6 @@ export async function verifyProviderAuth( rawBody: string, requestId: string ): Promise { - // Step 1: Fetch and decrypt environment variables for signature verification let decryptedEnvVars: Record = {} try { decryptedEnvVars = await getEffectiveDecryptedEnv( @@ -513,429 +393,20 @@ export async function verifyProviderAuth( }) } - // Step 2: Resolve {{VARIABLE}} references in providerConfig - const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} + const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars) - if (foundWebhook.provider === 'microsoft-teams') { - if (providerConfig.hmacSecret) { - const authHeader = request.headers.get('authorization') - - if (!authHeader || !authHeader.startsWith('HMAC ')) { - logger.warn( - `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` - ) - return new NextResponse('Unauthorized - Missing HMAC signature', { - status: 401, - }) - } - - const isValidSignature = validateMicrosoftTeamsSignature( - providerConfig.hmacSecret, - authHeader, - rawBody - ) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) - return new NextResponse('Unauthorized - Invalid HMAC signature', { - status: 401, - }) - } - } - } - - // Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header) - if (foundWebhook.provider === 'ashby') { - const secretToken = providerConfig.secretToken as string | undefined - - if (secretToken) { - const signature = request.headers.get('ashby-signature') - - if (!signature) { - logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`) - return new NextResponse('Unauthorized - Missing Ashby signature', { - status: 401, - }) - } - - if (!validateAshbySignature(secretToken, signature, rawBody)) { - logger.warn(`[${requestId}] Ashby webhook signature verification failed`) - return new NextResponse('Unauthorized - Invalid Ashby signature', { - status: 401, - }) - } - } - } - - // Provider-specific verification (utils may return a response for some providers) - const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId) - if (providerVerification) { - return providerVerification - } - - // Handle Google Forms shared-secret authentication (Apps Script forwarder) - if (foundWebhook.provider === 'google_forms') { - const expectedToken = providerConfig.token as string | undefined - const secretHeaderName = providerConfig.secretHeaderName as string | undefined - - if (expectedToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue === expectedToken) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (token === expectedToken) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - logger.warn(`[${requestId}] Google Forms webhook authentication failed`) - return new NextResponse('Unauthorized - Invalid secret', { - status: 401, - }) - } - } - } - - // Twilio Voice webhook signature verification - if (foundWebhook.provider === 'twilio_voice') { - const authToken = providerConfig.authToken as string | undefined - - if (authToken) { - const signature = request.headers.get('x-twilio-signature') - - if (!signature) { - logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Twilio signature', { - status: 401, - }) - } - - let params: Record = {} - try { - if (typeof rawBody === 'string') { - const urlParams = new URLSearchParams(rawBody) - params = Object.fromEntries(urlParams.entries()) - } - } catch (error) { - logger.error( - `[${requestId}] Error parsing Twilio webhook body for signature validation:`, - error - ) - return new NextResponse('Bad Request - Invalid body format', { - status: 400, - }) - } - - const fullUrl = getExternalUrl(request) - const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { - url: fullUrl, - signatureLength: signature.length, - paramsCount: Object.keys(params).length, - authTokenLength: authToken.length, - }) - return new NextResponse('Unauthorized - Invalid Twilio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'typeform') { - const secret = providerConfig.secret as string | undefined - - if (secret) { - const signature = request.headers.get('Typeform-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Typeform webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Typeform signature', { - status: 401, - }) - } - - const isValidSignature = validateTypeformSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Typeform signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Typeform signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'attio') { - const secret = providerConfig.webhookSecret as string | undefined - - if (!secret) { - logger.debug( - `[${requestId}] Attio webhook ${foundWebhook.id} has no signing secret, skipping signature verification` - ) - } else { - const signature = request.headers.get('Attio-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Attio webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Attio signature', { - status: 401, - }) - } - - const isValidSignature = validateAttioSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Attio signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Attio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'linear') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('Linear-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Linear webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Linear signature', { - status: 401, - }) - } - - const isValidSignature = validateLinearSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Linear signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Linear signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'circleback') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-signature') - - if (!signature) { - logger.warn(`[${requestId}] Circleback webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Circleback signature', { - status: 401, - }) - } - - const isValidSignature = validateCirclebackSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Circleback signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Circleback signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'calcom') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Cal-Signature-256') - - if (!signature) { - logger.warn(`[${requestId}] Cal.com webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Cal.com signature', { - status: 401, - }) - } - - const isValidSignature = validateCalcomSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Cal.com signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Cal.com signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'jira') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Jira webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Jira signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Jira signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Jira signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'confluence') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Confluence webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Confluence signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Confluence signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Confluence signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'github') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - // GitHub supports both SHA-256 (preferred) and SHA-1 (legacy) - const signature256 = request.headers.get('X-Hub-Signature-256') - const signature1 = request.headers.get('X-Hub-Signature') - const signature = signature256 || signature1 - - if (!signature) { - logger.warn(`[${requestId}] GitHub webhook missing signature header`) - return new NextResponse('Unauthorized - Missing GitHub signature', { - status: 401, - }) - } - - const isValidSignature = validateGitHubSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] GitHub signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - usingSha256: !!signature256, - }) - return new NextResponse('Unauthorized - Invalid GitHub signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'fireflies') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-hub-signature') - - if (!signature) { - logger.warn(`[${requestId}] Fireflies webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Fireflies signature', { - status: 401, - }) - } - - const isValidSignature = validateFirefliesSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Fireflies signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Fireflies signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'generic') { - if (providerConfig.requireAuth) { - const configToken = providerConfig.token - const secretHeaderName = providerConfig.secretHeaderName - - if (configToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue && safeCompare(headerValue, configToken)) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (safeCompare(token, configToken)) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } else { - return new NextResponse('Unauthorized - Authentication required but not configured', { - status: 401, - }) - } - } + const handler = getProviderHandler(foundWebhook.provider) + if (handler.verifyAuth) { + const authResult = await handler.verifyAuth({ + webhook: foundWebhook, + workflow: foundWorkflow, + request, + rawBody, + requestId, + providerConfig, + }) + if (authResult) return authResult } return null @@ -943,7 +414,6 @@ export async function verifyProviderAuth( /** * Run preprocessing checks for webhook execution - * This replaces the old checkRateLimits and checkUsageLimits functions */ export async function checkWebhookPreprocessing( foundWorkflow: any, @@ -984,20 +454,8 @@ export async function checkWebhookPreprocessing( statusCode: error.statusCode, }) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: error.message, - }, - { status: error.statusCode } - ), - } - } - return { - error: NextResponse.json({ error: error.message }, { status: error.statusCode }), + error: formatProviderErrorResponse(foundWebhook, error.message, error.statusCode), } } @@ -1010,20 +468,8 @@ export async function checkWebhookPreprocessing( } catch (preprocessError) { logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: 'Internal error during preprocessing', - }, - { status: 500 } - ), - } - } - return { - error: NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 }), + error: formatProviderErrorResponse(foundWebhook, 'Internal error during preprocessing', 500), } } } @@ -1035,188 +481,41 @@ export async function queueWebhookExecution( request: NextRequest, options: WebhookProcessorOptions ): Promise { - try { - // GitHub event filtering for event-specific triggers - if (foundWebhook.provider === 'github') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'github_webhook') { - const eventType = request.headers.get('x-github-event') - const action = body.action - - if (!isGitHubEventMatch(triggerId, eventType || '', action, body)) { - logger.debug( - `[${options.requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - receivedAction: action, - } - ) - - // Return 200 OK to prevent GitHub from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - } - } - - // Jira event filtering for event-specific triggers - if (foundWebhook.provider === 'jira') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'jira_webhook') { - const webhookEvent = body.webhookEvent as string | undefined + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const handler = getProviderHandler(foundWebhook.provider) - if (!isJiraEventMatch(triggerId, webhookEvent || '', body)) { - logger.debug( - `[${options.requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: webhookEvent, - } - ) - - // Return 200 OK to prevent Jira from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) + try { + if (handler.matchEvent) { + const result = await handler.matchEvent({ + webhook: foundWebhook, + workflow: foundWorkflow, + body, + request, + requestId: options.requestId, + providerConfig, + }) + if (result !== true) { + if (result instanceof NextResponse) { + return result } - } - } - - if (foundWebhook.provider === 'confluence') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && !isConfluencePayloadMatch(triggerId, body)) { - logger.debug( - `[${options.requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - message: 'Payload does not match trigger configuration. Ignoring.', + message: 'Event type does not match trigger configuration. Ignoring.', }) } } - if (foundWebhook.provider === 'attio') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'attio_webhook') { - const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') - if (!isAttioPayloadMatch(triggerId, body)) { - const event = getAttioEvent(body) - const eventType = event?.event_type as string | undefined - logger.debug( - `[${options.requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - status: 'skipped', - reason: 'event_type_mismatch', - }) - } - } - } - - if (foundWebhook.provider === 'hubspot') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('hubspot_')) { - const events = Array.isArray(body) ? body : [body] - const firstEvent = events[0] - - const subscriptionType = firstEvent?.subscriptionType as string | undefined - - if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { - logger.debug( - `[${options.requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - - // Return 200 OK to prevent HubSpot from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - - logger.info( - `[${options.requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - } - } - const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries()) - // For Microsoft Teams Graph notifications, extract unique identifiers for idempotency - if ( - foundWebhook.provider === 'microsoft-teams' && - body?.value && - Array.isArray(body.value) && - body.value.length > 0 - ) { - const notification = body.value[0] - const subscriptionId = notification.subscriptionId - const messageId = notification.resourceData?.id - - if (subscriptionId && messageId) { - headers['x-teams-notification-id'] = `${subscriptionId}:${messageId}` - } - } - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - if (foundWebhook.provider === 'generic') { - const idempotencyField = providerConfig.idempotencyField as string | undefined - if (idempotencyField && body) { - const value = idempotencyField - .split('.') - .reduce((acc: any, key: string) => acc?.[key], body) - if (value !== undefined && value !== null && typeof value !== 'object') { - headers['x-sim-idempotency-key'] = String(value) - } - } + if (handler.enrichHeaders) { + handler.enrichHeaders( + { webhook: foundWebhook, body, requestId: options.requestId, providerConfig }, + headers + ) } const credentialId = providerConfig.credentialId as string | undefined - - // credentialSetId is a direct field on webhook table, not in providerConfig const credentialSetId = foundWebhook.credentialSetId as string | undefined - // Verify billing for credential sets if (credentialSetId) { const billingCheck = await verifyCredentialSetBilling(credentialSetId) if (!billingCheck.valid) { @@ -1355,112 +654,18 @@ export async function queueWebhookExecution( } } - if (foundWebhook.provider === 'microsoft-teams') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Chat subscription (Graph API) returns 202 - if (triggerId === 'microsoftteams_chat_subscription') { - return new NextResponse(null, { status: 202 }) - } - - // Channel webhook (outgoing webhook) returns message response - return NextResponse.json({ - type: 'message', - text: 'Sim', - }) - } - - // Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.) - // A JSON body like {"message":"..."} is not a recognized response format and causes modal errors - if (foundWebhook.provider === 'slack') { - return new NextResponse(null, { status: 200 }) - } - - // Twilio Voice requires TwiML XML response - if (foundWebhook.provider === 'twilio_voice') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() - - // If user provided custom TwiML, convert square brackets to angle brackets and return - if (twimlResponse && twimlResponse.length > 0) { - const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) - return new NextResponse(convertedTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - // Default TwiML if none provided - const defaultTwiml = ` - - Your call is being processed. - -` - - return new NextResponse(defaultTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') { - const rawCode = Number(providerConfig.responseStatusCode) || 200 - const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 - const responseBody = (providerConfig.responseBody as string | undefined)?.trim() - - if (!responseBody) { - return new NextResponse(null, { status: statusCode }) - } - - try { - const parsed = JSON.parse(responseBody) - return NextResponse.json(parsed, { status: statusCode }) - } catch { - return new NextResponse(responseBody, { - status: statusCode, - headers: { 'Content-Type': 'text/plain' }, - }) - } + const successResponse = handler.formatSuccessResponse?.(providerConfig) ?? null + if (successResponse) { + return successResponse } return NextResponse.json({ message: 'Webhook processed' }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) - if (foundWebhook.provider === 'microsoft-teams') { - return NextResponse.json( - { - type: 'message', - text: 'Webhook processing failed', - }, - { status: 500 } - ) - } - - if (foundWebhook.provider === 'slack') { - // Return empty 200 to avoid Slack showing an error dialog to the user, - // even though processing failed. The error is already logged above. - return new NextResponse(null, { status: 200 }) - } - - if (foundWebhook.provider === 'twilio_voice') { - const errorTwiml = ` - - We're sorry, but an error occurred processing your call. Please try again later. - -` - - return new NextResponse(errorTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml', - }, - }) + const errorResponse = handler.formatQueueErrorResponse?.() ?? null + if (errorResponse) { + return errorResponse } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/lib/webhooks/provider-utils.ts b/apps/sim/lib/webhooks/provider-utils.ts deleted file mode 100644 index c475d1205e3..00000000000 --- a/apps/sim/lib/webhooks/provider-utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Provider-specific unique identifier extractors for webhook idempotency - */ - -function extractSlackIdentifier(body: any): string | null { - if (body.event_id) { - return body.event_id - } - - if (body.event?.ts && body.team_id) { - return `${body.team_id}:${body.event.ts}` - } - - return null -} - -function extractTwilioIdentifier(body: any): string | null { - return body.MessageSid || body.CallSid || null -} - -function extractStripeIdentifier(body: any): string | null { - if (body.id && body.object === 'event') { - return body.id - } - return null -} - -function extractHubSpotIdentifier(body: any): string | null { - if (Array.isArray(body) && body.length > 0 && body[0]?.eventId) { - return String(body[0].eventId) - } - return null -} - -function extractLinearIdentifier(body: any): string | null { - if (body.action && body.data?.id) { - return `${body.action}:${body.data.id}` - } - return null -} - -function extractJiraIdentifier(body: any): string | null { - if (body.webhookEvent && (body.issue?.id || body.project?.id)) { - return `${body.webhookEvent}:${body.issue?.id || body.project?.id}` - } - return null -} - -function extractMicrosoftTeamsIdentifier(body: any): string | null { - if (body.value && Array.isArray(body.value) && body.value.length > 0) { - const notification = body.value[0] - if (notification.subscriptionId && notification.resourceData?.id) { - return `${notification.subscriptionId}:${notification.resourceData.id}` - } - } - return null -} - -function extractAirtableIdentifier(body: any): string | null { - if (body.cursor && typeof body.cursor === 'string') { - return body.cursor - } - return null -} - -function extractGrainIdentifier(body: any): string | null { - if (body.type && body.data?.id) { - return `${body.type}:${body.data.id}` - } - return null -} - -const PROVIDER_EXTRACTORS: Record string | null> = { - slack: extractSlackIdentifier, - twilio: extractTwilioIdentifier, - twilio_voice: extractTwilioIdentifier, - stripe: extractStripeIdentifier, - hubspot: extractHubSpotIdentifier, - linear: extractLinearIdentifier, - jira: extractJiraIdentifier, - 'microsoft-teams': extractMicrosoftTeamsIdentifier, - airtable: extractAirtableIdentifier, - grain: extractGrainIdentifier, -} - -export function extractProviderIdentifierFromBody(provider: string, body: any): string | null { - if (!body || typeof body !== 'object') { - return null - } - - const extractor = PROVIDER_EXTRACTORS[provider] - return extractor ? extractor(body) : null -} diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts new file mode 100644 index 00000000000..6fd9c6caee3 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import type { FormatInputContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { fetchAndProcessAirtablePayloads } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Airtable') + +export const airtableHandler: WebhookProviderHandler = { + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (typeof obj.cursor === 'string') { + return obj.cursor + } + return null + }, + + async formatInput({ webhook, workflow, requestId }: FormatInputContext) { + logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) + + const webhookData = { + id: webhook.id, + provider: webhook.provider, + providerConfig: webhook.providerConfig, + } + + const mockWorkflow = { + id: workflow.id, + userId: workflow.userId, + } + + const airtableInput = await fetchAndProcessAirtablePayloads( + webhookData, + mockWorkflow, + requestId + ) + + if (airtableInput) { + logger.info(`[${requestId}] Executing workflow with Airtable changes`) + return { input: airtableInput } + } + + logger.info(`[${requestId}] No Airtable changes to process`) + return { input: null, skip: { message: 'No Airtable changes to process' } } + }, +} diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts new file mode 100644 index 00000000000..419777253ac --- /dev/null +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -0,0 +1,12 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateAshbySignature } from '@/lib/webhooks/utils.server' + +export const ashbyHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'secretToken', + headerName: 'ashby-signature', + validateFn: validateAshbySignature, + providerLabel: 'Ashby', + }), +} diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts new file mode 100644 index 00000000000..0b5b73806e6 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { validateAttioSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Attio') + +export const attioHandler: WebhookProviderHandler = { + verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + + if (!secret) { + logger.debug( + `[${requestId}] Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification` + ) + } else { + const signature = request.headers.get('Attio-Signature') + + if (!signature) { + logger.warn(`[${requestId}] Attio webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Attio signature', { + status: 401, + }) + } + + const isValidSignature = validateAttioSignature(secret, signature, rawBody) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Attio signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse('Unauthorized - Invalid Attio signature', { + status: 401, + }) + } + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'attio_webhook') { + const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') + if (!isAttioPayloadMatch(triggerId, obj)) { + const event = getAttioEvent(obj) + const eventType = event?.event_type as string | undefined + logger.debug( + `[${requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts new file mode 100644 index 00000000000..c4b288bf983 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -0,0 +1,12 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateCalcomSignature } from '@/lib/webhooks/utils.server' + +export const calcomHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Cal-Signature-256', + validateFn: validateCalcomSignature, + providerLabel: 'Cal.com', + }), +} diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts new file mode 100644 index 00000000000..05d127c9859 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -0,0 +1,12 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateCirclebackSignature } from '@/lib/webhooks/utils.server' + +export const circlebackHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-signature', + validateFn: validateCirclebackSignature, + providerLabel: 'Circleback', + }), +} diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts new file mode 100644 index 00000000000..ad9bfdbe297 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -0,0 +1,41 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateJiraSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Confluence') + +export const confluenceHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Confluence', + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId) { + const { isConfluencePayloadMatch } = await import('@/triggers/confluence/utils') + if (!isConfluencePayloadMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + message: 'Payload does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts new file mode 100644 index 00000000000..aba1b9d727d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -0,0 +1,12 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateFirefliesSignature } from '@/lib/webhooks/utils.server' + +export const firefliesHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-hub-signature', + validateFn: validateFirefliesSignature, + providerLabel: 'Fireflies', + }), +} diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts new file mode 100644 index 00000000000..e65cec2141c --- /dev/null +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -0,0 +1,139 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventFilterContext, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Generic') + +export const genericHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + if (providerConfig.requireAuth) { + const configToken = providerConfig.token as string | undefined + if (!configToken) { + return new NextResponse('Unauthorized - Authentication required but not configured', { + status: 401, + }) + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, configToken, secretHeaderName)) { + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } + } + + const allowedIps = providerConfig.allowedIps + if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) { + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) { + logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`) + return new NextResponse('Forbidden - IP not allowed', { + status: 403, + }) + } + } + + return null + }, + + enrichHeaders({ body, providerConfig }: EventFilterContext, headers: Record) { + const idempotencyField = providerConfig.idempotencyField as string | undefined + if (idempotencyField && body) { + const value = idempotencyField + .split('.') + .reduce( + (acc: unknown, key: string) => + acc && typeof acc === 'object' ? (acc as Record)[key] : undefined, + body + ) + if (value !== undefined && value !== null && typeof value !== 'object') { + headers['x-sim-idempotency-key'] = String(value) + } + } + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.responseMode === 'custom') { + const rawCode = Number(providerConfig.responseStatusCode) || 200 + const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 + const responseBody = (providerConfig.responseBody as string | undefined)?.trim() + + if (!responseBody) { + return new NextResponse(null, { status: statusCode }) + } + + try { + const parsed = JSON.parse(responseBody) + return NextResponse.json(parsed, { status: statusCode }) + } catch { + return new NextResponse(responseBody, { + status: statusCode, + headers: { 'Content-Type': 'text/plain' }, + }) + } + } + + return null + }, + + async processInputFiles({ + input, + blocks, + blockId, + workspaceId, + workflowId, + executionId, + requestId, + userId, + }: ProcessFilesContext) { + const triggerBlock = blocks[blockId] as Record | undefined + const subBlocks = triggerBlock?.subBlocks as Record | undefined + const inputFormatBlock = subBlocks?.inputFormat as Record | undefined + + if (inputFormatBlock?.value) { + const inputFormat = inputFormatBlock.value as Array<{ + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' + }> + + const fileFields = inputFormat.filter((field) => field.type === 'file[]') + + if (fileFields.length > 0) { + const { processExecutionFiles } = await import('@/lib/execution/files') + const executionContext = { + workspaceId, + workflowId, + executionId, + } + + for (const fileField of fileFields) { + const fieldValue = input[fileField.name] + + if (fieldValue && typeof fieldValue === 'object') { + const uploadedFiles = await processExecutionFiles( + fieldValue, + executionContext, + requestId, + userId + ) + + if (uploadedFiles.length > 0) { + input[fileField.name] = uploadedFiles + logger.info( + `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` + ) + } + } + } + } + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts new file mode 100644 index 00000000000..86580fe6d3d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { validateGitHubSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:GitHub') + +export const githubHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = + request.headers.get('X-Hub-Signature-256') || request.headers.get('X-Hub-Signature') + if (!signature) { + logger.warn(`[${requestId}] GitHub webhook missing signature header`) + return new NextResponse('Unauthorized - Missing GitHub signature', { status: 401 }) + } + + if (!validateGitHubSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] GitHub signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + usingSha256: !!request.headers.get('X-Hub-Signature-256'), + }) + return new NextResponse('Unauthorized - Invalid GitHub signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ + webhook, + workflow, + body, + request, + requestId, + providerConfig, + }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'github_webhook') { + const eventType = request.headers.get('x-github-event') + const action = obj.action as string | undefined + + const { isGitHubEventMatch } = await import('@/triggers/github/utils') + if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) { + logger.debug( + `[${requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + receivedAction: action, + } + ) + return false + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts new file mode 100644 index 00000000000..29fccfb250b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -0,0 +1,23 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:GoogleForms') + +export const googleFormsHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + const expectedToken = providerConfig.token as string | undefined + if (!expectedToken) { + return null + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, expectedToken, secretHeaderName)) { + logger.warn(`[${requestId}] Google Forms webhook authentication failed`) + return new NextResponse('Unauthorized - Invalid secret', { status: 401 }) + } + + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts new file mode 100644 index 00000000000..f12af9c0686 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Grain') + +export const grainHandler: WebhookProviderHandler = { + handleReachabilityTest(body: unknown, requestId: string) { + const obj = body as Record | null + const isVerificationRequest = !obj || Object.keys(obj).length === 0 || !obj.type + if (isVerificationRequest) { + logger.info( + `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` + ) + return NextResponse.json({ + status: 'ok', + message: 'Webhook endpoint verified', + }) + } + return null + }, + + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Grain', logger) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.type && data?.id) { + return `${obj.type}:${data.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts new file mode 100644 index 00000000000..6f45b01799d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -0,0 +1,52 @@ +import { createLogger } from '@sim/logger' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:HubSpot') + +export const hubspotHandler: WebhookProviderHandler = { + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId?.startsWith('hubspot_')) { + const events = Array.isArray(body) ? body : [body] + const firstEvent = events[0] as Record | undefined + const subscriptionType = firstEvent?.subscriptionType as string | undefined + + const { isHubSpotContactEventMatch } = await import('@/triggers/hubspot/utils') + if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { + logger.debug( + `[${requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + return false + } + + logger.info( + `[${requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + } + + return true + }, + + extractIdempotencyId(body: unknown) { + if (Array.isArray(body) && body.length > 0) { + const first = body[0] as Record + if (first?.eventId) { + return String(first.eventId) + } + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/index.ts b/apps/sim/lib/webhooks/providers/index.ts new file mode 100644 index 00000000000..dd43ec6c223 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/index.ts @@ -0,0 +1,24 @@ +export { getProviderHandler } from '@/lib/webhooks/providers/registry' +export type { + AuthContext, + EventFilterContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +import { getProviderHandler } from '@/lib/webhooks/providers/registry' + +/** + * Extract a provider-specific unique identifier from the webhook body for idempotency. + */ +export function extractProviderIdentifierFromBody(provider: string, body: unknown): string | null { + if (!body || typeof body !== 'object') { + return null + } + + const handler = getProviderHandler(provider) + return handler.extractIdempotencyId?.(body) ?? null +} diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts new file mode 100644 index 00000000000..068fcdedb84 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateJiraSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Jira') + +export const jiraHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Jira', + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'jira_webhook') { + const webhookEvent = obj.webhookEvent as string | undefined + const issueEventTypeName = obj.issue_event_type_name as string | undefined + + const { isJiraEventMatch } = await import('@/triggers/jira/utils') + if (!isJiraEventMatch(triggerId, webhookEvent || '', issueEventTypeName)) { + logger.debug( + `[${requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: webhookEvent, + } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const issue = obj.issue as Record | undefined + const project = obj.project as Record | undefined + if (obj.webhookEvent && (issue?.id || project?.id)) { + return `${obj.webhookEvent}:${issue?.id || project?.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts new file mode 100644 index 00000000000..98b3136a462 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -0,0 +1,21 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateLinearSignature } from '@/lib/webhooks/utils.server' + +export const linearHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'Linear-Signature', + validateFn: validateLinearSignature, + providerLabel: 'Linear', + }), + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.action && data?.id) { + return `${obj.action}:${data.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts new file mode 100644 index 00000000000..b7d9b23c836 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventFilterContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { validateMicrosoftTeamsSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:MicrosoftTeams') + +function parseFirstNotification( + body: unknown +): { subscriptionId: string; messageId: string } | null { + const obj = body as Record + const value = obj.value as unknown[] | undefined + if (!Array.isArray(value) || value.length === 0) { + return null + } + + const notification = value[0] as Record + const subscriptionId = notification.subscriptionId as string | undefined + const resourceData = notification.resourceData as Record | undefined + const messageId = resourceData?.id as string | undefined + + if (subscriptionId && messageId) { + return { subscriptionId, messageId } + } + return null +} + +export const microsoftTeamsHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + if (providerConfig.hmacSecret) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('HMAC ')) { + logger.warn( + `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` + ) + return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) + } + + if ( + !validateMicrosoftTeamsSignature(providerConfig.hmacSecret as string, authHeader, rawBody) + ) { + logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) + return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) + } + } + + return null + }, + + formatErrorResponse(error: string, status: number) { + return NextResponse.json({ type: 'message', text: error }, { status }) + }, + + enrichHeaders({ body }: EventFilterContext, headers: Record) { + const parsed = parseFirstNotification(body) + if (parsed) { + headers['x-teams-notification-id'] = `${parsed.subscriptionId}:${parsed.messageId}` + } + }, + + extractIdempotencyId(body: unknown) { + const parsed = parseFirstNotification(body) + return parsed ? `${parsed.subscriptionId}:${parsed.messageId}` : null + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.triggerId === 'microsoftteams_chat_subscription') { + return new NextResponse(null, { status: 202 }) + } + + return NextResponse.json({ type: 'message', text: 'Sim' }) + }, + + formatQueueErrorResponse() { + return NextResponse.json( + { type: 'message', text: 'Webhook processing failed' }, + { status: 500 } + ) + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts new file mode 100644 index 00000000000..426383201b2 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -0,0 +1,77 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { airtableHandler } from '@/lib/webhooks/providers/airtable' +import { ashbyHandler } from '@/lib/webhooks/providers/ashby' +import { attioHandler } from '@/lib/webhooks/providers/attio' +import { calcomHandler } from '@/lib/webhooks/providers/calcom' +import { circlebackHandler } from '@/lib/webhooks/providers/circleback' +import { confluenceHandler } from '@/lib/webhooks/providers/confluence' +import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' +import { genericHandler } from '@/lib/webhooks/providers/generic' +import { githubHandler } from '@/lib/webhooks/providers/github' +import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' +import { grainHandler } from '@/lib/webhooks/providers/grain' +import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' +import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { linearHandler } from '@/lib/webhooks/providers/linear' +import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' +import { slackHandler } from '@/lib/webhooks/providers/slack' +import { stripeHandler } from '@/lib/webhooks/providers/stripe' +import { telegramHandler } from '@/lib/webhooks/providers/telegram' +import { twilioHandler } from '@/lib/webhooks/providers/twilio' +import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice' +import { typeformHandler } from '@/lib/webhooks/providers/typeform' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { webflowHandler } from '@/lib/webhooks/providers/webflow' +import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp' + +const logger = createLogger('WebhookProviderRegistry') + +const PROVIDER_HANDLERS: Record = { + airtable: airtableHandler, + ashby: ashbyHandler, + attio: attioHandler, + calcom: calcomHandler, + circleback: circlebackHandler, + confluence: confluenceHandler, + fireflies: firefliesHandler, + generic: genericHandler, + github: githubHandler, + google_forms: googleFormsHandler, + grain: grainHandler, + hubspot: hubspotHandler, + jira: jiraHandler, + linear: linearHandler, + 'microsoft-teams': microsoftTeamsHandler, + slack: slackHandler, + stripe: stripeHandler, + telegram: telegramHandler, + twilio: twilioHandler, + twilio_voice: twilioVoiceHandler, + typeform: typeformHandler, + webflow: webflowHandler, + whatsapp: whatsappHandler, +} + +/** + * Default handler for unknown/future providers. + * Uses timing-safe comparison for bearer token validation. + */ +const defaultHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }) { + const token = providerConfig.token + if (typeof token === 'string') { + if (!verifyTokenAuth(request, token)) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + return null + }, +} + +/** Look up the provider handler, falling back to the default bearer token handler. */ +export function getProviderHandler(provider: string): WebhookProviderHandler { + return PROVIDER_HANDLERS[provider] ?? defaultHandler +} diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts new file mode 100644 index 00000000000..836628c3225 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +export const slackHandler: WebhookProviderHandler = { + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.event_id) { + return String(obj.event_id) + } + + const event = obj.event as Record | undefined + if (event?.ts && obj.team_id) { + return `${obj.team_id}:${event.ts}` + } + + return null + }, + + formatSuccessResponse() { + return new NextResponse(null, { status: 200 }) + }, + + formatQueueErrorResponse() { + return new NextResponse(null, { status: 200 }) + }, +} diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts new file mode 100644 index 00000000000..1faf65ee187 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -0,0 +1,19 @@ +import { createLogger } from '@sim/logger' +import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Stripe') + +export const stripeHandler: WebhookProviderHandler = { + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Stripe', logger) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.object === 'event') { + return String(obj.id) + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts new file mode 100644 index 00000000000..1c7e853deb9 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -0,0 +1,16 @@ +import { createLogger } from '@sim/logger' +import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Telegram') + +export const telegramHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId }: AuthContext) { + const userAgent = request.headers.get('user-agent') + if (!userAgent) { + logger.warn( + `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` + ) + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts new file mode 100644 index 00000000000..97629ecd567 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' +import { validateTwilioSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:TwilioVoice') + +function getExternalUrl(request: Request): string { + const proto = request.headers.get('x-forwarded-proto') || 'https' + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') + + if (host) { + const url = new URL(request.url) + const reconstructed = `${proto}://${host}${url.pathname}${url.search}` + return reconstructed + } + + return request.url +} + +export const twilioVoiceHandler: WebhookProviderHandler = { + async verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const authToken = providerConfig.authToken as string | undefined + + if (authToken) { + const signature = request.headers.get('x-twilio-signature') + + if (!signature) { + logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Twilio signature', { + status: 401, + }) + } + + let params: Record = {} + try { + if (typeof rawBody === 'string') { + const urlParams = new URLSearchParams(rawBody) + params = Object.fromEntries(urlParams.entries()) + } + } catch (error) { + logger.error( + `[${requestId}] Error parsing Twilio webhook body for signature validation:`, + error + ) + return new NextResponse('Bad Request - Invalid body format', { + status: 400, + }) + } + + const fullUrl = getExternalUrl(request) + const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { + url: fullUrl, + signatureLength: signature.length, + paramsCount: Object.keys(params).length, + authTokenLength: authToken.length, + }) + return new NextResponse('Unauthorized - Invalid Twilio signature', { + status: 401, + }) + } + } + + return null + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, + + formatSuccessResponse(providerConfig: Record) { + const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() + + if (twimlResponse && twimlResponse.length > 0) { + const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) + return new NextResponse(convertedTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + } + + const defaultTwiml = ` + + Your call is being processed. + +` + + return new NextResponse(defaultTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + }, + + formatQueueErrorResponse() { + const errorTwiml = ` + + We're sorry, but an error occurred processing your call. Please try again later. + +` + + return new NextResponse(errorTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml', + }, + }) + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio.ts b/apps/sim/lib/webhooks/providers/twilio.ts new file mode 100644 index 00000000000..3ba33decc11 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio.ts @@ -0,0 +1,8 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +export const twilioHandler: WebhookProviderHandler = { + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, +} diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts new file mode 100644 index 00000000000..611a5e8844a --- /dev/null +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -0,0 +1,12 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { validateTypeformSignature } from '@/lib/webhooks/utils.server' + +export const typeformHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'secret', + headerName: 'Typeform-Signature', + validateFn: validateTypeformSignature, + providerLabel: 'Typeform', + }), +} diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts new file mode 100644 index 00000000000..bf08b7782e2 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -0,0 +1,98 @@ +import type { NextRequest, NextResponse } from 'next/server' + +/** Context for signature/token verification. */ +export interface AuthContext { + webhook: Record + workflow: Record + request: NextRequest + rawBody: string + requestId: string + providerConfig: Record +} + +/** Context for event matching against trigger configuration. */ +export interface EventMatchContext { + webhook: Record + workflow: Record + body: unknown + request: NextRequest + requestId: string + providerConfig: Record +} + +/** Context for event filtering and header enrichment. */ +export interface EventFilterContext { + webhook: Record + body: unknown + requestId: string + providerConfig: Record +} + +/** Context for custom input preparation during execution. */ +export interface FormatInputContext { + webhook: Record + workflow: { id: string; userId: string } + body: unknown + headers: Record + requestId: string +} + +/** Result of custom input preparation. */ +export interface FormatInputResult { + input: unknown + skip?: { message: string } +} + +/** Context for provider-specific file processing before execution. */ +export interface ProcessFilesContext { + input: Record + blocks: Record + blockId: string + workspaceId: string + workflowId: string + executionId: string + requestId: string + userId: string +} + +/** + * Strategy interface for provider-specific webhook behavior. + * Each provider implements only the methods it needs — all methods are optional. + */ +export interface WebhookProviderHandler { + /** Verify signature/auth. Return NextResponse(401/403) on failure, null on success. */ + verifyAuth?(ctx: AuthContext): Promise | NextResponse | null + + /** Handle reachability/verification probes after webhook lookup. */ + handleReachabilityTest?(body: unknown, requestId: string): NextResponse | null + + /** Format error responses (some providers need special formats). */ + formatErrorResponse?(error: string, status: number): NextResponse + + /** Return true to skip this event (filtering by event type, collection, etc.). */ + shouldSkipEvent?(ctx: EventFilterContext): boolean + + /** Return true if event matches, false or NextResponse to skip with a custom response. */ + matchEvent?(ctx: EventMatchContext): Promise | boolean | NextResponse + + /** Add provider-specific headers (idempotency keys, notification IDs, etc.). */ + enrichHeaders?(ctx: EventFilterContext, headers: Record): void + + /** Extract unique identifier for idempotency dedup. */ + extractIdempotencyId?(body: unknown): string | null + + /** Custom success response after queuing. Return null for default `{message: "Webhook processed"}`. */ + formatSuccessResponse?(providerConfig: Record): NextResponse | null + + /** Custom error response when queuing fails. Return null for default 500. */ + formatQueueErrorResponse?(): NextResponse | null + + /** Custom input preparation. Replaces the standard `formatWebhookInput` call when defined. */ + formatInput?(ctx: FormatInputContext): Promise + + /** Called when standard `formatWebhookInput` returns null. Return skip message or null to proceed. */ + handleEmptyInput?(requestId: string): { message: string } | null + + /** Post-process input to handle file uploads before execution. */ + processInputFiles?(ctx: ProcessFilesContext): Promise +} diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts new file mode 100644 index 00000000000..f2a56047081 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -0,0 +1,102 @@ +import type { Logger } from '@sim/logger' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProviderAuth') + +interface HmacVerifierOptions { + configKey: string + headerName: string + validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise + providerLabel: string +} + +/** + * Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers. + * Covers the common pattern: get secret → check header → validate signature → return 401 or null. + */ +export function createHmacVerifier({ + configKey, + headerName, + validateFn, + providerLabel, +}: HmacVerifierOptions) { + return async ({ + request, + rawBody, + requestId, + providerConfig, + }: AuthContext): Promise => { + const secret = providerConfig[configKey] as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get(headerName) + if (!signature) { + logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`) + return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 }) + } + + const isValid = await validateFn(secret, signature, rawBody) + if (!isValid) { + logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 }) + } + + return null + } +} + +/** + * Verify a bearer token or custom header token using timing-safe comparison. + * Used by generic webhooks, Google Forms, and the default handler. + */ +export function verifyTokenAuth( + request: Request, + expectedToken: string, + secretHeaderName?: string +): boolean { + if (secretHeaderName) { + const headerValue = request.headers.get(secretHeaderName.toLowerCase()) + return !!headerValue && safeCompare(headerValue, expectedToken) + } + + const authHeader = request.headers.get('authorization') + if (authHeader?.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7) + return safeCompare(token, expectedToken) + } + + return false +} + +/** + * Skip events whose `body.type` is not in the `providerConfig.eventTypes` allowlist. + * Shared by providers that use a simple event-type filter (Stripe, Grain, etc.). + */ +export function skipByEventTypes( + { webhook, body, requestId, providerConfig }: EventFilterContext, + providerLabel: string, + eventLogger: Logger +): boolean { + const eventTypes = providerConfig.eventTypes + if (!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0) { + return false + } + + const eventType = (body as Record)?.type as string | undefined + if (eventType && !eventTypes.includes(eventType)) { + eventLogger.info( + `[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping` + ) + return true + } + + return false +} diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts new file mode 100644 index 00000000000..cc6b3b95bd7 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -0,0 +1,23 @@ +import { createLogger } from '@sim/logger' +import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Webflow') + +export const webflowHandler: WebhookProviderHandler = { + shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) { + const configuredCollectionId = providerConfig.collectionId as string | undefined + if (configuredCollectionId) { + const obj = body as Record + const payload = obj.payload as Record | undefined + const payloadCollectionId = (payload?.collectionId ?? obj.collectionId) as string | undefined + + if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { + logger.info( + `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping` + ) + return true + } + } + return false + }, +} diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts new file mode 100644 index 00000000000..414e1bfa047 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -0,0 +1,11 @@ +import { createLogger } from '@sim/logger' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:WhatsApp') + +export const whatsappHandler: WebhookProviderHandler = { + handleEmptyInput(requestId: string) { + logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) + return { message: 'No messages in WhatsApp payload' } + }, +} diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 49c29227ff4..8439d146d91 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1710,97 +1710,6 @@ export function validateGitHubSignature(secret: string, signature: string, body: } } -/** - * Process webhook provider-specific verification - */ -export function verifyProviderWebhook( - foundWebhook: any, - request: NextRequest, - requestId: string -): NextResponse | null { - const authHeader = request.headers.get('authorization') - const providerConfig = (foundWebhook.providerConfig as Record) || {} - switch (foundWebhook.provider) { - case 'github': - break - case 'stripe': - break - case 'gmail': - break - case 'telegram': { - // Check User-Agent to ensure it's not blocked by middleware - const userAgent = request.headers.get('user-agent') || '' - - if (!userAgent) { - logger.warn( - `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` - ) - } - - // Telegram uses IP addresses in specific ranges - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - break - } - case 'microsoft-teams': - break - case 'generic': - if (providerConfig.requireAuth) { - let isAuthenticated = false - if (providerConfig.token) { - const bearerMatch = authHeader?.match(/^bearer\s+(.+)$/i) - const providedToken = bearerMatch ? bearerMatch[1] : null - if (providedToken === providerConfig.token) { - isAuthenticated = true - } - if (!isAuthenticated && providerConfig.secretHeaderName) { - const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) - if (customHeaderValue === providerConfig.token) { - isAuthenticated = true - } - } - if (!isAuthenticated) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } - } - if ( - providerConfig.allowedIps && - Array.isArray(providerConfig.allowedIps) && - providerConfig.allowedIps.length > 0 - ) { - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { - logger.warn( - `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` - ) - return new NextResponse('Forbidden - IP not allowed', { - status: 403, - }) - } - } - break - default: - if (providerConfig.token) { - const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null - if (!providedToken || providedToken !== providerConfig.token) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - } - - return null -} - /** * Process Airtable payloads */ From ffa586459bed048ef658bfc1ebc495bfc75312f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 4 Apr 2026 20:56:45 -0700 Subject: [PATCH 02/18] fix(webhooks): address PR review feedback - Restore original fall-through behavior for generic requireAuth with no token - Replace `any` params with proper types in processor helper functions - Restore array-aware initializer in processTriggerFileOutputs --- apps/sim/background/webhook-execution.ts | 2 +- apps/sim/lib/webhooks/providers/generic.ts | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 83cea7a7216..e4ffe62d57c 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -69,7 +69,7 @@ async function processTriggerFileOutputs( return input } - const processed: Record = {} + const processed: Record | unknown[] = Array.isArray(input) ? [] : {} for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts index e65cec2141c..a45e34f7bc2 100644 --- a/apps/sim/lib/webhooks/providers/generic.ts +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -14,15 +14,11 @@ export const genericHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext) { if (providerConfig.requireAuth) { const configToken = providerConfig.token as string | undefined - if (!configToken) { - return new NextResponse('Unauthorized - Authentication required but not configured', { - status: 401, - }) - } - - const secretHeaderName = providerConfig.secretHeaderName as string | undefined - if (!verifyTokenAuth(request, configToken, secretHeaderName)) { - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + if (configToken) { + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, configToken, secretHeaderName)) { + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } } } From 403e32ff33dbda28b03593b5cefa01aa8beb563d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 4 Apr 2026 21:01:48 -0700 Subject: [PATCH 03/18] fix(webhooks): fix build error from union type indexing in processTriggerFileOutputs Cast array initializer to Record to allow string indexing while preserving array runtime semantics for the return value. --- apps/sim/background/webhook-execution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index e4ffe62d57c..793015585f2 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -69,7 +69,7 @@ async function processTriggerFileOutputs( return input } - const processed: Record | unknown[] = Array.isArray(input) ? [] : {} + const processed = (Array.isArray(input) ? [] : {}) as Record for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key From 5d9b95a904480432c6ff6ce47ffd83082ca1c4bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 09:45:53 -0700 Subject: [PATCH 04/18] fix(webhooks): return 401 when requireAuth is true but no token configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user explicitly sets requireAuth: true, they expect auth to be enforced. Returning 401 when no token is configured is the correct behavior — this is an intentional improvement over the original code which silently allowed unauthenticated access in this case. --- apps/sim/lib/webhooks/providers/generic.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts index a45e34f7bc2..797ad46ccd2 100644 --- a/apps/sim/lib/webhooks/providers/generic.ts +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -14,11 +14,15 @@ export const genericHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext) { if (providerConfig.requireAuth) { const configToken = providerConfig.token as string | undefined - if (configToken) { - const secretHeaderName = providerConfig.secretHeaderName as string | undefined - if (!verifyTokenAuth(request, configToken, secretHeaderName)) { - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } + if (!configToken) { + return new NextResponse('Unauthorized - Authentication required but no token configured', { + status: 401, + }) + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, configToken, secretHeaderName)) { + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) } } From 7b6b50bbd242cbabf272d027f56b00f79efbd71b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:03:07 -0700 Subject: [PATCH 05/18] refactor(webhooks): move signature validators into provider handler files Co-locate each validate*Signature function with its provider handler, eliminating the circular dependency where handlers imported back from utils.server.ts. validateJiraSignature is exported from jira.ts for shared use by confluence.ts. --- apps/sim/lib/webhooks/providers/ashby.ts | 19 +- apps/sim/lib/webhooks/providers/attio.ts | 18 +- apps/sim/lib/webhooks/providers/calcom.ts | 24 +- apps/sim/lib/webhooks/providers/circleback.ts | 21 +- apps/sim/lib/webhooks/providers/confluence.ts | 2 +- apps/sim/lib/webhooks/providers/fireflies.ts | 26 +- apps/sim/lib/webhooks/providers/github.ts | 30 +- apps/sim/lib/webhooks/providers/jira.ts | 23 +- apps/sim/lib/webhooks/providers/linear.ts | 21 +- .../lib/webhooks/providers/microsoft-teams.ts | 18 +- .../lib/webhooks/providers/twilio-voice.ts | 26 +- apps/sim/lib/webhooks/providers/typeform.ts | 19 +- apps/sim/lib/webhooks/utils.server.ts | 453 ------------------ 13 files changed, 235 insertions(+), 465 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 419777253ac..9d6d50b56cd 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,6 +1,23 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateAshbySignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Ashby') + +function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { + try { + if (!secretToken || !signature || !body) { return false } + if (!signature.startsWith('sha256=')) { return false } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Ashby signature:', error) + return false + } +} export const ashbyHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 0b5b73806e6..d60ddb2031b 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -1,14 +1,30 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventMatchContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { validateAttioSignature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:Attio') +function validateAttioSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Attio signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Attio signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Attio signature:', error) + return false + } +} + export const attioHandler: WebhookProviderHandler = { verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts index c4b288bf983..5340d77ce01 100644 --- a/apps/sim/lib/webhooks/providers/calcom.ts +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -1,6 +1,28 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateCalcomSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Calcom') + +function validateCalcomSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Cal.com signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + let providedSignature: string + if (signature.startsWith('sha256=')) { providedSignature = signature.substring(7) } + else { providedSignature = signature } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Cal.com signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Cal.com signature:', error) + return false + } +} export const calcomHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts index 05d127c9859..b1802d8e9c7 100644 --- a/apps/sim/lib/webhooks/providers/circleback.ts +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -1,6 +1,25 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateCirclebackSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Circleback') + +function validateCirclebackSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Circleback signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Circleback signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Circleback signature:', error) + return false + } +} export const circlebackHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts index ad9bfdbe297..8753535265b 100644 --- a/apps/sim/lib/webhooks/providers/confluence.ts +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateJiraSignature } from '@/lib/webhooks/utils.server' +import { validateJiraSignature } from '@/lib/webhooks/providers/jira' const logger = createLogger('WebhookProvider:Confluence') diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts index aba1b9d727d..9700bd57f1f 100644 --- a/apps/sim/lib/webhooks/providers/fireflies.ts +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -1,6 +1,30 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateFirefliesSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Fireflies') + +function validateFirefliesSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Fireflies signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Fireflies signature has invalid format (expected sha256=)', { signaturePrefix: signature.substring(0, 10) }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Fireflies signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Fireflies signature:', error) + return false + } +} export const firefliesHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts index 86580fe6d3d..a1ac8281554 100644 --- a/apps/sim/lib/webhooks/providers/github.ts +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -1,14 +1,42 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventMatchContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { validateGitHubSignature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:GitHub') +function validateGitHubSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('GitHub signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + let algorithm: 'sha256' | 'sha1' + let providedSignature: string + if (signature.startsWith('sha256=')) { + algorithm = 'sha256' + providedSignature = signature.substring(7) + } else if (signature.startsWith('sha1=')) { + algorithm = 'sha1' + providedSignature = signature.substring(5) + } else { + logger.warn('GitHub signature has invalid format', { signature: `${signature.substring(0, 10)}...` }) + return false + } + const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') + logger.debug('GitHub signature comparison', { algorithm, computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating GitHub signature:', error) + return false + } +} + export const githubHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index 068fcdedb84..083ae21d661 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -1,10 +1,31 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateJiraSignature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:Jira') +export function validateJiraSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Jira signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Jira signature has invalid format (expected sha256=)', { signaturePrefix: signature.substring(0, 10) }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Jira signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Jira signature:', error) + return false + } +} + export const jiraHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ configKey: 'webhookSecret', diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 98b3136a462..6d568a746f7 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -1,6 +1,25 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateLinearSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Linear') + +function validateLinearSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Linear signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Linear signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Linear signature:', error) + return false + } +} export const linearHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index b7d9b23c836..db483b485b1 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -1,14 +1,30 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventFilterContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { validateMicrosoftTeamsSignature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:MicrosoftTeams') +function validateMicrosoftTeamsSignature(hmacSecret: string, signature: string, body: string): boolean { + try { + if (!hmacSecret || !signature || !body) { return false } + if (!signature.startsWith('HMAC ')) { return false } + const providedSignature = signature.substring(5) + const secretBytes = Buffer.from(hmacSecret, 'base64') + const bodyBytes = Buffer.from(body, 'utf8') + const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Microsoft Teams signature:', error) + return false + } +} + function parseFirstNotification( body: unknown ): { subscriptionId: string; messageId: string } | null { diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index 97629ecd567..ebd16fe1649 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -1,11 +1,35 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' -import { validateTwilioSignature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:TwilioVoice') +async function validateTwilioSignature(authToken: string, signature: string, url: string, params: Record): Promise { + try { + if (!authToken || !signature || !url) { + logger.warn('Twilio signature validation missing required fields', { hasAuthToken: !!authToken, hasSignature: !!signature, hasUrl: !!url }) + return false + } + const sortedKeys = Object.keys(params).sort() + let data = url + for (const key of sortedKeys) { data += key + params[key] } + logger.debug('Twilio signature validation string built', { url, sortedKeys, dataLength: data.length }) + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey('raw', encoder.encode(authToken), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']) + const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) + const signatureArray = Array.from(new Uint8Array(signatureBytes)) + const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) + logger.debug('Twilio signature comparison', { computedSignature: `${signatureBase64.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: signatureBase64.length, providedLength: signature.length, match: signatureBase64 === signature }) + return safeCompare(signatureBase64, signature) + } catch (error) { + logger.error('Error validating Twilio signature:', error) + return false + } +} + function getExternalUrl(request: Request): string { const proto = request.headers.get('x-forwarded-proto') || 'https' const host = request.headers.get('x-forwarded-host') || request.headers.get('host') diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 611a5e8844a..600f1264925 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,6 +1,23 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validateTypeformSignature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:Typeform') + +function validateTypeformSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { return false } + if (!signature.startsWith('sha256=')) { return false } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Typeform signature:', error) + return false + } +} export const typeformHandler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8439d146d91..8c39d710436 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -481,63 +481,6 @@ async function formatTeamsGraphNotification( } } -export async function validateTwilioSignature( - authToken: string, - signature: string, - url: string, - params: Record -): Promise { - try { - if (!authToken || !signature || !url) { - logger.warn('Twilio signature validation missing required fields', { - hasAuthToken: !!authToken, - hasSignature: !!signature, - hasUrl: !!url, - }) - return false - } - - const sortedKeys = Object.keys(params).sort() - let data = url - for (const key of sortedKeys) { - data += key + params[key] - } - - logger.debug('Twilio signature validation string built', { - url, - sortedKeys, - dataLength: data.length, - }) - - const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(authToken), - { name: 'HMAC', hash: 'SHA-1' }, - false, - ['sign'] - ) - - const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) - - const signatureArray = Array.from(new Uint8Array(signatureBytes)) - const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) - - logger.debug('Twilio signature comparison', { - computedSignature: `${signatureBase64.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: signatureBase64.length, - providedLength: signature.length, - match: signatureBase64 === signature, - }) - - return safeCompare(signatureBase64, signature) - } catch (error) { - logger.error('Error validating Twilio signature:', error) - return false - } -} - const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB const SLACK_MAX_FILES = 15 @@ -1359,357 +1302,6 @@ export async function formatWebhookInput( return body } -/** - * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 - * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) - * @param signature - Authorization header value (should start with 'HMAC ') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateMicrosoftTeamsSignature( - hmacSecret: string, - signature: string, - body: string -): boolean { - try { - if (!hmacSecret || !signature || !body) { - return false - } - - if (!signature.startsWith('HMAC ')) { - return false - } - - const providedSignature = signature.substring(5) - - const secretBytes = Buffer.from(hmacSecret, 'base64') - const bodyBytes = Buffer.from(body, 'utf8') - const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Microsoft Teams signature:', error) - return false - } -} - -/** - * Validates a Typeform webhook request signature using HMAC SHA-256 - * @param secret - Typeform webhook secret (plain text) - * @param signature - Typeform-Signature header value (should be in format 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateTypeformSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Typeform signature:', error) - return false - } -} - -/** - * Validates a Linear webhook request signature using HMAC SHA-256 - * @param secret - Linear webhook secret (plain text) - * @param signature - Linear-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateLinearSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Linear signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Linear signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Linear signature:', error) - return false - } -} - -/** - * Validates an Attio webhook request signature using HMAC SHA-256 - * @param secret - Attio webhook signing secret (plain text) - * @param signature - Attio-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAttioSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Attio signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Attio signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Attio signature:', error) - return false - } -} - -/** - * Validates a Circleback webhook request signature using HMAC SHA-256 - * @param secret - Circleback signing secret (plain text) - * @param signature - x-signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCirclebackSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Circleback signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Circleback signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Circleback signature:', error) - return false - } -} - -/** - * Validates a Jira webhook request signature using HMAC SHA-256 - * @param secret - Jira webhook secret (plain text) - * @param signature - X-Hub-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateJiraSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Jira signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Jira signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Jira signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Jira signature:', error) - return false - } -} - -/** - * Validates a Fireflies webhook request signature using HMAC SHA-256 - * @param secret - Fireflies webhook secret (16-32 characters) - * @param signature - x-hub-signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateFirefliesSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Fireflies signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Fireflies signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Fireflies signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Fireflies signature:', error) - return false - } -} - -/** - * Validates an Ashby webhook signature using HMAC-SHA256. - * Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header. - * @param secretToken - The secret token configured when creating the webhook - * @param signature - Ashby-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAshbySignature( - secretToken: string, - signature: string, - body: string -): boolean { - try { - if (!secretToken || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Ashby signature:', error) - return false - } -} - -/** - * Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1 - * @param secret - GitHub webhook secret (plain text) - * @param signature - X-Hub-Signature-256 or X-Hub-Signature header value (format: 'sha256=' or 'sha1=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateGitHubSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('GitHub signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - let algorithm: 'sha256' | 'sha1' - let providedSignature: string - - if (signature.startsWith('sha256=')) { - algorithm = 'sha256' - providedSignature = signature.substring(7) - } else if (signature.startsWith('sha1=')) { - algorithm = 'sha1' - providedSignature = signature.substring(5) - } else { - logger.warn('GitHub signature has invalid format', { - signature: `${signature.substring(0, 10)}...`, - }) - return false - } - - const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') - - logger.debug('GitHub signature comparison', { - algorithm, - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating GitHub signature:', error) - return false - } -} - /** * Process Airtable payloads */ @@ -2773,48 +2365,3 @@ export function convertSquareBracketsToTwiML(twiml: string | undefined): string // Replace [Tag] with and [/Tag] with return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') } - -/** - * Validates a Cal.com webhook request signature using HMAC SHA-256 - * @param secret - Cal.com webhook secret (plain text) - * @param signature - X-Cal-Signature-256 header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCalcomSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Cal.com signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - // Cal.com sends signature in format: sha256= - // We need to strip the prefix before comparing - let providedSignature: string - if (signature.startsWith('sha256=')) { - providedSignature = signature.substring(7) - } else { - // If no prefix, use as-is (for backwards compatibility) - providedSignature = signature - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Cal.com signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Cal.com signature:', error) - return false - } -} From 1f92950dcec19440b19dae413be89723e78cbc28 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:04:09 -0700 Subject: [PATCH 06/18] refactor(webhooks): move challenge handlers into provider files Move handleWhatsAppVerification to providers/whatsapp.ts and handleSlackChallenge to providers/slack.ts. Update processor.ts imports to point to provider files. --- apps/sim/lib/webhooks/providers/slack.ts | 12 ++++ apps/sim/lib/webhooks/providers/whatsapp.ts | 70 +++++++++++++++++++ apps/sim/lib/webhooks/utils.server.ts | 77 --------------------- 3 files changed, 82 insertions(+), 77 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 836628c3225..4d0df403f72 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,6 +1,18 @@ import { NextResponse } from 'next/server' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +/** + * Handle Slack verification challenges + */ +export function handleSlackChallenge(body: unknown): NextResponse | null { + const obj = body as Record + if (obj.type === 'url_verification' && obj.challenge) { + return NextResponse.json({ challenge: obj.challenge }) + } + + return null +} + export const slackHandler: WebhookProviderHandler = { extractIdempotencyId(body: unknown) { const obj = body as Record diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 414e1bfa047..7b95cd1b530 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,8 +1,78 @@ +import { db, workflowDeploymentVersion } from '@sim/db' +import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { and, eq, isNull, or } from 'drizzle-orm' +import { NextResponse } from 'next/server' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:WhatsApp') +/** + * Handle WhatsApp verification requests + */ +export async function handleWhatsAppVerification( + requestId: string, + path: string, + mode: string | null, + token: string | null, + challenge: string | null +): Promise { + if (mode && token && challenge) { + logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) + + if (mode !== 'subscribe') { + logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) + return new NextResponse('Invalid mode', { status: 400 }) + } + + const webhooks = await db + .select({ webhook }) + .from(webhook) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, webhook.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, 'whatsapp'), + eq(webhook.isActive, true), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + for (const row of webhooks) { + const wh = row.webhook + const providerConfig = (wh.providerConfig as Record) || {} + const verificationToken = providerConfig.verificationToken + + if (!verificationToken) { + continue + } + + if (token === verificationToken) { + logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) + return new NextResponse(challenge, { + status: 200, + headers: { + 'Content-Type': 'text/plain', + }, + }) + } + } + + logger.warn(`[${requestId}] No matching WhatsApp verification token found`) + return new NextResponse('Verification failed', { status: 403 }) + } + + return null +} + export const whatsappHandler: WebhookProviderHandler = { handleEmptyInput(requestId: string) { logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8c39d710436..1584e024f8d 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -24,83 +24,6 @@ import { isPollingWebhookProvider } from '@/triggers/constants' const logger = createLogger('WebhookUtils') -/** - * Handle WhatsApp verification requests - */ -export async function handleWhatsAppVerification( - requestId: string, - path: string, - mode: string | null, - token: string | null, - challenge: string | null -): Promise { - if (mode && token && challenge) { - logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) - - if (mode !== 'subscribe') { - logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) - return new NextResponse('Invalid mode', { status: 400 }) - } - - const webhooks = await db - .select({ webhook }) - .from(webhook) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, webhook.workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.provider, 'whatsapp'), - eq(webhook.isActive, true), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - - for (const row of webhooks) { - const wh = row.webhook - const providerConfig = (wh.providerConfig as Record) || {} - const verificationToken = providerConfig.verificationToken - - if (!verificationToken) { - continue - } - - if (token === verificationToken) { - logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) - return new NextResponse(challenge, { - status: 200, - headers: { - 'Content-Type': 'text/plain', - }, - }) - } - } - - logger.warn(`[${requestId}] No matching WhatsApp verification token found`) - return new NextResponse('Verification failed', { status: 403 }) - } - - return null -} - -/** - * Handle Slack verification challenges - */ -export function handleSlackChallenge(body: any): NextResponse | null { - if (body.type === 'url_verification' && body.challenge) { - return NextResponse.json({ challenge: body.challenge }) - } - - return null -} - /** * Fetches a URL with DNS pinning to prevent DNS rebinding attacks * @param url - The URL to fetch From 46a1ea039aedff16f5a8c0ff496bb79cddd3f358 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:08:34 -0700 Subject: [PATCH 07/18] refactor(webhooks): move fetchAndProcessAirtablePayloads into airtable handler Co-locate the ~400-line Airtable payload processing function with its provider handler. Remove AirtableChange interface from utils.server.ts. --- .../api/webhooks/trigger/[path]/route.test.ts | 3 - apps/sim/lib/webhooks/providers/airtable.ts | 415 +++++++++++++++++- apps/sim/lib/webhooks/utils.server.ts | 407 ----------------- 3 files changed, 414 insertions(+), 411 deletions(-) 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 2c4b65facf2..88073b11cba 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -97,7 +97,6 @@ const { handleSlackChallengeMock, processWhatsAppDeduplicationMock, processGenericDeduplicationMock, - fetchAndProcessAirtablePayloadsMock, processWebhookMock, executeMock, getWorkspaceBilledAccountUserIdMock, @@ -109,7 +108,6 @@ const { handleSlackChallengeMock: vi.fn().mockReturnValue(null), processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null), processGenericDeduplicationMock: vi.fn().mockResolvedValue(null), - fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined), processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })), executeMock: vi.fn().mockResolvedValue({ success: true, @@ -158,7 +156,6 @@ vi.mock('@/lib/webhooks/utils', () => ({ handleSlackChallenge: handleSlackChallengeMock, processWhatsAppDeduplication: processWhatsAppDeduplicationMock, processGenericDeduplication: processGenericDeduplicationMock, - fetchAndProcessAirtablePayloads: fetchAndProcessAirtablePayloadsMock, processWebhook: processWebhookMock, })) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 6fd9c6caee3..1fd616037e7 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -1,9 +1,422 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import type { FormatInputContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { fetchAndProcessAirtablePayloads } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:Airtable') +interface AirtableChange { + tableId: string + recordId: string + changeType: 'created' | 'updated' + changedFields: Record // { fieldId: newValue } + previousFields?: Record // { fieldId: previousValue } (optional) +} + +/** + * Process Airtable payloads + */ +async function fetchAndProcessAirtablePayloads( + webhookData: any, + workflowData: any, + requestId: string // Original request ID from the ping, used for the final execution log +) { + // Logging handles all error logging + let currentCursor: number | null = null + let mightHaveMore = true + let payloadsFetched = 0 + let apiCallCount = 0 + // Use a Map to consolidate changes per record ID + const consolidatedChangesMap = new Map() + // Capture raw payloads from Airtable for exposure to workflows + const allPayloads = [] + const localProviderConfig = { + ...((webhookData.providerConfig as Record) || {}), + } + + try { + // --- Essential IDs & Config from localProviderConfig --- + const baseId = localProviderConfig.baseId + const airtableWebhookId = localProviderConfig.externalId + + if (!baseId || !airtableWebhookId) { + logger.error( + `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` + ) + return + } + + const credentialId: string | undefined = localProviderConfig.credentialId + if (!credentialId) { + logger.error( + `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` + ) + return + } + + const resolvedAirtable = await resolveOAuthAccountId(credentialId) + if (!resolvedAirtable) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` + ) + return + } + + let ownerUserId: string | null = null + try { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedAirtable.accountId)) + .limit(1) + ownerUserId = rows.length ? rows[0].userId : null + } catch (_e) { + ownerUserId = null + } + + if (!ownerUserId) { + logger.error( + `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` + ) + return + } + + const storedCursor = localProviderConfig.externalWebhookCursor + + if (storedCursor === undefined || storedCursor === null) { + logger.info( + `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` + ) + localProviderConfig.externalWebhookCursor = null + + try { + await db + .update(webhook) + .set({ + providerConfig: { + ...localProviderConfig, + externalWebhookCursor: null, + }, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id)) + + localProviderConfig.externalWebhookCursor = null + logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) + } catch (initError: any) { + logger.error(`[${requestId}] Failed to initialize cursor in DB`, { + webhookId: webhookData.id, + error: initError.message, + stack: initError.stack, + }) + } + } + + if (storedCursor && typeof storedCursor === 'number') { + currentCursor = storedCursor + } else { + currentCursor = null + } + + let accessToken: string | null = null + try { + accessToken = await refreshAccessTokenIfNeeded( + resolvedAirtable.accountId, + ownerUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` + ) + throw new Error('Airtable access token not found.') + } + } catch (tokenError: any) { + logger.error( + `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, + { + error: tokenError.message, + stack: tokenError.stack, + credentialId, + } + ) + return + } + + const airtableApiBase = 'https://api.airtable.com/v0' + + // --- Polling Loop --- + while (mightHaveMore) { + apiCallCount++ + // Safety break + if (apiCallCount > 10) { + mightHaveMore = false + break + } + + const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` + const queryParams = new URLSearchParams() + if (currentCursor !== null) { + queryParams.set('cursor', currentCursor.toString()) + } + const fullUrl = `${apiUrl}?${queryParams.toString()}` + + try { + const fetchStartTime = Date.now() + const response = await fetch(fullUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + const responseBody = await response.json() + + if (!response.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || + responseBody.error || + `Airtable API error Status ${response.status}` + logger.error( + `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, + { + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + } + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + + const receivedPayloads = responseBody.payloads || [] + + // --- Process and Consolidate Changes --- + if (receivedPayloads.length > 0) { + payloadsFetched += receivedPayloads.length + // Keep the raw payloads for later exposure to the workflow + for (const p of receivedPayloads) { + allPayloads.push(p) + } + let changeCount = 0 + for (const payload of receivedPayloads) { + if (payload.changedTablesById) { + for (const [tableId, tableChangesUntyped] of Object.entries( + payload.changedTablesById + )) { + const tableChanges = tableChangesUntyped as any // Assert type + + // Handle created records + if (tableChanges.createdRecordsById) { + const createdCount = Object.keys(tableChanges.createdRecordsById).length + changeCount += createdCount + + for (const [recordId, recordDataUntyped] of Object.entries( + tableChanges.createdRecordsById + )) { + const recordData = recordDataUntyped as any // Assert type + const existingChange = consolidatedChangesMap.get(recordId) + if (existingChange) { + // Record was created and possibly updated within the same batch + existingChange.changedFields = { + ...existingChange.changedFields, + ...(recordData.cellValuesByFieldId || {}), + } + // Keep changeType as 'created' if it started as created + } else { + // New creation + consolidatedChangesMap.set(recordId, { + tableId: tableId, + recordId: recordId, + changeType: 'created', + changedFields: recordData.cellValuesByFieldId || {}, + }) + } + } + } + + // Handle updated records + if (tableChanges.changedRecordsById) { + const updatedCount = Object.keys(tableChanges.changedRecordsById).length + changeCount += updatedCount + + for (const [recordId, recordDataUntyped] of Object.entries( + tableChanges.changedRecordsById + )) { + const recordData = recordDataUntyped as any // Assert type + const existingChange = consolidatedChangesMap.get(recordId) + const currentFields = recordData.current?.cellValuesByFieldId || {} + + if (existingChange) { + // Existing record was updated again + existingChange.changedFields = { + ...existingChange.changedFields, + ...currentFields, + } + // Ensure type is 'updated' if it was previously 'created' + existingChange.changeType = 'updated' + // Do not update previousFields again + } else { + // First update for this record in the batch + const newChange: AirtableChange = { + tableId: tableId, + recordId: recordId, + changeType: 'updated', + changedFields: currentFields, + } + if (recordData.previous?.cellValuesByFieldId) { + newChange.previousFields = recordData.previous.cellValuesByFieldId + } + consolidatedChangesMap.set(recordId, newChange) + } + } + } + // TODO: Handle deleted records (`destroyedRecordIds`) if needed + } + } + } + } + + const nextCursor = responseBody.cursor + mightHaveMore = responseBody.mightHaveMore || false + + if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { + currentCursor = nextCursor + + // Follow exactly the old implementation - use awaited update instead of parallel + const updatedConfig = { + ...localProviderConfig, + externalWebhookCursor: currentCursor, + } + try { + // Force a complete object update to ensure consistency in serverless env + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, // Use full object + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id)) + + localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too + } catch (dbError: any) { + logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { + webhookId: webhookData.id, + cursor: currentCursor, + error: dbError.message, + }) + // Error logging handled by logging session + mightHaveMore = false + throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly + } + } else if (!nextCursor || typeof nextCursor !== 'number') { + logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { + webhookId: webhookData.id, + apiCall: apiCallCount, + receivedCursor: nextCursor, + }) + mightHaveMore = false + } else if (nextCursor === currentCursor) { + mightHaveMore = false // Explicitly stop if cursor hasn't changed + } + } catch (fetchError: any) { + logger.error( + `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, + fetchError + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + } + // --- End Polling Loop --- + + // Convert map values to array for final processing + const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) + logger.info( + `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` + ) + + // --- Execute Workflow if we have changes (simplified - no lock check) --- + if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { + try { + // Build input exposing raw payloads and consolidated changes + const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null + const input: any = { + // Raw Airtable payloads as received from the API + payloads: allPayloads, + latestPayload, + // Consolidated, simplified changes for convenience + airtableChanges: finalConsolidatedChanges, + // Include webhook metadata for resolver fallbacks + webhook: { + data: { + provider: 'airtable', + providerConfig: webhookData.providerConfig, + payload: latestPayload, + }, + }, + } + + // CRITICAL EXECUTION TRACE POINT + logger.info( + `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + timestamp: new Date().toISOString(), + firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', + } + ) + + // Return the processed input for the trigger.dev task to handle + logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + rawPayloadCount: allPayloads.length, + timestamp: new Date().toISOString(), + }) + + return input + } catch (processingError: any) { + logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + workflowId: workflowData.id, + error: processingError.message, + stack: processingError.stack, + timestamp: new Date().toISOString(), + }) + + throw processingError + } + } else { + // DEBUG: Log when no changes are found + logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + workflowId: workflowData.id, + apiCallCount, + webhookId: webhookData.id, + }) + } + } catch (error) { + // Catch any unexpected errors during the setup/polling logic itself + logger.error( + `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, + { + webhookId: webhookData.id, + workflowId: workflowData.id, + error: (error as Error).message, + } + ) + // Error logging handled by logging session + } +} + export const airtableHandler: WebhookProviderHandler = { extractIdempotencyId(body: unknown) { const obj = body as Record diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 1584e024f8d..350d978236f 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1225,413 +1225,6 @@ export async function formatWebhookInput( return body } -/** - * Process Airtable payloads - */ -export async function fetchAndProcessAirtablePayloads( - webhookData: any, - workflowData: any, - requestId: string // Original request ID from the ping, used for the final execution log -) { - // Logging handles all error logging - let currentCursor: number | null = null - let mightHaveMore = true - let payloadsFetched = 0 - let apiCallCount = 0 - // Use a Map to consolidate changes per record ID - const consolidatedChangesMap = new Map() - // Capture raw payloads from Airtable for exposure to workflows - const allPayloads = [] - const localProviderConfig = { - ...((webhookData.providerConfig as Record) || {}), - } - - try { - // --- Essential IDs & Config from localProviderConfig --- - const baseId = localProviderConfig.baseId - const airtableWebhookId = localProviderConfig.externalId - - if (!baseId || !airtableWebhookId) { - logger.error( - `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` - ) - return - } - - const credentialId: string | undefined = localProviderConfig.credentialId - if (!credentialId) { - logger.error( - `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` - ) - return - } - - const resolvedAirtable = await resolveOAuthAccountId(credentialId) - if (!resolvedAirtable) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` - ) - return - } - - let ownerUserId: string | null = null - try { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedAirtable.accountId)) - .limit(1) - ownerUserId = rows.length ? rows[0].userId : null - } catch (_e) { - ownerUserId = null - } - - if (!ownerUserId) { - logger.error( - `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` - ) - return - } - - const storedCursor = localProviderConfig.externalWebhookCursor - - if (storedCursor === undefined || storedCursor === null) { - logger.info( - `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` - ) - localProviderConfig.externalWebhookCursor = null - - try { - await db - .update(webhook) - .set({ - providerConfig: { - ...localProviderConfig, - externalWebhookCursor: null, - }, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = null - logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) - } catch (initError: any) { - logger.error(`[${requestId}] Failed to initialize cursor in DB`, { - webhookId: webhookData.id, - error: initError.message, - stack: initError.stack, - }) - } - } - - if (storedCursor && typeof storedCursor === 'number') { - currentCursor = storedCursor - } else { - currentCursor = null - } - - let accessToken: string | null = null - try { - accessToken = await refreshAccessTokenIfNeeded( - resolvedAirtable.accountId, - ownerUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` - ) - throw new Error('Airtable access token not found.') - } - } catch (tokenError: any) { - logger.error( - `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, - { - error: tokenError.message, - stack: tokenError.stack, - credentialId, - } - ) - return - } - - const airtableApiBase = 'https://api.airtable.com/v0' - - // --- Polling Loop --- - while (mightHaveMore) { - apiCallCount++ - // Safety break - if (apiCallCount > 10) { - mightHaveMore = false - break - } - - const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` - const queryParams = new URLSearchParams() - if (currentCursor !== null) { - queryParams.set('cursor', currentCursor.toString()) - } - const fullUrl = `${apiUrl}?${queryParams.toString()}` - - try { - const fetchStartTime = Date.now() - const response = await fetch(fullUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - const responseBody = await response.json() - - if (!response.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || - responseBody.error || - `Airtable API error Status ${response.status}` - logger.error( - `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { - webhookId: webhookData.id, - status: response.status, - error: errorMessage, - } - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - - const receivedPayloads = responseBody.payloads || [] - - // --- Process and Consolidate Changes --- - if (receivedPayloads.length > 0) { - payloadsFetched += receivedPayloads.length - // Keep the raw payloads for later exposure to the workflow - for (const p of receivedPayloads) { - allPayloads.push(p) - } - let changeCount = 0 - for (const payload of receivedPayloads) { - if (payload.changedTablesById) { - for (const [tableId, tableChangesUntyped] of Object.entries( - payload.changedTablesById - )) { - const tableChanges = tableChangesUntyped as any // Assert type - - // Handle created records - if (tableChanges.createdRecordsById) { - const createdCount = Object.keys(tableChanges.createdRecordsById).length - changeCount += createdCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.createdRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - if (existingChange) { - // Record was created and possibly updated within the same batch - existingChange.changedFields = { - ...existingChange.changedFields, - ...(recordData.cellValuesByFieldId || {}), - } - // Keep changeType as 'created' if it started as created - } else { - // New creation - consolidatedChangesMap.set(recordId, { - tableId: tableId, - recordId: recordId, - changeType: 'created', - changedFields: recordData.cellValuesByFieldId || {}, - }) - } - } - } - - // Handle updated records - if (tableChanges.changedRecordsById) { - const updatedCount = Object.keys(tableChanges.changedRecordsById).length - changeCount += updatedCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.changedRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - const currentFields = recordData.current?.cellValuesByFieldId || {} - - if (existingChange) { - // Existing record was updated again - existingChange.changedFields = { - ...existingChange.changedFields, - ...currentFields, - } - // Ensure type is 'updated' if it was previously 'created' - existingChange.changeType = 'updated' - // Do not update previousFields again - } else { - // First update for this record in the batch - const newChange: AirtableChange = { - tableId: tableId, - recordId: recordId, - changeType: 'updated', - changedFields: currentFields, - } - if (recordData.previous?.cellValuesByFieldId) { - newChange.previousFields = recordData.previous.cellValuesByFieldId - } - consolidatedChangesMap.set(recordId, newChange) - } - } - } - // TODO: Handle deleted records (`destroyedRecordIds`) if needed - } - } - } - } - - const nextCursor = responseBody.cursor - mightHaveMore = responseBody.mightHaveMore || false - - if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { - currentCursor = nextCursor - - // Follow exactly the old implementation - use awaited update instead of parallel - const updatedConfig = { - ...localProviderConfig, - externalWebhookCursor: currentCursor, - } - try { - // Force a complete object update to ensure consistency in serverless env - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, // Use full object - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too - } catch (dbError: any) { - logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { - webhookId: webhookData.id, - cursor: currentCursor, - error: dbError.message, - }) - // Error logging handled by logging session - mightHaveMore = false - throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly - } - } else if (!nextCursor || typeof nextCursor !== 'number') { - logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { - webhookId: webhookData.id, - apiCall: apiCallCount, - receivedCursor: nextCursor, - }) - mightHaveMore = false - } else if (nextCursor === currentCursor) { - mightHaveMore = false // Explicitly stop if cursor hasn't changed - } - } catch (fetchError: any) { - logger.error( - `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, - fetchError - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - } - // --- End Polling Loop --- - - // Convert map values to array for final processing - const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) - logger.info( - `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` - ) - - // --- Execute Workflow if we have changes (simplified - no lock check) --- - if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { - try { - // Build input exposing raw payloads and consolidated changes - const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null - const input: any = { - // Raw Airtable payloads as received from the API - payloads: allPayloads, - latestPayload, - // Consolidated, simplified changes for convenience - airtableChanges: finalConsolidatedChanges, - // Include webhook metadata for resolver fallbacks - webhook: { - data: { - provider: 'airtable', - providerConfig: webhookData.providerConfig, - payload: latestPayload, - }, - }, - } - - // CRITICAL EXECUTION TRACE POINT - logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, - { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - timestamp: new Date().toISOString(), - firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', - } - ) - - // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - rawPayloadCount: allPayloads.length, - timestamp: new Date().toISOString(), - }) - - return input - } catch (processingError: any) { - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { - workflowId: workflowData.id, - error: processingError.message, - stack: processingError.stack, - timestamp: new Date().toISOString(), - }) - - throw processingError - } - } else { - // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { - workflowId: workflowData.id, - apiCallCount, - webhookId: webhookData.id, - }) - } - } catch (error) { - // Catch any unexpected errors during the setup/polling logic itself - logger.error( - `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, - { - webhookId: webhookData.id, - workflowId: workflowData.id, - error: (error as Error).message, - } - ) - // Error logging handled by logging session - } -} - -// Define an interface for AirtableChange -export interface AirtableChange { - tableId: string - recordId: string - changeType: 'created' | 'updated' - changedFields: Record // { fieldId: newValue } - previousFields?: Record // { fieldId: previousValue } (optional) -} /** * Result of syncing webhooks for a credential set From adf13bcbb34a0ca57123e1a8df3a6f186c23c20d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:11:13 -0700 Subject: [PATCH 08/18] refactor(webhooks): extract polling config functions into polling-config.ts Move configureGmailPolling, configureOutlookPolling, configureRssPolling, and configureImapPolling out of utils.server.ts into a dedicated module. Update imports in deploy.ts and webhooks/route.ts. --- apps/sim/app/api/webhooks/route.ts | 4 +- apps/sim/lib/webhooks/deploy.ts | 7 +- apps/sim/lib/webhooks/polling-config.ts | 283 ++++++++++++++++++++++++ apps/sim/lib/webhooks/utils.server.ts | 275 ----------------------- 4 files changed, 287 insertions(+), 282 deletions(-) create mode 100644 apps/sim/lib/webhooks/polling-config.ts diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index ea17b75cefe..e2328d56fcc 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -21,8 +21,8 @@ import { configureGmailPolling, configureOutlookPolling, configureRssPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +} from '@/lib/webhooks/polling-config' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1dd2f8b8f99..59fff51bf2e 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -11,11 +11,8 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' -import { - configureGmailPolling, - configureOutlookPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/webhooks/polling-config.ts b/apps/sim/lib/webhooks/polling-config.ts new file mode 100644 index 00000000000..c9c6f8ca53e --- /dev/null +++ b/apps/sim/lib/webhooks/polling-config.ts @@ -0,0 +1,283 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' + +/** + * Configure Gmail polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). + */ +export async function configureGmailPolling(webhookData: any, requestId: string): Promise { + const logger = createLogger('GmailWebhookSetup') + logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId: string | undefined = providerConfig.credentialId + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + const resolvedGmail = await resolveOAuthAccountId(credentialId) + if (!resolvedGmail) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedGmail.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedGmail.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } + + const maxEmailsPerPoll = + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : providerConfig.maxEmailsPerPoll || 25 + + const pollingInterval = + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : providerConfig.pollingInterval || 5 + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info( + `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` + ) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure Gmail polling`, { + webhookId: webhookData.id, + error: error.message, + stack: error.stack, + }) + return false + } +} + +/** + * Configure Outlook polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). + */ +export async function configureOutlookPolling( + webhookData: any, + requestId: string +): Promise { + const logger = createLogger('OutlookWebhookSetup') + logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId: string | undefined = providerConfig.credentialId + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + const resolvedOutlook = await resolveOAuthAccountId(credentialId) + if (!resolvedOutlook) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedOutlook.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedOutlook.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll: + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : providerConfig.maxEmailsPerPoll || 25, + pollingInterval: + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : providerConfig.pollingInterval || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info( + `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` + ) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure Outlook polling`, { + webhookId: webhookData.id, + error: error.message, + stack: error.stack, + }) + return false + } +} + +/** + * Configure RSS polling for a webhook + */ +export async function configureRssPolling(webhookData: any, requestId: string): Promise { + const logger = createLogger('RssWebhookSetup') + logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + lastCheckedTimestamp: now.toISOString(), + lastSeenGuids: [], + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure RSS polling`, { + webhookId: webhookData.id, + error: error.message, + }) + return false + } +} + +/** + * Configure IMAP polling for a webhook + */ +export async function configureImapPolling(webhookData: any, requestId: string): Promise { + const logger = createLogger('ImapWebhookSetup') + logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { + logger.error( + `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` + ) + return false + } + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + port: providerConfig.port || '993', + secure: providerConfig.secure !== false, + mailbox: providerConfig.mailbox || 'INBOX', + searchCriteria: providerConfig.searchCriteria || 'UNSEEN', + markAsRead: providerConfig.markAsRead || false, + includeAttachments: providerConfig.includeAttachments !== false, + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure IMAP polling`, { + webhookId: webhookData.id, + error: error.message, + }) + return false + } +} diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 350d978236f..3b260c7101f 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1598,281 +1598,6 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated, totalCreated, totalDeleted } } -/** - * Configure Gmail polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureGmailPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('GmailWebhookSetup') - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - const resolvedGmail = await resolveOAuthAccountId(credentialId) - if (!resolvedGmail) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedGmail.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedGmail.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - - const maxEmailsPerPoll = - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25 - - const pollingInterval = - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5 - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Gmail polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure Outlook polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureOutlookPolling( - webhookData: any, - requestId: string -): Promise { - const logger = createLogger('OutlookWebhookSetup') - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - const resolvedOutlook = await resolveOAuthAccountId(credentialId) - if (!resolvedOutlook) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedOutlook.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedOutlook.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll: - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25, - pollingInterval: - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - folderIds: providerConfig.folderIds || ['inbox'], - folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Outlook polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure RSS polling for a webhook - */ -export async function configureRssPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('RssWebhookSetup') - logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - lastCheckedTimestamp: now.toISOString(), - lastSeenGuids: [], - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure RSS polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -/** - * Configure IMAP polling for a webhook - */ -export async function configureImapPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('ImapWebhookSetup') - logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { - logger.error( - `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` - ) - return false - } - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, - mailbox: providerConfig.mailbox || 'INBOX', - searchCriteria: providerConfig.searchCriteria || 'UNSEEN', - markAsRead: providerConfig.markAsRead || false, - includeAttachments: providerConfig.includeAttachments !== false, - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure IMAP polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { if (!twiml) { return twiml From ced7d1478fbc3c11da344843b496c12208588314 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:21:08 -0700 Subject: [PATCH 09/18] refactor(webhooks): decompose formatWebhookInput into per-provider formatInput methods Move all provider-specific input formatting from the monolithic formatWebhookInput switch statement into each provider's handler file. Delete formatWebhookInput and all its helper functions (fetchWithDNSPinning, formatTeamsGraphNotification, Slack file helpers, convertSquareBracketsToTwiML) from utils.server.ts. Create new handler files for gmail, outlook, rss, imap, and calendly providers. Update webhook-execution.ts to use handler.formatInput as the primary path with raw body passthrough as fallback. utils.server.ts reduced from ~1600 lines to ~370 lines containing only credential-sync functions. Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/webhooks/route.ts | 10 +- apps/sim/background/webhook-execution.ts | 45 +- apps/sim/lib/webhooks/deploy.ts | 2 +- apps/sim/lib/webhooks/polling-config.ts | 5 +- apps/sim/lib/webhooks/providers/airtable.ts | 5 +- apps/sim/lib/webhooks/providers/ashby.ts | 25 +- apps/sim/lib/webhooks/providers/attio.ts | 71 +- apps/sim/lib/webhooks/providers/calcom.ts | 21 +- apps/sim/lib/webhooks/providers/calendly.ts | 19 + apps/sim/lib/webhooks/providers/circleback.ts | 42 +- apps/sim/lib/webhooks/providers/confluence.ts | 55 +- apps/sim/lib/webhooks/providers/fireflies.ts | 35 +- apps/sim/lib/webhooks/providers/generic.ts | 6 + apps/sim/lib/webhooks/providers/github.ts | 31 +- apps/sim/lib/webhooks/providers/gmail.ts | 15 + .../lib/webhooks/providers/google-forms.ts | 39 +- apps/sim/lib/webhooks/providers/grain.ts | 12 +- apps/sim/lib/webhooks/providers/hubspot.ts | 25 +- apps/sim/lib/webhooks/providers/imap.ts | 31 + apps/sim/lib/webhooks/providers/jira.ts | 40 +- apps/sim/lib/webhooks/providers/linear.ts | 37 +- .../lib/webhooks/providers/microsoft-teams.ts | 469 +++++- apps/sim/lib/webhooks/providers/outlook.ts | 15 + apps/sim/lib/webhooks/providers/registry.ts | 10 + apps/sim/lib/webhooks/providers/rss.ts | 24 + apps/sim/lib/webhooks/providers/slack.ts | 242 +++- apps/sim/lib/webhooks/providers/stripe.ts | 11 +- apps/sim/lib/webhooks/providers/telegram.ts | 84 +- .../lib/webhooks/providers/twilio-voice.ts | 87 +- apps/sim/lib/webhooks/providers/typeform.ts | 38 +- apps/sim/lib/webhooks/providers/webflow.ts | 59 +- apps/sim/lib/webhooks/providers/whatsapp.ts | 31 +- apps/sim/lib/webhooks/utils.server.ts | 1261 +---------------- 33 files changed, 1560 insertions(+), 1342 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/calendly.ts create mode 100644 apps/sim/lib/webhooks/providers/gmail.ts create mode 100644 apps/sim/lib/webhooks/providers/imap.ts create mode 100644 apps/sim/lib/webhooks/providers/outlook.ts create mode 100644 apps/sim/lib/webhooks/providers/rss.ts diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index e2328d56fcc..54424dc4e7b 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -11,17 +11,17 @@ import { generateId, generateShortId } from '@/lib/core/utils/uuid' import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' +import { + configureGmailPolling, + configureOutlookPolling, + configureRssPolling, +} from '@/lib/webhooks/polling-config' import { cleanupExternalWebhook, createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - configureGmailPolling, - configureOutlookPolling, - configureRssPolling, -} from '@/lib/webhooks/polling-config' import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 793015585f2..b15bb87cb6f 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -13,7 +13,6 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' import { getProviderHandler } from '@/lib/webhooks/providers' -import { formatWebhookInput } from '@/lib/webhooks/utils.server' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -317,12 +316,12 @@ async function executeWebhookJobInternal( let input: Record | null = null let skipMessage: string | undefined - if (handler.formatInput) { - const webhookRecord = webhookRows[0] - if (!webhookRecord) { - throw new Error(`Webhook record not found: ${payload.webhookId}`) - } + const webhookRecord = webhookRows[0] + if (!webhookRecord) { + throw new Error(`Webhook record not found: ${payload.webhookId}`) + } + if (handler.formatInput) { const result = await handler.formatInput({ webhook: webhookRecord, workflow: { id: payload.workflowId, userId: payload.userId }, @@ -333,35 +332,13 @@ async function executeWebhookJobInternal( input = result.input as Record | null skipMessage = result.skip?.message } else { - const actualWebhook = - webhookRows.length > 0 - ? webhookRows[0] - : { - provider: payload.provider, - blockId: payload.blockId, - providerConfig: {}, - } + input = payload.body as Record | null + } - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, - } - const mockRequest = { - headers: new Map(Object.entries(payload.headers)), - } as unknown as Parameters[3] - - input = (await formatWebhookInput( - actualWebhook, - mockWorkflow, - payload.body, - mockRequest - )) as Record | null - - if (!input && handler.handleEmptyInput) { - const skipResult = handler.handleEmptyInput(requestId) - if (skipResult) { - skipMessage = skipResult.message - } + if (!input && handler.handleEmptyInput) { + const skipResult = handler.handleEmptyInput(requestId) + if (skipResult) { + skipMessage = skipResult.message } } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 59fff51bf2e..2e05d85b0a4 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -6,12 +6,12 @@ import type { NextRequest } from 'next/server' import { generateShortId } from '@/lib/core/utils/uuid' import { getProviderIdFromServiceId } from '@/lib/oauth' import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' +import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config' import { cleanupExternalWebhook, createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' -import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config' import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' diff --git a/apps/sim/lib/webhooks/polling-config.ts b/apps/sim/lib/webhooks/polling-config.ts index c9c6f8ca53e..b4afd0699b8 100644 --- a/apps/sim/lib/webhooks/polling-config.ts +++ b/apps/sim/lib/webhooks/polling-config.ts @@ -2,10 +2,7 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' /** * Configure Gmail polling for a webhook. diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 1fd616037e7..6eb75236e13 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -2,11 +2,8 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' import type { FormatInputContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Airtable') diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 9d6d50b56cd..cd0f16e82af 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,15 +1,23 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Ashby') function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { try { - if (!secretToken || !signature || !body) { return false } - if (!signature.startsWith('sha256=')) { return false } + if (!secretToken || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } const providedSignature = signature.substring(7) const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') return safeCompare(computedHash, providedSignature) @@ -20,6 +28,17 @@ function validateAshbySignature(secretToken: string, signature: string, body: st } export const ashbyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + ...((b.data as Record) || {}), + action: b.action, + data: b.data || {}, + }, + } + }, + verifyAuth: createHmacVerifier({ configKey: 'secretToken', headerName: 'ashby-signature', diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index d60ddb2031b..7bc0ccb5c6c 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -5,6 +5,8 @@ import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventMatchContext, + FormatInputContext, + FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -13,11 +15,21 @@ const logger = createLogger('WebhookProvider:Attio') function validateAttioSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Attio signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Attio signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Attio signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + logger.debug('Attio signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Attio signature:', error) @@ -87,4 +99,59 @@ export const attioHandler: WebhookProviderHandler = { return true }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractAttioRecordData, + extractAttioRecordUpdatedData, + extractAttioRecordMergedData, + extractAttioNoteData, + extractAttioTaskData, + extractAttioCommentData, + extractAttioListEntryData, + extractAttioListEntryUpdatedData, + extractAttioListData, + extractAttioWorkspaceMemberData, + extractAttioGenericData, + } = await import('@/triggers/attio/utils') + + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId === 'attio_record_updated') { + return { input: extractAttioRecordUpdatedData(body) } + } + if (triggerId === 'attio_record_merged') { + return { input: extractAttioRecordMergedData(body) } + } + if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { + return { input: extractAttioRecordData(body) } + } + if (triggerId?.startsWith('attio_note_')) { + return { input: extractAttioNoteData(body) } + } + if (triggerId?.startsWith('attio_task_')) { + return { input: extractAttioTaskData(body) } + } + if (triggerId?.startsWith('attio_comment_')) { + return { input: extractAttioCommentData(body) } + } + if (triggerId === 'attio_list_entry_updated') { + return { input: extractAttioListEntryUpdatedData(body) } + } + if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { + return { input: extractAttioListEntryData(body) } + } + if ( + triggerId === 'attio_list_created' || + triggerId === 'attio_list_updated' || + triggerId === 'attio_list_deleted' + ) { + return { input: extractAttioListData(body) } + } + if (triggerId === 'attio_workspace_member_created') { + return { input: extractAttioWorkspaceMemberData(body) } + } + return { input: extractAttioGenericData(body) } + }, } diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts index 5340d77ce01..b018b16f581 100644 --- a/apps/sim/lib/webhooks/providers/calcom.ts +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -9,14 +9,27 @@ const logger = createLogger('WebhookProvider:Calcom') function validateCalcomSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Cal.com signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Cal.com signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } let providedSignature: string - if (signature.startsWith('sha256=')) { providedSignature = signature.substring(7) } - else { providedSignature = signature } + if (signature.startsWith('sha256=')) { + providedSignature = signature.substring(7) + } else { + providedSignature = signature + } const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Cal.com signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + logger.debug('Cal.com signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Cal.com signature:', error) diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts new file mode 100644 index 00000000000..e2946061702 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -0,0 +1,19 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const calendlyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + event: b.event, + created_at: b.created_at, + created_by: b.created_by, + payload: b.payload, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts index b1802d8e9c7..1beb8a6814f 100644 --- a/apps/sim/lib/webhooks/providers/circleback.ts +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -1,7 +1,11 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Circleback') @@ -9,11 +13,21 @@ const logger = createLogger('WebhookProvider:Circleback') function validateCirclebackSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Circleback signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Circleback signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Circleback signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + logger.debug('Circleback signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Circleback signature:', error) @@ -22,6 +36,28 @@ function validateCirclebackSignature(secret: string, signature: string, body: st } export const circlebackHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + id: b.id, + name: b.name, + createdAt: b.createdAt, + duration: b.duration, + url: b.url, + recordingUrl: b.recordingUrl, + tags: b.tags || [], + icalUid: b.icalUid, + attendees: b.attendees || [], + notes: b.notes || '', + actionItems: b.actionItems || [], + transcript: b.transcript || [], + insights: b.insights || {}, + meeting: b, + }, + } + }, + verifyAuth: createHmacVerifier({ configKey: 'webhookSecret', headerName: 'x-signature', diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts index 8753535265b..79fda7c0529 100644 --- a/apps/sim/lib/webhooks/providers/confluence.ts +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -1,8 +1,13 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' import { validateJiraSignature } from '@/lib/webhooks/providers/jira' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Confluence') @@ -14,6 +19,52 @@ export const confluenceHandler: WebhookProviderHandler = { providerLabel: 'Confluence', }), + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractPageData, + extractCommentData, + extractBlogData, + extractAttachmentData, + extractSpaceData, + extractLabelData, + } = await import('@/triggers/confluence/utils') + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId?.startsWith('confluence_comment_')) { + return { input: extractCommentData(body) } + } + if (triggerId?.startsWith('confluence_blog_')) { + return { input: extractBlogData(body) } + } + if (triggerId?.startsWith('confluence_attachment_')) { + return { input: extractAttachmentData(body) } + } + if (triggerId?.startsWith('confluence_space_')) { + return { input: extractSpaceData(body) } + } + if (triggerId?.startsWith('confluence_label_')) { + return { input: extractLabelData(body) } + } + if (triggerId === 'confluence_webhook') { + const b = body as Record + return { + input: { + timestamp: b.timestamp, + userAccountId: b.userAccountId, + accountType: b.accountType, + page: b.page || null, + comment: b.comment || null, + blog: b.blog || (b as Record).blogpost || null, + attachment: b.attachment || null, + space: b.space || null, + label: b.label || null, + content: b.content || null, + }, + } + } + return { input: extractPageData(body) } + }, + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined const obj = body as Record diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts index 9700bd57f1f..f3245d5cd3c 100644 --- a/apps/sim/lib/webhooks/providers/fireflies.ts +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -1,7 +1,11 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Fireflies') @@ -9,16 +13,28 @@ const logger = createLogger('WebhookProvider:Fireflies') function validateFirefliesSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Fireflies signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Fireflies signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } if (!signature.startsWith('sha256=')) { - logger.warn('Fireflies signature has invalid format (expected sha256=)', { signaturePrefix: signature.substring(0, 10) }) + logger.warn('Fireflies signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) return false } const providedSignature = signature.substring(7) const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Fireflies signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + logger.debug('Fireflies signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Fireflies signature:', error) @@ -27,6 +43,17 @@ function validateFirefliesSignature(secret: string, signature: string, body: str } export const firefliesHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + meetingId: (b.meetingId || '') as string, + eventType: (b.eventType || 'Transcription completed') as string, + clientReferenceId: (b.clientReferenceId || '') as string, + }, + } + }, + verifyAuth: createHmacVerifier({ configKey: 'webhookSecret', headerName: 'x-hub-signature', diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts index 797ad46ccd2..712cd09d12e 100644 --- a/apps/sim/lib/webhooks/providers/generic.ts +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server' import type { AuthContext, EventFilterContext, + FormatInputContext, + FormatInputResult, ProcessFilesContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -84,6 +86,10 @@ export const genericHandler: WebhookProviderHandler = { return null }, + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + async processInputFiles({ input, blocks, diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts index a1ac8281554..a0fd90f2e6d 100644 --- a/apps/sim/lib/webhooks/providers/github.ts +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -5,6 +5,8 @@ import { safeCompare } from '@/lib/core/security/encryption' import type { AuthContext, EventMatchContext, + FormatInputContext, + FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -13,7 +15,11 @@ const logger = createLogger('WebhookProvider:GitHub') function validateGitHubSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('GitHub signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('GitHub signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } let algorithm: 'sha256' | 'sha1' @@ -25,11 +31,20 @@ function validateGitHubSignature(secret: string, signature: string, body: string algorithm = 'sha1' providedSignature = signature.substring(5) } else { - logger.warn('GitHub signature has invalid format', { signature: `${signature.substring(0, 10)}...` }) + logger.warn('GitHub signature has invalid format', { + signature: `${signature.substring(0, 10)}...`, + }) return false } const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') - logger.debug('GitHub signature comparison', { algorithm, computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + logger.debug('GitHub signature comparison', { + algorithm, + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating GitHub signature:', error) @@ -63,6 +78,16 @@ export const githubHandler: WebhookProviderHandler = { return null }, + async formatInput({ body, headers }: FormatInputContext): Promise { + const b = body as Record + const eventType = headers['x-github-event'] || 'unknown' + const ref = (b?.ref as string) || '' + const branch = ref.replace('refs/heads/', '') + return { + input: { ...b, event_type: eventType, action: (b?.action || '') as string, branch }, + } + }, + async matchEvent({ webhook, workflow, diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts new file mode 100644 index 00000000000..68bb09eb574 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -0,0 +1,15 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const gmailHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, +} diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts index 29fccfb250b..67e7fd8c997 100644 --- a/apps/sim/lib/webhooks/providers/google-forms.ts +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -1,11 +1,48 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:GoogleForms') export const googleFormsHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const normalizeAnswers = (src: unknown): Record => { + if (!src || typeof src !== 'object') return {} + const out: Record = {} + for (const [k, v] of Object.entries(src as Record)) { + if (Array.isArray(v)) { + out[k] = v.length === 1 ? v[0] : v + } else { + out[k] = v + } + } + return out + } + const responseId = (b?.responseId || b?.id || '') as string + const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string + const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string + const formId = (b?.formId || providerConfig.formId || '') as string + const includeRaw = providerConfig.includeRawPayload !== false + return { + input: { + responseId, + createTime, + lastSubmittedTime, + formId, + answers: normalizeAnswers(b?.answers), + ...(includeRaw ? { raw: b?.raw ?? b } : {}), + }, + } + }, + verifyAuth({ request, requestId, providerConfig }: AuthContext) { const expectedToken = providerConfig.token as string | undefined if (!expectedToken) { diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts index f12af9c0686..3a0f8797159 100644 --- a/apps/sim/lib/webhooks/providers/grain.ts +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -1,6 +1,11 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + EventFilterContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { skipByEventTypes } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Grain') @@ -25,6 +30,11 @@ export const grainHandler: WebhookProviderHandler = { return skipByEventTypes(ctx, 'Grain', logger) }, + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { input: { type: b.type, user_id: b.user_id, data: b.data || {} } } + }, + extractIdempotencyId(body: unknown) { const obj = body as Record const data = obj.data as Record | undefined diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index 6f45b01799d..2591ee40175 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' -import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:HubSpot') @@ -40,6 +45,24 @@ export const hubspotHandler: WebhookProviderHandler = { return true }, + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const events = Array.isArray(b) ? b : [b] + const event = events[0] as Record | undefined + if (!event) { + logger.warn('HubSpot webhook received with empty payload') + return { input: null } + } + logger.info('Formatting HubSpot webhook input', { + subscriptionType: event.subscriptionType, + objectId: event.objectId, + portalId: event.portalId, + }) + return { + input: { payload: body, provider: 'hubspot', providerConfig: webhook.providerConfig }, + } + }, + extractIdempotencyId(body: unknown) { if (Array.isArray(body) && body.length > 0) { const first = body[0] as Record diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts new file mode 100644 index 00000000000..35283a8f0b0 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -0,0 +1,31 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const imapHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { + input: { + messageId: b.messageId, + subject: b.subject, + from: b.from, + to: b.to, + cc: b.cc, + date: b.date, + bodyText: b.bodyText, + bodyHtml: b.bodyHtml, + mailbox: b.mailbox, + hasAttachments: b.hasAttachments, + attachments: b.attachments, + email: b.email, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, +} diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts index 083ae21d661..1520238d11e 100644 --- a/apps/sim/lib/webhooks/providers/jira.ts +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -1,7 +1,12 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Jira') @@ -9,16 +14,28 @@ const logger = createLogger('WebhookProvider:Jira') export function validateJiraSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Jira signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Jira signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } if (!signature.startsWith('sha256=')) { - logger.warn('Jira signature has invalid format (expected sha256=)', { signaturePrefix: signature.substring(0, 10) }) + logger.warn('Jira signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) return false } const providedSignature = signature.substring(7) const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Jira signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${providedSignature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: providedSignature.length, match: computedHash === providedSignature }) + logger.debug('Jira signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Jira signature:', error) @@ -34,6 +51,21 @@ export const jiraHandler: WebhookProviderHandler = { providerLabel: 'Jira', }), + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { extractIssueData, extractCommentData, extractWorklogData } = await import( + '@/triggers/jira/utils' + ) + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'jira_issue_commented') { + return { input: extractCommentData(body) } + } + if (triggerId === 'jira_worklog_created') { + return { input: extractWorklogData(body) } + } + return { input: extractIssueData(body) } + }, + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined const obj = body as Record diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts index 6d568a746f7..9372b8d6009 100644 --- a/apps/sim/lib/webhooks/providers/linear.ts +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -1,7 +1,11 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Linear') @@ -9,11 +13,21 @@ const logger = createLogger('WebhookProvider:Linear') function validateLinearSignature(secret: string, signature: string, body: string): boolean { try { if (!secret || !signature || !body) { - logger.warn('Linear signature validation missing required fields', { hasSecret: !!secret, hasSignature: !!signature, hasBody: !!body }) + logger.warn('Linear signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) return false } const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - logger.debug('Linear signature comparison', { computedSignature: `${computedHash.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: computedHash.length, providedLength: signature.length, match: computedHash === signature }) + logger.debug('Linear signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Linear signature:', error) @@ -29,6 +43,23 @@ export const linearHandler: WebhookProviderHandler = { providerLabel: 'Linear', }), + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + action: b.action || '', + type: b.type || '', + webhookId: b.webhookId || '', + webhookTimestamp: b.webhookTimestamp || 0, + organizationId: b.organizationId || '', + createdAt: b.createdAt || '', + actor: b.actor || null, + data: b.data || null, + updatedFrom: b.updatedFrom || null, + }, + } + }, + extractIdempotencyId(body: unknown) { const obj = body as Record const data = obj.data as Record | undefined diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index db483b485b1..1a422efcaa4 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -1,19 +1,39 @@ import crypto from 'crypto' +import { db } from '@sim/db' +import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { AuthContext, EventFilterContext, + FormatInputContext, + FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:MicrosoftTeams') -function validateMicrosoftTeamsSignature(hmacSecret: string, signature: string, body: string): boolean { +function validateMicrosoftTeamsSignature( + hmacSecret: string, + signature: string, + body: string +): boolean { try { - if (!hmacSecret || !signature || !body) { return false } - if (!signature.startsWith('HMAC ')) { return false } + if (!hmacSecret || !signature || !body) { + return false + } + if (!signature.startsWith('HMAC ')) { + return false + } const providedSignature = signature.substring(5) const secretBytes = Buffer.from(hmacSecret, 'base64') const bodyBytes = Buffer.from(body, 'utf8') @@ -45,6 +65,389 @@ function parseFirstNotification( return null } +async function fetchWithDNSPinning( + url: string, + accessToken: string, + requestId: string +): Promise { + try { + const urlValidation = await validateUrlWithDNS(url, 'contentUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url }) + return null + } + const headers: Record = {} + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { headers }) + return response + } catch (error) { + logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { + error: error instanceof Error ? error.message : String(error), + url: sanitizeUrlForLog(url), + }) + return null + } +} + +/** + * Format Microsoft Teams Graph change notification + */ +async function formatTeamsGraphNotification( + body: Record, + foundWebhook: Record, + foundWorkflow: { id: string; userId: string }, + request: { headers: Map } +): Promise { + const notification = (body.value as unknown[])?.[0] as Record | undefined + if (!notification) { + logger.warn('Received empty Teams notification body') + return null + } + const changeType = (notification.changeType as string) || 'created' + const resource = (notification.resource as string) || '' + const subscriptionId = (notification.subscriptionId as string) || '' + + let chatId: string | null = null + let messageId: string | null = null + + const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (fullMatch) { + chatId = fullMatch[1] + messageId = fullMatch[2] + } + + if (!chatId || !messageId) { + const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (quotedMatch) { + chatId = quotedMatch[1] + messageId = quotedMatch[2] + } + } + + if (!chatId || !messageId) { + const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) + const rdId = ((body?.value as unknown[])?.[0] as Record)?.resourceData as + | Record + | undefined + const rdIdValue = rdId?.id as string | undefined + if (collectionMatch && rdIdValue) { + chatId = collectionMatch[1] + messageId = rdIdValue + } + } + + if ( + (!chatId || !messageId) && + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ) { + const resourceData = ((body.value as unknown[])[0] as Record) + .resourceData as Record + const odataId = resourceData['@odata.id'] + if (typeof odataId === 'string') { + const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (odataMatch) { + chatId = odataMatch[1] + messageId = odataMatch[2] + } + } + } + + if (!chatId || !messageId) { + logger.warn('Could not resolve chatId/messageId from Teams notification', { + resource, + hasResourceDataId: Boolean( + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ), + valueLength: Array.isArray(body?.value) ? (body.value as unknown[]).length : 0, + keys: Object.keys(body || {}), + }) + return { + from: null, + message: { raw: body }, + activity: body, + conversation: null, + } + } + const resolvedChatId = chatId as string + const resolvedMessageId = messageId as string + const providerConfig = (foundWebhook?.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + let message: Record | null = null + const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = + [] + let accessToken: string | null = null + + if (!credentialId) { + logger.error('Missing credentialId for Teams chat subscription', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + webhookId: foundWebhook?.id, + blockId: foundWebhook?.blockId, + providerConfig, + }) + } else { + try { + const resolved = await resolveOAuthAccountId(credentialId as string) + if (!resolved) { + logger.error('Teams credential could not be resolved', { credentialId }) + } else { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + effectiveUserId, + 'teams-graph-notification' + ) + } + } + + if (accessToken) { + const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` + const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (res.ok) { + message = (await res.json()) as Record + + if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) { + const attachments = Array.isArray(message?.attachments) + ? (message.attachments as Record[]) + : [] + for (const att of attachments) { + try { + const contentUrl = + typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined + const contentTypeHint = + typeof att?.contentType === 'string' ? (att.contentType as string) : undefined + let attachmentName = (att?.name as string) || 'teams-attachment' + + if (!contentUrl) continue + + let buffer: Buffer | null = null + let mimeType = 'application/octet-stream' + + if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { + try { + const directRes = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment' + ) + + if (directRes?.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else if (directRes) { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` + const graphRes = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (graphRes.ok) { + const arrayBuffer = await graphRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + graphRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } + } catch { + continue + } + } else if ( + contentUrl.includes('1drv.ms') || + contentUrl.includes('onedrive.live.com') || + contentUrl.includes('onedrive.com') || + contentUrl.includes('my.microsoftpersonalcontent.com') + ) { + try { + let shareToken: string | null = null + + if (contentUrl.includes('1drv.ms')) { + const urlParts = contentUrl.split('/').pop() + if (urlParts) shareToken = urlParts + } else if (contentUrl.includes('resid=')) { + const urlParams = new URL(contentUrl).searchParams + const resId = urlParams.get('resid') + if (resId) shareToken = resId + } + + if (!shareToken) { + const base64Url = Buffer.from(contentUrl, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } else if (!shareToken.startsWith('u!')) { + const base64Url = Buffer.from(shareToken, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataRes.ok) { + const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` + const directRes = await fetch(directUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } else { + const metadata = (await metadataRes.json()) as Record + const downloadUrl = metadata['@microsoft.graph.downloadUrl'] as + | string + | undefined + + if (downloadUrl) { + const downloadRes = await fetchWithDNSPinning( + downloadUrl, + '', + 'teams-onedrive-download' + ) + + if (downloadRes?.ok) { + const arrayBuffer = await downloadRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + const fileInfo = metadata.file as Record | undefined + mimeType = + downloadRes.headers.get('content-type') || + (fileInfo?.mimeType as string | undefined) || + contentTypeHint || + 'application/octet-stream' + + if (metadata.name && metadata.name !== attachmentName) { + attachmentName = metadata.name as string + } + } else { + continue + } + } else { + continue + } + } + } catch { + continue + } + } else { + try { + const ares = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment-generic' + ) + if (ares?.ok) { + const arrayBuffer = await ares.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + ares.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } + } catch { + continue + } + } + + if (!buffer) continue + + const size = buffer.length + + rawAttachments.push({ + name: attachmentName, + data: buffer, + contentType: mimeType, + size, + }) + } catch { + /* skip attachment on error */ + } + } + } + } + } + } catch (error) { + logger.error('Failed to fetch Teams message', { + error, + chatId: resolvedChatId, + messageId: resolvedMessageId, + }) + } + } + + if (!message) { + logger.warn('No message data available for Teams notification', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + hasCredential: !!credentialId, + }) + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: '', + text: '', + created_at: '', + attachments: [], + } + } + + const messageText = (message.body as Record)?.content || '' + const from = ((message.from as Record)?.user as Record) || {} + const createdAt = (message.createdDateTime as string) || '' + + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: (from.displayName as string) || '', + text: messageText, + created_at: createdAt, + attachments: rawAttachments, + } +} + export const microsoftTeamsHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { if (providerConfig.hmacSecret) { @@ -98,4 +501,64 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { { status: 500 } ) }, + + async formatInput({ + body, + webhook, + workflow, + headers, + requestId, + }: FormatInputContext): Promise { + const b = body as Record + const value = b?.value as unknown[] | undefined + + if (value && Array.isArray(value) && value.length > 0) { + const mockRequest = { + headers: new Map(Object.entries(headers)), + } as unknown as import('next/server').NextRequest + const result = await formatTeamsGraphNotification( + b, + webhook, + workflow, + mockRequest as unknown as { headers: Map } + ) + return { input: result } + } + + const messageText = (b?.text as string) || '' + const messageId = (b?.id as string) || '' + const timestamp = (b?.timestamp as string) || (b?.localTimestamp as string) || '' + const from = (b?.from || {}) as Record + const conversation = (b?.conversation || {}) as Record + + return { + input: { + from: { + id: (from.id || '') as string, + name: (from.name || '') as string, + aadObjectId: (from.aadObjectId || '') as string, + }, + message: { + raw: { + attachments: b?.attachments || [], + channelData: b?.channelData || {}, + conversation: b?.conversation || {}, + text: messageText, + messageType: (b?.type || 'message') as string, + channelId: (b?.channelId || '') as string, + timestamp, + }, + }, + activity: b || {}, + conversation: { + id: (conversation.id || '') as string, + name: (conversation.name || '') as string, + isGroup: (conversation.isGroup || false) as boolean, + tenantId: (conversation.tenantId || '') as string, + aadObjectId: (conversation.aadObjectId || '') as string, + conversationType: (conversation.conversationType || '') as string, + }, + }, + } + }, } diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts new file mode 100644 index 00000000000..59dedb2210d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -0,0 +1,15 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const outlookHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 426383201b2..d6990651745 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -4,17 +4,22 @@ import { airtableHandler } from '@/lib/webhooks/providers/airtable' import { ashbyHandler } from '@/lib/webhooks/providers/ashby' import { attioHandler } from '@/lib/webhooks/providers/attio' import { calcomHandler } from '@/lib/webhooks/providers/calcom' +import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' import { confluenceHandler } from '@/lib/webhooks/providers/confluence' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' +import { gmailHandler } from '@/lib/webhooks/providers/gmail' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' +import { imapHandler } from '@/lib/webhooks/providers/imap' import { jiraHandler } from '@/lib/webhooks/providers/jira' import { linearHandler } from '@/lib/webhooks/providers/linear' import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' +import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { rssHandler } from '@/lib/webhooks/providers/rss' import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' import { telegramHandler } from '@/lib/webhooks/providers/telegram' @@ -32,18 +37,23 @@ const PROVIDER_HANDLERS: Record = { airtable: airtableHandler, ashby: ashbyHandler, attio: attioHandler, + calendly: calendlyHandler, calcom: calcomHandler, circleback: circlebackHandler, confluence: confluenceHandler, fireflies: firefliesHandler, generic: genericHandler, + gmail: gmailHandler, github: githubHandler, google_forms: googleFormsHandler, grain: grainHandler, hubspot: hubspotHandler, + imap: imapHandler, jira: jiraHandler, linear: linearHandler, 'microsoft-teams': microsoftTeamsHandler, + outlook: outlookHandler, + rss: rssHandler, slack: slackHandler, stripe: stripeHandler, telegram: telegramHandler, diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts new file mode 100644 index 00000000000..e343e8258ff --- /dev/null +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -0,0 +1,24 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const rssHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'item' in b) { + return { + input: { + title: b.title, + link: b.link, + pubDate: b.pubDate, + item: b.item, + feed: b.feed, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, +} diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 4d0df403f72..e5dd3de4b7f 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,5 +1,181 @@ +import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Slack') + +const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +const SLACK_MAX_FILES = 15 + +const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) + +async function resolveSlackFileInfo( + fileId: string, + botToken: string +): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { + try { + const response = await fetch( + `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, + { headers: { Authorization: `Bearer ${botToken}` } } + ) + const data = (await response.json()) as { + ok: boolean + error?: string + file?: Record + } + if (!data.ok || !data.file) { + logger.warn('Slack files.info failed', { fileId, error: data.error }) + return null + } + return { + url_private: data.file.url_private as string | undefined, + name: data.file.name as string | undefined, + mimetype: data.file.mimetype as string | undefined, + size: data.file.size as number | undefined, + } + } catch (error) { + logger.error('Error calling Slack files.info', { + fileId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +async function downloadSlackFiles( + rawFiles: unknown[], + botToken: string +): Promise> { + const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) + const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + + for (const file of filesToProcess) { + const f = file as Record + let urlPrivate = f.url_private as string | undefined + let fileName = f.name as string | undefined + let fileMimeType = f.mimetype as string | undefined + let fileSize = f.size as number | undefined + + if (!urlPrivate && f.id) { + const resolved = await resolveSlackFileInfo(f.id as string, botToken) + if (resolved?.url_private) { + urlPrivate = resolved.url_private + fileName = fileName || resolved.name + fileMimeType = fileMimeType || resolved.mimetype + fileSize = fileSize ?? resolved.size + } + } + + if (!urlPrivate) { + logger.warn('Slack file has no url_private and could not be resolved, skipping', { + fileId: f.id, + }) + continue + } + + const reportedSize = Number(fileSize) || 0 + if (reportedSize > SLACK_MAX_FILE_SIZE) { + logger.warn('Slack file exceeds size limit, skipping', { + fileId: f.id, + size: reportedSize, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + try { + const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') + if (!urlValidation.isValid) { + logger.warn('Slack file url_private failed DNS validation, skipping', { + fileId: f.id, + error: urlValidation.error, + }) + continue + } + + const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + + if (!response.ok) { + logger.warn('Failed to download Slack file, skipping', { + fileId: f.id, + status: response.status, + }) + continue + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + if (buffer.length > SLACK_MAX_FILE_SIZE) { + logger.warn('Downloaded Slack file exceeds size limit, skipping', { + fileId: f.id, + actualSize: buffer.length, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + downloaded.push({ + name: fileName || 'download', + data: buffer.toString('base64'), + mimeType: fileMimeType || 'application/octet-stream', + size: buffer.length, + }) + } catch (error) { + logger.error('Error downloading Slack file, skipping', { + fileId: f.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return downloaded +} + +async function fetchSlackMessageText( + channel: string, + messageTs: string, + botToken: string +): Promise { + try { + const params = new URLSearchParams({ channel, timestamp: messageTs }) + const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + const data = (await response.json()) as { + ok: boolean + error?: string + type?: string + message?: { text?: string } + } + if (!data.ok) { + logger.warn('Slack reactions.get failed — message text unavailable', { + channel, + messageTs, + error: data.error, + }) + return '' + } + return data.message?.text ?? '' + } catch (error) { + logger.warn('Error fetching Slack message text', { + channel, + messageTs, + error: error instanceof Error ? error.message : String(error), + }) + return '' + } +} /** * Handle Slack verification challenges @@ -35,4 +211,68 @@ export const slackHandler: WebhookProviderHandler = { formatQueueErrorResponse() { return new NextResponse(null, { status: 200 }) }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const botToken = providerConfig.botToken as string | undefined + const includeFiles = Boolean(providerConfig.includeFiles) + + const rawEvent = b?.event as Record | undefined + + if (!rawEvent) { + logger.warn('Unknown Slack event type', { + type: b?.type, + hasEvent: false, + bodyKeys: Object.keys(b || {}), + }) + } + + const eventType: string = (rawEvent?.type as string) || (b?.type as string) || 'unknown' + const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) + + const item = rawEvent?.item as Record | undefined + const channel: string = isReactionEvent + ? (item?.channel as string) || '' + : (rawEvent?.channel as string) || '' + const messageTs: string = isReactionEvent + ? (item?.ts as string) || '' + : (rawEvent?.ts as string) || (rawEvent?.event_ts as string) || '' + + let text: string = (rawEvent?.text as string) || '' + if (isReactionEvent && channel && messageTs && botToken) { + text = await fetchSlackMessageText(channel, messageTs, botToken) + } + + const rawFiles: unknown[] = (rawEvent?.files as unknown[]) ?? [] + const hasFiles = rawFiles.length > 0 + + let files: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + if (hasFiles && includeFiles && botToken) { + files = await downloadSlackFiles(rawFiles, botToken) + } else if (hasFiles && includeFiles && !botToken) { + logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') + } + + return { + input: { + event: { + event_type: eventType, + channel, + channel_name: '', + user: (rawEvent?.user as string) || '', + user_name: '', + text, + timestamp: messageTs, + thread_ts: (rawEvent?.thread_ts as string) || '', + team_id: (b?.team_id as string) || (rawEvent?.team as string) || '', + event_id: (b?.event_id as string) || '', + reaction: (rawEvent?.reaction as string) || '', + item_user: (rawEvent?.item_user as string) || '', + hasFiles, + files, + }, + }, + } + }, } diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts index 1faf65ee187..7b9414ab788 100644 --- a/apps/sim/lib/webhooks/providers/stripe.ts +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -1,10 +1,19 @@ import { createLogger } from '@sim/logger' -import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + EventFilterContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { skipByEventTypes } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Stripe') export const stripeHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + shouldSkipEvent(ctx: EventFilterContext) { return skipByEventTypes(ctx, 'Stripe', logger) }, diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts index 1c7e853deb9..3e3ce41317d 100644 --- a/apps/sim/lib/webhooks/providers/telegram.ts +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' -import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:Telegram') @@ -13,4 +18,81 @@ export const telegramHandler: WebhookProviderHandler = { } return null }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const rawMessage = (b?.message || + b?.edited_message || + b?.channel_post || + b?.edited_channel_post) as Record | undefined + + const updateType = b.message + ? 'message' + : b.edited_message + ? 'edited_message' + : b.channel_post + ? 'channel_post' + : b.edited_channel_post + ? 'edited_channel_post' + : 'unknown' + + if (rawMessage) { + const messageType = rawMessage.photo + ? 'photo' + : rawMessage.document + ? 'document' + : rawMessage.audio + ? 'audio' + : rawMessage.video + ? 'video' + : rawMessage.voice + ? 'voice' + : rawMessage.sticker + ? 'sticker' + : rawMessage.location + ? 'location' + : rawMessage.contact + ? 'contact' + : rawMessage.poll + ? 'poll' + : 'text' + + const from = rawMessage.from as Record | undefined + return { + input: { + message: { + id: rawMessage.message_id, + text: rawMessage.text, + date: rawMessage.date, + messageType, + raw: rawMessage, + }, + sender: from + ? { + id: from.id, + username: from.username, + firstName: from.first_name, + lastName: from.last_name, + languageCode: from.language_code, + isBot: from.is_bot, + } + : null, + updateId: b.update_id, + updateType, + }, + } + } + + logger.warn('Unknown Telegram update type', { + updateId: b.update_id, + bodyKeys: Object.keys(b || {}), + }) + + return { + input: { + updateId: b.update_id, + updateType, + }, + } + }, } diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts index ebd16fe1649..be0417ef71d 100644 --- a/apps/sim/lib/webhooks/providers/twilio-voice.ts +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -2,27 +2,59 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' -import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' const logger = createLogger('WebhookProvider:TwilioVoice') -async function validateTwilioSignature(authToken: string, signature: string, url: string, params: Record): Promise { +async function validateTwilioSignature( + authToken: string, + signature: string, + url: string, + params: Record +): Promise { try { if (!authToken || !signature || !url) { - logger.warn('Twilio signature validation missing required fields', { hasAuthToken: !!authToken, hasSignature: !!signature, hasUrl: !!url }) + logger.warn('Twilio signature validation missing required fields', { + hasAuthToken: !!authToken, + hasSignature: !!signature, + hasUrl: !!url, + }) return false } const sortedKeys = Object.keys(params).sort() let data = url - for (const key of sortedKeys) { data += key + params[key] } - logger.debug('Twilio signature validation string built', { url, sortedKeys, dataLength: data.length }) + for (const key of sortedKeys) { + data += key + params[key] + } + logger.debug('Twilio signature validation string built', { + url, + sortedKeys, + dataLength: data.length, + }) const encoder = new TextEncoder() - const key = await crypto.subtle.importKey('raw', encoder.encode(authToken), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']) + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ) const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) const signatureArray = Array.from(new Uint8Array(signatureBytes)) const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) - logger.debug('Twilio signature comparison', { computedSignature: `${signatureBase64.substring(0, 10)}...`, providedSignature: `${signature.substring(0, 10)}...`, computedLength: signatureBase64.length, providedLength: signature.length, match: signatureBase64 === signature }) + logger.debug('Twilio signature comparison', { + computedSignature: `${signatureBase64.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: signatureBase64.length, + providedLength: signature.length, + match: signatureBase64 === signature, + }) return safeCompare(signatureBase64, signature) } catch (error) { logger.error('Error validating Twilio signature:', error) @@ -124,6 +156,47 @@ export const twilioVoiceHandler: WebhookProviderHandler = { }) }, + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + callSid: b.CallSid, + accountSid: b.AccountSid, + from: b.From, + to: b.To, + callStatus: b.CallStatus, + direction: b.Direction, + apiVersion: b.ApiVersion, + callerName: b.CallerName, + forwardedFrom: b.ForwardedFrom, + digits: b.Digits, + speechResult: b.SpeechResult, + recordingUrl: b.RecordingUrl, + recordingSid: b.RecordingSid, + called: b.Called, + caller: b.Caller, + toCity: b.ToCity, + toState: b.ToState, + toZip: b.ToZip, + toCountry: b.ToCountry, + fromCity: b.FromCity, + fromState: b.FromState, + fromZip: b.FromZip, + fromCountry: b.FromCountry, + calledCity: b.CalledCity, + calledState: b.CalledState, + calledZip: b.CalledZip, + calledCountry: b.CalledCountry, + callerCity: b.CallerCity, + callerState: b.CallerState, + callerZip: b.CallerZip, + callerCountry: b.CallerCountry, + callToken: b.CallToken, + raw: JSON.stringify(b), + }, + } + }, + formatQueueErrorResponse() { const errorTwiml = ` diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 600f1264925..4e544d01346 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,15 +1,23 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Typeform') function validateTypeformSignature(secret: string, signature: string, body: string): boolean { try { - if (!secret || !signature || !body) { return false } - if (!signature.startsWith('sha256=')) { return false } + if (!secret || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } const providedSignature = signature.substring(7) const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') return safeCompare(computedHash, providedSignature) @@ -20,6 +28,30 @@ function validateTypeformSignature(secret: string, signature: string, body: stri } export const typeformHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const formResponse = (b?.form_response || {}) as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const includeDefinition = providerConfig.includeDefinition === true + return { + input: { + event_id: b?.event_id || '', + event_type: b?.event_type || 'form_response', + form_id: formResponse.form_id || '', + token: formResponse.token || '', + submitted_at: formResponse.submitted_at || '', + landed_at: formResponse.landed_at || '', + calculated: formResponse.calculated || {}, + variables: formResponse.variables || [], + hidden: formResponse.hidden || {}, + answers: formResponse.answers || [], + ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), + ending: formResponse.ending || {}, + raw: b, + }, + } + }, + verifyAuth: createHmacVerifier({ configKey: 'secret', headerName: 'Typeform-Signature', diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index cc6b3b95bd7..1a02744a9f0 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,9 +1,66 @@ import { createLogger } from '@sim/logger' -import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + EventFilterContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:Webflow') export const webflowHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'webflow_form_submission') { + return { + input: { + siteId: b?.siteId || '', + formId: b?.formId || '', + name: b?.name || '', + id: b?.id || '', + submittedAt: b?.submittedAt || '', + data: b?.data || {}, + schema: b?.schema || {}, + formElementId: b?.formElementId || '', + }, + } + } + const { _cid, _id, ...itemFields } = b || ({} as Record) + return { + input: { + siteId: b?.siteId || '', + collectionId: (_cid || b?.collectionId || '') as string, + payload: { + id: (_id || '') as string, + cmsLocaleId: (itemFields as Record)?.cmsLocaleId || '', + lastPublished: + (itemFields as Record)?.lastPublished || + (itemFields as Record)?.['last-published'] || + '', + lastUpdated: + (itemFields as Record)?.lastUpdated || + (itemFields as Record)?.['last-updated'] || + '', + createdOn: + (itemFields as Record)?.createdOn || + (itemFields as Record)?.['created-on'] || + '', + isArchived: + (itemFields as Record)?.isArchived || + (itemFields as Record)?._archived || + false, + isDraft: + (itemFields as Record)?.isDraft || + (itemFields as Record)?._draft || + false, + fieldData: itemFields, + }, + }, + } + }, + shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) { const configuredCollectionId = providerConfig.collectionId as string | undefined if (configuredCollectionId) { diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 7b95cd1b530..ca1cc6f1135 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -3,7 +3,11 @@ import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' -import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:WhatsApp') @@ -74,6 +78,31 @@ export async function handleWhatsAppVerification( } export const whatsappHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const entry = b?.entry as Array> | undefined + const changes = entry?.[0]?.changes as Array> | undefined + const data = changes?.[0]?.value as Record | undefined + const messages = (data?.messages as Array>) || [] + + if (messages.length > 0) { + const message = messages[0] + const metadata = data?.metadata as Record | undefined + const text = message.text as Record | undefined + return { + input: { + messageId: message.id, + from: message.from, + phoneNumberId: metadata?.phone_number_id, + text: text?.body, + timestamp: message.timestamp, + raw: JSON.stringify(message), + }, + } + } + return { input: null } + }, + handleEmptyInput(requestId: string) { logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) return { message: 'No messages in WhatsApp payload' } diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 3b260c7101f..6d4a4e718bb 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,6 +1,5 @@ -import crypto from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' -import { account, webhook, workflow } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -15,1217 +14,9 @@ import { generateShortId } from '@/lib/core/utils/uuid' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { - getCredentialsForCredentialSet, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -const logger = createLogger('WebhookUtils') - -/** - * Fetches a URL with DNS pinning to prevent DNS rebinding attacks - * @param url - The URL to fetch - * @param accessToken - Authorization token (optional for pre-signed URLs) - * @param requestId - Request ID for logging - * @returns The fetch Response or null if validation fails - */ -async function fetchWithDNSPinning( - url: string, - accessToken: string, - requestId: string -): Promise { - try { - const urlValidation = await validateUrlWithDNS(url, 'contentUrl') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { - url, - }) - return null - } - - const headers: Record = {} - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - headers, - }) - - return response - } catch (error) { - logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { - error: error instanceof Error ? error.message : String(error), - url: sanitizeUrlForLog(url), - }) - return null - } -} - -/** - * Format Microsoft Teams Graph change notification - */ -async function formatTeamsGraphNotification( - body: any, - foundWebhook: any, - foundWorkflow: any, - request: NextRequest -): Promise { - const notification = body.value?.[0] - if (!notification) { - logger.warn('Received empty Teams notification body') - return null - } - const changeType = notification.changeType || 'created' - const resource = notification.resource || '' - const subscriptionId = notification.subscriptionId || '' - - let chatId: string | null = null - let messageId: string | null = null - - const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) - if (fullMatch) { - chatId = fullMatch[1] - messageId = fullMatch[2] - } - - if (!chatId || !messageId) { - const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (quotedMatch) { - chatId = quotedMatch[1] - messageId = quotedMatch[2] - } - } - - if (!chatId || !messageId) { - const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) - const rdId = body?.value?.[0]?.resourceData?.id - if (collectionMatch && rdId) { - chatId = collectionMatch[1] - messageId = rdId - } - } - - if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { - const odataId = String(body.value[0].resourceData['@odata.id']) - const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (odataMatch) { - chatId = odataMatch[1] - messageId = odataMatch[2] - } - } - - if (!chatId || !messageId) { - logger.warn('Could not resolve chatId/messageId from Teams notification', { - resource, - hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), - valueLength: Array.isArray(body?.value) ? body.value.length : 0, - keys: Object.keys(body || {}), - }) - return { - from: null, - message: { raw: body }, - activity: body, - conversation: null, - } - } - const resolvedChatId = chatId as string - const resolvedMessageId = messageId as string - const providerConfig = (foundWebhook?.providerConfig as Record) || {} - const credentialId = providerConfig.credentialId - const includeAttachments = providerConfig.includeAttachments !== false - - let message: any = null - const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = - [] - let accessToken: string | null = null - - if (!credentialId) { - logger.error('Missing credentialId for Teams chat subscription', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - webhookId: foundWebhook?.id, - blockId: foundWebhook?.blockId, - providerConfig, - }) - } else { - try { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - logger.error('Teams credential could not be resolved', { credentialId }) - } else { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) - } else { - const effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - effectiveUserId, - 'teams-graph-notification' - ) - } - } - - if (accessToken) { - const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` - const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) - if (res.ok) { - message = await res.json() - - if (includeAttachments && message?.attachments?.length > 0) { - const attachments = Array.isArray(message?.attachments) ? message.attachments : [] - for (const att of attachments) { - try { - const contentUrl = - typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined - const contentTypeHint = - typeof att?.contentType === 'string' ? (att.contentType as string) : undefined - let attachmentName = (att?.name as string) || 'teams-attachment' - - if (!contentUrl) continue - - let buffer: Buffer | null = null - let mimeType = 'application/octet-stream' - - if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { - try { - const directRes = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment' - ) - - if (directRes?.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else if (directRes) { - const encodedUrl = Buffer.from(contentUrl) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` - const graphRes = await fetch(graphUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (graphRes.ok) { - const arrayBuffer = await graphRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - graphRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } - } catch { - continue - } - } else if ( - contentUrl.includes('1drv.ms') || - contentUrl.includes('onedrive.live.com') || - contentUrl.includes('onedrive.com') || - contentUrl.includes('my.microsoftpersonalcontent.com') - ) { - try { - let shareToken: string | null = null - - if (contentUrl.includes('1drv.ms')) { - const urlParts = contentUrl.split('/').pop() - if (urlParts) shareToken = urlParts - } else if (contentUrl.includes('resid=')) { - const urlParams = new URL(contentUrl).searchParams - const resId = urlParams.get('resid') - if (resId) shareToken = resId - } - - if (!shareToken) { - const base64Url = Buffer.from(contentUrl, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } else if (!shareToken.startsWith('u!')) { - const base64Url = Buffer.from(shareToken, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } - - const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` - const metadataRes = await fetch(metadataUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!metadataRes.ok) { - const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` - const directRes = await fetch(directUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } else { - const metadata = await metadataRes.json() - const downloadUrl = metadata['@microsoft.graph.downloadUrl'] - - if (downloadUrl) { - const downloadRes = await fetchWithDNSPinning( - downloadUrl, - '', // downloadUrl is a pre-signed URL, no auth needed - 'teams-onedrive-download' - ) - - if (downloadRes?.ok) { - const arrayBuffer = await downloadRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - downloadRes.headers.get('content-type') || - metadata.file?.mimeType || - contentTypeHint || - 'application/octet-stream' - - if (metadata.name && metadata.name !== attachmentName) { - attachmentName = metadata.name - } - } else { - continue - } - } else { - continue - } - } - } catch { - continue - } - } else { - try { - const ares = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment-generic' - ) - if (ares?.ok) { - const arrayBuffer = await ares.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - ares.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } - } catch { - continue - } - } - - if (!buffer) continue - - const size = buffer.length - - // Store raw attachment (will be uploaded to execution storage later) - rawAttachments.push({ - name: attachmentName, - data: buffer, - contentType: mimeType, - size, - }) - } catch {} - } - } - } - } - } catch (error) { - logger.error('Failed to fetch Teams message', { - error, - chatId: resolvedChatId, - messageId: resolvedMessageId, - }) - } - } - - if (!message) { - logger.warn('No message data available for Teams notification', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - hasCredential: !!credentialId, - }) - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: '', - text: '', - created_at: '', - attachments: [], - } - } - - const messageText = message.body?.content || '' - const from = message.from?.user || {} - const createdAt = message.createdDateTime || '' - - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: from.displayName || '', - text: messageText, - created_at: createdAt, - attachments: rawAttachments, - } -} - -const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB -const SLACK_MAX_FILES = 15 - -/** - * Resolves the full file object from the Slack API when the event payload - * only contains a partial file (e.g. missing url_private due to file_access restrictions). - * @see https://docs.slack.dev/reference/methods/files.info - */ -async function resolveSlackFileInfo( - fileId: string, - botToken: string -): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { - try { - const response = await fetch( - `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, - { - headers: { Authorization: `Bearer ${botToken}` }, - } - ) - - const data = (await response.json()) as { - ok: boolean - error?: string - file?: Record - } - - if (!data.ok || !data.file) { - logger.warn('Slack files.info failed', { fileId, error: data.error }) - return null - } - - return { - url_private: data.file.url_private, - name: data.file.name, - mimetype: data.file.mimetype, - size: data.file.size, - } - } catch (error) { - logger.error('Error calling Slack files.info', { - fileId, - error: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -/** - * Downloads file attachments from Slack using the bot token. - * Returns files in the format expected by WebhookAttachmentProcessor: - * { name, data (base64 string), mimeType, size } - * - * When the event payload contains partial file objects (missing url_private), - * falls back to the Slack files.info API to resolve the full file metadata. - * - * Security: - * - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF - * - Enforces per-file size limit and max file count - */ -async function downloadSlackFiles( - rawFiles: any[], - botToken: string -): Promise> { - const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) - const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] - - for (const file of filesToProcess) { - let urlPrivate = file.url_private as string | undefined - let fileName = file.name as string | undefined - let fileMimeType = file.mimetype as string | undefined - let fileSize = file.size as number | undefined - - // If url_private is missing, resolve via files.info API - if (!urlPrivate && file.id) { - const resolved = await resolveSlackFileInfo(file.id, botToken) - if (resolved?.url_private) { - urlPrivate = resolved.url_private - fileName = fileName || resolved.name - fileMimeType = fileMimeType || resolved.mimetype - fileSize = fileSize ?? resolved.size - } - } - - if (!urlPrivate) { - logger.warn('Slack file has no url_private and could not be resolved, skipping', { - fileId: file.id, - }) - continue - } - - // Skip files that exceed the size limit - const reportedSize = Number(fileSize) || 0 - if (reportedSize > SLACK_MAX_FILE_SIZE) { - logger.warn('Slack file exceeds size limit, skipping', { - fileId: file.id, - size: reportedSize, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - try { - const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') - if (!urlValidation.isValid) { - logger.warn('Slack file url_private failed DNS validation, skipping', { - fileId: file.id, - error: urlValidation.error, - }) - continue - } - - const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - if (!response.ok) { - logger.warn('Failed to download Slack file, skipping', { - fileId: file.id, - status: response.status, - }) - continue - } - - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Verify the actual downloaded size doesn't exceed our limit - if (buffer.length > SLACK_MAX_FILE_SIZE) { - logger.warn('Downloaded Slack file exceeds size limit, skipping', { - fileId: file.id, - actualSize: buffer.length, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - downloaded.push({ - name: fileName || 'download', - data: buffer.toString('base64'), - mimeType: fileMimeType || 'application/octet-stream', - size: buffer.length, - }) - } catch (error) { - logger.error('Error downloading Slack file, skipping', { - fileId: file.id, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - return downloaded -} - -const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) - -/** - * Fetches the text of a reacted-to message from Slack using the reactions.get API. - * Unlike conversations.history, reactions.get works for both top-level messages and - * thread replies, since it looks up the item directly by channel + timestamp. - * Requires the bot token to have the reactions:read scope. - */ -async function fetchSlackMessageText( - channel: string, - messageTs: string, - botToken: string -): Promise { - try { - const params = new URLSearchParams({ - channel, - timestamp: messageTs, - }) - const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - const data = (await response.json()) as { - ok: boolean - error?: string - type?: string - message?: { text?: string } - } - - if (!data.ok) { - logger.warn('Slack reactions.get failed — message text unavailable', { - channel, - messageTs, - error: data.error, - }) - return '' - } - - return data.message?.text ?? '' - } catch (error) { - logger.warn('Error fetching Slack message text', { - channel, - messageTs, - error: error instanceof Error ? error.message : String(error), - }) - return '' - } -} - -/** - * Format webhook input based on provider - */ -export async function formatWebhookInput( - foundWebhook: any, - foundWorkflow: any, - body: any, - request: NextRequest -): Promise { - if (foundWebhook.provider === 'whatsapp') { - const data = body?.entry?.[0]?.changes?.[0]?.value - const messages = data?.messages || [] - - if (messages.length > 0) { - const message = messages[0] - return { - messageId: message.id, - from: message.from, - phoneNumberId: data.metadata?.phone_number_id, - text: message.text?.body, - timestamp: message.timestamp, - raw: JSON.stringify(message), - } - } - return null - } - - if (foundWebhook.provider === 'telegram') { - const rawMessage = - body?.message || body?.edited_message || body?.channel_post || body?.edited_channel_post - - const updateType = body.message - ? 'message' - : body.edited_message - ? 'edited_message' - : body.channel_post - ? 'channel_post' - : body.edited_channel_post - ? 'edited_channel_post' - : 'unknown' - - if (rawMessage) { - const messageType = rawMessage.photo - ? 'photo' - : rawMessage.document - ? 'document' - : rawMessage.audio - ? 'audio' - : rawMessage.video - ? 'video' - : rawMessage.voice - ? 'voice' - : rawMessage.sticker - ? 'sticker' - : rawMessage.location - ? 'location' - : rawMessage.contact - ? 'contact' - : rawMessage.poll - ? 'poll' - : 'text' - - return { - message: { - id: rawMessage.message_id, - text: rawMessage.text, - date: rawMessage.date, - messageType, - raw: rawMessage, - }, - sender: rawMessage.from - ? { - id: rawMessage.from.id, - username: rawMessage.from.username, - firstName: rawMessage.from.first_name, - lastName: rawMessage.from.last_name, - languageCode: rawMessage.from.language_code, - isBot: rawMessage.from.is_bot, - } - : null, - updateId: body.update_id, - updateType, - } - } - - logger.warn('Unknown Telegram update type', { - updateId: body.update_id, - bodyKeys: Object.keys(body || {}), - }) - - return { - updateId: body.update_id, - updateType, - } - } - - if (foundWebhook.provider === 'twilio_voice') { - return { - callSid: body.CallSid, - accountSid: body.AccountSid, - from: body.From, - to: body.To, - callStatus: body.CallStatus, - direction: body.Direction, - apiVersion: body.ApiVersion, - callerName: body.CallerName, - forwardedFrom: body.ForwardedFrom, - digits: body.Digits, - speechResult: body.SpeechResult, - recordingUrl: body.RecordingUrl, - recordingSid: body.RecordingSid, - called: body.Called, - caller: body.Caller, - toCity: body.ToCity, - toState: body.ToState, - toZip: body.ToZip, - toCountry: body.ToCountry, - fromCity: body.FromCity, - fromState: body.FromState, - fromZip: body.FromZip, - fromCountry: body.FromCountry, - calledCity: body.CalledCity, - calledState: body.CalledState, - calledZip: body.CalledZip, - calledCountry: body.CalledCountry, - callerCity: body.CallerCity, - callerState: body.CallerState, - callerZip: body.CallerZip, - callerCountry: body.CallerCountry, - callToken: body.CallToken, - raw: JSON.stringify(body), - } - } - - if (foundWebhook.provider === 'gmail') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'outlook') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'rss') { - if (body && typeof body === 'object' && 'item' in body) { - return { - title: body.title, - link: body.link, - pubDate: body.pubDate, - item: body.item, - feed: body.feed, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'imap') { - if (body && typeof body === 'object' && 'email' in body) { - return { - messageId: body.messageId, - subject: body.subject, - from: body.from, - to: body.to, - cc: body.cc, - date: body.date, - bodyText: body.bodyText, - bodyHtml: body.bodyHtml, - mailbox: body.mailbox, - hasAttachments: body.hasAttachments, - attachments: body.attachments, - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'hubspot') { - const events = Array.isArray(body) ? body : [body] - const event = events[0] - - if (!event) { - logger.warn('HubSpot webhook received with empty payload') - return null - } - - logger.info('Formatting HubSpot webhook input', { - subscriptionType: event.subscriptionType, - objectId: event.objectId, - portalId: event.portalId, - }) - - return { - payload: body, - provider: 'hubspot', - providerConfig: foundWebhook.providerConfig, - } - } - - if (foundWebhook.provider === 'microsoft-teams') { - if (body?.value && Array.isArray(body.value) && body.value.length > 0) { - return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) - } - - const messageText = body?.text || '' - const messageId = body?.id || '' - const timestamp = body?.timestamp || body?.localTimestamp || '' - const from = body?.from || {} - const conversation = body?.conversation || {} - - const messageObj = { - raw: { - attachments: body?.attachments || [], - channelData: body?.channelData || {}, - conversation: body?.conversation || {}, - text: messageText, - messageType: body?.type || 'message', - channelId: body?.channelId || '', - timestamp, - }, - } - - const fromObj = { - id: from.id || '', - name: from.name || '', - aadObjectId: from.aadObjectId || '', - } - - const conversationObj = { - id: conversation.id || '', - name: conversation.name || '', - isGroup: conversation.isGroup || false, - tenantId: conversation.tenantId || '', - aadObjectId: conversation.aadObjectId || '', - conversationType: conversation.conversationType || '', - } - - const activityObj = body || {} - - return { - from: fromObj, - message: messageObj, - activity: activityObj, - conversation: conversationObj, - } - } - - if (foundWebhook.provider === 'slack') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const botToken = providerConfig.botToken as string | undefined - const includeFiles = Boolean(providerConfig.includeFiles) - - const rawEvent = body?.event - - if (!rawEvent) { - logger.warn('Unknown Slack event type', { - type: body?.type, - hasEvent: false, - bodyKeys: Object.keys(body || {}), - }) - } - - const eventType: string = rawEvent?.type || body?.type || 'unknown' - const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) - - // Reaction events nest channel/ts inside event.item - const channel: string = isReactionEvent - ? rawEvent?.item?.channel || '' - : rawEvent?.channel || '' - const messageTs: string = isReactionEvent - ? rawEvent?.item?.ts || '' - : rawEvent?.ts || rawEvent?.event_ts || '' - - // For reaction events, attempt to fetch the original message text - let text: string = rawEvent?.text || '' - if (isReactionEvent && channel && messageTs && botToken) { - text = await fetchSlackMessageText(channel, messageTs, botToken) - } - - const rawFiles: any[] = rawEvent?.files ?? [] - const hasFiles = rawFiles.length > 0 - - let files: any[] = [] - if (hasFiles && includeFiles && botToken) { - files = await downloadSlackFiles(rawFiles, botToken) - } else if (hasFiles && includeFiles && !botToken) { - logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') - } - - return { - event: { - event_type: eventType, - channel, - channel_name: '', - user: rawEvent?.user || '', - user_name: '', - text, - timestamp: messageTs, - thread_ts: rawEvent?.thread_ts || '', - team_id: body?.team_id || rawEvent?.team || '', - event_id: body?.event_id || '', - reaction: rawEvent?.reaction || '', - item_user: rawEvent?.item_user || '', - hasFiles, - files, - }, - } - } - - if (foundWebhook.provider === 'webflow') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Form submission trigger - if (triggerId === 'webflow_form_submission') { - return { - siteId: body?.siteId || '', - formId: body?.formId || '', - name: body?.name || '', - id: body?.id || '', - submittedAt: body?.submittedAt || '', - data: body?.data || {}, - schema: body?.schema || {}, - formElementId: body?.formElementId || '', - } - } - - // Collection item triggers (created, changed, deleted) - // Webflow uses _cid for collection ID and _id for item ID - const { _cid, _id, ...itemFields } = body || {} - return { - siteId: body?.siteId || '', - collectionId: _cid || body?.collectionId || '', - payload: { - id: _id || '', - cmsLocaleId: itemFields?.cmsLocaleId || '', - lastPublished: itemFields?.lastPublished || itemFields?.['last-published'] || '', - lastUpdated: itemFields?.lastUpdated || itemFields?.['last-updated'] || '', - createdOn: itemFields?.createdOn || itemFields?.['created-on'] || '', - isArchived: itemFields?.isArchived || itemFields?._archived || false, - isDraft: itemFields?.isDraft || itemFields?._draft || false, - fieldData: itemFields, - }, - } - } - - if (foundWebhook.provider === 'generic') { - return body - } - - if (foundWebhook.provider === 'google_forms') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - const normalizeAnswers = (src: unknown): Record => { - if (!src || typeof src !== 'object') return {} - const out: Record = {} - for (const [k, v] of Object.entries(src as Record)) { - if (Array.isArray(v)) { - out[k] = v.length === 1 ? v[0] : v - } else { - out[k] = v as unknown - } - } - return out - } - - const responseId = body?.responseId || body?.id || '' - const createTime = body?.createTime || body?.timestamp || new Date().toISOString() - const lastSubmittedTime = body?.lastSubmittedTime || createTime - const formId = body?.formId || providerConfig.formId || '' - const includeRaw = providerConfig.includeRawPayload !== false - - return { - responseId, - createTime, - lastSubmittedTime, - formId, - answers: normalizeAnswers(body?.answers), - ...(includeRaw ? { raw: body?.raw ?? body } : {}), - } - } - - if (foundWebhook.provider === 'github') { - const eventType = request.headers.get('x-github-event') || 'unknown' - const branch = body?.ref?.replace('refs/heads/', '') || '' - - return { - ...body, - event_type: eventType, - action: body?.action || '', - branch, - } - } - - if (foundWebhook.provider === 'typeform') { - const formResponse = body?.form_response || {} - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const includeDefinition = providerConfig.includeDefinition === true - - return { - event_id: body?.event_id || '', - event_type: body?.event_type || 'form_response', - form_id: formResponse.form_id || '', - token: formResponse.token || '', - submitted_at: formResponse.submitted_at || '', - landed_at: formResponse.landed_at || '', - calculated: formResponse.calculated || {}, - variables: formResponse.variables || [], - hidden: formResponse.hidden || {}, - answers: formResponse.answers || [], - ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), - ending: formResponse.ending || {}, - raw: body, - } - } - - if (foundWebhook.provider === 'linear') { - return { - action: body.action || '', - type: body.type || '', - webhookId: body.webhookId || '', - webhookTimestamp: body.webhookTimestamp || 0, - organizationId: body.organizationId || '', - createdAt: body.createdAt || '', - actor: body.actor || null, - data: body.data || null, - updatedFrom: body.updatedFrom || null, - } - } - - if (foundWebhook.provider === 'jira') { - const { extractIssueData, extractCommentData, extractWorklogData } = await import( - '@/triggers/jira/utils' - ) - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'jira_issue_commented') { - return extractCommentData(body) - } - if (triggerId === 'jira_worklog_created') { - return extractWorklogData(body) - } - return extractIssueData(body) - } - - if (foundWebhook.provider === 'confluence') { - const { - extractPageData, - extractCommentData, - extractBlogData, - extractAttachmentData, - extractSpaceData, - extractLabelData, - } = await import('@/triggers/confluence/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('confluence_comment_')) { - return extractCommentData(body) - } - if (triggerId?.startsWith('confluence_blog_')) { - return extractBlogData(body) - } - if (triggerId?.startsWith('confluence_attachment_')) { - return extractAttachmentData(body) - } - if (triggerId?.startsWith('confluence_space_')) { - return extractSpaceData(body) - } - if (triggerId?.startsWith('confluence_label_')) { - return extractLabelData(body) - } - // Generic webhook — preserve all entity fields since event type varies - if (triggerId === 'confluence_webhook') { - return { - timestamp: body.timestamp, - userAccountId: body.userAccountId, - accountType: body.accountType, - page: body.page || null, - comment: body.comment || null, - blog: body.blog || body.blogpost || null, - attachment: body.attachment || null, - space: body.space || null, - label: body.label || null, - content: body.content || null, - } - } - // Default: page events - return extractPageData(body) - } - - if (foundWebhook.provider === 'ashby') { - return { - ...(body.data || {}), - action: body.action, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'stripe') { - return body - } - - if (foundWebhook.provider === 'calendly') { - return { - event: body.event, - created_at: body.created_at, - created_by: body.created_by, - payload: body.payload, - } - } - - if (foundWebhook.provider === 'circleback') { - return { - id: body.id, - name: body.name, - createdAt: body.createdAt, - duration: body.duration, - url: body.url, - recordingUrl: body.recordingUrl, - tags: body.tags || [], - icalUid: body.icalUid, - attendees: body.attendees || [], - notes: body.notes || '', - actionItems: body.actionItems || [], - transcript: body.transcript || [], - insights: body.insights || {}, - meeting: body, - } - } - - if (foundWebhook.provider === 'grain') { - return { - type: body.type, - user_id: body.user_id, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'fireflies') { - return { - meetingId: body.meetingId || '', - eventType: body.eventType || 'Transcription completed', - clientReferenceId: body.clientReferenceId || '', - } - } - - if (foundWebhook.provider === 'attio') { - const { - extractAttioRecordData, - extractAttioRecordUpdatedData, - extractAttioRecordMergedData, - extractAttioNoteData, - extractAttioTaskData, - extractAttioCommentData, - extractAttioListEntryData, - extractAttioListEntryUpdatedData, - extractAttioListData, - extractAttioWorkspaceMemberData, - extractAttioGenericData, - } = await import('@/triggers/attio/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'attio_record_updated') { - return extractAttioRecordUpdatedData(body) - } - if (triggerId === 'attio_record_merged') { - return extractAttioRecordMergedData(body) - } - if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { - return extractAttioRecordData(body) - } - if (triggerId?.startsWith('attio_note_')) { - return extractAttioNoteData(body) - } - if (triggerId?.startsWith('attio_task_')) { - return extractAttioTaskData(body) - } - if (triggerId?.startsWith('attio_comment_')) { - return extractAttioCommentData(body) - } - if (triggerId === 'attio_list_entry_updated') { - return extractAttioListEntryUpdatedData(body) - } - if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { - return extractAttioListEntryData(body) - } - if ( - triggerId === 'attio_list_created' || - triggerId === 'attio_list_updated' || - triggerId === 'attio_list_deleted' - ) { - return extractAttioListData(body) - } - if (triggerId === 'attio_workspace_member_created') { - return extractAttioWorkspaceMemberData(body) - } - return extractAttioGenericData(body) - } - - return body -} - - /** * Result of syncing webhooks for a credential set */ @@ -1268,7 +59,7 @@ export async function syncWebhooksForCredentialSet(params: { basePath: string credentialSetId: string oauthProviderId: string - providerConfig: Record + providerConfig: Record requestId: string tx?: DbOrTx deploymentVersionId?: string @@ -1308,7 +99,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` ) - // Get existing webhooks for this workflow+block that belong to this credential set const existingWebhooks = await dbCtx .select() .from(webhook) @@ -1327,7 +117,6 @@ export async function syncWebhooksForCredentialSet(params: { ) ) - // Filter to only webhooks belonging to this credential set const credentialSetWebhooks = existingWebhooks.filter( (wh) => wh.credentialSetId === credentialSetId ) @@ -1336,12 +125,11 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` ) - // Build maps for efficient lookup const existingByCredentialId = new Map() for (const wh of credentialSetWebhooks) { - const config = wh.providerConfig as Record + const config = wh.providerConfig as Record if (config?.credentialId) { - existingByCredentialId.set(config.credentialId, wh) + existingByCredentialId.set(config.credentialId as string, wh) } } @@ -1364,21 +152,18 @@ export async function syncWebhooksForCredentialSet(params: { failed: [], } - // Process each credential in the set for (const cred of credentials) { try { const existingWebhook = existingByCredentialId.get(cred.credentialId) if (existingWebhook) { - // Update existing webhook - preserve state fields - const existingConfig = existingWebhook.providerConfig as Record + const existingConfig = existingWebhook.providerConfig as Record const updatedConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, - // Preserve state fields from existing config historyId: existingConfig?.historyId, lastCheckedTimestamp: existingConfig?.lastCheckedTimestamp, setupCompleted: existingConfig?.setupCompleted, @@ -1407,7 +192,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` ) } else { - // Create new webhook for this credential const webhookId = generateShortId() const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` @@ -1415,7 +199,7 @@ export async function syncWebhooksForCredentialSet(params: { const newConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, userId: cred.userId, @@ -1428,7 +212,7 @@ export async function syncWebhooksForCredentialSet(params: { path: webhookPath, provider, providerConfig: newConfig, - credentialSetId, // Indexed column for efficient credential set queries + credentialSetId, isActive: true, ...(deploymentVersionId ? { deploymentVersionId } : {}), createdAt: new Date(), @@ -1458,7 +242,6 @@ export async function syncWebhooksForCredentialSet(params: { } } - // Delete webhooks for credentials no longer in the set for (const [credentialId, existingWebhook] of existingByCredentialId) { if (!credentialIdsInSet.has(credentialId)) { try { @@ -1506,7 +289,6 @@ export async function syncAllWebhooksForCredentialSet( const syncLogger = createLogger('CredentialSetMembershipSync') syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) - // Find all webhooks that use this credential set using the indexed column const webhooksForSet = await dbCtx .select({ webhook }) .from(webhook) @@ -1533,12 +315,10 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } } - // Group webhooks by workflow+block to find unique triggers const triggerGroups = new Map() for (const row of webhooksForSet) { const wh = row.webhook const key = `${wh.workflowId}:${wh.blockId}` - // Keep the first webhook as representative (they all have same config) if (!triggerGroups.has(key)) { triggerGroups.set(key, wh) } @@ -1558,15 +338,15 @@ export async function syncAllWebhooksForCredentialSet( continue } - const config = representativeWebhook.providerConfig as Record + const config = representativeWebhook.providerConfig as Record const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config - // Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks) - const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path + const basePath = + (config.basePath as string) || representativeWebhook.blockId || representativeWebhook.path try { - const result = await syncWebhooksForCredentialSet({ + const syncResult = await syncWebhooksForCredentialSet({ workflowId: representativeWebhook.workflowId, blockId: representativeWebhook.blockId || '', provider: representativeWebhook.provider, @@ -1580,11 +360,11 @@ export async function syncAllWebhooksForCredentialSet( }) workflowsUpdated++ - totalCreated += result.created - totalDeleted += result.deleted + totalCreated += syncResult.created + totalDeleted += syncResult.deleted syncLogger.debug( - `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted` + `[${requestId}] Synced webhooks for ${key}: ${syncResult.created} created, ${syncResult.deleted} deleted` ) } catch (error) { syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) @@ -1597,12 +377,3 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated, totalCreated, totalDeleted } } - -export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { - if (!twiml) { - return twiml - } - - // Replace [Tag] with and [/Tag] with - return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') -} From 0d2f78bc8f78936bf4168c8c34ddf7503919b426 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 10:38:43 -0700 Subject: [PATCH 10/18] refactor(webhooks): decompose provider-subscriptions into handler registry pattern Move all provider-specific subscription create/delete logic from the monolithic provider-subscriptions.ts into individual provider handler files via new createSubscription/deleteSubscription methods on WebhookProviderHandler. Replace the two massive if-else dispatch chains (11 branches each) with simple registry lookups via getProviderHandler(). provider-subscriptions.ts reduced from 2,337 lines to 128 lines (orchestration only). Also migrate polling configuration (gmail, outlook, rss, imap) into provider handlers via configurePolling() method, and challenge/verification handling (slack, whatsapp, teams) via handleChallenge() method. Delete polling-config.ts. Create new handler files for fathom and lemlist providers. Extract shared subscription utilities into subscription-utils.ts. Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/webhooks/route.ts | 147 +- apps/sim/lib/webhooks/deploy.ts | 37 +- apps/sim/lib/webhooks/polling-config.ts | 280 -- .../lib/webhooks/provider-subscriptions.ts | 2298 +---------------- apps/sim/lib/webhooks/providers/airtable.ts | 304 ++- apps/sim/lib/webhooks/providers/ashby.ts | 159 ++ apps/sim/lib/webhooks/providers/attio.ts | 208 ++ apps/sim/lib/webhooks/providers/calendly.ts | 192 ++ apps/sim/lib/webhooks/providers/fathom.ts | 173 ++ apps/sim/lib/webhooks/providers/gmail.ts | 102 + apps/sim/lib/webhooks/providers/grain.ts | 205 ++ apps/sim/lib/webhooks/providers/imap.ts | 53 + apps/sim/lib/webhooks/providers/lemlist.ts | 218 ++ .../lib/webhooks/providers/microsoft-teams.ts | 225 +- apps/sim/lib/webhooks/providers/outlook.ts | 98 + apps/sim/lib/webhooks/providers/registry.ts | 4 + apps/sim/lib/webhooks/providers/rss.ts | 41 + apps/sim/lib/webhooks/providers/slack.ts | 4 + .../webhooks/providers/subscription-utils.ts | 39 + apps/sim/lib/webhooks/providers/telegram.ts | 107 + apps/sim/lib/webhooks/providers/typeform.ts | 152 ++ apps/sim/lib/webhooks/providers/types.ts | 45 + apps/sim/lib/webhooks/providers/webflow.ts | 227 ++ apps/sim/lib/webhooks/providers/whatsapp.ts | 10 +- 24 files changed, 2659 insertions(+), 2669 deletions(-) delete mode 100644 apps/sim/lib/webhooks/polling-config.ts create mode 100644 apps/sim/lib/webhooks/providers/fathom.ts create mode 100644 apps/sim/lib/webhooks/providers/lemlist.ts create mode 100644 apps/sim/lib/webhooks/providers/subscription-utils.ts diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 54424dc4e7b..5f0f6b31376 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -11,16 +11,12 @@ import { generateId, generateShortId } from '@/lib/core/utils/uuid' import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' -import { - configureGmailPolling, - configureOutlookPolling, - configureRssPolling, -} from '@/lib/webhooks/polling-config' import { cleanupExternalWebhook, createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -402,16 +398,13 @@ export async function POST(request: NextRequest) { ) } - const needsConfiguration = provider === 'gmail' || provider === 'outlook' + const providerHandler = getProviderHandler(provider) - if (needsConfiguration) { - const configureFunc = - provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + if (providerHandler.configurePolling) { const configureErrors: string[] = [] for (const wh of syncResult.webhooks) { if (wh.isNew) { - // Fetch the webhook data for configuration const webhookRows = await db .select() .from(webhook) @@ -419,7 +412,10 @@ export async function POST(request: NextRequest) { .limit(1) if (webhookRows.length > 0) { - const success = await configureFunc(webhookRows[0], requestId) + const success = await providerHandler.configurePolling({ + webhook: webhookRows[0], + requestId, + }) if (!success) { configureErrors.push( `Failed to configure webhook for credential ${wh.credentialId}` @@ -436,7 +432,6 @@ export async function POST(request: NextRequest) { configureErrors.length > 0 && configureErrors.length === syncResult.webhooks.length ) { - // All configurations failed - roll back logger.error(`[${requestId}] All webhook configurations failed, rolling back`) for (const wh of syncResult.webhooks) { await db.delete(webhook).where(eq(webhook.id, wh.id)) @@ -629,115 +624,51 @@ export async function POST(request: NextRequest) { } } - // --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) --- - if (savedWebhook && provider === 'gmail') { - logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) - try { - const success = await configureGmailPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure Gmail polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail polling', - details: 'Please check your Gmail account permissions and try again', - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Successfully configured Gmail polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Gmail webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } + // --- Polling provider setup (Gmail, Outlook, RSS, IMAP, etc.) --- + if (savedWebhook) { + const pollingHandler = getProviderHandler(provider) + if (pollingHandler.configurePolling) { + logger.info( + `[${requestId}] ${provider} provider detected. Setting up polling configuration.` ) - } - } - // --- End Gmail specific logic --- + try { + const success = await pollingHandler.configurePolling({ + webhook: savedWebhook, + requestId, + }) - // --- Outlook webhook setup --- - if (savedWebhook && provider === 'outlook') { - logger.info( - `[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.` - ) - try { - const success = await configureOutlookPolling(savedWebhook, requestId) + if (!success) { + logger.error( + `[${requestId}] Failed to configure ${provider} polling, rolling back webhook` + ) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check your account permissions and try again', + }, + { status: 500 } + ) + } - if (!success) { - logger.error(`[${requestId}] Failed to configure Outlook polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook polling', - details: 'Please check your Outlook account permissions and try again', - }, - { status: 500 } + logger.info(`[${requestId}] Successfully configured ${provider} polling`) + } catch (err) { + logger.error( + `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`, + err ) - } - - logger.info(`[${requestId}] Successfully configured Outlook polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Outlook webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Outlook specific logic --- - - // --- RSS webhook setup --- - if (savedWebhook && provider === 'rss') { - logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`) - try { - const success = await configureRssPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { - error: 'Failed to configure RSS polling', - details: 'Please try again', + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', }, { status: 500 } ) } - - logger.info(`[${requestId}] Successfully configured RSS polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure RSS webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) } } - // --- End RSS specific logic --- + // --- End polling provider setup --- if (!targetWebhookId && savedWebhook) { try { diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 2e05d85b0a4..a9e7d41471e 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -6,12 +6,12 @@ import type { NextRequest } from 'next/server' import { generateShortId } from '@/lib/core/utils/uuid' import { getProviderIdFromServiceId } from '@/lib/oauth' import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' -import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config' import { cleanupExternalWebhook, createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' @@ -230,29 +230,20 @@ function buildProviderConfig( async function configurePollingIfNeeded( provider: string, - savedWebhook: any, + savedWebhook: Record, requestId: string ): Promise { - if (provider === 'gmail') { - const success = await configureGmailPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: 'Failed to configure Gmail polling. Please check your Gmail account permissions.', - status: 500, - } - } + const handler = getProviderHandler(provider) + if (!handler.configurePolling) { + return null } - if (provider === 'outlook') { - const success = await configureOutlookPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: - 'Failed to configure Outlook polling. Please check your Outlook account permissions.', - status: 500, - } + 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, } } @@ -319,13 +310,13 @@ async function syncCredentialSetWebhooks(params: { } } - if (provider === 'gmail' || provider === 'outlook') { - const configureFunc = provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + const handler = getProviderHandler(provider) + if (handler.configurePolling) { for (const wh of syncResult.webhooks) { if (wh.isNew) { const rows = await db.select().from(webhook).where(eq(webhook.id, wh.id)).limit(1) if (rows.length > 0) { - const success = await configureFunc(rows[0], requestId) + const success = await handler.configurePolling({ webhook: rows[0], requestId }) if (!success) { await db.delete(webhook).where(eq(webhook.id, wh.id)) return { diff --git a/apps/sim/lib/webhooks/polling-config.ts b/apps/sim/lib/webhooks/polling-config.ts deleted file mode 100644 index b4afd0699b8..00000000000 --- a/apps/sim/lib/webhooks/polling-config.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { db } from '@sim/db' -import { account, webhook } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' - -/** - * Configure Gmail polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureGmailPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('GmailWebhookSetup') - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - const resolvedGmail = await resolveOAuthAccountId(credentialId) - if (!resolvedGmail) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedGmail.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedGmail.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - - const maxEmailsPerPoll = - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25 - - const pollingInterval = - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5 - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Gmail polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure Outlook polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureOutlookPolling( - webhookData: any, - requestId: string -): Promise { - const logger = createLogger('OutlookWebhookSetup') - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - const resolvedOutlook = await resolveOAuthAccountId(credentialId) - if (!resolvedOutlook) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedOutlook.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedOutlook.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll: - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25, - pollingInterval: - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - folderIds: providerConfig.folderIds || ['inbox'], - folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Outlook polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure RSS polling for a webhook - */ -export async function configureRssPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('RssWebhookSetup') - logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - lastCheckedTimestamp: now.toISOString(), - lastSeenGuids: [], - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure RSS polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -/** - * Configure IMAP polling for a webhook - */ -export async function configureImapPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('ImapWebhookSetup') - logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { - logger.error( - `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` - ) - return false - } - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, - mailbox: providerConfig.mailbox || 'INBOX', - searchCriteria: providerConfig.searchCriteria || 'UNSEEN', - markAsRead: providerConfig.markAsRead || false, - includeAttachments: providerConfig.includeAttachments !== false, - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure IMAP polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 4c5e16ffc1a..227e05753ab 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -1,1978 +1,8 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { generateId } from '@/lib/core/utils/uuid' -import { - getOAuthToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getProviderHandler } from '@/lib/webhooks/providers' -const teamsLogger = createLogger('TeamsSubscription') -const telegramLogger = createLogger('TelegramWebhook') -const airtableLogger = createLogger('AirtableWebhook') -const typeformLogger = createLogger('TypeformWebhook') -const calendlyLogger = createLogger('CalendlyWebhook') -const ashbyLogger = createLogger('AshbyWebhook') -const grainLogger = createLogger('GrainWebhook') -const fathomLogger = createLogger('FathomWebhook') -const lemlistLogger = createLogger('LemlistWebhook') -const webflowLogger = createLogger('WebflowWebhook') -const attioLogger = createLogger('AttioWebhook') -const providerSubscriptionsLogger = createLogger('WebhookProviderSubscriptions') - -function getProviderConfig(webhook: any): Record { - return (webhook.providerConfig as Record) || {} -} - -function getNotificationUrl(webhook: any): string { - return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` -} - -async function getCredentialOwner( - credentialId: string, - requestId: string -): Promise<{ userId: string; accountId: string } | null> { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - providerSubscriptionsLogger.warn( - `[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}` - ) - return null - } - const [credentialRecord] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentialRecord?.userId) { - providerSubscriptionsLogger.warn( - `[${requestId}] Credential owner not found for credentialId ${credentialId}` - ) - return null - } - - return { userId: credentialRecord.userId, accountId: resolved.accountId } -} - -/** - * Create a Microsoft Teams chat subscription - * Throws errors with friendly messages if subscription creation fails - */ -export async function createTeamsSubscription( - request: NextRequest, - webhook: any, - workflow: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return undefined - } - - const credentialId = config.credentialId as string | undefined - const chatId = config.chatId as string | undefined - - if (!credentialId) { - teamsLogger.warn( - `[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}` - ) - throw new Error( - 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' - ) - } - - if (!chatId) { - teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) - throw new Error( - 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) - : null - if (!accessToken) { - teamsLogger.error( - `[${requestId}] Failed to get access token for Teams subscription ${webhook.id}` - ) - throw new Error( - 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' - ) - } - - const existingSubscriptionId = config.externalSubscriptionId as string | undefined - if (existingSubscriptionId) { - try { - const checkRes = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, - { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (checkRes.ok) { - teamsLogger.info( - `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` - ) - return existingSubscriptionId - } - } catch { - teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`) - } - } - - const notificationUrl = getNotificationUrl(webhook) - const resource = `/chats/${chatId}/messages` - - // Max lifetime: 4230 minutes (~3 days) - Microsoft Graph API limit - const maxLifetimeMinutes = 4230 - const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() - - const body = { - changeType: 'created,updated', - notificationUrl, - lifecycleNotificationUrl: notificationUrl, - resource, - includeResourceData: false, - expirationDateTime, - clientState: webhook.id, - } - - try { - const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) - - const payload = await res.json() - if (!res.ok) { - const errorMessage = - payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' - teamsLogger.error( - `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, - { - status: res.status, - error: payload.error, - } - ) - - let userFriendlyMessage = 'Failed to create Teams subscription' - if (res.status === 401 || res.status === 403) { - userFriendlyMessage = - 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' - } else if (res.status === 404) { - userFriendlyMessage = - 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' - } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { - userFriendlyMessage = `Teams error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - teamsLogger.info( - `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` - ) - return payload.id as string - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('credentials') || - error.message.includes('Chat ID') || - error.message.includes('authenticate')) - ) { - throw error - } - - teamsLogger.error( - `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Teams subscription. Please try again.' - ) - } -} - -/** - * Delete a Microsoft Teams chat subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTeamsSubscription( - webhook: any, - workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return - } - - const externalSubscriptionId = config.externalSubscriptionId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalSubscriptionId || !credentialId) { - teamsLogger.info( - `[${requestId}] No external subscription to delete for webhook ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - teamsLogger.warn( - `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` - ) - return - } - - const res = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ) - - if (res.ok || res.status === 404) { - teamsLogger.info( - `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` - ) - } else { - const errorBody = await res.text() - teamsLogger.warn( - `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` - ) - } - } catch (error) { - teamsLogger.error( - `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Create a Telegram bot webhook - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTelegramWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`) - throw new Error( - 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' - ) - } - - const notificationUrl = getNotificationUrl(webhook) - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` - - try { - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', - }, - body: JSON.stringify({ url: notificationUrl }), - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to create Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - - let userFriendlyMessage = 'Failed to create Telegram webhook' - if (telegramResponse.status === 401) { - userFriendlyMessage = - 'Invalid bot token. Please verify that the bot token is correct and try again.' - } else if (responseBody.description) { - userFriendlyMessage = `Telegram error: ${responseBody.description}` - } - - throw new Error(userFriendlyMessage) - } - - telegramLogger.info( - `[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}` - ) - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Bot token') || error.message.includes('Telegram error')) - ) { - throw error - } - - telegramLogger.error( - `[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Telegram webhook. Please try again.' - ) - } -} - -/** - * Delete a Telegram bot webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn( - `[${requestId}] Missing botToken for Telegram webhook deletion ${webhook.id}` - ) - return - } - - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - } else { - telegramLogger.info( - `[${requestId}] Successfully deleted Telegram webhook for webhook ${webhook.id}` - ) - } - } catch (error) { - telegramLogger.error( - `[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Delete an Airtable webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteAirtableWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const { baseId, externalId } = config as { - baseId?: string - externalId?: string - } - - if (!baseId) { - airtableLogger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { - webhookId: webhook.id, - }) - return - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { - webhookId: webhook.id, - baseId: baseId.substring(0, 20), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - airtableLogger.warn( - `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, - { webhookId: webhook.id } - ) - return - } - - let resolvedExternalId: string | undefined = externalId - - if (!resolvedExternalId) { - try { - const expectedNotificationUrl = getNotificationUrl(webhook) - - const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - const listResp = await fetch(listUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - const listBody = await listResp.json().catch(() => null) - - if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { - const match = listBody.webhooks.find((w: any) => { - const url: string | undefined = w?.notificationUrl - if (!url) return false - return ( - url === expectedNotificationUrl || - url.endsWith(`/api/webhooks/trigger/${webhook.path}`) - ) - }) - if (match?.id) { - resolvedExternalId = match.id as string - airtableLogger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { - baseId, - externalId: resolvedExternalId, - }) - } else { - airtableLogger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { - baseId, - expectedNotificationUrl, - }) - } - } else { - airtableLogger.warn( - `[${requestId}] Failed to list Airtable webhooks to resolve externalId`, - { - baseId, - status: listResp.status, - body: listBody, - } - ) - } - } catch (e: any) { - airtableLogger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { - error: e?.message, - }) - } - } - - if (!resolvedExternalId) { - airtableLogger.info( - `[${requestId}] Airtable externalId not found; skipping remote deletion`, - { baseId } - ) - return - } - - const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') - if (!webhookIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: resolvedExternalId.substring(0, 20), - }) - return - } - - const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` - const airtableResponse = await fetch(airtableDeleteUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!airtableResponse.ok) { - let responseBody: any = null - try { - responseBody = await airtableResponse.json() - } catch { - // Ignore parse errors - } - - airtableLogger.warn( - `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, - { baseId, externalId: resolvedExternalId, response: responseBody } - ) - } else { - airtableLogger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { - baseId, - externalId: resolvedExternalId, - }) - } - } catch (error: any) { - airtableLogger.error(`[${requestId}] Error deleting Airtable webhook`, { - webhookId: webhook.id, - error: error.message, - stack: error.stack, - }) - } -} - -/** - * Create a Typeform webhook subscription - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTypeformWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - const secret = config.secret as string | undefined - - if (!formId) { - typeformLogger.warn(`[${requestId}] Missing formId for Typeform webhook ${webhook.id}`) - throw new Error( - 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' - ) - } - - if (!apiKey) { - typeformLogger.warn(`[${requestId}] Missing apiKey for Typeform webhook ${webhook.id}`) - throw new Error( - 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' - ) - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const notificationUrl = getNotificationUrl(webhook) - - try { - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const requestBody: Record = { - url: notificationUrl, - enabled: true, - verify_ssl: true, - event_types: { - form_response: true, - }, - } - - if (secret) { - requestBody.secret = secret - } - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!typeformResponse.ok) { - const responseBody = await typeformResponse.json().catch(() => ({})) - const errorMessage = responseBody.description || responseBody.message || 'Unknown error' - - typeformLogger.error(`[${requestId}] Typeform API error: ${errorMessage}`, { - status: typeformResponse.status, - response: responseBody, - }) - - let userFriendlyMessage = 'Failed to create Typeform webhook' - if (typeformResponse.status === 401) { - userFriendlyMessage = - 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' - } else if (typeformResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' - } else if (typeformResponse.status === 404) { - userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' - } else if (responseBody.description || responseBody.message) { - userFriendlyMessage = `Typeform error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await typeformResponse.json() - typeformLogger.info( - `[${requestId}] Successfully created Typeform webhook for webhook ${webhook.id} with tag ${tag}`, - { webhookId: responseBody.id } - ) - - return tag - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Form ID') || - error.message.includes('Personal Access Token') || - error.message.includes('Typeform error')) - ) { - throw error - } - - typeformLogger.error( - `[${requestId}] Error creating Typeform webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Typeform webhook. Please try again.' - ) - } -} - -/** - * Delete a Typeform webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTypeformWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - - if (!formId || !apiKey) { - typeformLogger.warn( - `[${requestId}] Missing formId or apiKey for Typeform webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!typeformResponse.ok && typeformResponse.status !== 404) { - typeformLogger.warn( - `[${requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` - ) - } else { - typeformLogger.info(`[${requestId}] Successfully deleted Typeform webhook with tag ${tag}`) - } - } catch (error) { - typeformLogger.warn(`[${requestId}] Error deleting Typeform webhook (non-fatal)`, error) - } -} - -/** - * Delete a Calendly webhook subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteCalendlyWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - calendlyLogger.warn( - `[${requestId}] Missing apiKey for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - calendlyLogger.warn( - `[${requestId}] Missing externalId for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!calendlyResponse.ok && calendlyResponse.status !== 404) { - const responseBody = await calendlyResponse.json().catch(() => ({})) - calendlyLogger.warn( - `[${requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, - { response: responseBody } - ) - } else { - calendlyLogger.info( - `[${requestId}] Successfully deleted Calendly webhook subscription ${externalId}` - ) - } - } catch (error) { - calendlyLogger.warn(`[${requestId}] Error deleting Calendly webhook (non-fatal)`, error) - } -} - -/** - * Delete a Grain webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteGrainWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - grainLogger.warn( - `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - grainLogger.warn( - `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` - - const grainResponse = await fetch(grainApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }) - - if (!grainResponse.ok && grainResponse.status !== 404) { - const responseBody = await grainResponse.json().catch(() => ({})) - grainLogger.warn( - `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, - { response: responseBody } - ) - } else { - grainLogger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) - } - } catch (error) { - grainLogger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) - } -} - -/** - * Delete a Fathom webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteFathomWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - fathomLogger.warn( - `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - fathomLogger.warn( - `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) - if (!idValidation.isValid) { - fathomLogger.warn( - `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` - - const fathomResponse = await fetch(fathomApiUrl, { - method: 'DELETE', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!fathomResponse.ok && fathomResponse.status !== 404) { - fathomLogger.warn( - `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` - ) - } else { - fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) - } - } catch (error) { - fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) - } -} - -/** - * Delete a Lemlist webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteLemlistWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - lemlistLogger.warn( - `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - const deleteById = async (id: string) => { - const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) - if (!validation.isValid) { - lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { - id: id.substring(0, 30), - }) - return - } - - const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` - const lemlistResponse = await fetch(lemlistApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Basic ${authString}`, - }, - }) - - if (!lemlistResponse.ok && lemlistResponse.status !== 404) { - const responseBody = await lemlistResponse.json().catch(() => ({})) - lemlistLogger.warn( - `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, - { response: responseBody } - ) - } else { - lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) - } - } - - if (externalId) { - await deleteById(externalId) - return - } - - const notificationUrl = getNotificationUrl(webhook) - const listResponse = await fetch('https://api.lemlist.com/api/hooks', { - method: 'GET', - headers: { - Authorization: `Basic ${authString}`, - }, - }) - - if (!listResponse.ok) { - lemlistLogger.warn( - `[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, - { status: listResponse.status } - ) - return - } - - const listBody = await listResponse.json().catch(() => null) - const hooks: Array> = Array.isArray(listBody) - ? listBody - : listBody?.hooks || listBody?.data || [] - const matches = hooks.filter((hook) => { - const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url - return typeof targetUrl === 'string' && targetUrl === notificationUrl - }) - - if (matches.length === 0) { - lemlistLogger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { - notificationUrl, - }) - return - } - - for (const hook of matches) { - const hookId = hook?._id || hook?.id - if (typeof hookId === 'string' && hookId.length > 0) { - await deleteById(hookId) - } - } - } catch (error) { - lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) - } -} - -export async function deleteWebflowWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const siteId = config.siteId as string | undefined - const externalId = config.externalId as string | undefined - - if (!siteId) { - webflowLogger.warn( - `[${requestId}] Missing siteId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - webflowLogger.warn( - `[${requestId}] Missing externalId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { - webhookId: webhook.id, - siteId: siteId.substring(0, 30), - }) - return - } - - const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) - if (!webhookIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: externalId.substring(0, 30), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - webflowLogger.warn( - `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!webflowResponse.ok && webflowResponse.status !== 404) { - const responseBody = await webflowResponse.json().catch(() => ({})) - webflowLogger.warn( - `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, - { response: responseBody } - ) - } else { - webflowLogger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) - } - } catch (error) { - webflowLogger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) - } -} - -export async function createAttioWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise<{ externalId: string; webhookSecret: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { triggerId, credentialId } = providerConfig || {} - - if (!credentialId) { - attioLogger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` - ) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') - - let subscriptions: Array<{ event_type: string; filter: null }> = [] - if (triggerId === 'attio_webhook') { - const allEvents = new Set() - for (const events of Object.values(TRIGGER_EVENT_MAP)) { - for (const event of events) { - allEvents.add(event) - } - } - subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) - } else { - const events = TRIGGER_EVENT_MAP[triggerId] - if (!events || events.length === 0) { - attioLogger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Unknown Attio trigger type: ${triggerId}`) - } - subscriptions = events.map((event_type) => ({ event_type, filter: null })) - } - - const requestBody = { - data: { - target_url: notificationUrl, - subscriptions, - }, - } - - const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!attioResponse.ok) { - const errorBody = await attioResponse.json().catch(() => ({})) - attioLogger.error( - `[${requestId}] Failed to create webhook in Attio for webhook ${webhookData.id}. Status: ${attioResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Attio' - if (attioResponse.status === 401) { - userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' - } else if (attioResponse.status === 403) { - userFriendlyMessage = - 'Attio access denied. Please ensure your integration has webhook permissions.' - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await attioResponse.json() - const data = responseBody.data || responseBody - const webhookId = data.id?.webhook_id || data.webhook_id || data.id - const secret = data.secret - - if (!webhookId) { - attioLogger.error( - `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Attio webhook creation succeeded but no webhook ID was returned') - } - - if (!secret) { - attioLogger.warn( - `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookData.id}. Signature verification will be skipped.`, - { response: responseBody } - ) - } - - attioLogger.info( - `[${requestId}] Successfully created webhook in Attio for webhook ${webhookData.id}.`, - { - attioWebhookId: webhookId, - targetUrl: notificationUrl, - subscriptionCount: subscriptions.length, - status: data.status, - } - ) - - return { externalId: webhookId, webhookSecret: secret || '' } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - attioLogger.error( - `[${requestId}] Exception during Attio webhook creation for webhook ${webhookData.id}.`, - { message } - ) - throw error - } -} - -export async function deleteAttioWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const externalId = config.externalId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalId) { - attioLogger.warn( - `[${requestId}] Missing externalId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!credentialId) { - attioLogger.warn( - `[${requestId}] Missing credentialId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!attioResponse.ok && attioResponse.status !== 404) { - const responseBody = await attioResponse.json().catch(() => ({})) - attioLogger.warn( - `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, - { response: responseBody } - ) - } else { - attioLogger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) - } - } catch (error) { - attioLogger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) - } -} - -export async function createGrainWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string; eventTypes: string[] } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, viewId } = providerConfig || {} - - if (!apiKey) { - grainLogger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' - ) - } - - if (!viewId) { - grainLogger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { - webhookId: webhookData.id, - triggerId, - }) - throw new Error( - 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' - ) - } - - const actionMap: Record> = { - grain_item_added: ['added'], - grain_item_updated: ['updated'], - grain_recording_created: ['added'], - grain_recording_updated: ['updated'], - grain_highlight_created: ['added'], - grain_highlight_updated: ['updated'], - grain_story_created: ['added'], - } - - const eventTypeMap: Record = { - grain_webhook: [], - grain_item_added: [], - grain_item_updated: [], - grain_recording_created: ['recording_added'], - grain_recording_updated: ['recording_updated'], - grain_highlight_created: ['highlight_added'], - grain_highlight_updated: ['highlight_updated'], - grain_story_created: ['story_added'], - } - - const actions = actionMap[triggerId] ?? [] - const eventTypes = eventTypeMap[triggerId] ?? [] - - if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { - grainLogger.warn( - `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, - { - webhookId: webhookData.id, - } - ) - } - - grainLogger.info(`[${requestId}] Creating Grain webhook`, { - triggerId, - viewId, - actions, - eventTypes, - webhookId: webhookData.id, - }) - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' - - const requestBody: Record = { - version: 2, - hook_url: notificationUrl, - view_id: viewId, - } - if (actions.length > 0) { - requestBody.actions = actions - } - - const grainResponse = await fetch(grainApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await grainResponse.json() - - if (!grainResponse.ok || responseBody.error || responseBody.errors) { - const errorMessage = - responseBody.errors?.detail || - responseBody.error?.message || - responseBody.error || - responseBody.message || - 'Unknown Grain API error' - grainLogger.error( - `[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Grain' - if (grainResponse.status === 401) { - userFriendlyMessage = - 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' - } else if (grainResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Grain API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { - userFriendlyMessage = `Grain error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const grainWebhookId = responseBody.id - - if (!grainWebhookId) { - grainLogger.error( - `[${requestId}] Grain webhook creation response missing id for webhook ${webhookData.id}.`, - { - response: responseBody, - } - ) - throw new Error( - 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' - ) - } - - grainLogger.info( - `[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`, - { - grainWebhookId, - eventTypes, - } - ) - - return { id: grainWebhookId, eventTypes } - } catch (error: any) { - grainLogger.error( - `[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createFathomWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { - apiKey, - triggerId, - triggeredFor, - includeSummary, - includeTranscript, - includeActionItems, - includeCrmMatches, - } = providerConfig || {} - - if (!apiKey) { - fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Fathom API Key is required. Please provide your API key in the trigger configuration.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggeredForValue = triggeredFor || 'my_recordings' - - const toBool = (val: unknown, fallback: boolean): boolean => { - if (val === undefined) return fallback - return val === true || val === 'true' - } - - const requestBody: Record = { - destination_url: notificationUrl, - triggered_for: [triggeredForValue], - include_summary: toBool(includeSummary, true), - include_transcript: toBool(includeTranscript, false), - include_action_items: toBool(includeActionItems, false), - include_crm_matches: toBool(includeCrmMatches, false), - } - - fathomLogger.info(`[${requestId}] Creating Fathom webhook`, { - triggerId, - triggeredFor: triggeredForValue, - webhookId: webhookData.id, - }) - - const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { - method: 'POST', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await fathomResponse.json().catch(() => ({})) - - if (!fathomResponse.ok) { - const errorMessage = - (responseBody as Record).message || - (responseBody as Record).error || - 'Unknown Fathom API error' - fathomLogger.error( - `[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' - if (fathomResponse.status === 401) { - userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' - } else if (fathomResponse.status === 400) { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - if (!responseBody.id) { - fathomLogger.error( - `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.` - ) - throw new Error('Fathom webhook created but no ID returned. Please try again.') - } - - fathomLogger.info( - `[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`, - { - fathomWebhookId: responseBody.id, - } - ) - - return { id: responseBody.id } - } catch (error: any) { - fathomLogger.error( - `[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createLemlistWebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, campaignId } = providerConfig || {} - - if (!apiKey) { - lemlistLogger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' - ) - } - - const eventTypeMap: Record = { - lemlist_email_replied: 'emailsReplied', - lemlist_linkedin_replied: 'linkedinReplied', - lemlist_interested: 'interested', - lemlist_not_interested: 'notInterested', - lemlist_email_opened: 'emailsOpened', - lemlist_email_clicked: 'emailsClicked', - lemlist_email_bounced: 'emailsBounced', - lemlist_email_sent: 'emailsSent', - lemlist_webhook: undefined, - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - lemlistLogger.info(`[${requestId}] Creating Lemlist webhook`, { - triggerId, - eventType, - hasCampaignId: !!campaignId, - webhookId: webhookData.id, - }) - - const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' - - const requestBody: Record = { - targetUrl: notificationUrl, - } - - if (eventType) { - requestBody.type = eventType - } - - if (campaignId) { - requestBody.campaignId = campaignId - } - - const lemlistResponse = await fetch(lemlistApiUrl, { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await lemlistResponse.json() - - if (!lemlistResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error' - lemlistLogger.error( - `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' - if (lemlistResponse.status === 401) { - userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' - } else if (lemlistResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { - userFriendlyMessage = `Lemlist error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - lemlistLogger.info( - `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, - { - lemlistWebhookId: responseBody._id, - } - ) - - return { id: responseBody._id } - } catch (error: any) { - lemlistLogger.error( - `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createAirtableWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = providerConfig || {} - - if (!baseId || !tableId) { - airtableLogger.warn( - `[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' - ) - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - throw new Error(baseIdValidation.error) - } - - const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') - if (!tableIdValidation.isValid) { - throw new Error(tableIdValidation.error) - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'airtable') - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` - ) - throw new Error( - 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - - const specification: any = { - options: { - filters: { - dataTypes: ['tableData'], - recordChangeScope: tableId, - }, - }, - } - - if (includeCellValuesInFieldIds === 'all') { - specification.options.includes = { - includeCellValuesInFieldIds: 'all', - } - } - - const requestBody: any = { - notificationUrl: notificationUrl, - specification: specification, - } - - const airtableResponse = await fetch(airtableApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await airtableResponse.json() - - if (!airtableResponse.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' - const errorType = responseBody.error?.type - airtableLogger.error( - `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`, - { type: errorType, message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' - if (airtableResponse.status === 404) { - userFriendlyMessage = - 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' - } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { - userFriendlyMessage = `Airtable error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - airtableLogger.info( - `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`, - { - airtableWebhookId: responseBody.id, - } - ) - return responseBody.id - } catch (error: any) { - airtableLogger.error( - `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createCalendlyWebhookSubscription( - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { apiKey, organization, triggerId } = providerConfig || {} - - if (!apiKey) { - calendlyLogger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' - ) - } - - if (!organization) { - calendlyLogger.warn( - `[${requestId}] Missing organization URI for Calendly webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' - ) - } - - if (!triggerId) { - calendlyLogger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger ID is required to create Calendly webhook') - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const eventTypeMap: Record = { - calendly_invitee_created: ['invitee.created'], - calendly_invitee_canceled: ['invitee.canceled'], - calendly_routing_form_submitted: ['routing_form_submission.created'], - calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], - } - - const events = eventTypeMap[triggerId] || ['invitee.created'] - - const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' - - const requestBody = { - url: notificationUrl, - events, - organization, - scope: 'organization', - } - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!calendlyResponse.ok) { - const errorBody = await calendlyResponse.json().catch(() => ({})) - const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' - calendlyLogger.error( - `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' - if (calendlyResponse.status === 401) { - userFriendlyMessage = - 'Calendly authentication failed. Please verify your Personal Access Token is correct.' - } else if (calendlyResponse.status === 403) { - userFriendlyMessage = - 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' - } else if (calendlyResponse.status === 404) { - userFriendlyMessage = - 'Calendly organization not found. Please verify the Organization URI is correct.' - } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { - userFriendlyMessage = `Calendly error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await calendlyResponse.json() - const webhookUri = responseBody.resource?.uri - - if (!webhookUri) { - calendlyLogger.error( - `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') - } - - const webhookId = webhookUri.split('/').pop() - - if (!webhookId) { - calendlyLogger.error( - `[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, - { - response: responseBody, - } - ) - throw new Error('Failed to extract webhook ID from Calendly response') - } - - calendlyLogger.info( - `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, - { - calendlyWebhookUri: webhookUri, - calendlyWebhookId: webhookId, - } - ) - return webhookId - } catch (error: any) { - calendlyLogger.error( - `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createWebflowWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { siteId, triggerId, collectionId, formName, credentialId } = providerConfig || {} - - if (!siteId) { - webflowLogger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Site ID is required to create Webflow webhook') - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - throw new Error(siteIdValidation.error) - } - - if (!triggerId) { - webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger type is required to create Webflow webhook') - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'webflow') - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` - ) - throw new Error( - 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggerTypeMap: Record = { - webflow_collection_item_created: 'collection_item_created', - webflow_collection_item_changed: 'collection_item_changed', - webflow_collection_item_deleted: 'collection_item_deleted', - webflow_form_submission: 'form_submission', - } - - const webflowTriggerType = triggerTypeMap[triggerId] - if (!webflowTriggerType) { - webflowLogger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Invalid Webflow trigger type: ${triggerId}`) - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` - - const requestBody: any = { - triggerType: webflowTriggerType, - url: notificationUrl, - } - - // Note: Webflow API only supports 'filter' for form_submission triggers. - if (formName && webflowTriggerType === 'form_submission') { - requestBody.filter = { - name: formName, - } - } - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await webflowResponse.json() - - if (!webflowResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' - webflowLogger.error( - `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`, - { message: errorMessage, response: responseBody } - ) - throw new Error(errorMessage) - } - - webflowLogger.info( - `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`, - { - webflowWebhookId: responseBody.id || responseBody._id, - } - ) - - return responseBody.id || responseBody._id - } catch (error: any) { - webflowLogger.error( - `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} +const logger = createLogger('WebhookProviderSubscriptions') type ExternalSubscriptionResult = { updatedProviderConfig: Record @@ -1986,21 +16,6 @@ type RecreateCheckInput = { nextConfig: Record } -/** Providers that create external webhook subscriptions */ -const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([ - 'airtable', - 'ashby', - 'attio', - 'calendly', - 'fathom', - 'webflow', - 'typeform', - 'grain', - 'lemlist', - 'telegram', - 'microsoft-teams', -]) - /** System-managed fields that shouldn't trigger recreation */ const SYSTEM_MANAGED_FIELDS = new Set([ 'externalId', @@ -2020,14 +35,16 @@ export function shouldRecreateExternalWebhookSubscription({ previousConfig, nextConfig, }: RecreateCheckInput): boolean { + const hasSubscription = (provider: string) => { + const handler = getProviderHandler(provider) + return Boolean(handler.createSubscription) + } + if (previousProvider !== nextProvider) { - return ( - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(previousProvider) || - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider) - ) + return hasSubscription(previousProvider) || hasSubscription(nextProvider) } - if (!PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider)) { + if (!hasSubscription(nextProvider)) { return false } @@ -2052,287 +69,60 @@ export function shouldRecreateExternalWebhookSubscription({ export async function createExternalWebhookSubscription( request: NextRequest, - webhookData: any, - workflow: any, + webhookData: Record, + workflow: Record, userId: string, requestId: string ): Promise { const provider = webhookData.provider as string const providerConfig = (webhookData.providerConfig as Record) || {} - let updatedProviderConfig = providerConfig - let externalSubscriptionCreated = false + const handler = getProviderHandler(provider) - if (provider === 'ashby') { - const result = await createAshbyWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - secretToken: result.secretToken, - } - externalSubscriptionCreated = true - } - } else if (provider === 'airtable') { - const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'attio') { - const result = await createAttioWebhookSubscription(userId, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.externalId, - webhookSecret: result.webhookSecret, - } - externalSubscriptionCreated = true - } - } else if (provider === 'calendly') { - const externalId = await createCalendlyWebhookSubscription(webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'microsoft-teams') { - const subscriptionId = await createTeamsSubscription(request, webhookData, workflow, requestId) - if (subscriptionId) { - updatedProviderConfig = { ...updatedProviderConfig, externalSubscriptionId: subscriptionId } - externalSubscriptionCreated = true - } - } else if (provider === 'telegram') { - await createTelegramWebhook(request, webhookData, requestId) - externalSubscriptionCreated = true - } else if (provider === 'webflow') { - const externalId = await createWebflowWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'typeform') { - const usedTag = await createTypeformWebhook(request, webhookData, requestId) - if (!updatedProviderConfig.webhookTag && usedTag) { - updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag } - } - externalSubscriptionCreated = true - } else if (provider === 'fathom') { - const result = await createFathomWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } - } else if (provider === 'grain') { - const result = await createGrainWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - eventTypes: result.eventTypes, - } - externalSubscriptionCreated = true - } - } else if (provider === 'lemlist') { - const result = await createLemlistWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } + if (!handler.createSubscription) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } } - return { updatedProviderConfig, externalSubscriptionCreated } -} + const result = await handler.createSubscription({ + webhook: webhookData, + workflow, + userId, + requestId, + request, + }) -/** - * Clean up external webhook subscriptions for a webhook - * Handles Airtable, Attio, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup - * Don't fail deletion if cleanup fails - */ -export async function cleanupExternalWebhook( - webhook: any, - workflow: any, - requestId: string -): Promise { - if (webhook.provider === 'ashby') { - await deleteAshbyWebhook(webhook, requestId) - } else if (webhook.provider === 'airtable') { - await deleteAirtableWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'attio') { - await deleteAttioWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'microsoft-teams') { - await deleteTeamsSubscription(webhook, workflow, requestId) - } else if (webhook.provider === 'telegram') { - await deleteTelegramWebhook(webhook, requestId) - } else if (webhook.provider === 'typeform') { - await deleteTypeformWebhook(webhook, requestId) - } else if (webhook.provider === 'calendly') { - await deleteCalendlyWebhook(webhook, requestId) - } else if (webhook.provider === 'webflow') { - await deleteWebflowWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'fathom') { - await deleteFathomWebhook(webhook, requestId) - } else if (webhook.provider === 'grain') { - await deleteGrainWebhook(webhook, requestId) - } else if (webhook.provider === 'lemlist') { - await deleteLemlistWebhook(webhook, requestId) + if (!result) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } + } + + return { + updatedProviderConfig: { ...providerConfig, ...result.providerConfigUpdates }, + externalSubscriptionCreated: true, } } /** - * Creates a webhook subscription in Ashby via webhook.create API. - * Ashby uses Basic Auth and one webhook per event type (webhookType). + * Clean up external webhook subscriptions for a webhook. + * Errors are swallowed — cleanup failure should not block webhook deletion. */ -export async function createAshbyWebhookSubscription( - webhookData: any, +export async function cleanupExternalWebhook( + webhook: Record, + workflow: Record, requestId: string -): Promise<{ id: string; secretToken: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId } = providerConfig || {} - - if (!apiKey) { - throw new Error( - 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' - ) - } - - if (!triggerId) { - throw new Error('Trigger ID is required to create Ashby webhook.') - } - - const webhookTypeMap: Record = { - ashby_application_submit: 'applicationSubmit', - ashby_candidate_stage_change: 'candidateStageChange', - ashby_candidate_hire: 'candidateHire', - ashby_candidate_delete: 'candidateDelete', - ashby_job_create: 'jobCreate', - ashby_offer_create: 'offerCreate', - } - - const webhookType = webhookTypeMap[triggerId] - if (!webhookType) { - throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - ashbyLogger.info(`[${requestId}] Creating Ashby webhook`, { - triggerId, - webhookType, - webhookId: webhookData.id, - }) - - const secretToken = generateId() - - const requestBody: Record = { - requestUrl: notificationUrl, - webhookType, - secretToken, - } - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await ashbyResponse.json().catch(() => ({})) - - if (!ashbyResponse.ok || !responseBody.success) { - const errorMessage = - responseBody.errorInfo?.message || responseBody.message || 'Unknown Ashby API error' - - let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' - if (ashbyResponse.status === 401) { - userFriendlyMessage = - 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' - } else if (ashbyResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' - } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { - userFriendlyMessage = `Ashby error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const externalId = responseBody.results?.id - if (!externalId) { - throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') - } +): Promise { + const provider = webhook.provider as string + const handler = getProviderHandler(provider) - ashbyLogger.info( - `[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}` - ) - return { id: externalId, secretToken } - } catch (error: any) { - ashbyLogger.error( - `[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error + if (!handler.deleteSubscription) { + return } -} -/** - * Deletes an Ashby webhook subscription via webhook.delete API. - * Ashby uses POST with webhookId in the body (not DELETE method). - */ -export async function deleteAshbyWebhook(webhook: any, requestId: string): Promise { try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - ashbyLogger.warn( - `[${requestId}] Missing apiKey for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - ashbyLogger.warn( - `[${requestId}] Missing externalId for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ webhookId: externalId }), - }) - - if (ashbyResponse.ok) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Successfully deleted Ashby webhook subscription ${externalId}` - ) - } else if (ashbyResponse.status === 404) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` - ) - } else { - const responseBody = await ashbyResponse.json().catch(() => ({})) - ashbyLogger.warn( - `[${requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, - { response: responseBody } - ) - } + await handler.deleteSubscription({ webhook, workflow, requestId }) } catch (error) { - ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error) + logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, { + provider, + webhookId: webhook.id, + error: error instanceof Error ? error.message : String(error), + }) } } diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 6eb75236e13..cfd38b8fe71 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -2,8 +2,25 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import type { FormatInputContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { validateAirtableId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Airtable') @@ -415,6 +432,289 @@ async function fetchAndProcessAirtablePayloads( } export const airtableHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = config as { + baseId?: string + tableId?: string + includeCellValuesInFieldIds?: string + credentialId?: string + } + + if (!baseId || !tableId) { + logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' + ) + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + throw new Error(baseIdValidation.error) + } + + const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') + if (!tableIdValidation.isValid) { + throw new Error(tableIdValidation.error) + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'airtable') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` + ) + throw new Error( + 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + + const specification: Record = { + options: { + filters: { + dataTypes: ['tableData'], + recordChangeScope: tableId, + }, + }, + } + + if (includeCellValuesInFieldIds === 'all') { + ;(specification.options as Record).includes = { + includeCellValuesInFieldIds: 'all', + } + } + + const requestBody: Record = { + notificationUrl: notificationUrl, + specification: specification, + } + + const airtableResponse = await fetch(airtableApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await airtableResponse.json() + + if (!airtableResponse.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' + const errorType = responseBody.error?.type + logger.error( + `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`, + { type: errorType, message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' + if (airtableResponse.status === 404) { + userFriendlyMessage = + 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' + } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { + userFriendlyMessage = `Airtable error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + logger.info( + `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`, + { + airtableWebhookId: responseBody.id, + } + ) + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const { baseId, externalId } = config as { + baseId?: string + externalId?: string + } + + if (!baseId) { + logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { + webhookId: webhookRecord.id, + }) + return + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { + webhookId: webhookRecord.id, + baseId: baseId.substring(0, 20), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, + { webhookId: webhookRecord.id } + ) + return + } + + let resolvedExternalId: string | undefined = externalId + + if (!resolvedExternalId) { + try { + const expectedNotificationUrl = getNotificationUrl(webhookRecord) + + const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + const listResp = await fetch(listUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const listBody = await listResp.json().catch(() => null) + + if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { + const match = listBody.webhooks.find((w: Record) => { + const url: string | undefined = w?.notificationUrl as string | undefined + if (!url) return false + return ( + url === expectedNotificationUrl || + url.endsWith(`/api/webhooks/trigger/${webhookRecord.path}`) + ) + }) + if (match?.id) { + resolvedExternalId = match.id as string + logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { + baseId, + externalId: resolvedExternalId, + }) + } else { + logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { + baseId, + expectedNotificationUrl, + }) + } + } else { + logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, { + baseId, + status: listResp.status, + body: listBody, + }) + } + } catch (e: unknown) { + logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { + error: (e as Error)?.message, + }) + } + } + + if (!resolvedExternalId) { + logger.info(`[${requestId}] Airtable externalId not found; skipping remote deletion`, { + baseId, + }) + return + } + + const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: resolvedExternalId.substring(0, 20), + }) + return + } + + const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` + const airtableResponse = await fetch(airtableDeleteUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!airtableResponse.ok) { + let responseBody: unknown = null + try { + responseBody = await airtableResponse.json() + } catch { + // Ignore parse errors + } + + logger.warn( + `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, + { baseId, externalId: resolvedExternalId, response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { + baseId, + externalId: resolvedExternalId, + }) + } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Error deleting Airtable webhook`, { + webhookId: webhookRecord.id, + error: err.message, + stack: err.stack, + }) + } + }, + extractIdempotencyId(body: unknown) { const obj = body as Record if (typeof obj.cursor === 'string') { diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index cd0f16e82af..6b35bdc8751 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,9 +1,13 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + DeleteSubscriptionContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' @@ -45,4 +49,159 @@ export const ashbyHandler: WebhookProviderHandler = { validateFn: validateAshbySignature, providerLabel: 'Ashby', }), + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, triggerId } = providerConfig as { + apiKey?: string + triggerId?: string + } + + if (!apiKey) { + throw new Error( + 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' + ) + } + + if (!triggerId) { + throw new Error('Trigger ID is required to create Ashby webhook.') + } + + const webhookTypeMap: Record = { + ashby_application_submit: 'applicationSubmit', + ashby_candidate_stage_change: 'candidateStageChange', + ashby_candidate_hire: 'candidateHire', + ashby_candidate_delete: 'candidateDelete', + ashby_job_create: 'jobCreate', + ashby_offer_create: 'offerCreate', + } + + const webhookType = webhookTypeMap[triggerId] + if (!webhookType) { + throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + logger.info(`[${ctx.requestId}] Creating Ashby webhook`, { + triggerId, + webhookType, + webhookId: ctx.webhook.id, + }) + + const secretToken = crypto.randomUUID() + + const requestBody: Record = { + requestUrl: notificationUrl, + webhookType, + secretToken, + } + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record + + if (!ashbyResponse.ok || !responseBody.success) { + const errorInfo = responseBody.errorInfo as Record | undefined + const errorMessage = + errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error' + + let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' + if (ashbyResponse.status === 401) { + userFriendlyMessage = + 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' + } else if (ashbyResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' + } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { + userFriendlyMessage = `Ashby error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const results = responseBody.results as Record | undefined + const externalId = results?.id as string | undefined + if (!externalId) { + throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') + } + + logger.info( + `[${ctx.requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}` + ) + return { providerConfigUpdates: { externalId, secretToken } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ webhookId: externalId }), + }) + + if (ashbyResponse.ok) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` + ) + } else if (ashbyResponse.status === 404) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` + ) + } else { + const responseBody = await ashbyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, + { response: responseBody } + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) + } + }, } diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 7bc0ccb5c6c..6db9928f121 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -2,13 +2,19 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { AuthContext, + DeleteSubscriptionContext, EventMatchContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Attio') @@ -100,6 +106,208 @@ export const attioHandler: WebhookProviderHandler = { return true }, + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { triggerId, credentialId } = config as { + triggerId?: string + credentialId?: string + } + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` + ) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') + + let subscriptions: Array<{ event_type: string; filter: null }> = [] + if (triggerId === 'attio_webhook') { + const allEvents = new Set() + for (const events of Object.values(TRIGGER_EVENT_MAP)) { + for (const event of events) { + allEvents.add(event) + } + } + subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) + } else { + const events = TRIGGER_EVENT_MAP[triggerId as string] + if (!events || events.length === 0) { + logger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Unknown Attio trigger type: ${triggerId}`) + } + subscriptions = events.map((event_type) => ({ event_type, filter: null })) + } + + const requestBody = { + data: { + target_url: notificationUrl, + subscriptions, + }, + } + + const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!attioResponse.ok) { + const errorBody = await attioResponse.json().catch(() => ({})) + logger.error( + `[${requestId}] Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Attio' + if (attioResponse.status === 401) { + userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' + } else if (attioResponse.status === 403) { + userFriendlyMessage = + 'Attio access denied. Please ensure your integration has webhook permissions.' + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await attioResponse.json() + const data = responseBody.data || responseBody + const webhookId = data.id?.webhook_id || data.webhook_id || data.id + const secret = data.secret + + if (!webhookId) { + logger.error( + `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`, + { response: responseBody } + ) + throw new Error('Attio webhook creation succeeded but no webhook ID was returned') + } + + if (!secret) { + logger.warn( + `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`, + { response: responseBody } + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Attio for webhook ${webhookRecord.id}.`, + { + attioWebhookId: webhookId, + targetUrl: notificationUrl, + subscriptionCount: subscriptions.length, + status: data.status, + } + ) + + return { providerConfigUpdates: { externalId: webhookId, webhookSecret: secret || '' } } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + logger.error( + `[${requestId}] Exception during Attio webhook creation for webhook ${webhookRecord.id}.`, + { message } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const externalId = config.externalId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!attioResponse.ok && attioResponse.status !== 404) { + const responseBody = await attioResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) + } + }, + async formatInput({ body, webhook }: FormatInputContext): Promise { const { extractAttioRecordData, diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts index e2946061702..7fcca4a8e8f 100644 --- a/apps/sim/lib/webhooks/providers/calendly.ts +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -1,9 +1,16 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + DeleteSubscriptionContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +const logger = createLogger('WebhookProvider:Calendly') + export const calendlyHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const b = body as Record @@ -16,4 +23,189 @@ export const calendlyHandler: WebhookProviderHandler = { }, } }, + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, organization, triggerId } = providerConfig as { + apiKey?: string + organization?: string + triggerId?: string + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' + ) + } + + if (!organization) { + logger.warn(`[${ctx.requestId}] Missing organization URI for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' + ) + } + + if (!triggerId) { + logger.warn(`[${ctx.requestId}] Missing triggerId for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error('Trigger ID is required to create Calendly webhook') + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + + const eventTypeMap: Record = { + calendly_invitee_created: ['invitee.created'], + calendly_invitee_canceled: ['invitee.canceled'], + calendly_routing_form_submitted: ['routing_form_submission.created'], + calendly_webhook: [ + 'invitee.created', + 'invitee.canceled', + 'routing_form_submission.created', + ], + } + + const events = eventTypeMap[triggerId] || ['invitee.created'] + + const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' + + const requestBody = { + url: notificationUrl, + events, + organization, + scope: 'organization', + } + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!calendlyResponse.ok) { + const errorBody = await calendlyResponse.json().catch(() => ({})) + const errorMessage = + (errorBody as Record).message || + (errorBody as Record).title || + 'Unknown Calendly API error' + logger.error( + `[${ctx.requestId}] Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' + if (calendlyResponse.status === 401) { + userFriendlyMessage = + 'Calendly authentication failed. Please verify your Personal Access Token is correct.' + } else if (calendlyResponse.status === 403) { + userFriendlyMessage = + 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' + } else if (calendlyResponse.status === 404) { + userFriendlyMessage = + 'Calendly organization not found. Please verify the Organization URI is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { + userFriendlyMessage = `Calendly error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = (await calendlyResponse.json()) as Record + const resource = responseBody.resource as Record | undefined + const webhookUri = resource?.uri as string | undefined + + if (!webhookUri) { + logger.error( + `[${ctx.requestId}] Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`, + { response: responseBody } + ) + throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') + } + + const webhookId = webhookUri.split('/').pop() + + if (!webhookId) { + logger.error( + `[${ctx.requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, + { + response: responseBody, + } + ) + throw new Error('Failed to extract webhook ID from Calendly response') + } + + logger.info( + `[${ctx.requestId}] Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`, + { + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + } + ) + return { providerConfigUpdates: { externalId: webhookId } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!calendlyResponse.ok && calendlyResponse.status !== 404) { + const responseBody = await calendlyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, + { response: responseBody } + ) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Calendly webhook subscription ${externalId}` + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Calendly webhook (non-fatal)`, error) + } + }, } diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts new file mode 100644 index 00000000000..dfc76bd0d02 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -0,0 +1,173 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('FathomWebhook') + +export const fathomHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const triggeredFor = providerConfig.triggeredFor as string | undefined + const includeSummary = providerConfig.includeSummary as unknown + const includeTranscript = providerConfig.includeTranscript as unknown + const includeActionItems = providerConfig.includeActionItems as unknown + const includeCrmMatches = providerConfig.includeCrmMatches as unknown + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Fathom API Key is required. Please provide your API key in the trigger configuration.' + ) + } + + const notificationUrl = getNotificationUrl(webhook) + + const triggeredForValue = triggeredFor || 'my_recordings' + + const toBool = (val: unknown, fallback: boolean): boolean => { + if (val === undefined) return fallback + return val === true || val === 'true' + } + + const requestBody: Record = { + destination_url: notificationUrl, + triggered_for: [triggeredForValue], + include_summary: toBool(includeSummary, true), + include_transcript: toBool(includeTranscript, false), + include_action_items: toBool(includeActionItems, false), + include_crm_matches: toBool(includeCrmMatches, false), + } + + logger.info(`[${requestId}] Creating Fathom webhook`, { + triggerId, + triggeredFor: triggeredForValue, + webhookId: webhook.id, + }) + + const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await fathomResponse.json().catch(() => ({}))) as Record< + string, + unknown + > + + if (!fathomResponse.ok) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Fathom API error' + logger.error( + `[${requestId}] Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' + if (fathomResponse.status === 401) { + userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' + } else if (fathomResponse.status === 400) { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + if (!responseBody.id) { + logger.error( + `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhook.id}.` + ) + throw new Error('Fathom webhook created but no ID returned. Please try again.') + } + + logger.info( + `[${requestId}] Successfully created webhook in Fathom for webhook ${webhook.id}.`, + { + fathomWebhookId: responseBody.id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Fathom webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) + if (!idValidation.isValid) { + logger.warn( + `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` + + const fathomResponse = await fetch(fathomApiUrl, { + method: 'DELETE', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + }) + + if (!fathomResponse.ok && fathomResponse.status !== 404) { + logger.warn( + `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` + ) + } else { + logger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts index 68bb09eb574..3244ba7a817 100644 --- a/apps/sim/lib/webhooks/providers/gmail.ts +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -1,8 +1,16 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { FormatInputContext, FormatInputResult, + PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GmailWebhookSetup') export const gmailHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { @@ -12,4 +20,98 @@ export const gmailHandler: WebhookProviderHandler = { } return { input: b } }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + const resolvedGmail = await resolveOAuthAccountId(credentialId) + if (!resolvedGmail) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedGmail.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedGmail.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } + + const maxEmailsPerPoll = + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25 + + const pollingInterval = + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5 + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Gmail polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, } diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts index 3a0f8797159..02bb0122076 100644 --- a/apps/sim/lib/webhooks/providers/grain.ts +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -1,9 +1,13 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + DeleteSubscriptionContext, EventFilterContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { skipByEventTypes } from '@/lib/webhooks/providers/utils' @@ -43,4 +47,205 @@ export const grainHandler: WebhookProviderHandler = { } return null }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const viewId = providerConfig.viewId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' + ) + } + + if (!viewId) { + logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { + webhookId: webhook.id, + triggerId, + }) + throw new Error( + 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' + ) + } + + const actionMap: Record> = { + grain_item_added: ['added'], + grain_item_updated: ['updated'], + grain_recording_created: ['added'], + grain_recording_updated: ['updated'], + grain_highlight_created: ['added'], + grain_highlight_updated: ['updated'], + grain_story_created: ['added'], + } + + const eventTypeMap: Record = { + grain_webhook: [], + grain_item_added: [], + grain_item_updated: [], + grain_recording_created: ['recording_added'], + grain_recording_updated: ['recording_updated'], + grain_highlight_created: ['highlight_added'], + grain_highlight_updated: ['highlight_updated'], + grain_story_created: ['story_added'], + } + + const actions = actionMap[triggerId ?? ''] ?? [] + const eventTypes = eventTypeMap[triggerId ?? ''] ?? [] + + if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { + logger.warn( + `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, + { + webhookId: webhook.id, + } + ) + } + + logger.info(`[${requestId}] Creating Grain webhook`, { + triggerId, + viewId, + actions, + eventTypes, + webhookId: webhook.id, + }) + + const notificationUrl = getNotificationUrl(webhook) + + const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' + + const requestBody: Record = { + version: 2, + hook_url: notificationUrl, + view_id: viewId, + } + if (actions.length > 0) { + requestBody.actions = actions + } + + const grainResponse = await fetch(grainApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await grainResponse.json()) as Record + + if (!grainResponse.ok || responseBody.error || responseBody.errors) { + const errors = responseBody.errors as Record | undefined + const error = responseBody.error as Record | string | undefined + const errorMessage = + errors?.detail || + (typeof error === 'object' ? error?.message : undefined) || + (typeof error === 'string' ? error : undefined) || + (responseBody.message as string) || + 'Unknown Grain API error' + logger.error( + `[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Grain' + if (grainResponse.status === 401) { + userFriendlyMessage = + 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' + } else if (grainResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Grain API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { + userFriendlyMessage = `Grain error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const grainWebhookId = responseBody.id as string | undefined + + if (!grainWebhookId) { + logger.error( + `[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`, + { + response: responseBody, + } + ) + throw new Error( + 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`, + { + grainWebhookId, + eventTypes, + } + ) + + return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Grain webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` + + const grainResponse = await fetch(grainApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + + if (!grainResponse.ok && grainResponse.status !== 404) { + const responseBody = await grainResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) + } + }, } diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index 35283a8f0b0..c00650c1932 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -1,9 +1,16 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { FormatInputContext, FormatInputResult, + PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +const logger = createLogger('ImapWebhookSetup') + export const imapHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const b = body as Record @@ -28,4 +35,50 @@ export const imapHandler: WebhookProviderHandler = { } return { input: b } }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { + logger.error( + `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` + ) + return false + } + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + port: providerConfig.port || '993', + secure: providerConfig.secure !== false, + mailbox: providerConfig.mailbox || 'INBOX', + searchCriteria: providerConfig.searchCriteria || 'UNSEEN', + markAsRead: providerConfig.markAsRead || false, + includeAttachments: providerConfig.includeAttachments !== false, + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure IMAP polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, } diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts new file mode 100644 index 00000000000..88c6476d1a5 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -0,0 +1,218 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('LemlistWebhook') + +export const lemlistHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const campaignId = providerConfig.campaignId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' + ) + } + + const eventTypeMap: Record = { + lemlist_email_replied: 'emailsReplied', + lemlist_linkedin_replied: 'linkedinReplied', + lemlist_interested: 'interested', + lemlist_not_interested: 'notInterested', + lemlist_email_opened: 'emailsOpened', + lemlist_email_clicked: 'emailsClicked', + lemlist_email_bounced: 'emailsBounced', + lemlist_email_sent: 'emailsSent', + lemlist_webhook: undefined, + } + + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(webhook) + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + logger.info(`[${requestId}] Creating Lemlist webhook`, { + triggerId, + eventType, + hasCampaignId: !!campaignId, + webhookId: webhook.id, + }) + + const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' + + const requestBody: Record = { + targetUrl: notificationUrl, + } + + if (eventType) { + requestBody.type = eventType + } + + if (campaignId) { + requestBody.campaignId = campaignId + } + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await lemlistResponse.json()) as Record + + if (!lemlistResponse.ok || responseBody.error) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Lemlist API error' + logger.error( + `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhook.id}. Status: ${lemlistResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' + if (lemlistResponse.status === 401) { + userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' + } else if (lemlistResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { + userFriendlyMessage = `Lemlist error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhook.id}.`, + { + lemlistWebhookId: responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + const deleteById = async (id: string) => { + const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) + if (!validation.isValid) { + logger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { + id: id.substring(0, 30), + }) + return + } + + const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!lemlistResponse.ok && lemlistResponse.status !== 404) { + const responseBody = await lemlistResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) + } + } + + if (externalId) { + await deleteById(externalId) + return + } + + const notificationUrl = getNotificationUrl(webhook) + const listResponse = await fetch('https://api.lemlist.com/api/hooks', { + method: 'GET', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!listResponse.ok) { + logger.warn(`[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, { + status: listResponse.status, + }) + return + } + + const listBody = (await listResponse.json().catch(() => null)) as + | Record + | Array> + | null + const hooks: Array> = Array.isArray(listBody) + ? listBody + : ((listBody as Record)?.hooks as Array>) || + ((listBody as Record)?.data as Array>) || + [] + const matches = hooks.filter((hook) => { + const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url + return typeof targetUrl === 'string' && targetUrl === notificationUrl + }) + + if (matches.length === 0) { + logger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { + notificationUrl, + }) + return + } + + for (const hook of matches) { + const hookId = (hook?._id || hook?.id) as string | undefined + if (typeof hookId === 'string' && hookId.length > 0) { + await deleteById(hookId) + } + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index 1a422efcaa4..8270eb93e01 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' import { type SecureFetchResponse, @@ -11,11 +11,19 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' import type { AuthContext, + DeleteSubscriptionContext, EventFilterContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' @@ -449,6 +457,19 @@ async function formatTeamsGraphNotification( } export const microsoftTeamsHandler: WebhookProviderHandler = { + handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const validationToken = url.searchParams.get('validationToken') + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + return null + }, + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { if (providerConfig.hmacSecret) { const authHeader = request.headers.get('authorization') @@ -502,6 +523,208 @@ export const microsoftTeamsHandler: WebhookProviderHandler = { ) }, + async createSubscription({ + webhook, + workflow, + userId, + requestId, + request, + }: SubscriptionContext): Promise { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return undefined + } + + const credentialId = config.credentialId as string | undefined + const chatId = config.chatId as string | undefined + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' + ) + } + + if (!chatId) { + logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`) + throw new Error( + 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' + ) + } + + const existingSubscriptionId = config.externalSubscriptionId as string | undefined + if (existingSubscriptionId) { + try { + const checkRes = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, + { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (checkRes.ok) { + logger.info( + `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: existingSubscriptionId } } + } + } catch { + logger.debug(`[${requestId}] Existing subscription check failed, will create new one`) + } + } + + const notificationUrl = getNotificationUrl(webhook) + const resource = `/chats/${chatId}/messages` + + const maxLifetimeMinutes = 4230 + const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() + + const body = { + changeType: 'created,updated', + notificationUrl, + lifecycleNotificationUrl: notificationUrl, + resource, + includeResourceData: false, + expirationDateTime, + clientState: webhook.id, + } + + try { + const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + const payload = await res.json() + if (!res.ok) { + const errorMessage = + payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' + logger.error( + `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, + { + status: res.status, + error: payload.error, + } + ) + + let userFriendlyMessage = 'Failed to create Teams subscription' + if (res.status === 401 || res.status === 403) { + userFriendlyMessage = + 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' + } else if (res.status === 404) { + userFriendlyMessage = + 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' + } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { + userFriendlyMessage = `Teams error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: payload.id as string } } + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('credentials') || + error.message.includes('Chat ID') || + error.message.includes('authenticate')) + ) { + throw error + } + + logger.error( + `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Teams subscription. Please try again.' + ) + } + }, + + async deleteSubscription({ + webhook, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return + } + + const externalSubscriptionId = config.externalSubscriptionId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalSubscriptionId || !credentialId) { + logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` + ) + return + } + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (res.ok || res.status === 404) { + logger.info( + `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` + ) + } else { + const errorBody = await res.text() + logger.warn( + `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` + ) + } + } catch (error) { + logger.error( + `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, + error + ) + } + }, + async formatInput({ body, webhook, diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts index 59dedb2210d..89a772c4ace 100644 --- a/apps/sim/lib/webhooks/providers/outlook.ts +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -1,8 +1,16 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { FormatInputContext, FormatInputResult, + PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('OutlookWebhookSetup') export const outlookHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { @@ -12,4 +20,94 @@ export const outlookHandler: WebhookProviderHandler = { } return { input: b } }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + const resolvedOutlook = await resolveOAuthAccountId(credentialId) + if (!resolvedOutlook) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedOutlook.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedOutlook.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll: + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25, + pollingInterval: + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Outlook polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, } diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index d6990651745..00ae58a21b1 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -7,6 +7,7 @@ import { calcomHandler } from '@/lib/webhooks/providers/calcom' import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' import { confluenceHandler } from '@/lib/webhooks/providers/confluence' +import { fathomHandler } from '@/lib/webhooks/providers/fathom' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' @@ -16,6 +17,7 @@ import { grainHandler } from '@/lib/webhooks/providers/grain' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' import { imapHandler } from '@/lib/webhooks/providers/imap' import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' import { outlookHandler } from '@/lib/webhooks/providers/outlook' @@ -46,10 +48,12 @@ const PROVIDER_HANDLERS: Record = { gmail: gmailHandler, github: githubHandler, google_forms: googleFormsHandler, + fathom: fathomHandler, grain: grainHandler, hubspot: hubspotHandler, imap: imapHandler, jira: jiraHandler, + lemlist: lemlistHandler, linear: linearHandler, 'microsoft-teams': microsoftTeamsHandler, outlook: outlookHandler, diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts index e343e8258ff..64afdb0db15 100644 --- a/apps/sim/lib/webhooks/providers/rss.ts +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -1,9 +1,16 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { FormatInputContext, FormatInputResult, + PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +const logger = createLogger('RssWebhookSetup') + export const rssHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const b = body as Record @@ -21,4 +28,38 @@ export const rssHandler: WebhookProviderHandler = { } return { input: b } }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + lastCheckedTimestamp: now.toISOString(), + lastSeenGuids: [], + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure RSS polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, } diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index e5dd3de4b7f..1bcedd628b9 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -190,6 +190,10 @@ export function handleSlackChallenge(body: unknown): NextResponse | null { } export const slackHandler: WebhookProviderHandler = { + handleChallenge(body: unknown) { + return handleSlackChallenge(body) + }, + extractIdempotencyId(body: unknown) { const obj = body as Record if (obj.event_id) { diff --git a/apps/sim/lib/webhooks/providers/subscription-utils.ts b/apps/sim/lib/webhooks/providers/subscription-utils.ts new file mode 100644 index 00000000000..17c6ca29514 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/subscription-utils.ts @@ -0,0 +1,39 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProviderSubscriptions') + +export function getProviderConfig(webhook: Record): Record { + return (webhook.providerConfig as Record) || {} +} + +export function getNotificationUrl(webhook: Record): string { + return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` +} + +export async function getCredentialOwner( + credentialId: string, + requestId: string +): Promise<{ userId: string; accountId: string } | null> { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.warn(`[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}`) + return null + } + const [credentialRecord] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + + if (!credentialRecord?.userId) { + logger.warn(`[${requestId}] Credential owner not found for credentialId ${credentialId}`) + return null + } + + return { userId: credentialRecord.userId, accountId: resolved.accountId } +} diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts index 3e3ce41317d..8511f9b1198 100644 --- a/apps/sim/lib/webhooks/providers/telegram.ts +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -1,8 +1,12 @@ import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { AuthContext, + DeleteSubscriptionContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -95,4 +99,107 @@ export const telegramHandler: WebhookProviderHandler = { }, } }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn(`[${ctx.requestId}] Missing botToken for Telegram webhook ${ctx.webhook.id}`) + throw new Error( + 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' + ) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` + + try { + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TelegramBot/1.0', + }, + body: JSON.stringify({ url: notificationUrl }), + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to create Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + + let userFriendlyMessage = 'Failed to create Telegram webhook' + if (telegramResponse.status === 401) { + userFriendlyMessage = + 'Invalid bot token. Please verify that the bot token is correct and try again.' + } else if (responseBody.description) { + userFriendlyMessage = `Telegram error: ${responseBody.description}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${ctx.requestId}] Successfully created Telegram webhook for webhook ${ctx.webhook.id}` + ) + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Bot token') || error.message.includes('Telegram error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Telegram webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn( + `[${ctx.requestId}] Missing botToken for Telegram webhook deletion ${ctx.webhook.id}` + ) + return + } + + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}` + ) + } + } catch (error) { + logger.error( + `[${ctx.requestId}] Error deleting Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + } + }, } diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts index 4e544d01346..068c72d9cd4 100644 --- a/apps/sim/lib/webhooks/providers/typeform.ts +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -1,9 +1,13 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + DeleteSubscriptionContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' @@ -58,4 +62,152 @@ export const typeformHandler: WebhookProviderHandler = { validateFn: validateTypeformSignature, providerLabel: 'Typeform', }), + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + const secret = config.secret as string | undefined + + if (!formId) { + logger.warn(`[${ctx.requestId}] Missing formId for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' + ) + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' + ) + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const notificationUrl = getNotificationUrl(ctx.webhook) + + try { + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const requestBody: Record = { + url: notificationUrl, + enabled: true, + verify_ssl: true, + event_types: { + form_response: true, + }, + } + + if (secret) { + requestBody.secret = secret + } + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!typeformResponse.ok) { + const responseBody = await typeformResponse.json().catch(() => ({})) + const errorMessage = + (responseBody as Record).description || + (responseBody as Record).message || + 'Unknown error' + + logger.error(`[${ctx.requestId}] Typeform API error: ${errorMessage}`, { + status: typeformResponse.status, + response: responseBody, + }) + + let userFriendlyMessage = 'Failed to create Typeform webhook' + if (typeformResponse.status === 401) { + userFriendlyMessage = + 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' + } else if (typeformResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' + } else if (typeformResponse.status === 404) { + userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' + } else if ( + (responseBody as Record).description || + (responseBody as Record).message + ) { + userFriendlyMessage = `Typeform error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await typeformResponse.json() + logger.info( + `[${ctx.requestId}] Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`, + { webhookId: (responseBody as Record).id } + ) + + if (!webhookTag && tag) { + return { providerConfigUpdates: { webhookTag: tag } } + } + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Form ID') || + error.message.includes('Personal Access Token') || + error.message.includes('Typeform error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Typeform webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Typeform webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + + if (!formId || !apiKey) { + logger.warn( + `[${ctx.requestId}] Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!typeformResponse.ok && typeformResponse.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted Typeform webhook with tag ${tag}`) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Typeform webhook (non-fatal)`, error) + } + }, } diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index bf08b7782e2..34587ce7a42 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -55,6 +55,34 @@ export interface ProcessFilesContext { userId: string } +/** Context for creating an external webhook subscription during deployment. */ +export interface SubscriptionContext { + webhook: Record + workflow: Record + userId: string + requestId: string + request: NextRequest +} + +/** Result of creating an external webhook subscription. */ +export interface SubscriptionResult { + /** Fields to merge into providerConfig (externalId, webhookSecret, etc.) */ + providerConfigUpdates?: Record +} + +/** Context for deleting an external webhook subscription during undeployment. */ +export interface DeleteSubscriptionContext { + webhook: Record + workflow: Record + requestId: string +} + +/** Context for configuring polling after webhook creation. */ +export interface PollingConfigContext { + webhook: Record + requestId: string +} + /** * Strategy interface for provider-specific webhook behavior. * Each provider implements only the methods it needs — all methods are optional. @@ -95,4 +123,21 @@ export interface WebhookProviderHandler { /** Post-process input to handle file uploads before execution. */ processInputFiles?(ctx: ProcessFilesContext): Promise + + /** Create an external webhook subscription (e.g., register with Telegram, Airtable, etc.). */ + createSubscription?(ctx: SubscriptionContext): Promise + + /** Delete an external webhook subscription during cleanup. Errors should not throw. */ + deleteSubscription?(ctx: DeleteSubscriptionContext): Promise + + /** Configure polling after webhook creation (gmail, outlook, rss, imap). */ + configurePolling?(ctx: PollingConfigContext): Promise + + /** Handle verification challenges before webhook lookup (Slack url_verification, WhatsApp hub.verify_token, Teams validationToken). */ + handleChallenge?( + body: unknown, + request: NextRequest, + requestId: string, + path: string + ): Promise | NextResponse | null } diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 1a02744a9f0..4596e8381fc 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,14 +1,241 @@ import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { + DeleteSubscriptionContext, EventFilterContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Webflow') export const webflowHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { siteId, triggerId, collectionId, formName, credentialId } = config as { + siteId?: string + triggerId?: string + collectionId?: string + formName?: string + credentialId?: string + } + + if (!siteId) { + logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Site ID is required to create Webflow webhook') + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + throw new Error(siteIdValidation.error) + } + + if (!triggerId) { + logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Trigger type is required to create Webflow webhook') + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'webflow') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` + ) + throw new Error( + 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const triggerTypeMap: Record = { + webflow_collection_item_created: 'collection_item_created', + webflow_collection_item_changed: 'collection_item_changed', + webflow_collection_item_deleted: 'collection_item_deleted', + webflow_form_submission: 'form_submission', + } + + const webflowTriggerType = triggerTypeMap[triggerId] + if (!webflowTriggerType) { + logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Invalid Webflow trigger type: ${triggerId}`) + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` + + const requestBody: Record = { + triggerType: webflowTriggerType, + url: notificationUrl, + } + + if (formName && webflowTriggerType === 'form_submission') { + requestBody.filter = { + name: formName, + } + } + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await webflowResponse.json() + + if (!webflowResponse.ok || responseBody.error) { + const errorMessage = + responseBody.message || responseBody.error || 'Unknown Webflow API error' + logger.error( + `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`, + { message: errorMessage, response: responseBody } + ) + throw new Error(errorMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, + { + webflowWebhookId: responseBody.id || responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const siteId = config.siteId as string | undefined + const externalId = config.externalId as string | undefined + + if (!siteId) { + logger.warn( + `[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { + webhookId: webhookRecord.id, + siteId: siteId.substring(0, 30), + }) + return + } + + const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: externalId.substring(0, 30), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + }) + + if (!webflowResponse.ok && webflowResponse.status !== 404) { + const responseBody = await webflowResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) + } + }, + async formatInput({ body, webhook }: FormatInputContext): Promise { const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index ca1cc6f1135..5f0116b2a1b 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -2,7 +2,7 @@ import { db, workflowDeploymentVersion } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import type { FormatInputContext, FormatInputResult, @@ -78,6 +78,14 @@ export async function handleWhatsAppVerification( } export const whatsappHandler: WebhookProviderHandler = { + async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const mode = url.searchParams.get('hub.mode') + const token = url.searchParams.get('hub.verify_token') + const challenge = url.searchParams.get('hub.challenge') + return handleWhatsAppVerification(requestId, path, mode, token, challenge) + }, + async formatInput({ body }: FormatInputContext): Promise { const b = body as Record const entry = b?.entry as Array> | undefined From 3ad355e95a54a28b9d350d0b2e47fa44345b8c10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 11:07:55 -0700 Subject: [PATCH 11/18] fix(webhooks): fix attio build error, restore imap field, remove demarcation comments - Cast `body` to `Record` in attio formatInput to fix type error with extractor functions - Restore `rejectUnauthorized` field in imap configurePolling for parity - Remove `// ---` section demarcation comments from route.ts and airtable.ts - Update add-trigger skill to reflect handler-based architecture Co-Authored-By: Claude Opus 4.6 --- .claude/commands/add-trigger.md | 389 ++++++++++---------- apps/sim/app/api/webhooks/route.ts | 5 - apps/sim/lib/webhooks/providers/airtable.ts | 6 - apps/sim/lib/webhooks/providers/attio.ts | 23 +- apps/sim/lib/webhooks/providers/imap.ts | 1 + 5 files changed, 208 insertions(+), 216 deletions(-) diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index 8f10752d39e..d53e1bb609f 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -275,13 +275,15 @@ export const {Service}Block: BlockConfig = { If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. +All subscription lifecycle logic lives on the provider handler — **no code touches `route.ts` or `provider-subscriptions.ts`**. + ### When to Use Automatic Registration Check the service's API documentation for endpoints like: - `POST /webhooks` or `POST /hooks` - Create webhook - `DELETE /webhooks/{id}` - Delete webhook -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. +Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, Ashby, Attio, etc. ### Implementation Steps @@ -337,188 +339,145 @@ export function {service}SetupInstructions(eventType: string): string { } ``` -#### 3. Add Webhook Creation to API Route +#### 3. Add `createSubscription` and `deleteSubscription` to the Provider Handler -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: +In `apps/sim/lib/webhooks/providers/{service}.ts`, add both lifecycle methods to your handler. The orchestration layer (`provider-subscriptions.ts`, `deploy.ts`, `route.ts`) calls these automatically — you never touch those files. ```typescript -// --- {Service} specific logic --- -if (savedWebhook && provider === '{service}') { - logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) - try { - const result = await create{Service}WebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' - if (result) { - // Update the webhook record with the external webhook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: result.id, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created {Service} webhook`, { - externalHookId: result.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in {Service}', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } -} -// --- End {Service} specific logic --- -``` +const logger = createLogger('WebhookProvider:{Service}') -Then add the helper function at the end of the file: +export const {service}Handler: WebhookProviderHandler = { + // ... other methods (verifyAuth, formatInput, etc.) ... -```typescript -async function create{Service}WebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, projectId } = providerConfig || {} + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } + if (!apiKey) { + throw new Error('{Service} API Key is required.') + } - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } + // Map trigger IDs to service event types + const eventTypeMap: Record = { + {service}_event_a: 'eventA', + {service}_event_b: 'eventB', + {service}_webhook: undefined, // Generic - no filter + } - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(ctx.webhook) - const requestBody: Record = { - url: notificationUrl, - } + const requestBody: Record = { + url: notificationUrl, + } + if (eventType) { + requestBody.eventType = eventType + } - if (eventType) { - requestBody.eventType = eventType - } + const response = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) - if (projectId) { - requestBody.projectId = projectId - } + const responseBody = (await response.json()) as Record + + if (!response.ok) { + const errorMessage = (responseBody.message as string) || 'Unknown API error' + let userFriendlyMessage = 'Failed to create webhook in {Service}' + if (response.status === 401) { + userFriendlyMessage = 'Invalid API Key. Please verify and try again.' + } else if (errorMessage) { + userFriendlyMessage = `{Service} error: ${errorMessage}` + } + throw new Error(userFriendlyMessage) + } - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) + const externalId = responseBody.id as string | undefined + if (!externalId) { + throw new Error('{Service} webhook created but no ID was returned.') + } - const responseBody = await response.json() + logger.info(`[${ctx.requestId}] Created {Service} webhook ${externalId}`) + return { providerConfigUpdates: { externalId } } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${ctx.requestId}] {Service} webhook creation failed`, { + message: err.message, + }) + throw error + } + }, - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` + if (!apiKey || !externalId) { + logger.warn(`[${ctx.requestId}] Missing apiKey or externalId, skipping cleanup`) + return } - throw new Error(userFriendlyMessage) - } + const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }) - return { id: responseBody.id } - } catch (error: any) { - logger.error(`Exception during {Service} webhook creation`, { error: error.message }) - throw error - } + if (!response.ok && response.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete {Service} webhook (non-fatal): ${response.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted {Service} webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting {Service} webhook (non-fatal)`, error) + } + }, } ``` -#### 4. Add Webhook Deletion to Provider Subscriptions +#### How It Works -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +The orchestration layer handles everything automatically: -1. Add a logger: -```typescript -const {service}Logger = createLogger('{Service}Webhook') -``` +1. **Creation**: `provider-subscriptions.ts` → `createExternalWebhookSubscription()` calls `handler.createSubscription()` → merges `providerConfigUpdates` into the saved webhook record. +2. **Deletion**: `provider-subscriptions.ts` → `cleanupExternalWebhook()` calls `handler.deleteSubscription()` → errors are caught and logged non-fatally. +3. **Polling config**: `deploy.ts` → `configurePollingIfNeeded()` calls `handler.configurePolling()` for credential-based providers (Gmail, Outlook, RSS, IMAP). -2. Add the delete function: -```typescript -export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined +You do NOT need to modify any orchestration files. Just implement the methods on your handler. - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } +#### Shared Utilities for Subscriptions - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) +Import from `@/lib/webhooks/providers/subscription-utils`: - if (!response.ok && response.status !== 404) { - {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) - } else { - {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) - } - } catch (error) { - {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) - } -} -``` - -3. Add to `cleanupExternalWebhook`: -```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` +- `getProviderConfig(webhook)` — safely extract `providerConfig` as `Record` +- `getNotificationUrl(webhook)` — build the full callback URL: `{baseUrl}/api/webhooks/trigger/{path}` +- `getCredentialOwner(credentialId, requestId)` — resolve OAuth credential to `{ userId, accountId }` (for OAuth-based providers like Airtable, Attio) ### Key Points for Automatic Registration - **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Roll back the database webhook if external creation fails -- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` -- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) -- **User-friendly errors**: Map HTTP status codes to helpful error messages +- **Error handling**: Throw from `createSubscription` — the orchestration layer catches it, rolls back the DB webhook, and returns a 500 +- **External ID storage**: Return `{ providerConfigUpdates: { externalId } }` — the orchestration layer merges it into `providerConfig` +- **Graceful cleanup**: In `deleteSubscription`, catch errors and log non-fatally (never throw) +- **User-friendly errors**: Map HTTP status codes to helpful error messages in `createSubscription` ## The buildTriggerSubBlocks Helper @@ -554,17 +513,18 @@ All fields automatically have: ## Webhook Provider Handler (Optional) -If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), or **idempotency dedup**, create a provider handler in the webhook provider registry. +If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), **idempotency dedup**, **custom input formatting**, or **subscription lifecycle** — all of this lives in a single provider handler file. ### Directory ``` apps/sim/lib/webhooks/providers/ -├── types.ts # WebhookProviderHandler interface -├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) -├── registry.ts # Handler map + default handler -├── index.ts # Barrel export -└── {service}.ts # Your provider handler +├── types.ts # WebhookProviderHandler interface (16 optional methods) +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl, getCredentialOwner) +├── registry.ts # Handler map + default handler +├── index.ts # Barrel export +└── {service}.ts # Your provider handler (ALL provider-specific logic here) ``` ### When to Create a Handler @@ -578,15 +538,39 @@ apps/sim/lib/webhooks/providers/ | Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | | Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Microsoft Teams | | Custom error format | `formatErrorResponse` | Microsoft Teams | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby, Gmail, Outlook | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Polling setup | `configurePolling` | Gmail, Outlook, RSS, IMAP | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Microsoft Teams | If none of these apply, you do NOT need a handler file. The default handler provides bearer token auth for providers that set `providerConfig.token`. ### Simple Example: HMAC Auth Only +Signature validators are defined as private functions **inside the handler file** (not in a shared utils file): + ```typescript +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validate{Service}Signature } from '@/lib/webhooks/utils.server' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + if (!signature.startsWith('sha256=')) return false + const provided = signature.substring(7) + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, provided) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} export const {service}Handler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ @@ -601,13 +585,25 @@ export const {service}Handler: WebhookProviderHandler = { ### Example: Auth + Event Matching + Idempotency ```typescript +import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { validate{Service}Signature } from '@/lib/webhooks/utils.server' const logger = createLogger('WebhookProvider:{Service}') +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} + export const {service}Handler: WebhookProviderHandler = { verifyAuth: createHmacVerifier({ configKey: 'webhookSecret', @@ -657,10 +653,6 @@ const PROVIDER_HANDLERS: Record = { } ``` -### Adding a Signature Validator - -If the service uses HMAC signatures, add a `validate{Service}Signature` function in `apps/sim/lib/webhooks/utils.server.ts` alongside the existing validators. Then reference it from your handler via `createHmacVerifier`. - ## Trigger Outputs & Webhook Input Formatting ### Important: Two Sources of Truth @@ -668,35 +660,48 @@ If the service uses HMAC signatures, add a `validate{Service}Signature` function There are two related but separate concerns: 1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: +**These MUST be aligned.** The fields returned by `formatInput` should match what's defined in trigger `outputs`. If they differ: - Tag dropdown shows fields that don't exist (broken variable resolution) - Or actual data has fields not shown in dropdown (users can't discover them) -### When to Add a formatWebhookInput Handler +### When to Add `formatInput` -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need it. The fallback passes through the raw body directly. +- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add `formatInput` to your handler. -### Adding a Handler +### Adding `formatInput` to Your Handler -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: +In `apps/sim/lib/webhooks/providers/{service}.ts`: ```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const {service}Handler: WebhookProviderHandler = { + // ... other methods ... + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + timestamp: b.created_at, + resource: b.data, + }, + } + }, } ``` **Key rules:** -- Return fields that match your trigger `outputs` definition exactly +- Return `{ input: { ... } }` where the inner object matches your trigger `outputs` definition exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution for this event - No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` - No duplication (don't spread body AND add individual fields) - Use `null` for missing optional data, not empty objects with empty strings @@ -797,29 +802,25 @@ export const {service}WebhookTrigger: TriggerConfig = { - [ ] Block has all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` -### Automatic Webhook Registration (if supported) -- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` -- [ ] Updated setup instructions for automatic webhook creation -- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` -- [ ] Added `create{Service}WebhookSubscription` helper function -- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` -- [ ] Added provider to `cleanupExternalWebhook` function - -### Webhook Provider Handler (if needed) -- [ ] Created `apps/sim/lib/webhooks/providers/{service}.ts` handler file +### Webhook Provider Handler (`providers/{service}.ts`) +- [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts` - [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) +- [ ] Signature validator defined as private function inside handler file (not in a shared file) - [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth - [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth -- [ ] Added `validate{Service}Signature` in `utils.server.ts` (if HMAC auth needed) - [ ] Event matching uses dynamic `await import()` for trigger utils +- [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`) -### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +### Automatic Webhook Registration (if supported) +- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` +- [ ] Updated setup instructions for automatic webhook creation +- [ ] Added `createSubscription` method to handler (uses `getNotificationUrl`, `getProviderConfig` from `subscription-utils`) +- [ ] Added `deleteSubscription` method to handler (catches errors, logs non-fatally) +- [ ] NO changes needed to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` ### Testing - [ ] Run `bun run type-check` to verify no TypeScript errors +- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify output alignment - [ ] Restart dev server to pick up new triggers - [ ] Test trigger UI shows correctly in the block - [ ] Test automatic webhook creation works (if applicable) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 5f0f6b31376..c6ef9e992e1 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -344,7 +344,6 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) - // --- Credential Set Handling --- // For credential sets, we fan out to create one webhook per credential at save time. // This applies to all OAuth-based triggers, not just polling ones. // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields @@ -483,8 +482,6 @@ export async function POST(request: NextRequest) { } } } - // --- End Credential Set Handling --- - let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -624,7 +621,6 @@ export async function POST(request: NextRequest) { } } - // --- Polling provider setup (Gmail, Outlook, RSS, IMAP, etc.) --- if (savedWebhook) { const pollingHandler = getProviderHandler(provider) if (pollingHandler.configurePolling) { @@ -668,7 +664,6 @@ export async function POST(request: NextRequest) { } } } - // --- End polling provider setup --- if (!targetWebhookId && savedWebhook) { try { diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index cfd38b8fe71..7b0e1270195 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -54,7 +54,6 @@ async function fetchAndProcessAirtablePayloads( } try { - // --- Essential IDs & Config from localProviderConfig --- const baseId = localProviderConfig.baseId const airtableWebhookId = localProviderConfig.externalId @@ -164,7 +163,6 @@ async function fetchAndProcessAirtablePayloads( const airtableApiBase = 'https://api.airtable.com/v0' - // --- Polling Loop --- while (mightHaveMore) { apiCallCount++ // Safety break @@ -212,7 +210,6 @@ async function fetchAndProcessAirtablePayloads( const receivedPayloads = responseBody.payloads || [] - // --- Process and Consolidate Changes --- if (receivedPayloads.length > 0) { payloadsFetched += receivedPayloads.length // Keep the raw payloads for later exposure to the workflow @@ -350,15 +347,12 @@ async function fetchAndProcessAirtablePayloads( break } } - // --- End Polling Loop --- - // Convert map values to array for final processing const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) logger.info( `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` ) - // --- Execute Workflow if we have changes (simplified - no lock check) --- if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { try { // Build input exposing raw payloads and consolidated changes diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 6db9928f121..883d979334f 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -323,43 +323,44 @@ export const attioHandler: WebhookProviderHandler = { extractAttioGenericData, } = await import('@/triggers/attio/utils') + const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} const triggerId = providerConfig.triggerId as string | undefined if (triggerId === 'attio_record_updated') { - return { input: extractAttioRecordUpdatedData(body) } + return { input: extractAttioRecordUpdatedData(b) } } if (triggerId === 'attio_record_merged') { - return { input: extractAttioRecordMergedData(body) } + return { input: extractAttioRecordMergedData(b) } } if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { - return { input: extractAttioRecordData(body) } + return { input: extractAttioRecordData(b) } } if (triggerId?.startsWith('attio_note_')) { - return { input: extractAttioNoteData(body) } + return { input: extractAttioNoteData(b) } } if (triggerId?.startsWith('attio_task_')) { - return { input: extractAttioTaskData(body) } + return { input: extractAttioTaskData(b) } } if (triggerId?.startsWith('attio_comment_')) { - return { input: extractAttioCommentData(body) } + return { input: extractAttioCommentData(b) } } if (triggerId === 'attio_list_entry_updated') { - return { input: extractAttioListEntryUpdatedData(body) } + return { input: extractAttioListEntryUpdatedData(b) } } if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { - return { input: extractAttioListEntryData(body) } + return { input: extractAttioListEntryData(b) } } if ( triggerId === 'attio_list_created' || triggerId === 'attio_list_updated' || triggerId === 'attio_list_deleted' ) { - return { input: extractAttioListData(body) } + return { input: extractAttioListData(b) } } if (triggerId === 'attio_workspace_member_created') { - return { input: extractAttioWorkspaceMemberData(body) } + return { input: extractAttioWorkspaceMemberData(b) } } - return { input: extractAttioGenericData(body) } + return { input: extractAttioGenericData(b) } }, } diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index c00650c1932..df05082d766 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -57,6 +57,7 @@ export const imapHandler: WebhookProviderHandler = { ...providerConfig, port: providerConfig.port || '993', secure: providerConfig.secure !== false, + rejectUnauthorized: providerConfig.rejectUnauthorized !== false, mailbox: providerConfig.mailbox || 'INBOX', searchCriteria: providerConfig.searchCriteria || 'UNSEEN', markAsRead: providerConfig.markAsRead || false, From 8bcf450d22672be3e17effb163218230d2139575 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 11:41:01 -0700 Subject: [PATCH 12/18] fix(webhooks): remove unused imports from utils.server.ts after rebase Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/utils.server.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 6d4a4e718bb..4e0d3d168be 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -2,14 +2,6 @@ import { db, workflowDeploymentVersion } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' -import { - type SecureFetchResponse, - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateShortId } from '@/lib/core/utils/uuid' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' From 60610b7351f5f068d0ffd8d4684291498a9391e3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 11:55:21 -0700 Subject: [PATCH 13/18] fix(webhooks): remove duplicate generic file processing from webhook-execution The generic provider's processInputFiles handler already handles file[] field processing via the handler.processInputFiles call. The hardcoded block from staging was incorrectly preserved during rebase, causing double processing. Co-Authored-By: Claude Opus 4.6 --- apps/sim/background/webhook-execution.ts | 47 ------------------------ 1 file changed, 47 deletions(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index b15bb87cb6f..7926ea0e382 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -7,7 +7,6 @@ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency' import { generateId } from '@/lib/core/utils/uuid' -import { processExecutionFiles } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -429,52 +428,6 @@ async function executeWebhookJobInternal( } } - // Process generic webhook files based on inputFormat - if (input && payload.provider === 'generic' && payload.blockId && blocks[payload.blockId]) { - try { - const triggerBlock = blocks[payload.blockId] - - if (triggerBlock?.subBlocks?.inputFormat?.value) { - const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{ - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' - }> - - const fileFields = inputFormat.filter((field) => field.type === 'file[]') - - if (fileFields.length > 0 && typeof input === 'object' && input !== null) { - const executionContext = { - workspaceId, - workflowId: payload.workflowId, - executionId, - } - - for (const fileField of fileFields) { - const fieldValue = input[fileField.name] - - if (fieldValue && typeof fieldValue === 'object') { - const uploadedFiles = await processExecutionFiles( - fieldValue, - executionContext, - requestId, - payload.userId - ) - - if (uploadedFiles.length > 0) { - input[fileField.name] = uploadedFiles - logger.info( - `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` - ) - } - } - } - } - } - } catch (error) { - logger.error(`[${requestId}] Error processing generic webhook files:`, error) - } - } - logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`) const metadata: ExecutionMetadata = { From 1478de13324132e8e8052afd2a3bcf6d5aa437ed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 12:09:01 -0700 Subject: [PATCH 14/18] fix(webhooks): validate auth token is set when requireAuth is enabled at deploy time Rejects deployment with a clear error message if a generic webhook trigger has requireAuth enabled but no authentication token configured, rather than letting requests fail with 401 at runtime. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/deploy.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index a9e7d41471e..90850f25d6b 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -447,6 +447,18 @@ export async function saveTriggerWebhooksForDeploy({ } } + if (providerConfig.requireAuth && !providerConfig.token) { + await restorePreviousSubscriptions() + return { + success: false, + error: { + message: + 'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.', + status: 400, + }, + } + } + webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef }) if (providerConfig.credentialSetId) { From 78e6de5c12c3e5823d9fccb6dda9dcf6f9aaf18e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 12:13:21 -0700 Subject: [PATCH 15/18] fix(webhooks): remove unintended rejectUnauthorized field from IMAP polling config The refactored IMAP handler added a rejectUnauthorized field that was not present in the original configureImapPolling function. This would default to true for all existing IMAP webhooks, potentially breaking connections to servers with self-signed certificates. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/providers/imap.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index df05082d766..c00650c1932 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -57,7 +57,6 @@ export const imapHandler: WebhookProviderHandler = { ...providerConfig, port: providerConfig.port || '993', secure: providerConfig.secure !== false, - rejectUnauthorized: providerConfig.rejectUnauthorized !== false, mailbox: providerConfig.mailbox || 'INBOX', searchCriteria: providerConfig.searchCriteria || 'UNSEEN', markAsRead: providerConfig.markAsRead || false, From 7afed0d5892bb0b3d718262b18c9a9831831fce4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 12:28:45 -0700 Subject: [PATCH 16/18] fix(webhooks): replace crypto.randomUUID() with generateId() in ashby handler Per project coding standards, use generateId() from @/lib/core/utils/uuid instead of crypto.randomUUID() directly. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/providers/ashby.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 6b35bdc8751..ce044495009 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@/lib/core/security/encryption' +import { generateId } from '@/lib/core/utils/uuid' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' import type { DeleteSubscriptionContext, @@ -91,7 +92,7 @@ export const ashbyHandler: WebhookProviderHandler = { webhookId: ctx.webhook.id, }) - const secretToken = crypto.randomUUID() + const secretToken = generateId() const requestBody: Record = { requestUrl: notificationUrl, From 220aa91dab29f11669ae159f0b35c7003f99393e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 12:36:05 -0700 Subject: [PATCH 17/18] refactor(webhooks): standardize logger names and remove any types from providers - Standardize logger names to WebhookProvider:X pattern across 6 providers (fathom, gmail, imap, lemlist, outlook, rss) - Replace all `any` types in airtable handler with proper types: - Add AirtableTableChanges interface for API response typing - Change function params from `any` to `Record` - Change AirtableChange fields from Record to Record - Change all catch blocks from `error: any` to `error: unknown` - Change input object from `any` to `Record` Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/providers/airtable.ts | 70 ++++++++++++--------- apps/sim/lib/webhooks/providers/fathom.ts | 2 +- apps/sim/lib/webhooks/providers/gmail.ts | 2 +- apps/sim/lib/webhooks/providers/imap.ts | 2 +- apps/sim/lib/webhooks/providers/lemlist.ts | 2 +- apps/sim/lib/webhooks/providers/outlook.ts | 2 +- apps/sim/lib/webhooks/providers/rss.ts | 2 +- 7 files changed, 47 insertions(+), 35 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 7b0e1270195..80fecf73854 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -28,16 +28,28 @@ interface AirtableChange { tableId: string recordId: string changeType: 'created' | 'updated' - changedFields: Record // { fieldId: newValue } - previousFields?: Record // { fieldId: previousValue } (optional) + changedFields: Record + previousFields?: Record +} + +interface AirtableTableChanges { + createdRecordsById?: Record }> + changedRecordsById?: Record< + string, + { + current?: { cellValuesByFieldId?: Record } + previous?: { cellValuesByFieldId?: Record } + } + > + destroyedRecordIds?: string[] } /** * Process Airtable payloads */ async function fetchAndProcessAirtablePayloads( - webhookData: any, - workflowData: any, + webhookData: Record, + workflowData: Record, requestId: string // Original request ID from the ping, used for the final execution log ) { // Logging handles all error logging @@ -50,8 +62,8 @@ async function fetchAndProcessAirtablePayloads( // Capture raw payloads from Airtable for exposure to workflows const allPayloads = [] const localProviderConfig = { - ...((webhookData.providerConfig as Record) || {}), - } + ...((webhookData.providerConfig as Record) || {}), + } as Record try { const baseId = localProviderConfig.baseId @@ -64,7 +76,7 @@ async function fetchAndProcessAirtablePayloads( return } - const credentialId: string | undefined = localProviderConfig.credentialId + const credentialId = localProviderConfig.credentialId as string | undefined if (!credentialId) { logger.error( `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` @@ -117,15 +129,16 @@ async function fetchAndProcessAirtablePayloads( }, updatedAt: new Date(), }) - .where(eq(webhook.id, webhookData.id)) + .where(eq(webhook.id, webhookData.id as string)) localProviderConfig.externalWebhookCursor = null logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) - } catch (initError: any) { + } catch (initError: unknown) { + const err = initError as Error logger.error(`[${requestId}] Failed to initialize cursor in DB`, { webhookId: webhookData.id, - error: initError.message, - stack: initError.stack, + error: err.message, + stack: err.stack, }) } } @@ -149,12 +162,13 @@ async function fetchAndProcessAirtablePayloads( ) throw new Error('Airtable access token not found.') } - } catch (tokenError: any) { + } catch (tokenError: unknown) { + const err = tokenError as Error logger.error( `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, { - error: tokenError.message, - stack: tokenError.stack, + error: err.message, + stack: err.stack, credentialId, } ) @@ -222,17 +236,15 @@ async function fetchAndProcessAirtablePayloads( for (const [tableId, tableChangesUntyped] of Object.entries( payload.changedTablesById )) { - const tableChanges = tableChangesUntyped as any // Assert type + const tableChanges = tableChangesUntyped as AirtableTableChanges - // Handle created records if (tableChanges.createdRecordsById) { const createdCount = Object.keys(tableChanges.createdRecordsById).length changeCount += createdCount - for (const [recordId, recordDataUntyped] of Object.entries( + for (const [recordId, recordData] of Object.entries( tableChanges.createdRecordsById )) { - const recordData = recordDataUntyped as any // Assert type const existingChange = consolidatedChangesMap.get(recordId) if (existingChange) { // Record was created and possibly updated within the same batch @@ -258,10 +270,9 @@ async function fetchAndProcessAirtablePayloads( const updatedCount = Object.keys(tableChanges.changedRecordsById).length changeCount += updatedCount - for (const [recordId, recordDataUntyped] of Object.entries( + for (const [recordId, recordData] of Object.entries( tableChanges.changedRecordsById )) { - const recordData = recordDataUntyped as any // Assert type const existingChange = consolidatedChangesMap.get(recordId) const currentFields = recordData.current?.cellValuesByFieldId || {} @@ -314,14 +325,15 @@ async function fetchAndProcessAirtablePayloads( providerConfig: updatedConfig, // Use full object updatedAt: new Date(), }) - .where(eq(webhook.id, webhookData.id)) + .where(eq(webhook.id, webhookData.id as string)) localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too - } catch (dbError: any) { + } catch (dbError: unknown) { + const err = dbError as Error logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { webhookId: webhookData.id, cursor: currentCursor, - error: dbError.message, + error: err.message, }) // Error logging handled by logging session mightHaveMore = false @@ -337,7 +349,7 @@ async function fetchAndProcessAirtablePayloads( } else if (nextCursor === currentCursor) { mightHaveMore = false // Explicitly stop if cursor hasn't changed } - } catch (fetchError: any) { + } catch (fetchError: unknown) { logger.error( `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, fetchError @@ -357,8 +369,7 @@ async function fetchAndProcessAirtablePayloads( try { // Build input exposing raw payloads and consolidated changes const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null - const input: any = { - // Raw Airtable payloads as received from the API + const input: Record = { payloads: allPayloads, latestPayload, // Consolidated, simplified changes for convenience @@ -393,11 +404,12 @@ async function fetchAndProcessAirtablePayloads( }) return input - } catch (processingError: any) { + } catch (processingError: unknown) { + const err = processingError as Error logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { workflowId: workflowData.id, - error: processingError.message, - stack: processingError.stack, + error: err.message, + stack: err.stack, timestamp: new Date().toISOString(), }) diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts index dfc76bd0d02..c705d00353f 100644 --- a/apps/sim/lib/webhooks/providers/fathom.ts +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -8,7 +8,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -const logger = createLogger('FathomWebhook') +const logger = createLogger('WebhookProvider:Fathom') export const fathomHandler: WebhookProviderHandler = { async createSubscription(ctx: SubscriptionContext): Promise { diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts index 3244ba7a817..650ebfb0980 100644 --- a/apps/sim/lib/webhooks/providers/gmail.ts +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -10,7 +10,7 @@ import type { } from '@/lib/webhooks/providers/types' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' -const logger = createLogger('GmailWebhookSetup') +const logger = createLogger('WebhookProvider:Gmail') export const gmailHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index c00650c1932..aff02b2a341 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -9,7 +9,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -const logger = createLogger('ImapWebhookSetup') +const logger = createLogger('WebhookProvider:Imap') export const imapHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts index 88c6476d1a5..2127512f9d3 100644 --- a/apps/sim/lib/webhooks/providers/lemlist.ts +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -8,7 +8,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -const logger = createLogger('LemlistWebhook') +const logger = createLogger('WebhookProvider:Lemlist') export const lemlistHandler: WebhookProviderHandler = { async createSubscription(ctx: SubscriptionContext): Promise { diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts index 89a772c4ace..01f9656bc15 100644 --- a/apps/sim/lib/webhooks/providers/outlook.ts +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -10,7 +10,7 @@ import type { } from '@/lib/webhooks/providers/types' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' -const logger = createLogger('OutlookWebhookSetup') +const logger = createLogger('WebhookProvider:Outlook') export const outlookHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts index 64afdb0db15..e517fd1cda1 100644 --- a/apps/sim/lib/webhooks/providers/rss.ts +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -9,7 +9,7 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -const logger = createLogger('RssWebhookSetup') +const logger = createLogger('WebhookProvider:Rss') export const rssHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { From 98b4586df2d75b4634e1b6becbc9840e9b709f36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 5 Apr 2026 23:39:01 -0700 Subject: [PATCH 18/18] refactor(webhooks): remove remaining any types from deploy.ts Replace 3 `catch (error: any)` with `catch (error: unknown)` and 1 `Record` with `Record`. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/deploy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 90850f25d6b..43188e88f2b 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -285,7 +285,7 @@ async function syncCredentialSetWebhooks(params: { basePath: triggerPath, credentialSetId, oauthProviderId, - providerConfig: baseConfig as Record, + providerConfig: baseConfig as Record, requestId, deploymentVersionId, }) @@ -558,13 +558,13 @@ export async function saveTriggerWebhooksForDeploy({ await restorePreviousSubscriptions() return { success: false, error: syncResult.error, warnings: collectedWarnings } } - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create webhook for ${block.id}`, error) await restorePreviousSubscriptions() return { success: false, error: { - message: error?.message || 'Failed to save trigger configuration', + message: (error as Error)?.message || 'Failed to save trigger configuration', status: 500, }, warnings: collectedWarnings, @@ -621,7 +621,7 @@ export async function saveTriggerWebhooksForDeploy({ updatedProviderConfig: result.updatedProviderConfig as Record, externalSubscriptionCreated: result.externalSubscriptionCreated, }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error) await pendingVerificationTracker.clearAll() for (const sub of createdSubscriptions) { @@ -649,7 +649,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to create external subscription', + message: (error as Error)?.message || 'Failed to create external subscription', status: 500, }, } @@ -722,7 +722,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: pollingError } } } - } catch (error: any) { + } catch (error: unknown) { await pendingVerificationTracker.clearAll() logger.error(`[${requestId}] Failed to insert webhook records`, error) for (const sub of createdSubscriptions) { @@ -750,7 +750,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to save webhook records', + message: (error as Error)?.message || 'Failed to save webhook records', status: 500, }, }