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 <noreply@anthropic.com>
This commit is contained in:
Waleed Latif
2026-04-05 10:21:08 -07:00
parent adf13bcbb3
commit ced7d1478f
33 changed files with 1560 additions and 1342 deletions

View File

@@ -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'

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null
}
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
const mockRequest = {
headers: new Map(Object.entries(payload.headers)),
} as unknown as Parameters<typeof formatWebhookInput>[3]
input = (await formatWebhookInput(
actualWebhook,
mockWorkflow,
payload.body,
mockRequest
)) as Record<string, unknown> | 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
}
}

View File

@@ -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'

View File

@@ -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.

View File

@@ -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')

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
return {
input: {
...((b.data as Record<string, unknown>) || {}),
action: b.action,
data: b.data || {},
},
}
},
verifyAuth: createHmacVerifier({
configKey: 'secretToken',
headerName: 'ashby-signature',

View File

@@ -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<FormatInputResult> {
const {
extractAttioRecordData,
extractAttioRecordUpdatedData,
extractAttioRecordMergedData,
extractAttioNoteData,
extractAttioTaskData,
extractAttioCommentData,
extractAttioListEntryData,
extractAttioListEntryUpdatedData,
extractAttioListData,
extractAttioWorkspaceMemberData,
extractAttioGenericData,
} = await import('@/triggers/attio/utils')
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
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) }
},
}

View File

@@ -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)

View File

@@ -0,0 +1,19 @@
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const calendlyHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
return {
input: {
event: b.event,
created_at: b.created_at,
created_by: b.created_by,
payload: b.payload,
},
}
},
}

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
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',

View File

@@ -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<FormatInputResult> {
const {
extractPageData,
extractCommentData,
extractBlogData,
extractAttachmentData,
extractSpaceData,
extractLabelData,
} = await import('@/triggers/confluence/utils')
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
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<string, unknown>
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<string, unknown>).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<string, unknown>

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
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',

View File

@@ -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<FormatInputResult> {
return { input: body }
},
async processInputFiles({
input,
blocks,

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
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,

View File

@@ -0,0 +1,15 @@
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const gmailHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
if (b && typeof b === 'object' && 'email' in b) {
return { input: { email: b.email, timestamp: b.timestamp } }
}
return { input: b }
},
}

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
const normalizeAnswers = (src: unknown): Record<string, unknown> => {
if (!src || typeof src !== 'object') return {}
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(src as Record<string, unknown>)) {
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) {

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
return { input: { type: b.type, user_id: b.user_id, data: b.data || {} } }
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
const data = obj.data as Record<string, unknown> | undefined

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const events = Array.isArray(b) ? b : [b]
const event = events[0] as Record<string, unknown> | 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<string, unknown>

View File

@@ -0,0 +1,31 @@
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const imapHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
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 }
},
}

View File

@@ -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<FormatInputResult> {
const { extractIssueData, extractCommentData, extractWorklogData } = await import(
'@/triggers/jira/utils'
)
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
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<string, unknown>

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
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<string, unknown>
const data = obj.data as Record<string, unknown> | undefined

View File

@@ -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<SecureFetchResponse | null> {
try {
const urlValidation = await validateUrlWithDNS(url, 'contentUrl')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url })
return null
}
const headers: Record<string, string> = {}
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<string, unknown>,
foundWebhook: Record<string, unknown>,
foundWorkflow: { id: string; userId: string },
request: { headers: Map<string, string> }
): Promise<unknown> {
const notification = (body.value as unknown[])?.[0] as Record<string, unknown> | 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<string, unknown>)?.resourceData as
| Record<string, unknown>
| 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<string, unknown>)?.resourceData
) {
const resourceData = ((body.value as unknown[])[0] as Record<string, unknown>)
.resourceData as Record<string, unknown>
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<string, unknown>)?.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<string, unknown>) || {}
const credentialId = providerConfig.credentialId
const includeAttachments = providerConfig.includeAttachments !== false
let message: Record<string, unknown> | 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<string, unknown>
if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) {
const attachments = Array.isArray(message?.attachments)
? (message.attachments as Record<string, unknown>[])
: []
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<string, unknown>
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<string, unknown> | 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<string, unknown>)?.content || ''
const from = ((message.from as Record<string, unknown>)?.user as Record<string, unknown>) || {}
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<FormatInputResult> {
const b = body as Record<string, unknown>
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<string, string> }
)
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<string, unknown>
const conversation = (b?.conversation || {}) as Record<string, unknown>
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,
},
},
}
},
}

View File

@@ -0,0 +1,15 @@
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const outlookHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
if (b && typeof b === 'object' && 'email' in b) {
return { input: { email: b.email, timestamp: b.timestamp } }
}
return { input: b }
},
}

View File

@@ -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<string, WebhookProviderHandler> = {
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,

View File

@@ -0,0 +1,24 @@
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const rssHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
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 }
},
}

View File

@@ -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<string, unknown>
}
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<Array<{ name: string; data: string; mimeType: string; size: number }>> {
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<string, unknown>
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<string> {
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<FormatInputResult> {
const b = body as Record<string, unknown>
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
const botToken = providerConfig.botToken as string | undefined
const includeFiles = Boolean(providerConfig.includeFiles)
const rawEvent = b?.event as Record<string, unknown> | 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<string, unknown> | 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,
},
},
}
},
}

View File

@@ -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<FormatInputResult> {
return { input: body }
},
shouldSkipEvent(ctx: EventFilterContext) {
return skipByEventTypes(ctx, 'Stripe', logger)
},

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const rawMessage = (b?.message ||
b?.edited_message ||
b?.channel_post ||
b?.edited_channel_post) as Record<string, unknown> | 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<string, unknown> | 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,
},
}
},
}

View File

@@ -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<string, unknown>): Promise<boolean> {
async function validateTwilioSignature(
authToken: string,
signature: string,
url: string,
params: Record<string, unknown>
): Promise<boolean> {
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<FormatInputResult> {
const b = body as Record<string, unknown>
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 = `<?xml version="1.0" encoding="UTF-8"?>
<Response>

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const formResponse = (b?.form_response || {}) as Record<string, unknown>
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
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',

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
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<string, unknown>)
return {
input: {
siteId: b?.siteId || '',
collectionId: (_cid || b?.collectionId || '') as string,
payload: {
id: (_id || '') as string,
cmsLocaleId: (itemFields as Record<string, unknown>)?.cmsLocaleId || '',
lastPublished:
(itemFields as Record<string, unknown>)?.lastPublished ||
(itemFields as Record<string, unknown>)?.['last-published'] ||
'',
lastUpdated:
(itemFields as Record<string, unknown>)?.lastUpdated ||
(itemFields as Record<string, unknown>)?.['last-updated'] ||
'',
createdOn:
(itemFields as Record<string, unknown>)?.createdOn ||
(itemFields as Record<string, unknown>)?.['created-on'] ||
'',
isArchived:
(itemFields as Record<string, unknown>)?.isArchived ||
(itemFields as Record<string, unknown>)?._archived ||
false,
isDraft:
(itemFields as Record<string, unknown>)?.isDraft ||
(itemFields as Record<string, unknown>)?._draft ||
false,
fieldData: itemFields,
},
},
}
},
shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) {
const configuredCollectionId = providerConfig.collectionId as string | undefined
if (configuredCollectionId) {

View File

@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const entry = b?.entry as Array<Record<string, unknown>> | undefined
const changes = entry?.[0]?.changes as Array<Record<string, unknown>> | undefined
const data = changes?.[0]?.value as Record<string, unknown> | undefined
const messages = (data?.messages as Array<Record<string, unknown>>) || []
if (messages.length > 0) {
const message = messages[0]
const metadata = data?.metadata as Record<string, unknown> | undefined
const text = message.text as Record<string, unknown> | 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' }

File diff suppressed because it is too large Load Diff