mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-24 14:27:56 -05:00
* fix(envvars): resolution standardized * remove comments * address bugbot * fix highlighting for env vars * remove comments * address greptile * address bugbot
1095 lines
36 KiB
TypeScript
1095 lines
36 KiB
TypeScript
import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db'
|
|
import { credentialSet, subscription } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { tasks } from '@trigger.dev/sdk'
|
|
import { and, eq, isNull, or } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
|
import { isProd, isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
|
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
|
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
|
import {
|
|
handleSlackChallenge,
|
|
handleWhatsAppVerification,
|
|
validateMicrosoftTeamsSignature,
|
|
verifyProviderWebhook,
|
|
} from '@/lib/webhooks/utils.server'
|
|
import { executeWebhookJob } from '@/background/webhook-execution'
|
|
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
|
|
|
const logger = createLogger('WebhookProcessor')
|
|
|
|
export interface WebhookProcessorOptions {
|
|
requestId: string
|
|
path?: string
|
|
webhookId?: string
|
|
}
|
|
|
|
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
|
|
}> {
|
|
if (!isProd) {
|
|
return { valid: true }
|
|
}
|
|
|
|
const [set] = await db
|
|
.select({ organizationId: credentialSet.organizationId })
|
|
.from(credentialSet)
|
|
.where(eq(credentialSet.id, credentialSetId))
|
|
.limit(1)
|
|
|
|
if (!set) {
|
|
return { valid: false, error: 'Credential set not found' }
|
|
}
|
|
|
|
const [orgSub] = await db
|
|
.select()
|
|
.from(subscription)
|
|
.where(and(eq(subscription.referenceId, set.organizationId), eq(subscription.status, 'active')))
|
|
.limit(1)
|
|
|
|
if (!orgSub) {
|
|
return {
|
|
valid: false,
|
|
error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.',
|
|
}
|
|
}
|
|
|
|
const hasTeamPlan = checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)
|
|
if (!hasTeamPlan) {
|
|
return {
|
|
valid: false,
|
|
error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.',
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
export async function parseWebhookBody(
|
|
request: NextRequest,
|
|
requestId: string
|
|
): Promise<{ body: any; 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) {
|
|
logger.debug(`[${requestId}] Received request with empty body, treating as empty object`)
|
|
return { body: {}, rawBody: '' }
|
|
}
|
|
} catch (bodyError) {
|
|
logger.error(`[${requestId}] Failed to read request body`, {
|
|
error: bodyError instanceof Error ? bodyError.message : String(bodyError),
|
|
})
|
|
return new NextResponse('Failed to read request body', { status: 400 })
|
|
}
|
|
|
|
let body: any
|
|
try {
|
|
const contentType = request.headers.get('content-type') || ''
|
|
|
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
const formData = new URLSearchParams(rawBody)
|
|
const payloadString = formData.get('payload')
|
|
|
|
if (payloadString) {
|
|
body = JSON.parse(payloadString)
|
|
logger.debug(`[${requestId}] Parsed form-encoded GitHub webhook payload`)
|
|
} else {
|
|
body = Object.fromEntries(formData.entries())
|
|
logger.debug(`[${requestId}] Parsed form-encoded webhook data (direct fields)`)
|
|
}
|
|
} else {
|
|
body = JSON.parse(rawBody)
|
|
logger.debug(`[${requestId}] Parsed JSON webhook payload`)
|
|
}
|
|
|
|
// Allow empty JSON objects - some webhooks send empty payloads
|
|
if (Object.keys(body).length === 0) {
|
|
logger.debug(`[${requestId}] Received empty JSON object`)
|
|
}
|
|
} catch (parseError) {
|
|
logger.error(`[${requestId}] Failed to parse webhook body`, {
|
|
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
contentType: request.headers.get('content-type'),
|
|
bodyPreview: `${rawBody?.slice(0, 100)}...`,
|
|
})
|
|
return new NextResponse('Invalid payload format', { status: 400 })
|
|
}
|
|
|
|
return { body, rawBody }
|
|
}
|
|
|
|
export async function handleProviderChallenges(
|
|
body: any,
|
|
request: NextRequest,
|
|
requestId: string,
|
|
path: string
|
|
): Promise<NextResponse | null> {
|
|
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
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export function handleProviderReachabilityTest(
|
|
webhook: any,
|
|
body: any,
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Format error response based on provider requirements.
|
|
* Some providers (like Microsoft Teams) require specific response formats.
|
|
*/
|
|
export function formatProviderErrorResponse(
|
|
webhook: any,
|
|
error: string,
|
|
status: number
|
|
): NextResponse {
|
|
if (webhook.provider === 'microsoft-teams') {
|
|
return NextResponse.json({ type: 'message', text: error }, { status })
|
|
}
|
|
return 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.
|
|
*/
|
|
export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean {
|
|
const providerConfig = (webhook.providerConfig as Record<string, any>) || {}
|
|
|
|
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
|
|
}
|
|
|
|
/** Providers that validate webhook URLs during creation, before workflow deployment */
|
|
const PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION = new Set(['grain'])
|
|
|
|
/** Returns 200 OK for providers that validate URLs before the workflow is deployed */
|
|
export function handlePreDeploymentVerification(
|
|
webhook: any,
|
|
requestId: string
|
|
): NextResponse | null {
|
|
if (PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION.has(webhook.provider)) {
|
|
logger.info(
|
|
`[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation`
|
|
)
|
|
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function findWebhookAndWorkflow(
|
|
options: WebhookProcessorOptions
|
|
): Promise<{ webhook: any; workflow: any } | null> {
|
|
if (options.webhookId) {
|
|
const results = await db
|
|
.select({
|
|
webhook: webhook,
|
|
workflow: workflow,
|
|
})
|
|
.from(webhook)
|
|
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
|
.leftJoin(
|
|
workflowDeploymentVersion,
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(webhook.id, options.webhookId),
|
|
eq(webhook.isActive, true),
|
|
or(
|
|
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
|
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
|
)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (results.length === 0) {
|
|
logger.warn(`[${options.requestId}] No active webhook found for id: ${options.webhookId}`)
|
|
return null
|
|
}
|
|
|
|
return { webhook: results[0].webhook, workflow: results[0].workflow }
|
|
}
|
|
|
|
if (options.path) {
|
|
const results = await db
|
|
.select({
|
|
webhook: webhook,
|
|
workflow: workflow,
|
|
})
|
|
.from(webhook)
|
|
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
|
.leftJoin(
|
|
workflowDeploymentVersion,
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(webhook.path, options.path),
|
|
eq(webhook.isActive, true),
|
|
or(
|
|
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
|
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
|
)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (results.length === 0) {
|
|
logger.warn(`[${options.requestId}] No active webhook found for path: ${options.path}`)
|
|
return null
|
|
}
|
|
|
|
return { webhook: results[0].webhook, workflow: results[0].workflow }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Find ALL webhooks matching a path.
|
|
* Used for credential sets where multiple webhooks share the same path.
|
|
*/
|
|
export async function findAllWebhooksForPath(
|
|
options: WebhookProcessorOptions
|
|
): Promise<Array<{ webhook: any; workflow: any }>> {
|
|
if (!options.path) {
|
|
return []
|
|
}
|
|
|
|
const results = await db
|
|
.select({
|
|
webhook: webhook,
|
|
workflow: workflow,
|
|
})
|
|
.from(webhook)
|
|
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
|
.leftJoin(
|
|
workflowDeploymentVersion,
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(webhook.path, options.path),
|
|
eq(webhook.isActive, true),
|
|
or(
|
|
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
|
|
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
|
|
)
|
|
)
|
|
)
|
|
|
|
if (results.length === 0) {
|
|
logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`)
|
|
} else if (results.length > 1) {
|
|
logger.info(
|
|
`[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)`
|
|
)
|
|
}
|
|
|
|
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, string>): 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<string, any>,
|
|
envVars: Record<string, string>
|
|
): Record<string, any> {
|
|
const resolved: Record<string, any> = {}
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (typeof value === 'string') {
|
|
resolved[key] = resolveEnvVars(value, envVars)
|
|
} else {
|
|
resolved[key] = value
|
|
}
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
/**
|
|
* Verify webhook provider authentication and signatures
|
|
* @returns NextResponse with 401 if auth fails, null if auth passes
|
|
*/
|
|
export async function verifyProviderAuth(
|
|
foundWebhook: any,
|
|
foundWorkflow: any,
|
|
request: NextRequest,
|
|
rawBody: string,
|
|
requestId: string
|
|
): Promise<NextResponse | null> {
|
|
// Step 1: Fetch and decrypt environment variables for signature verification
|
|
let decryptedEnvVars: Record<string, string> = {}
|
|
try {
|
|
const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils')
|
|
decryptedEnvVars = await getEffectiveDecryptedEnv(
|
|
foundWorkflow.userId,
|
|
foundWorkflow.workspaceId
|
|
)
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Failed to fetch environment variables`, { error })
|
|
}
|
|
|
|
// Step 2: Resolve {{VARIABLE}} references in providerConfig
|
|
const rawProviderConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Microsoft Teams HMAC signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
// 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<string, any> = {}
|
|
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 { validateTwilioSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Twilio Voice signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
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 { validateTypeformSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Typeform signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
if (foundWebhook.provider === 'linear') {
|
|
const secret = providerConfig.secret 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 { validateLinearSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Linear signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
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 { validateCirclebackSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Circleback signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
if (foundWebhook.provider === 'jira') {
|
|
const secret = providerConfig.secret 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 { validateJiraSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Jira signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
if (foundWebhook.provider === 'github') {
|
|
const secret = providerConfig.secret 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 { validateGitHubSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] GitHub signature verified successfully`, {
|
|
usingSha256: !!signature256,
|
|
})
|
|
}
|
|
}
|
|
|
|
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 { validateFirefliesSignature } = await import('@/lib/webhooks/utils.server')
|
|
|
|
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 })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Fireflies signature verified successfully`)
|
|
}
|
|
}
|
|
|
|
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 === configToken) {
|
|
isTokenValid = true
|
|
}
|
|
} else {
|
|
const authHeader = request.headers.get('authorization')
|
|
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
|
const token = authHeader.substring(7)
|
|
if (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,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Run preprocessing checks for webhook execution
|
|
* This replaces the old checkRateLimits and checkUsageLimits functions
|
|
*/
|
|
export async function checkWebhookPreprocessing(
|
|
foundWorkflow: any,
|
|
foundWebhook: any,
|
|
requestId: string
|
|
): Promise<NextResponse | null> {
|
|
try {
|
|
const executionId = uuidv4()
|
|
|
|
const preprocessResult = await preprocessExecution({
|
|
workflowId: foundWorkflow.id,
|
|
userId: foundWorkflow.userId,
|
|
triggerType: 'webhook',
|
|
executionId,
|
|
requestId,
|
|
checkRateLimit: true,
|
|
checkDeployment: true,
|
|
workspaceId: foundWorkflow.workspaceId,
|
|
})
|
|
|
|
if (!preprocessResult.success) {
|
|
const error = preprocessResult.error!
|
|
logger.warn(`[${requestId}] Webhook preprocessing failed`, {
|
|
provider: foundWebhook.provider,
|
|
error: error.message,
|
|
statusCode: error.statusCode,
|
|
})
|
|
|
|
if (foundWebhook.provider === 'microsoft-teams') {
|
|
return NextResponse.json(
|
|
{
|
|
type: 'message',
|
|
text: error.message,
|
|
},
|
|
{ status: error.statusCode }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ error: error.message }, { status: error.statusCode })
|
|
}
|
|
|
|
logger.debug(`[${requestId}] Webhook preprocessing passed`, {
|
|
provider: foundWebhook.provider,
|
|
})
|
|
|
|
return null
|
|
} catch (preprocessError) {
|
|
logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError)
|
|
|
|
if (foundWebhook.provider === 'microsoft-teams') {
|
|
return NextResponse.json(
|
|
{
|
|
type: 'message',
|
|
text: 'Internal error during preprocessing',
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
export async function queueWebhookExecution(
|
|
foundWebhook: any,
|
|
foundWorkflow: any,
|
|
body: any,
|
|
request: NextRequest,
|
|
options: WebhookProcessorOptions
|
|
): Promise<NextResponse> {
|
|
try {
|
|
// GitHub event filtering for event-specific triggers
|
|
if (foundWebhook.provider === 'github') {
|
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
const triggerId = providerConfig.triggerId as string | undefined
|
|
|
|
if (triggerId && triggerId !== 'github_webhook') {
|
|
const eventType = request.headers.get('x-github-event')
|
|
const action = body.action
|
|
|
|
const { isGitHubEventMatch } = await import('@/triggers/github/utils')
|
|
|
|
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<string, any>) || {}
|
|
const triggerId = providerConfig.triggerId as string | undefined
|
|
|
|
if (triggerId && triggerId !== 'jira_webhook') {
|
|
const webhookEvent = body.webhookEvent as string | undefined
|
|
|
|
const { isJiraEventMatch } = await import('@/triggers/jira/utils')
|
|
|
|
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.',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (foundWebhook.provider === 'hubspot') {
|
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
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
|
|
|
|
const { isHubSpotContactEventMatch } = await import('@/triggers/hubspot/utils')
|
|
|
|
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 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}`
|
|
}
|
|
}
|
|
|
|
// Extract credentialId from webhook config
|
|
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
|
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
const credentialId = providerConfig.credentialId as string | undefined
|
|
const credentialSetId = providerConfig.credentialSetId as string | undefined
|
|
|
|
// Verify billing for credential sets
|
|
if (credentialSetId) {
|
|
const billingCheck = await verifyCredentialSetBilling(credentialSetId)
|
|
if (!billingCheck.valid) {
|
|
logger.warn(
|
|
`[${options.requestId}] Credential set billing check failed: ${billingCheck.error}`
|
|
)
|
|
return NextResponse.json({ error: billingCheck.error }, { status: 403 })
|
|
}
|
|
}
|
|
|
|
const payload = {
|
|
webhookId: foundWebhook.id,
|
|
workflowId: foundWorkflow.id,
|
|
userId: foundWorkflow.userId,
|
|
provider: foundWebhook.provider,
|
|
body,
|
|
headers,
|
|
path: options.path || foundWebhook.path,
|
|
blockId: foundWebhook.blockId,
|
|
...(credentialId ? { credentialId } : {}),
|
|
}
|
|
|
|
if (isTriggerDevEnabled) {
|
|
const handle = await tasks.trigger('webhook-execution', payload)
|
|
logger.info(
|
|
`[${options.requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
|
|
)
|
|
} else {
|
|
void executeWebhookJob(payload).catch((error) => {
|
|
logger.error(`[${options.requestId}] Direct webhook execution failed`, error)
|
|
})
|
|
logger.info(
|
|
`[${options.requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
|
|
)
|
|
}
|
|
|
|
if (foundWebhook.provider === 'microsoft-teams') {
|
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
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',
|
|
})
|
|
}
|
|
|
|
// Twilio Voice requires TwiML XML response
|
|
if (foundWebhook.provider === 'twilio_voice') {
|
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>Your call is being processed.</Say>
|
|
<Pause length="1"/>
|
|
</Response>`
|
|
|
|
return new NextResponse(defaultTwiml, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
|
},
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ message: 'Webhook processed' })
|
|
} catch (error: any) {
|
|
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 === 'twilio_voice') {
|
|
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Say>We're sorry, but an error occurred processing your call. Please try again later.</Say>
|
|
<Hangup/>
|
|
</Response>`
|
|
|
|
return new NextResponse(errorTwiml, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/xml',
|
|
},
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
}
|
|
}
|