chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+780
View File
@@ -0,0 +1,780 @@
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { validateAirtableId } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
} from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import {
getOAuthToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Airtable')
interface AirtableChange {
tableId: string
recordId: string
changeType: 'created' | 'updated'
changedFields: Record<string, unknown>
previousFields?: Record<string, unknown>
}
interface AirtableTableChanges {
createdRecordsById?: Record<string, { cellValuesByFieldId?: Record<string, unknown> }>
changedRecordsById?: Record<
string,
{
current?: { cellValuesByFieldId?: Record<string, unknown> }
previous?: { cellValuesByFieldId?: Record<string, unknown> }
}
>
destroyedRecordIds?: string[]
}
/**
* Process Airtable payloads
*/
async function fetchAndProcessAirtablePayloads(
webhookData: Record<string, unknown>,
workflowData: Record<string, unknown>,
requestId: string // Original request ID from the ping, used for the final execution log
) {
// Logging handles all error logging
let currentCursor: number | null = null
let mightHaveMore = true
let payloadsFetched = 0
let apiCallCount = 0
// Use a Map to consolidate changes per record ID
const consolidatedChangesMap = new Map<string, AirtableChange>()
// Capture raw payloads from Airtable for exposure to workflows
const allPayloads = []
const localProviderConfig = {
...((webhookData.providerConfig as Record<string, unknown>) || {}),
} as Record<string, unknown>
try {
const baseId = localProviderConfig.baseId
const airtableWebhookId = localProviderConfig.externalId
if (!baseId || !airtableWebhookId) {
logger.error(
`[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.`
)
return
}
const credentialId = localProviderConfig.credentialId as string | undefined
if (!credentialId) {
logger.error(
`[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.`
)
return
}
const resolvedAirtable = await resolveOAuthAccountId(credentialId)
if (!resolvedAirtable) {
logger.error(
`[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook`
)
return
}
let ownerUserId: string | null = null
try {
const rows = await db
.select()
.from(account)
.where(eq(account.id, resolvedAirtable.accountId))
.limit(1)
ownerUserId = rows.length ? rows[0].userId : null
} catch (_e) {
ownerUserId = null
}
if (!ownerUserId) {
logger.error(
`[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}`
)
return
}
const storedCursor = localProviderConfig.externalWebhookCursor
if (storedCursor === undefined || storedCursor === null) {
logger.info(
`[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...`
)
localProviderConfig.externalWebhookCursor = null
try {
await db
.update(webhook)
.set({
providerConfig: {
...localProviderConfig,
externalWebhookCursor: null,
},
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookData.id as string))
localProviderConfig.externalWebhookCursor = null
logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`)
} catch (initError: unknown) {
const err = initError as Error
logger.error(`[${requestId}] Failed to initialize cursor in DB`, {
webhookId: webhookData.id,
error: err.message,
stack: err.stack,
})
}
}
if (storedCursor && typeof storedCursor === 'number') {
currentCursor = storedCursor
} else {
currentCursor = null
}
let accessToken: string | null = null
try {
accessToken = await refreshAccessTokenIfNeeded(
resolvedAirtable.accountId,
ownerUserId,
requestId
)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.`
)
throw new Error('Airtable access token not found.')
}
} catch (tokenError: unknown) {
const err = tokenError as Error
logger.error(
`[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`,
{
error: err.message,
stack: err.stack,
credentialId,
}
)
return
}
const airtableApiBase = 'https://api.airtable.com/v0'
while (mightHaveMore) {
apiCallCount++
// Safety break
if (apiCallCount > 10) {
mightHaveMore = false
break
}
const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads`
const queryParams = new URLSearchParams()
if (currentCursor !== null) {
queryParams.set('cursor', currentCursor.toString())
}
const fullUrl = `${apiUrl}?${queryParams.toString()}`
try {
const fetchStartTime = Date.now()
const response = await fetch(fullUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
const responseBody = await response.json()
if (!response.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
`Airtable API error Status ${response.status}`
logger.error(
`[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`,
{
webhookId: webhookData.id,
status: response.status,
error: errorMessage,
}
)
// Error logging handled by logging session
mightHaveMore = false
break
}
const receivedPayloads = responseBody.payloads || []
if (receivedPayloads.length > 0) {
payloadsFetched += receivedPayloads.length
// Keep the raw payloads for later exposure to the workflow
for (const p of receivedPayloads) {
allPayloads.push(p)
}
let changeCount = 0
for (const payload of receivedPayloads) {
if (payload.changedTablesById) {
for (const [tableId, tableChangesUntyped] of Object.entries(
payload.changedTablesById
)) {
const tableChanges = tableChangesUntyped as AirtableTableChanges
if (tableChanges.createdRecordsById) {
const createdCount = Object.keys(tableChanges.createdRecordsById).length
changeCount += createdCount
for (const [recordId, recordData] of Object.entries(
tableChanges.createdRecordsById
)) {
const existingChange = consolidatedChangesMap.get(recordId)
if (existingChange) {
// Record was created and possibly updated within the same batch
existingChange.changedFields = {
...existingChange.changedFields,
...(recordData.cellValuesByFieldId || {}),
}
// Keep changeType as 'created' if it started as created
} else {
// New creation
consolidatedChangesMap.set(recordId, {
tableId: tableId,
recordId: recordId,
changeType: 'created',
changedFields: recordData.cellValuesByFieldId || {},
})
}
}
}
// Handle updated records
if (tableChanges.changedRecordsById) {
const updatedCount = Object.keys(tableChanges.changedRecordsById).length
changeCount += updatedCount
for (const [recordId, recordData] of Object.entries(
tableChanges.changedRecordsById
)) {
const existingChange = consolidatedChangesMap.get(recordId)
const currentFields = recordData.current?.cellValuesByFieldId || {}
if (existingChange) {
// Existing record was updated again
existingChange.changedFields = {
...existingChange.changedFields,
...currentFields,
}
// Ensure type is 'updated' if it was previously 'created'
existingChange.changeType = 'updated'
// Do not update previousFields again
} else {
// First update for this record in the batch
const newChange: AirtableChange = {
tableId: tableId,
recordId: recordId,
changeType: 'updated',
changedFields: currentFields,
}
if (recordData.previous?.cellValuesByFieldId) {
newChange.previousFields = recordData.previous.cellValuesByFieldId
}
consolidatedChangesMap.set(recordId, newChange)
}
}
}
// TODO: Handle deleted records (`destroyedRecordIds`) if needed
}
}
}
}
const nextCursor = responseBody.cursor
mightHaveMore = responseBody.mightHaveMore || false
if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) {
currentCursor = nextCursor
// Follow exactly the old implementation - use awaited update instead of parallel
const updatedConfig = {
...localProviderConfig,
externalWebhookCursor: currentCursor,
}
try {
// Force a complete object update to ensure consistency in serverless env
await db
.update(webhook)
.set({
providerConfig: updatedConfig, // Use full object
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookData.id as string))
localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too
} catch (dbError: unknown) {
const err = dbError as Error
logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, {
webhookId: webhookData.id,
cursor: currentCursor,
error: err.message,
})
// Error logging handled by logging session
mightHaveMore = false
throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly
}
} else if (!nextCursor || typeof nextCursor !== 'number') {
logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, {
webhookId: webhookData.id,
apiCall: apiCallCount,
receivedCursor: nextCursor,
})
mightHaveMore = false
} else if (nextCursor === currentCursor) {
mightHaveMore = false // Explicitly stop if cursor hasn't changed
}
} catch (fetchError: unknown) {
logger.error(
`[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`,
fetchError
)
// Error logging handled by logging session
mightHaveMore = false
break
}
}
// Convert map values to array for final processing
const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values())
logger.info(
`[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls`
)
if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) {
try {
// Build input exposing raw payloads and consolidated changes
const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null
const input: Record<string, unknown> = {
payloads: allPayloads,
latestPayload,
// Consolidated, simplified changes for convenience
airtableChanges: finalConsolidatedChanges,
// Include webhook metadata for resolver fallbacks
webhook: {
data: {
provider: 'airtable',
providerConfig: webhookData.providerConfig,
payload: latestPayload,
},
},
}
// CRITICAL EXECUTION TRACE POINT
logger.info(
`[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`,
{
workflowId: workflowData.id,
recordCount: finalConsolidatedChanges.length,
timestamp: new Date().toISOString(),
firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none',
}
)
// Return the processed input for the trigger.dev task to handle
logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, {
workflowId: workflowData.id,
recordCount: finalConsolidatedChanges.length,
rawPayloadCount: allPayloads.length,
timestamp: new Date().toISOString(),
})
return input
} catch (processingError: unknown) {
const err = processingError as Error
logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, {
workflowId: workflowData.id,
error: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
})
throw processingError
}
} else {
// DEBUG: Log when no changes are found
logger.info(`[${requestId}] TRACE: No Airtable changes to process`, {
workflowId: workflowData.id,
apiCallCount,
webhookId: webhookData.id,
})
}
} catch (error) {
// Catch any unexpected errors during the setup/polling logic itself
logger.error(
`[${requestId}] Unexpected error during asynchronous Airtable payload processing task`,
{
webhookId: webhookData.id,
workflowId: workflowData.id,
error: (error as Error).message,
}
)
// Error logging handled by logging session
}
}
export const airtableHandler: WebhookProviderHandler = {
async createSubscription({
webhook: webhookRecord,
workflow,
userId,
requestId,
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const { path, providerConfig } = webhookRecord as Record<string, unknown>
const config = (providerConfig as Record<string, unknown>) || {}
const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = config as {
baseId?: string
tableId?: string
includeCellValuesInFieldIds?: string
credentialId?: string
}
if (!baseId || !tableId) {
logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, {
webhookId: webhookRecord.id,
})
throw new Error(
'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.'
)
}
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
if (!baseIdValidation.isValid) {
throw new Error(baseIdValidation.error)
}
const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId')
if (!tableIdValidation.isValid) {
throw new Error(tableIdValidation.error)
}
const credentialOwner = credentialId
? await getCredentialOwner(credentialId, requestId)
: null
const accessToken = credentialId
? credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
: await getOAuthToken(userId, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
)
throw new Error(
'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const specification: Record<string, unknown> = {
options: {
filters: {
dataTypes: ['tableData'],
recordChangeScope: tableId,
},
},
}
if (includeCellValuesInFieldIds === 'all') {
;(specification.options as Record<string, unknown>).includes = {
includeCellValuesInFieldIds: 'all',
}
}
const requestBody: Record<string, unknown> = {
notificationUrl: notificationUrl,
specification: specification,
}
const airtableResponse = await fetch(airtableApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await airtableResponse.json()
if (!airtableResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message || responseBody.error || 'Unknown Airtable API error'
const errorType = responseBody.error?.type
logger.error(
`[${requestId}] Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`,
{ type: errorType, message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Airtable'
if (airtableResponse.status === 404) {
userFriendlyMessage =
'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.'
} else if (errorMessage && errorMessage !== 'Unknown Airtable API error') {
userFriendlyMessage = `Airtable error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`,
{
airtableWebhookId: responseBody.id,
}
)
return { providerConfigUpdates: { externalId: responseBody.id } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription({
webhook: webhookRecord,
workflow,
requestId,
strict,
}: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(webhookRecord)
const { baseId, externalId } = config as {
baseId?: string
externalId?: string
}
if (!baseId) {
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, {
webhookId: webhookRecord.id,
})
if (strict) throw new Error('Missing Airtable baseId for webhook deletion')
return
}
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
if (!baseIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, {
webhookId: webhookRecord.id,
baseId: baseId.substring(0, 20),
})
if (strict) throw new Error('Invalid Airtable baseId for webhook deletion')
return
}
const credentialId = config.credentialId as string | undefined
if (!credentialId) {
logger.warn(
`[${requestId}] Missing credentialId for Airtable webhook deletion ${webhookRecord.id}`
)
if (strict) throw new Error('Missing Airtable credentialId for webhook deletion')
return
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
const message = `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`
logger.warn(message, { webhookId: webhookRecord.id })
if (strict) throw new Error(message)
return
}
let resolvedExternalId: string | undefined = externalId
let externalIdLookupFailed = false
if (!resolvedExternalId && strict) {
logger.warn(
`[${requestId}] Missing Airtable externalId during strict cleanup; skipping unsafe URL-based remote deletion`,
{ webhookId: webhookRecord.id, baseId }
)
throw new Error('Missing Airtable externalId for strict cleanup')
}
if (!resolvedExternalId) {
try {
const expectedNotificationUrl = getNotificationUrl(webhookRecord)
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const listBody = await listResp.json().catch(() => null)
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
const match = listBody.webhooks.find((w: Record<string, unknown>) => {
const url: string | undefined = w?.notificationUrl as string | undefined
if (!url) return false
return (
url === expectedNotificationUrl ||
url.endsWith(`/api/webhooks/trigger/${webhookRecord.path}`)
)
})
if (match?.id) {
resolvedExternalId = match.id as string
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
baseId,
externalId: resolvedExternalId,
})
} else {
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
baseId,
expectedNotificationUrl,
})
}
} else {
externalIdLookupFailed = true
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
baseId,
status: listResp.status,
body: listBody,
})
}
} catch (e: unknown) {
externalIdLookupFailed = true
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
error: (e as Error)?.message,
})
}
}
if (!resolvedExternalId) {
logger.info(`[${requestId}] Airtable externalId not found; skipping remote deletion`, {
baseId,
confirmedAbsent: !externalIdLookupFailed,
})
if (strict && externalIdLookupFailed) {
throw new Error('Could not resolve Airtable externalId for strict cleanup')
}
return
}
const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId')
if (!webhookIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, {
webhookId: webhookRecord.id,
externalId: resolvedExternalId.substring(0, 20),
})
if (strict) throw new Error('Invalid Airtable webhook ID for deletion')
return
}
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
const airtableResponse = await fetch(airtableDeleteUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!airtableResponse.ok && airtableResponse.status !== 404) {
let responseBody: unknown = null
try {
responseBody = await airtableResponse.json()
} catch {}
logger.warn(
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
{ baseId, externalId: resolvedExternalId, response: responseBody }
)
if (strict) throw new Error(`Failed to delete Airtable webhook: ${airtableResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
baseId,
externalId: resolvedExternalId,
alreadyDeleted: airtableResponse.status === 404,
})
}
} catch (error: unknown) {
const err = error as Error
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
webhookId: webhookRecord.id,
error: err.message,
stack: err.stack,
})
if (strict) throw error
}
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
if (typeof obj.cursor === 'string') {
return obj.cursor
}
return null
},
async formatInput({ webhook, workflow, requestId }: FormatInputContext) {
logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`)
const webhookData = {
id: webhook.id,
provider: webhook.provider,
providerConfig: webhook.providerConfig,
}
const mockWorkflow = {
id: workflow.id,
userId: workflow.userId,
}
const airtableInput = await fetchAndProcessAirtablePayloads(
webhookData,
mockWorkflow,
requestId
)
if (airtableInput) {
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
return { input: airtableInput }
}
logger.info(`[${requestId}] No Airtable changes to process`)
return { input: null, skip: { message: 'No Airtable changes to process' } }
},
}
@@ -0,0 +1,231 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { ashbyHandler } from '@/lib/webhooks/providers/ashby'
describe('ashbyHandler', () => {
describe('verifyAuth', () => {
const secret = 'test-secret-token'
const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } })
const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`
it('returns 401 when secretToken is missing', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': signature,
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns 401 when signature header is missing', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns 401 when signature is invalid', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': 'sha256=deadbeef',
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns null when signature is valid', () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'ashby-signature': signature,
})
const res = ashbyHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { secretToken: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('matchEvent', () => {
it('rejects ping events', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'ping', data: { webhookActionType: 'ping' } },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(false)
})
it('matches when action equals the configured trigger event', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'applicationSubmit', data: {} },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(true)
})
it('rejects when action does not match the configured trigger event', async () => {
const matched = await ashbyHandler.matchEvent!({
webhook: { id: 'w1' } as any,
body: { action: 'jobCreate', data: {} },
requestId: 'r1',
providerConfig: { triggerId: 'ashby_application_submit' },
} as any)
expect(matched).toBe(false)
})
})
describe('formatInput', () => {
it('spreads data fields to the top level alongside action', async () => {
const result = await ashbyHandler.formatInput!({
body: {
action: 'applicationSubmit',
data: { application: { id: 'app-1', status: 'Active' } },
},
} as any)
expect(result.input).toEqual({
action: 'applicationSubmit',
application: { id: 'app-1', status: 'Active' },
})
})
it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => {
const result = await ashbyHandler.formatInput!({
body: {
action: 'candidateStageChange',
data: {
application: {
id: 'app-1',
currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' },
},
},
},
} as any)
expect(result.input.application).toEqual({
id: 'app-1',
currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' },
})
})
})
describe('extractIdempotencyId', () => {
it('derives a stable key from application id + updatedAt', () => {
const body = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } },
}
expect(ashbyHandler.extractIdempotencyId!(body)).toBe(
'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z'
)
expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe(
ashbyHandler.extractIdempotencyId!(body)
)
})
it('derives a key from candidate id for candidateDelete', () => {
const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } }
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1')
})
it('derives a key from job id for jobCreate', () => {
const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } }
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1')
})
it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => {
const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } }
expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1')
const retriedAfterDecision = {
action: 'offerCreate',
data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } },
}
expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe(
ashbyHandler.extractIdempotencyId!(created)
)
})
it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => {
const body = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', status: 'Active' } },
}
const key = ashbyHandler.extractIdempotencyId!(body)
expect(key).not.toBeNull()
expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key)
const different = {
action: 'candidateStageChange',
data: { application: { id: 'app-1', status: 'Hired' } },
}
expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key)
})
it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => {
const application = { id: 'app-1', status: 'Hired' }
const first = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-1' } },
}
const second = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-2' } },
}
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
ashbyHandler.extractIdempotencyId!(second)
)
})
it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => {
const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' }
const first = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-1' } },
}
const second = {
action: 'candidateHire',
data: { application, offer: { id: 'offer-2' } },
}
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
ashbyHandler.extractIdempotencyId!(second)
)
// a genuine retry of `first` (identical offer too) still dedupes
expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe(
ashbyHandler.extractIdempotencyId!(first)
)
})
it('returns null when no recognizable resource is present', () => {
expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull()
expect(ashbyHandler.extractIdempotencyId!({})).toBeNull()
})
})
})
+315
View File
@@ -0,0 +1,315 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { generateId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { buildFallbackDeliveryFingerprint } 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
}
const providedSignature = signature.substring(7)
const computedHash = hmacSha256Hex(body, secretToken)
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Ashby signature:', error)
return false
}
}
export const ashbyHandler: WebhookProviderHandler = {
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown>
const action = typeof obj.action === 'string' ? obj.action : undefined
const data = obj.data as Record<string, unknown> | undefined
if (!action || !data) return null
const application = data.application as Record<string, unknown> | undefined
const candidate = data.candidate as Record<string, unknown> | undefined
const job = data.job as Record<string, unknown> | undefined
const offer = data.offer as Record<string, unknown> | undefined
if (application?.id) {
const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data)
const offerSuffix = offer?.id ? `:${offer.id}` : ''
return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}`
}
if (offer?.id) {
return `ashby:${action}:${offer.id}`
}
if (candidate?.id) {
return `ashby:${action}:${candidate.id}`
}
if (job?.id) {
return `ashby:${action}:${job.id}`
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const data = (b.data as Record<string, unknown>) || {}
const application = data.application as Record<string, unknown> | undefined
const currentInterviewStage = application?.currentInterviewStage as
| Record<string, unknown>
| undefined
return {
input: {
...data,
...(application && currentInterviewStage
? {
application: {
...application,
currentInterviewStage: {
...omit(currentInterviewStage, ['type']),
stageType: currentInterviewStage.type,
},
},
}
: {}),
action: b.action,
},
}
},
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
const secretToken = (providerConfig.secretToken as string | undefined)?.trim()
if (!secretToken) {
logger.warn(
`[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request`
)
return new NextResponse(
'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
{ status: 401 }
)
}
const signature = request.headers.get('ashby-signature')
if (!signature) {
logger.warn(`[${requestId}] Ashby webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 })
}
if (!validateAshbySignature(secretToken, signature, rawBody)) {
logger.warn(`[${requestId}] Ashby signature verification failed`, {
signatureLength: signature.length,
secretLength: secretToken.length,
})
return new NextResponse('Unauthorized - Invalid Ashby signature', { status: 401 })
}
return null
},
async matchEvent({
webhook,
body,
requestId,
providerConfig,
}: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
const action = typeof obj?.action === 'string' ? obj.action : ''
if (action === 'ping') {
logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, {
webhookId: webhook.id,
triggerId,
})
return false
}
if (!triggerId) return true
const { isAshbyEventMatch } = await import('@/triggers/ashby/utils')
if (!isAshbyEventMatch(triggerId, action)) {
logger.debug(
`[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`,
{
webhookId: webhook.id,
triggerId,
receivedAction: action,
}
)
return false
}
return true
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const providerConfig = getProviderConfig(ctx.webhook)
const { apiKey, triggerId } = providerConfig as {
apiKey?: string
triggerId?: string
}
if (!apiKey) {
throw new Error(
'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.'
)
}
if (!triggerId) {
throw new Error('Trigger ID is required to create Ashby webhook.')
}
const { ASHBY_TRIGGER_ACTION_MAP } = await import('@/triggers/ashby/utils')
const webhookType = ASHBY_TRIGGER_ACTION_MAP[triggerId]
if (!webhookType) {
throw new Error(
`Unknown Ashby triggerId: ${triggerId}. Add it to ASHBY_TRIGGER_ACTION_MAP.`
)
}
const notificationUrl = getNotificationUrl(ctx.webhook)
const authString = Buffer.from(`${apiKey}:`).toString('base64')
logger.info(`[${ctx.requestId}] Creating Ashby webhook`, {
triggerId,
webhookType,
webhookId: ctx.webhook.id,
})
const secretToken = generateId()
const requestBody: Record<string, unknown> = {
requestUrl: notificationUrl,
webhookType,
secretToken,
}
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', {
method: 'POST',
headers: {
Authorization: `Basic ${authString}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record<string, unknown>
if (!ashbyResponse.ok || !responseBody.success) {
const errorInfo = responseBody.errorInfo as Record<string, string> | undefined
const errorMessage =
errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error'
let userFriendlyMessage = 'Failed to create webhook subscription in Ashby'
if (ashbyResponse.status === 401) {
userFriendlyMessage =
'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.'
} else if (ashbyResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
} else if (/duplicate webhook/i.test(errorMessage)) {
userFriendlyMessage =
'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.'
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
userFriendlyMessage = `Ashby error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const results = responseBody.results as Record<string, unknown> | undefined
const externalId = results?.id as string | undefined
if (!externalId) {
throw new Error('Ashby webhook creation succeeded but no webhook ID was returned')
}
logger.info(
`[${ctx.requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}`
)
return { providerConfigUpdates: { externalId, secretToken } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${ctx.requestId}] Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${ctx.requestId}] Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Ashby apiKey for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${ctx.requestId}] Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Ashby externalId for webhook deletion')
return
}
const authString = Buffer.from(`${apiKey}:`).toString('base64')
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', {
method: 'POST',
headers: {
Authorization: `Basic ${authString}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ webhookId: externalId }),
})
if (ashbyResponse.ok) {
await ashbyResponse.body?.cancel()
logger.info(
`[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}`
)
} else if (ashbyResponse.status === 404) {
await ashbyResponse.body?.cancel()
logger.info(
`[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)`
)
} else {
const responseBody = await ashbyResponse.json().catch(() => ({}))
logger.warn(
`[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) throw new Error(`Failed to delete Ashby webhook: ${ashbyResponse.status}`)
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
+361
View File
@@ -0,0 +1,361 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { toError } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Attio')
function validateAttioSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Attio signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
const computedHash = hmacSha256Hex(body, secret)
return safeCompare(computedHash, signature)
} catch (error) {
logger.error('Error validating Attio signature:', error)
return false
}
}
export const attioHandler: WebhookProviderHandler = {
verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
logger.debug(
`[${requestId}] Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification`
)
} else {
const signature = request.headers.get('Attio-Signature')
if (!signature) {
logger.warn(`[${requestId}] Attio webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Attio signature', {
status: 401,
})
}
const isValidSignature = validateAttioSignature(secret, signature, rawBody)
if (!isValidSignature) {
logger.warn(`[${requestId}] Attio signature verification failed`)
return new NextResponse('Unauthorized - Invalid Attio signature', {
status: 401,
})
}
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
if (triggerId && triggerId !== 'attio_webhook') {
const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils')
if (!isAttioPayloadMatch(triggerId, obj)) {
const event = getAttioEvent(obj)
const eventType = event?.event_type as string | undefined
logger.debug(
`[${requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedEvent: eventType,
bodyKeys: Object.keys(obj),
}
)
return NextResponse.json({
status: 'skipped',
reason: 'event_type_mismatch',
})
}
}
return true
},
async createSubscription({
webhook: webhookRecord,
workflow,
userId,
requestId,
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const { path, providerConfig } = webhookRecord as Record<string, unknown>
const config = (providerConfig as Record<string, unknown>) || {}
const { triggerId, credentialId } = config as {
triggerId?: string
credentialId?: string
}
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, {
webhookId: webhookRecord.id,
})
throw new Error(
'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.'
)
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.`
)
throw new Error(
'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils')
let subscriptions: Array<{ event_type: string; filter: null }> = []
if (triggerId === 'attio_webhook') {
const allEvents = new Set<string>()
for (const events of Object.values(TRIGGER_EVENT_MAP)) {
for (const event of events) {
allEvents.add(event)
}
}
subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null }))
} else {
const events = TRIGGER_EVENT_MAP[triggerId as string]
if (!events || events.length === 0) {
logger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, {
webhookId: webhookRecord.id,
})
throw new Error(`Unknown Attio trigger type: ${triggerId}`)
}
subscriptions = events.map((event_type) => ({ event_type, filter: null }))
}
const requestBody = {
data: {
target_url: notificationUrl,
subscriptions,
},
}
const attioResponse = await fetch('https://api.attio.com/v2/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!attioResponse.ok) {
const errorBody = await attioResponse.json().catch(() => ({}))
logger.error(
`[${requestId}] Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`,
{ response: errorBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Attio'
if (attioResponse.status === 401) {
userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.'
} else if (attioResponse.status === 403) {
userFriendlyMessage =
'Attio access denied. Please ensure your integration has webhook permissions.'
}
throw new Error(userFriendlyMessage)
}
const responseBody = await attioResponse.json()
const data = responseBody.data || responseBody
const webhookId = data.id?.webhook_id || data.webhook_id || data.id
const secret = data.secret
if (!webhookId) {
logger.error(
`[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`,
{ response: responseBody }
)
throw new Error('Attio webhook creation succeeded but no webhook ID was returned')
}
if (!secret) {
logger.warn(
`[${requestId}] Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`,
{ response: responseBody }
)
}
logger.info(
`[${requestId}] Successfully created webhook in Attio for webhook ${webhookRecord.id}.`,
{
attioWebhookId: webhookId,
targetUrl: notificationUrl,
subscriptionCount: subscriptions.length,
status: data.status,
}
)
return { providerConfigUpdates: { externalId: webhookId, webhookSecret: secret || '' } }
} catch (error: unknown) {
const message = toError(error).message
logger.error(
`[${requestId}] Exception during Attio webhook creation for webhook ${webhookRecord.id}.`,
{ message }
)
throw error
}
},
async deleteSubscription({
webhook: webhookRecord,
workflow,
requestId,
strict,
}: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(webhookRecord)
const externalId = config.externalId as string | undefined
const credentialId = config.credentialId as string | undefined
if (!externalId) {
logger.warn(
`[${requestId}] Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup`
)
if (strict) throw new Error('Missing Attio externalId for webhook deletion')
return
}
if (!credentialId) {
logger.warn(
`[${requestId}] Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup`
)
if (strict) throw new Error('Missing Attio credentialId for webhook deletion')
return
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
const message = `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`
logger.warn(message, { webhookId: webhookRecord.id })
if (strict) throw new Error(message)
return
}
const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!attioResponse.ok && attioResponse.status !== 404) {
const responseBody = await attioResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`,
{ response: responseBody }
)
if (strict) throw new Error(`Failed to delete Attio webhook: ${attioResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error)
if (strict) throw error
}
},
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 b = body as Record<string, unknown>
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId === 'attio_record_updated') {
return { input: extractAttioRecordUpdatedData(b) }
}
if (triggerId === 'attio_record_merged') {
return { input: extractAttioRecordMergedData(b) }
}
if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') {
return { input: extractAttioRecordData(b) }
}
if (triggerId?.startsWith('attio_note_')) {
return { input: extractAttioNoteData(b) }
}
if (triggerId?.startsWith('attio_task_')) {
return { input: extractAttioTaskData(b) }
}
if (triggerId?.startsWith('attio_comment_')) {
return { input: extractAttioCommentData(b) }
}
if (triggerId === 'attio_list_entry_updated') {
return { input: extractAttioListEntryUpdatedData(b) }
}
if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') {
return { input: extractAttioListEntryData(b) }
}
if (
triggerId === 'attio_list_created' ||
triggerId === 'attio_list_updated' ||
triggerId === 'attio_list_deleted'
) {
return { input: extractAttioListData(b) }
}
if (triggerId === 'attio_workspace_member_created') {
return { input: extractAttioWorkspaceMemberData(b) }
}
return { input: extractAttioGenericData(b) }
},
}
@@ -0,0 +1,90 @@
import { createLogger } from '@sim/logger'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import {
AZURE_DEVOPS_BUILD_FAILED_EVENT,
AZURE_DEVOPS_WORK_ITEM_CREATED_EVENT,
formatBuildCompleteInput,
formatWebhookEnvelopeInput,
formatWorkItemCreatedInput,
} from '@/triggers/azure_devops/utils'
const logger = createLogger('WebhookProvider:AzureDevOps')
export const azureDevOpsHandler: WebhookProviderHandler = {
async matchEvent({
body,
requestId,
providerConfig,
webhook,
workflow,
}: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
const b = body as Record<string, unknown>
if (triggerId && triggerId !== 'azure_devops_webhook') {
const { isAzureDevOpsEventMatch } = await import('@/triggers/azure_devops/utils')
if (!isAzureDevOpsEventMatch(triggerId, b)) {
logger.debug(
`[${requestId}] Azure DevOps event mismatch for trigger ${triggerId}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
eventType: b.eventType,
}
)
return false
}
}
return true
},
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown> | null
if (!obj) return null
const subscriptionId =
typeof obj.subscriptionId === 'string' && obj.subscriptionId ? obj.subscriptionId : null
const notificationId =
typeof obj.notificationId === 'number' || typeof obj.notificationId === 'string'
? String(obj.notificationId)
: null
if (!subscriptionId || !notificationId) return null
return `azure_devops:${subscriptionId}:${notificationId}`
},
async formatInput({ body, webhook, requestId }: 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
const eventType = b.eventType as string | undefined
if (triggerId === 'azure_devops_webhook') {
return { input: formatWebhookEnvelopeInput(b) }
}
if (eventType === AZURE_DEVOPS_BUILD_FAILED_EVENT) {
return { input: formatBuildCompleteInput(b) }
}
if (eventType === AZURE_DEVOPS_WORK_ITEM_CREATED_EVENT) {
return { input: formatWorkItemCreatedInput(b) }
}
logger.warn(`[${requestId}] Azure DevOps: unknown eventType for specialized trigger`, {
triggerId,
eventType,
})
return {
input: null,
skip: {
message: `Unsupported Azure DevOps event type "${eventType ?? 'unknown'}" for trigger ${triggerId ?? 'unknown'}`,
},
}
},
}
+47
View File
@@ -0,0 +1,47 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Calcom')
function validateCalcomSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Cal.com signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
let providedSignature: string
if (signature.startsWith('sha256=')) {
providedSignature = signature.substring(7)
} else {
providedSignature = signature
}
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Cal.com signature comparison', {
computedSignature: `${computedHash.substring(0, 10)}...`,
providedSignature: `${providedSignature.substring(0, 10)}...`,
computedLength: computedHash.length,
providedLength: providedSignature.length,
match: computedHash === providedSignature,
})
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Cal.com signature:', error)
return false
}
}
export const calcomHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-Cal-Signature-256',
validateFn: validateCalcomSignature,
providerLabel: 'Cal.com',
}),
}
+217
View File
@@ -0,0 +1,217 @@
import { createLogger } from '@sim/logger'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Calendly')
export const calendlyHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<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,
},
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const providerConfig = getProviderConfig(ctx.webhook)
const { apiKey, organization, triggerId } = providerConfig as {
apiKey?: string
organization?: string
triggerId?: string
}
if (!apiKey) {
logger.warn(`[${ctx.requestId}] Missing apiKey for Calendly webhook creation.`, {
webhookId: ctx.webhook.id,
})
throw new Error(
'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.'
)
}
if (!organization) {
logger.warn(`[${ctx.requestId}] Missing organization URI for Calendly webhook creation.`, {
webhookId: ctx.webhook.id,
})
throw new Error(
'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.'
)
}
if (!triggerId) {
logger.warn(`[${ctx.requestId}] Missing triggerId for Calendly webhook creation.`, {
webhookId: ctx.webhook.id,
})
throw new Error('Trigger ID is required to create Calendly webhook')
}
const notificationUrl = getNotificationUrl(ctx.webhook)
const eventTypeMap: Record<string, string[]> = {
calendly_invitee_created: ['invitee.created'],
calendly_invitee_canceled: ['invitee.canceled'],
calendly_routing_form_submitted: ['routing_form_submission.created'],
calendly_webhook: [
'invitee.created',
'invitee.canceled',
'routing_form_submission.created',
],
}
const events = eventTypeMap[triggerId] || ['invitee.created']
const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions'
const requestBody = {
url: notificationUrl,
events,
organization,
scope: 'organization',
}
const calendlyResponse = await fetch(calendlyApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!calendlyResponse.ok) {
const errorBody = await calendlyResponse.json().catch(() => ({}))
const errorMessage =
(errorBody as Record<string, string>).message ||
(errorBody as Record<string, string>).title ||
'Unknown Calendly API error'
logger.error(
`[${ctx.requestId}] Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`,
{ response: errorBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Calendly'
if (calendlyResponse.status === 401) {
userFriendlyMessage =
'Calendly authentication failed. Please verify your Personal Access Token is correct.'
} else if (calendlyResponse.status === 403) {
userFriendlyMessage =
'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.'
} else if (calendlyResponse.status === 404) {
userFriendlyMessage =
'Calendly organization not found. Please verify the Organization URI is correct.'
} else if (errorMessage && errorMessage !== 'Unknown Calendly API error') {
userFriendlyMessage = `Calendly error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const responseBody = (await calendlyResponse.json()) as Record<string, unknown>
const resource = responseBody.resource as Record<string, unknown> | undefined
const webhookUri = resource?.uri as string | undefined
if (!webhookUri) {
logger.error(
`[${ctx.requestId}] Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`,
{ response: responseBody }
)
throw new Error('Calendly webhook creation succeeded but no webhook URI was returned')
}
const webhookId = webhookUri.split('/').pop()
if (!webhookId) {
logger.error(
`[${ctx.requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`,
{
response: responseBody,
}
)
throw new Error('Failed to extract webhook ID from Calendly response')
}
logger.info(
`[${ctx.requestId}] Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`,
{
calendlyWebhookUri: webhookUri,
calendlyWebhookId: webhookId,
}
)
return { providerConfigUpdates: { externalId: webhookId } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${ctx.requestId}] Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${ctx.requestId}] Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Calendly apiKey for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${ctx.requestId}] Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Calendly externalId for webhook deletion')
return
}
const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}`
const calendlyResponse = await fetch(calendlyApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!calendlyResponse.ok && calendlyResponse.status !== 404) {
const responseBody = await calendlyResponse.json().catch(() => ({}))
logger.warn(
`[${ctx.requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) {
throw new Error(`Failed to delete Calendly webhook: ${calendlyResponse.status}`)
}
} else {
logger.info(
`[${ctx.requestId}] Successfully deleted Calendly webhook subscription ${externalId}`
)
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Calendly webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Circleback')
function validateCirclebackSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Circleback signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Circleback signature comparison', {
computedSignature: `${computedHash.substring(0, 10)}...`,
providedSignature: `${signature.substring(0, 10)}...`,
computedLength: computedHash.length,
providedLength: signature.length,
match: computedHash === signature,
})
return safeCompare(computedHash, signature)
} catch (error) {
logger.error('Error validating Circleback signature:', error)
return false
}
}
export const circlebackHandler: WebhookProviderHandler = {
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',
validateFn: validateCirclebackSignature,
providerLabel: 'Circleback',
}),
}
@@ -0,0 +1,174 @@
import crypto from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { clerkHandler } from '@/lib/webhooks/providers/clerk'
const SECRET_BASE64 = Buffer.from('clerk-test-signing-secret').toString('base64')
const SIGNING_SECRET = `whsec_${SECRET_BASE64}`
function signSvix(msgId: string, timestamp: string, body: string): string {
const toSign = `${msgId}.${timestamp}.${body}`
const sig = crypto
.createHmac('sha256', Buffer.from(SECRET_BASE64, 'base64'))
.update(toSign, 'utf8')
.digest('base64')
return `v1,${sig}`
}
function makeRequest(headers: Record<string, string>) {
return { headers: new Headers(headers) } as unknown as Parameters<
NonNullable<typeof clerkHandler.verifyAuth>
>[0]['request']
}
describe('Clerk webhook provider', () => {
it('verifyAuth accepts a valid Svix signature', async () => {
const msgId = 'msg_123'
const timestamp = String(Math.floor(Date.now() / 1000))
const rawBody = JSON.stringify({ type: 'user.created', data: { id: 'user_1' } })
const signature = signSvix(msgId, timestamp, rawBody)
const result = await clerkHandler.verifyAuth!({
request: makeRequest({
'svix-id': msgId,
'svix-timestamp': timestamp,
'svix-signature': signature,
}),
rawBody,
requestId: 'test',
providerConfig: { signingSecret: SIGNING_SECRET },
webhook: {},
workflow: {},
})
expect(result).toBeNull()
})
it('verifyAuth rejects an invalid Svix signature', async () => {
const timestamp = String(Math.floor(Date.now() / 1000))
const rawBody = JSON.stringify({ type: 'user.created', data: { id: 'user_1' } })
const result = await clerkHandler.verifyAuth!({
request: makeRequest({
'svix-id': 'msg_123',
'svix-timestamp': timestamp,
'svix-signature': 'v1,not-a-valid-signature',
}),
rawBody,
requestId: 'test',
providerConfig: { signingSecret: SIGNING_SECRET },
webhook: {},
workflow: {},
})
expect(result).not.toBeNull()
expect(result?.status).toBe(401)
})
it('verifyAuth rejects when the signing secret is missing', async () => {
const result = await clerkHandler.verifyAuth!({
request: makeRequest({
'svix-id': 'msg_123',
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
'svix-signature': 'v1,whatever',
}),
rawBody: '{}',
requestId: 'test',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(result?.status).toBe(401)
})
it('formatInput maps user.created fields to documented outputs', async () => {
const { input } = await clerkHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: {
type: 'user.created',
object: 'event',
timestamp: 1654012591835,
instance_id: 'ins_abc',
data: {
id: 'user_29w83sxmDNGwOuEthce5gg56FcC',
first_name: 'Ada',
last_name: 'Lovelace',
username: 'ada',
image_url: 'https://img.clerk.com/ada.png',
primary_email_address_id: 'idn_email_1',
email_addresses: [{ id: 'idn_email_1', email_address: 'ada@example.com' }],
external_id: 'ext_1',
created_at: 1654012591514,
updated_at: 1654012591835,
},
},
headers: {},
requestId: 'test',
})
expect(input).toMatchObject({
type: 'user.created',
object: 'event',
timestamp: 1654012591835,
instance_id: 'ins_abc',
userId: 'user_29w83sxmDNGwOuEthce5gg56FcC',
firstName: 'Ada',
lastName: 'Lovelace',
username: 'ada',
primaryEmailAddressId: 'idn_email_1',
externalId: 'ext_1',
createdAt: 1654012591514,
})
})
it('formatInput resolves userId from session and membership payloads', async () => {
const session = await clerkHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: {
type: 'session.created',
data: { id: 'sess_1', user_id: 'user_1', client_id: 'client_1', status: 'active' },
},
headers: {},
requestId: 'test',
})
expect(session.input).toMatchObject({
sessionId: 'sess_1',
userId: 'user_1',
clientId: 'client_1',
status: 'active',
})
const membership = await clerkHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: {
type: 'organizationMembership.created',
data: {
id: 'orgmem_1',
role: 'org:admin',
organization: { id: 'org_1', name: 'Acme' },
public_user_data: { user_id: 'user_2' },
},
},
headers: {},
requestId: 'test',
})
expect(membership.input).toMatchObject({
membershipId: 'orgmem_1',
role: 'org:admin',
organizationId: 'org_1',
userId: 'user_2',
})
})
it('extractIdempotencyId derives a stable, timestamp-free key from type and data id', () => {
const id = clerkHandler.extractIdempotencyId!({
type: 'user.updated',
timestamp: 1654012591835,
data: { id: 'user_1' },
})
expect(id).toBe('user.updated:user_1')
})
})
+174
View File
@@ -0,0 +1,174 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Clerk')
/**
* Verify a Clerk webhook signature using the Svix signing scheme.
* Clerk uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
* signed with the base64-decoded `whsec_...` secret, compared against the
* space-delimited, versioned (`v1,<sig>`) `svix-signature` header.
*/
function verifySvixSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
const providedSignatures = signatures.split(' ')
for (const versionedSig of providedSignatures) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
const sig = parts[1]
if (safeCompare(sig, expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Clerk Svix signature:', error)
return false
}
}
export const clerkHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Clerk webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Clerk signing secret is required', { status: 401 })
}
const svixId = request.headers.get('svix-id')
const svixTimestamp = request.headers.get('svix-timestamp')
const svixSignature = request.headers.get('svix-signature')
if (!svixId || !svixTimestamp || !svixSignature) {
logger.warn(`[${requestId}] Clerk webhook missing Svix signature headers`)
return new NextResponse('Unauthorized - Missing Clerk signature headers', { status: 401 })
}
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
logger.warn(`[${requestId}] Clerk Svix signature verification failed`)
return new NextResponse('Unauthorized - Invalid Clerk signature', { status: 401 })
}
return null
},
async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'clerk_webhook') {
return true
}
const { isClerkEventMatch } = await import('@/triggers/clerk/utils')
if (!isClerkEventMatch(triggerId, body as Record<string, unknown>)) {
const actualType = (body as Record<string, unknown>)?.type
logger.debug(
`[${requestId}] Clerk event type mismatch for trigger ${triggerId}, got ${String(actualType)}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
const type = (payload.type as string | undefined) ?? ''
const data = (payload.data as Record<string, unknown> | undefined) ?? undefined
const isUser = type.startsWith('user.')
const isSession = type.startsWith('session.')
const isMembership = type.startsWith('organizationMembership.')
const isOrganization = type.startsWith('organization.') && !isMembership
const organization = data?.organization as Record<string, unknown> | undefined
const publicUserData = data?.public_user_data as Record<string, unknown> | undefined
const userId = isUser
? (data?.id ?? null)
: isSession
? (data?.user_id ?? null)
: isMembership
? (publicUserData?.user_id ?? null)
: null
const organizationId = isMembership
? (organization?.id ?? null)
: isOrganization
? (data?.id ?? null)
: null
return {
input: {
type: payload.type ?? null,
object: payload.object ?? null,
timestamp: payload.timestamp ?? null,
instance_id: payload.instance_id ?? null,
data: data ?? null,
userId,
sessionId: isSession ? (data?.id ?? null) : null,
organizationId,
membershipId: isMembership ? (data?.id ?? null) : null,
firstName: data?.first_name ?? null,
lastName: data?.last_name ?? null,
username: data?.username ?? null,
imageUrl: data?.image_url ?? null,
primaryEmailAddressId: data?.primary_email_address_id ?? null,
emailAddresses: data?.email_addresses ?? null,
phoneNumbers: data?.phone_numbers ?? null,
externalId: data?.external_id ?? null,
deleted: data?.deleted ?? null,
status: data?.status ?? null,
clientId: data?.client_id ?? null,
role: data?.role ?? null,
name: data?.name ?? null,
slug: data?.slug ?? null,
createdBy: data?.created_by ?? null,
membersCount: data?.members_count ?? null,
maxAllowedMemberships: data?.max_allowed_memberships ?? null,
createdAt: data?.created_at ?? null,
updatedAt: data?.updated_at ?? null,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const payload = body as Record<string, unknown>
const type = payload?.type as string | undefined
const data = payload?.data as Record<string, unknown> | undefined
const dataId = data?.id as string | undefined
if (type && dataId) {
return `${type}:${dataId}`
}
return null
},
}
@@ -0,0 +1,123 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
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')
export const confluenceHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-Hub-Signature',
validateFn: validateJiraSignature,
providerLabel: 'Confluence',
}),
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
const {
extractPageData,
extractCommentData,
extractBlogData,
extractAttachmentData,
extractSpaceData,
extractLabelData,
extractPagePermissionsData,
extractUserData,
} = 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_page_permissions_updated') {
return { input: extractPagePermissionsData(body as Record<string, unknown>) }
}
if (triggerId === 'confluence_user_created') {
return { input: extractUserData(body as Record<string, unknown>) }
}
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,
user: b.user || null,
},
}
}
return { input: extractPageData(body) }
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
const event = obj.event as string | undefined
const timestamp = obj.timestamp ?? ''
const page = obj.page as Record<string, unknown> | undefined
const comment = obj.comment as Record<string, unknown> | undefined
const attachment = obj.attachment as Record<string, unknown> | undefined
const blog = (obj.blog || obj.blogpost) as Record<string, unknown> | undefined
const space = obj.space as Record<string, unknown> | undefined
const user = obj.user as Record<string, unknown> | undefined
const entityId =
comment?.id || attachment?.id || blog?.id || page?.id || space?.id || user?.accountId
if (event && entityId) {
return `confluence:${event}:${entityId}:${timestamp}`
}
if (event && timestamp) {
return `confluence:${event}:${timestamp}`
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
if (triggerId) {
const { isConfluencePayloadMatch } = await import('@/triggers/confluence/utils')
if (!isConfluencePayloadMatch(triggerId, obj)) {
logger.debug(
`[${requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
bodyKeys: Object.keys(obj),
}
)
return NextResponse.json({
message: 'Payload does not match trigger configuration. Ignoring.',
})
}
}
return true
},
}
@@ -0,0 +1,179 @@
/**
* @vitest-environment node
*/
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison'
const WEBHOOK_ID = 'webhook-uuid-1234'
const PUBLIC_BASE_URL = 'https://my-instance.emailbison.com'
function makeWebhook(providerConfig: Record<string, unknown>) {
return {
id: WEBHOOK_ID,
path: 'abc',
providerConfig,
} as unknown as Parameters<typeof emailBisonHandler.deleteSubscription>[0]['webhook']
}
function jsonSecureResponse(status: number, body: Record<string, unknown>) {
return {
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: { get: () => null },
body: { cancel: vi.fn() },
json: vi.fn().mockResolvedValue(body),
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
arrayBuffer: vi.fn(),
}
}
describe('emailBisonHandler createSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'
})
afterEach(() => {
process.env.NEXT_PUBLIC_APP_URL = undefined
})
it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: false,
error: 'URL resolves to a blocked address',
})
const webhook = makeWebhook({
apiKey: 'test-key',
apiBaseUrl: 'https://169.254.169.254',
triggerId: 'emailbison_email_sent',
})
await expect(
emailBisonHandler.createSubscription({
webhook,
workflow: {} as never,
userId: 'user-1',
requestId: 'req-1',
} as never)
).rejects.toThrow('Email Bison Instance URL could not be validated.')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
it('creates the webhook subscription for a valid public instance URL', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '203.0.113.10',
})
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
jsonSecureResponse(200, { data: { id: 42 } })
)
const webhook = makeWebhook({
apiKey: 'test-key',
apiBaseUrl: PUBLIC_BASE_URL,
triggerId: 'emailbison_email_sent',
})
const result = await emailBisonHandler.createSubscription({
webhook,
workflow: {} as never,
userId: 'user-1',
requestId: 'req-1',
} as never)
expect(result).toEqual({ providerConfigUpdates: { externalId: '42' } })
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
expect.stringContaining(PUBLIC_BASE_URL),
'203.0.113.10',
expect.objectContaining({ method: 'POST' })
)
})
})
describe('emailBisonHandler deleteSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: false,
error: 'URL resolves to a blocked address',
})
const webhook = makeWebhook({
apiKey: 'test-key',
apiBaseUrl: 'https://127.0.0.1',
externalId: '42',
})
await emailBisonHandler.deleteSubscription({
webhook,
workflow: {} as never,
requestId: 'req-1',
strict: false,
} as never)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
it('throws when strict and the apiBaseUrl resolves to a blocked address', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: false,
error: 'URL resolves to a blocked address',
})
const webhook = makeWebhook({
apiKey: 'test-key',
apiBaseUrl: 'https://127.0.0.1',
externalId: '42',
})
await expect(
emailBisonHandler.deleteSubscription({
webhook,
workflow: {} as never,
requestId: 'req-1',
strict: true,
} as never)
).rejects.toThrow('Email Bison Instance URL could not be validated.')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
it('deletes the webhook subscription for a valid public instance URL', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '203.0.113.10',
})
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
jsonSecureResponse(200, {})
)
const webhook = makeWebhook({
apiKey: 'test-key',
apiBaseUrl: PUBLIC_BASE_URL,
externalId: '42',
})
await emailBisonHandler.deleteSubscription({
webhook,
workflow: {} as never,
requestId: 'req-1',
strict: false,
} as never)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
expect.stringContaining(PUBLIC_BASE_URL),
'203.0.113.10',
expect.objectContaining({ method: 'DELETE' })
)
})
})
@@ -0,0 +1,337 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import {
type SecureFetchResponse,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { emailBisonHeaders, emailBisonUrl } from '@/tools/emailbison/utils'
const logger = createLogger('WebhookProvider:EmailBison')
export const emailBisonHandler: WebhookProviderHandler = {
async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) return true
if (!isRecordLike(body)) {
logger.warn(`[${requestId}] Email Bison webhook payload was not an object`)
return false
}
const { isEmailBisonEventMatch } = await import('@/triggers/emailbison/utils')
if (!isEmailBisonEventMatch(triggerId, unwrapEmailBisonPayload(body))) {
logger.info(`[${requestId}] Email Bison event did not match trigger`, {
triggerId,
})
return false
}
return true
},
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
const payload = isRecordLike(body) ? unwrapEmailBisonPayload(body) : {}
const event = isRecordLike(payload.event) ? payload.event : null
const data = isRecordLike(payload.data) ? payload.data : null
const providerConfig = getProviderConfig(webhook)
const triggerId = providerConfig.triggerId as string | undefined
const input: Record<string, unknown> = {
eventType: toStringOrNull(event?.type),
eventName: toStringOrNull(event?.name),
instanceUrl: toStringOrNull(event?.instance_url),
workspaceId: toNumberOrNull(event?.workspace_id),
workspaceName: toStringOrNull(event?.workspace_name),
event,
data,
}
if (
triggerId === 'emailbison_email_sent' ||
triggerId === 'emailbison_lead_first_contacted' ||
triggerId === 'emailbison_lead_unsubscribed' ||
triggerId === 'emailbison_email_opened'
) {
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.campaignEvent = renameTypeField(data?.campaign_event, 'event_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (
triggerId === 'emailbison_lead_replied' ||
triggerId === 'emailbison_lead_interested' ||
triggerId === 'emailbison_email_bounced'
) {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.campaignEvent = renameTypeField(data?.campaign_event, 'event_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_untracked_reply_received') {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (
triggerId === 'emailbison_email_account_added' ||
triggerId === 'emailbison_email_account_removed' ||
triggerId === 'emailbison_email_account_disconnected' ||
triggerId === 'emailbison_email_account_reconnected' ||
triggerId === 'emailbison_warmup_disabled_receiving_bounces' ||
triggerId === 'emailbison_warmup_disabled_causing_bounces'
) {
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_manual_email_sent') {
input.reply = renameTypeField(data?.reply, 'reply_type')
input.lead = toRecordOrNull(data?.lead)
input.campaign = toRecordOrNull(data?.campaign)
input.scheduledEmail = toRecordOrNull(data?.scheduled_email)
input.senderEmail = renameTypeField(data?.sender_email, 'account_type')
}
if (triggerId === 'emailbison_tag_attached' || triggerId === 'emailbison_tag_removed') {
input.tagId = toNumberOrNull(data?.tag_id)
input.tagName = toStringOrNull(data?.tag_name)
input.taggableId = toNumberOrNull(data?.taggable_id)
input.taggableType = toStringOrNull(data?.taggable_type)
}
return {
input,
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const apiBaseUrl = providerConfig.apiBaseUrl as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
if (!apiKey?.trim()) {
throw new Error('Email Bison API Key is required.')
}
if (!apiBaseUrl?.trim()) {
throw new Error('Email Bison Instance URL is required.')
}
if (!triggerId) {
throw new Error('Email Bison trigger ID is required.')
}
const { getEmailBisonEventTypeForTrigger } = await import('@/triggers/emailbison/utils')
const eventType = getEmailBisonEventTypeForTrigger(triggerId)
if (!eventType) {
throw new Error(`Unknown Email Bison trigger type: ${triggerId}`)
}
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Email Bison webhook`, {
triggerId,
eventType,
webhookId: webhook.id,
})
const targetUrl = emailBisonUrl('/api/webhook-url', {}, apiBaseUrl)
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`)
throw new Error('Email Bison Instance URL could not be validated.')
}
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
method: 'POST',
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
body: JSON.stringify({
name: `Sim - ${eventType}`,
url: notificationUrl,
events: [eventType],
}),
})
const responseBody = await parseJsonResponse(response)
if (!response.ok) {
const message = extractEmailBisonError(responseBody)
logger.error(`[${requestId}] Failed to create Email Bison webhook`, {
status: response.status,
message,
response: responseBody,
})
if (response.status === 401 || response.status === 403) {
throw new Error(
'Invalid Email Bison API Key or Instance URL. Confirm both came from the same Email Bison instance.'
)
}
throw new Error(
message ? `Email Bison error: ${message}` : 'Failed to create Email Bison webhook'
)
}
const data = isRecordLike(responseBody?.data) ? responseBody.data : null
const externalId = data?.id
if (externalId === undefined || externalId === null || externalId === '') {
throw new Error('Email Bison webhook was created but the API response did not include an ID.')
}
logger.info(`[${requestId}] Successfully created Email Bison webhook`, {
externalId,
webhookId: webhook.id,
})
return { providerConfigUpdates: { externalId: String(externalId) } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const apiBaseUrl = providerConfig.apiBaseUrl as string | undefined
const externalId = providerConfig.externalId as string | undefined
if (!apiKey?.trim() || !apiBaseUrl?.trim() || !externalId?.trim()) {
logger.warn(`[${requestId}] Missing Email Bison webhook cleanup configuration`, {
webhookId: webhook.id,
hasApiKey: Boolean(apiKey),
hasApiBaseUrl: Boolean(apiBaseUrl),
hasExternalId: Boolean(externalId),
})
if (ctx.strict)
throw new AlreadyLoggedError('Missing Email Bison webhook cleanup configuration')
return
}
const targetUrl = emailBisonUrl(
`/api/webhook-url/${encodeURIComponent(externalId)}`,
{},
apiBaseUrl
)
const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`, {
webhookId: webhook.id,
})
if (ctx.strict)
throw new AlreadyLoggedError('Email Bison Instance URL could not be validated.')
return
}
const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, {
method: 'DELETE',
headers: emailBisonHeaders({ apiKey, apiBaseUrl }),
})
if (!response.ok && response.status !== 404) {
const responseBody = await parseJsonResponse(response)
logger.warn(`[${requestId}] Failed to delete Email Bison webhook`, {
status: response.status,
response: responseBody,
})
if (ctx.strict)
throw new AlreadyLoggedError(`Failed to delete Email Bison webhook: ${response.status}`)
return
}
await response.body?.cancel()
logger.info(`[${requestId}] Successfully deleted Email Bison webhook`, {
externalId,
webhookId: webhook.id,
})
} catch (error) {
if (!(error instanceof AlreadyLoggedError)) {
logger.warn(`[${requestId}] Error deleting Email Bison webhook`, {
message: toError(error).message,
})
}
if (ctx.strict) throw error
}
},
}
/**
* Marks an error whose failure reason has already been logged with full context
* at the throw site, so the outer catch in `deleteSubscription` does not emit
* a second, redundant warning for the same failure.
*/
class AlreadyLoggedError extends Error {}
async function parseJsonResponse(
response: SecureFetchResponse
): Promise<Record<string, unknown> | null> {
try {
const body: unknown = await response.json()
return isRecordLike(body) ? body : null
} catch {
return null
}
}
function extractEmailBisonError(body: Record<string, unknown> | null): string | null {
if (!body) return null
if (typeof body.message === 'string') return body.message
if (typeof body.error === 'string') return body.error
const data = body.data
if (isRecordLike(data) && typeof data.message === 'string') return data.message
return null
}
function unwrapEmailBisonPayload(body: Record<string, unknown>): Record<string, unknown> {
if (isRecordLike(body.event)) return body
const data = body.data
if (isRecordLike(data) && isRecordLike(data.event)) return data
const payload = isRecordLike(data) ? data.payload : body.payload
if (isRecordLike(payload) && isRecordLike(payload.event)) return payload
return body
}
function toStringOrNull(value: unknown): string | null {
if (value === undefined || value === null) return null
return String(value)
}
function toNumberOrNull(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value !== 'string' || value.trim() === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function toRecordOrNull(value: unknown): Record<string, unknown> | null {
return isRecordLike(value) ? value : null
}
function renameTypeField(value: unknown, targetKey: string): Record<string, unknown> | null {
if (!isRecordLike(value)) return null
const { type, ...rest } = value
return { ...rest, [targetKey]: type ?? null }
}
+178
View File
@@ -0,0 +1,178 @@
import { createLogger } from '@sim/logger'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Fathom')
export const fathomHandler: WebhookProviderHandler = {
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const triggeredFor = providerConfig.triggeredFor as string | undefined
const includeSummary = providerConfig.includeSummary as unknown
const includeTranscript = providerConfig.includeTranscript as unknown
const includeActionItems = providerConfig.includeActionItems as unknown
const includeCrmMatches = providerConfig.includeCrmMatches as unknown
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Fathom API Key is required. Please provide your API key in the trigger configuration.'
)
}
const notificationUrl = getNotificationUrl(webhook)
const triggeredForValue = triggeredFor || 'my_recordings'
const toBool = (val: unknown, fallback: boolean): boolean => {
if (val === undefined) return fallback
return val === true || val === 'true'
}
const requestBody: Record<string, unknown> = {
destination_url: notificationUrl,
triggered_for: [triggeredForValue],
include_summary: toBool(includeSummary, true),
include_transcript: toBool(includeTranscript, false),
include_action_items: toBool(includeActionItems, false),
include_crm_matches: toBool(includeCrmMatches, false),
}
logger.info(`[${requestId}] Creating Fathom webhook`, {
triggerId,
triggeredFor: triggeredForValue,
webhookId: webhook.id,
})
const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', {
method: 'POST',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await fathomResponse.json().catch(() => ({}))) as Record<
string,
unknown
>
if (!fathomResponse.ok) {
const errorMessage =
(responseBody.message as string) ||
(responseBody.error as string) ||
'Unknown Fathom API error'
logger.error(
`[${requestId}] Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Fathom'
if (fathomResponse.status === 401) {
userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.'
} else if (fathomResponse.status === 400) {
userFriendlyMessage = `Fathom error: ${errorMessage}`
} else if (errorMessage && errorMessage !== 'Unknown Fathom API error') {
userFriendlyMessage = `Fathom error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
if (!responseBody.id) {
logger.error(
`[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhook.id}.`
)
throw new Error('Fathom webhook created but no ID returned. Please try again.')
}
logger.info(
`[${requestId}] Successfully created webhook in Fathom for webhook ${webhook.id}.`,
{
fathomWebhookId: responseBody.id,
}
)
return { providerConfigUpdates: { externalId: responseBody.id } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Fathom webhook creation for webhook ${webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Fathom apiKey for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Fathom externalId for webhook deletion')
return
}
const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100)
if (!idValidation.isValid) {
logger.warn(
`[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Invalid Fathom externalId for webhook deletion')
return
}
const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}`
const fathomResponse = await fetch(fathomApiUrl, {
method: 'DELETE',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
})
if (!fathomResponse.ok && fathomResponse.status !== 404) {
logger.warn(
`[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}`
)
if (ctx.strict) throw new Error(`Failed to delete Fathom webhook: ${fathomResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,75 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Fireflies')
function validateFirefliesSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Fireflies signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
if (!signature.startsWith('sha256=')) {
logger.warn('Fireflies signature has invalid format (expected sha256=)', {
signaturePrefix: signature.substring(0, 10),
})
return false
}
const providedSignature = signature.substring(7)
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Fireflies signature comparison', {
computedSignature: `${computedHash.substring(0, 10)}...`,
providedSignature: `${providedSignature.substring(0, 10)}...`,
computedLength: computedHash.length,
providedLength: providedSignature.length,
match: computedHash === providedSignature,
})
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Fireflies signature:', error)
return false
}
}
export const firefliesHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
// Fireflies V2 webhooks use snake_case field names and "event" instead of "eventType".
// Both meeting_id and event are required in every V2 payload, so AND is the correct check.
const isV2 = typeof b.meeting_id === 'string' && typeof b.event === 'string'
const meetingId = ((isV2 ? b.meeting_id : b.meetingId) || '') as string
const eventType = ((isV2 ? b.event : b.eventType) || 'Transcription completed') as string
const clientReferenceId = ((isV2 ? b.client_reference_id : b.clientReferenceId) || '') as string
const rawTimestamp = b.timestamp != null ? Number(b.timestamp) : null
const timestamp = rawTimestamp !== null && Number.isFinite(rawTimestamp) ? rawTimestamp : null
return {
input: {
meetingId,
eventType,
clientReferenceId,
...(timestamp !== null ? { timestamp } : {}),
},
}
},
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'x-hub-signature',
validateFn: validateFirefliesSignature,
providerLabel: 'Fireflies',
}),
}
+143
View File
@@ -0,0 +1,143 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { getClientIp } from '@/lib/core/utils/request'
import type {
AuthContext,
EventFilterContext,
FormatInputContext,
FormatInputResult,
ProcessFilesContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Generic')
export const genericHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext) {
if (providerConfig.requireAuth) {
const configToken = providerConfig.token as string | undefined
if (!configToken) {
return new NextResponse('Unauthorized - Authentication required but no token configured', {
status: 401,
})
}
const secretHeaderName = providerConfig.secretHeaderName as string | undefined
if (!verifyTokenAuth(request, configToken, secretHeaderName)) {
return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 })
}
}
const allowedIps = providerConfig.allowedIps
if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) {
const clientIp = getClientIp(request)
if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`)
return new NextResponse('Forbidden - IP not allowed', {
status: 403,
})
}
}
return null
},
enrichHeaders({ body, providerConfig }: EventFilterContext, headers: Record<string, string>) {
const idempotencyField = providerConfig.idempotencyField as string | undefined
if (idempotencyField && body) {
const value = idempotencyField
.split('.')
.reduce(
(acc: unknown, key: string) =>
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
body
)
if (value !== undefined && value !== null && typeof value !== 'object') {
headers['x-sim-idempotency-key'] = String(value)
}
}
},
formatSuccessResponse(providerConfig: Record<string, unknown>) {
if (providerConfig.responseMode === 'custom') {
const rawCode = Number(providerConfig.responseStatusCode) || 200
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
if (!responseBody) {
return new NextResponse(null, { status: statusCode })
}
try {
const parsed = JSON.parse(responseBody)
return NextResponse.json(parsed, { status: statusCode })
} catch {
return new NextResponse(responseBody, {
status: statusCode,
headers: { 'Content-Type': 'text/plain' },
})
}
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
return { input: body }
},
async processInputFiles({
input,
blocks,
blockId,
workspaceId,
workflowId,
executionId,
requestId,
userId,
}: ProcessFilesContext) {
const triggerBlock = blocks[blockId] as Record<string, unknown> | undefined
const subBlocks = triggerBlock?.subBlocks as Record<string, unknown> | undefined
const inputFormatBlock = subBlocks?.inputFormat as Record<string, unknown> | undefined
if (inputFormatBlock?.value) {
const inputFormat = inputFormatBlock.value as Array<{
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
}>
const fileFields = inputFormat.filter((field) => field.type === 'file[]')
if (fileFields.length > 0) {
const { processExecutionFiles } = await import('@/lib/execution/files')
const executionContext = {
workspaceId,
workflowId,
executionId,
}
for (const fileField of fileFields) {
const fieldValue = input[fileField.name]
if (fieldValue && typeof fieldValue === 'object') {
const uploadedFiles = await processExecutionFiles(
fieldValue,
executionContext,
requestId,
userId
)
if (uploadedFiles.length > 0) {
input[fileField.name] = uploadedFiles
logger.info(
`[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`
)
}
}
}
}
}
},
}
@@ -0,0 +1,246 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { githubHandler } from '@/lib/webhooks/providers/github'
import { isGitHubEventMatch } from '@/triggers/github/utils'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('GitHub webhook provider', () => {
it('verifyAuth allows unsigned requests when no webhookSecret is configured', () => {
const res = githubHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('verifyAuth rejects when the signature header is missing but a secret is configured', () => {
const res = githubHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects an invalid X-Hub-Signature-256', () => {
const res = githubHandler.verifyAuth!({
request: reqWithHeaders({ 'X-Hub-Signature-256': 'sha256=deadbeef' }),
rawBody: '{}',
requestId: 't3',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth accepts a valid X-Hub-Signature-256', async () => {
const crypto = await import('crypto')
const body = '{"action":"opened"}'
const secret = 'my-secret'
const signature = `sha256=${crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')}`
const res = githubHandler.verifyAuth!({
request: reqWithHeaders({ 'X-Hub-Signature-256': signature }),
rawBody: body,
requestId: 't4',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('isGitHubEventMatch matches workflow_run events to the workflow_run trigger only', () => {
expect(isGitHubEventMatch('github_workflow_run', 'workflow_run')).toBe(true)
expect(isGitHubEventMatch('github_workflow_run', 'push')).toBe(false)
expect(isGitHubEventMatch('github_workflow_run', 'issues')).toBe(false)
})
it('isGitHubEventMatch distinguishes issue comments from PR comments', () => {
expect(
isGitHubEventMatch('github_issue_comment', 'issue_comment', undefined, { issue: {} })
).toBe(true)
expect(
isGitHubEventMatch('github_issue_comment', 'issue_comment', undefined, {
issue: { pull_request: { url: 'x' } },
})
).toBe(false)
expect(
isGitHubEventMatch('github_pr_comment', 'issue_comment', undefined, {
issue: { pull_request: { url: 'x' } },
})
).toBe(true)
})
it('matchEvent passes through all events for the generic webhook trigger', async () => {
const result = await githubHandler.matchEvent!({
body: { action: 'opened' },
requestId: 't5',
providerConfig: { triggerId: 'github_webhook' },
webhook: {},
workflow: {},
request: reqWithHeaders({ 'x-github-event': 'push' }),
})
expect(result).toBe(true)
})
it('matchEvent filters events that do not match the configured trigger', async () => {
const result = await githubHandler.matchEvent!({
body: { action: 'opened' },
requestId: 't6',
providerConfig: { triggerId: 'github_workflow_run' },
webhook: {},
workflow: {},
request: reqWithHeaders({ 'x-github-event': 'push' }),
})
expect(result).toBe(false)
})
it('matchEvent does not throw when the body is null', async () => {
await expect(
githubHandler.matchEvent!({
body: null,
requestId: 't7',
providerConfig: { triggerId: 'github_pr_comment' },
webhook: {},
workflow: {},
request: reqWithHeaders({ 'x-github-event': 'issue_comment' }),
})
).resolves.toBe(false)
})
it('formatInput does not throw when the body is null', async () => {
const { input } = await githubHandler.formatInput!({
body: null,
headers: { 'x-github-event': 'push' },
requestId: 't8',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.event_type).toBe('push')
expect(i.action).toBe('')
})
it('formatInput exposes user.type as user_type, keeping the raw type key too', async () => {
const { input } = await githubHandler.formatInput!({
body: {
action: 'opened',
issue: { id: 1, user: { login: 'octocat', type: 'User' } },
},
headers: { 'x-github-event': 'issues' },
requestId: 't9',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
const issue = i.issue as Record<string, unknown>
const user = issue.user as Record<string, unknown>
expect(user.user_type).toBe('User')
expect(user.type).toBe('User')
})
it('formatInput exposes repository.owner.type as owner_type', async () => {
const { input } = await githubHandler.formatInput!({
body: {
action: 'opened',
repository: { full_name: 'octocat/hello', owner: { login: 'octocat', type: 'User' } },
},
headers: { 'x-github-event': 'issues' },
requestId: 't10',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
const repository = i.repository as Record<string, unknown>
const owner = repository.owner as Record<string, unknown>
expect(owner.owner_type).toBe('User')
expect(owner.user_type).toBe('User')
})
it('formatInput exposes repository.description as repo_description, keeping the raw description key too', async () => {
const { input } = await githubHandler.formatInput!({
body: {
action: 'opened',
repository: { full_name: 'octocat/hello', description: 'A test repo' },
},
headers: { 'x-github-event': 'issues' },
requestId: 't11',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
const repository = i.repository as Record<string, unknown>
expect(repository.repo_description).toBe('A test repo')
expect(repository.description).toBe('A test repo')
})
it('formatInput does not alias a nested `type` field on objects that are not user-like', async () => {
const { input } = await githubHandler.formatInput!({
body: {
action: 'labeled',
issue: {
id: 1,
label: { name: 'bug', color: 'ff0000', type: 'default' },
},
},
headers: { 'x-github-event': 'issues' },
requestId: 't13',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
const issue = i.issue as Record<string, unknown>
const label = issue.label as Record<string, unknown>
expect(label.type).toBe('default')
expect(label.user_type).toBeUndefined()
expect(label.owner_type).toBeUndefined()
})
it('formatInput derives branch from ref', async () => {
const { input } = await githubHandler.formatInput!({
body: { ref: 'refs/heads/main', action: '' },
headers: { 'x-github-event': 'push' },
requestId: 't12',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.branch).toBe('main')
})
it('extractIdempotencyId derives a stable key from the most specific nested entity', () => {
const body = { action: 'created', comment: { id: 5, updated_at: '2026-01-01T00:00:00Z' } }
const first = githubHandler.extractIdempotencyId!(body)
const second = githubHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
expect(first).toContain('5')
})
it('extractIdempotencyId falls back to ref+after for push events', () => {
const id = githubHandler.extractIdempotencyId!({
ref: 'refs/heads/main',
before: 'a',
after: 'b',
})
expect(id).toBe('github:push:refs/heads/main:b')
})
it('extractIdempotencyId returns null when there is no stable identifier', () => {
expect(githubHandler.extractIdempotencyId!({})).toBeNull()
expect(githubHandler.extractIdempotencyId!(null)).toBeNull()
})
})
+208
View File
@@ -0,0 +1,208 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:GitHub')
/**
* GitHub's "simple user" shape (issue.user, pull_request.merged_by,
* repository.owner, sender, ...) always carries `login` alongside `type`.
*/
function isGitHubUserLike(value: unknown): value is Record<string, unknown> & { type: string } {
return isRecordLike(value) && typeof value.login === 'string' && typeof value.type === 'string'
}
/**
* GitHub embeds a `type` field (User/Bot/Organization) on every user-like
* object. `type` is a reserved TriggerOutput meta-key, so the trigger output
* schemas expose it under `user_type` (or `owner_type` for repository.owner)
* instead. This walks the payload adding both aliases next to the raw `type`
* key, so the delivered data matches whichever name a given trigger's output
* schema declares. The raw `type` key is kept alongside the aliases (this is
* plain passthrough data, not schema-constrained) so a workflow already
* referencing the undocumented raw path keeps working.
*/
function withGitHubUserTypeAliases(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(withGitHubUserTypeAliases)
}
if (!isRecordLike(value)) {
return value
}
const result: Record<string, unknown> = {}
for (const [key, nested] of Object.entries(value)) {
result[key] = withGitHubUserTypeAliases(nested)
}
if (isGitHubUserLike(value)) {
result.user_type = value.type
result.owner_type = value.type
}
return result
}
/**
* Not built on the shared `createHmacVerifier` factory: GitHub supports two
* signature headers (`X-Hub-Signature-256` primary, legacy `X-Hub-Signature`
* sha1 fallback) and picks the algorithm from the header value itself, which
* the single-header/single-algorithm factory doesn't model.
*/
function validateGitHubSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('GitHub signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
let algorithm: 'sha256' | 'sha1'
let providedSignature: string
if (signature.startsWith('sha256=')) {
algorithm = 'sha256'
providedSignature = signature.substring(7)
} else if (signature.startsWith('sha1=')) {
algorithm = 'sha1'
providedSignature = signature.substring(5)
} else {
logger.warn('GitHub signature has invalid format', {
signature: `${signature.substring(0, 10)}...`,
})
return false
}
const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex')
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating GitHub signature:', error)
return false
}
}
export const githubHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
return null
}
const signature =
request.headers.get('X-Hub-Signature-256') || request.headers.get('X-Hub-Signature')
if (!signature) {
logger.warn(`[${requestId}] GitHub webhook missing signature header`)
return new NextResponse('Unauthorized - Missing GitHub signature', { status: 401 })
}
if (!validateGitHubSignature(secret, signature, rawBody)) {
logger.warn(`[${requestId}] GitHub signature verification failed`, {
usingSha256: !!request.headers.get('X-Hub-Signature-256'),
})
return new NextResponse('Unauthorized - Invalid GitHub signature', { status: 401 })
}
return null
},
async formatInput({ body, headers }: FormatInputContext): Promise<FormatInputResult> {
const eventType = headers['x-github-event'] || 'unknown'
if (!isRecordLike(body)) {
return { input: { event_type: eventType, action: '', branch: '' } }
}
const ref = typeof body.ref === 'string' ? body.ref : ''
const branch = ref.replace('refs/heads/', '')
const aliased = withGitHubUserTypeAliases(body) as Record<string, unknown>
const repository = aliased.repository
if (isRecordLike(repository) && typeof repository.description === 'string') {
aliased.repository = { ...repository, repo_description: repository.description }
}
return {
input: {
...aliased,
event_type: eventType,
action: typeof body.action === 'string' ? body.action : '',
branch,
},
}
},
async matchEvent({
webhook,
workflow,
body,
request,
requestId,
providerConfig,
}: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = isRecordLike(body) ? body : {}
if (triggerId && triggerId !== 'github_webhook') {
const eventType = request.headers.get('x-github-event')
const action = typeof obj.action === 'string' ? obj.action : undefined
const { isGitHubEventMatch } = await import('@/triggers/github/utils')
if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) {
logger.debug(
`[${requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedEvent: eventType,
receivedAction: action,
}
)
return false
}
}
return true
},
/**
* GitHub always sends `X-GitHub-Delivery`, which is already checked ahead
* of this method by the shared idempotency header allowlist. This is a
* content-derived fallback for the rare case that header is stripped in
* transit (e.g. by an intermediary proxy). Prefers the most specific
* nested entity so distinct sub-resources (a comment vs. its parent issue)
* on the same delivery don't collide, and includes `updated_at` where
* available so re-deliveries of the same entity version dedupe while a
* later edit of that same entity is treated as a new key.
*/
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
const action = typeof body.action === 'string' ? body.action : ''
const entity =
(isRecordLike(body.comment) && body.comment) ||
(isRecordLike(body.review) && body.review) ||
(isRecordLike(body.pull_request) && body.pull_request) ||
(isRecordLike(body.issue) && body.issue) ||
(isRecordLike(body.release) && body.release) ||
(isRecordLike(body.workflow_run) && body.workflow_run) ||
null
if (entity && entity.id != null) {
const version = typeof entity.updated_at === 'string' ? `:${entity.updated_at}` : ''
return `github:${action}:${entity.id}${version}`
}
if (typeof body.ref === 'string' && typeof body.after === 'string') {
return `github:push:${body.ref}:${body.after}`
}
return null
},
}
@@ -0,0 +1,229 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { gitlabHandler } from '@/lib/webhooks/providers/gitlab'
import { isGitLabEventMatch } from '@/triggers/gitlab/utils'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('GitLab webhook provider', () => {
it('verifyAuth rejects when webhookSecret is missing', async () => {
const res = await gitlabHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects when X-Gitlab-Token header is missing', async () => {
const res = await gitlabHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects when the token does not match', async () => {
const res = await gitlabHandler.verifyAuth!({
request: reqWithHeaders({ 'X-Gitlab-Token': 'wrong' }),
rawBody: '{}',
requestId: 't3',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth accepts a matching X-Gitlab-Token', async () => {
const res = await gitlabHandler.verifyAuth!({
request: reqWithHeaders({ 'X-Gitlab-Token': 'my-secret' }),
rawBody: '{}',
requestId: 't4',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('isGitLabEventMatch matches the configured trigger to its object_kind', () => {
expect(isGitLabEventMatch('gitlab_push', 'push')).toBe(true)
expect(isGitLabEventMatch('gitlab_push', 'issue')).toBe(false)
expect(isGitLabEventMatch('gitlab_comment', 'note')).toBe(true)
expect(isGitLabEventMatch('gitlab_webhook', 'anything')).toBe(true)
})
it('matchEvent passes through all events for the all-events trigger', async () => {
const result = await gitlabHandler.matchEvent!({
body: { object_kind: 'issue' },
requestId: 't5',
providerConfig: { triggerId: 'gitlab_webhook' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('matchEvent filters events that do not match the configured trigger', async () => {
const result = await gitlabHandler.matchEvent!({
body: { object_kind: 'issue' },
requestId: 't6',
providerConfig: { triggerId: 'gitlab_push' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
it('formatInput derives event_type and branch from the push payload', async () => {
const { input } = await gitlabHandler.formatInput!({
body: { object_kind: 'push', ref: 'refs/heads/main', checkout_sha: 'abc123' },
headers: { 'x-gitlab-event': 'Push Hook' },
requestId: 't7',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.event_type).toBe('Push Hook')
expect(i.branch).toBe('main')
expect(i.checkout_sha).toBe('abc123')
})
it('formatInput exposes object_attributes.type as work_item_type on issue payloads, keeping the raw type key too', async () => {
const { input } = await gitlabHandler.formatInput!({
body: {
object_kind: 'issue',
object_attributes: { id: 1, iid: 2, title: 'Bug', type: 'Issue' },
},
headers: { 'x-gitlab-event': 'Issue Hook' },
requestId: 't8',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
const attrs = i.object_attributes as Record<string, unknown>
expect(attrs.work_item_type).toBe('Issue')
expect(attrs.type).toBe('Issue')
expect(attrs.title).toBe('Bug')
})
it('extractIdempotencyId derives a stable key for push events from checkout_sha', () => {
const body = {
object_kind: 'push',
project: { id: 42 },
ref: 'refs/heads/main',
checkout_sha: 'abc123',
}
const first = gitlabHandler.extractIdempotencyId!(body)
const second = gitlabHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
expect(first).toContain('abc123')
expect(first).toContain('42')
})
it('extractIdempotencyId does not collide across different branches deleted in the same project', () => {
const deleteMain = {
object_kind: 'push',
project: { id: 42 },
ref: 'refs/heads/main',
checkout_sha: null,
after: '0000000000000000000000000000000000000000',
}
const deleteFeature = {
object_kind: 'push',
project: { id: 42 },
ref: 'refs/heads/feature',
checkout_sha: null,
after: '0000000000000000000000000000000000000000',
}
const first = gitlabHandler.extractIdempotencyId!(deleteMain)
const second = gitlabHandler.extractIdempotencyId!(deleteFeature)
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(first).not.toBe(second)
})
it('extractIdempotencyId is stable for a repeated delivery of the same branch deletion', () => {
const body = {
object_kind: 'push',
project: { id: 42 },
ref: 'refs/heads/main',
checkout_sha: null,
after: '0000000000000000000000000000000000000000',
}
const first = gitlabHandler.extractIdempotencyId!(body)
const second = gitlabHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
})
it('extractIdempotencyId derives a stable key for issue events from object_attributes', () => {
const body = {
object_kind: 'issue',
project: { id: 7 },
object_attributes: { id: 99, updated_at: '2026-01-01T00:00:00.000Z' },
}
const first = gitlabHandler.extractIdempotencyId!(body)
const second = gitlabHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
expect(first).toContain('99')
expect(first).toContain('7')
})
it('extractIdempotencyId distinguishes pipeline lifecycle transitions despite no updated_at', () => {
const pending = gitlabHandler.extractIdempotencyId!({
object_kind: 'pipeline',
project: { id: 7 },
object_attributes: { id: 31, status: 'pending', created_at: '2026-01-01T00:00:00Z' },
})
const running = gitlabHandler.extractIdempotencyId!({
object_kind: 'pipeline',
project: { id: 7 },
object_attributes: { id: 31, status: 'running', created_at: '2026-01-01T00:00:00Z' },
})
const success = gitlabHandler.extractIdempotencyId!({
object_kind: 'pipeline',
project: { id: 7 },
object_attributes: {
id: 31,
status: 'success',
created_at: '2026-01-01T00:00:00Z',
finished_at: '2026-01-01T00:03:00Z',
},
})
expect(pending).not.toBeNull()
expect(pending).not.toBe(running)
expect(running).not.toBe(success)
const retryOfSuccess = gitlabHandler.extractIdempotencyId!({
object_kind: 'pipeline',
project: { id: 7 },
object_attributes: {
id: 31,
status: 'success',
created_at: '2026-01-01T00:00:00Z',
finished_at: '2026-01-01T00:03:00Z',
},
})
expect(success).toBe(retryOfSuccess)
})
it('extractIdempotencyId returns null when there is no stable identifier', () => {
expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'push' })).toBeNull()
expect(gitlabHandler.extractIdempotencyId!({ object_kind: 'issue' })).toBeNull()
})
})
+294
View File
@@ -0,0 +1,294 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { generateId } from '@sim/utils/id'
import { NextResponse } from 'next/server'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { getGitLabApiBase, UnsafeGitLabHostError } from '@/tools/gitlab/utils'
const logger = createLogger('WebhookProvider:GitLab')
function asRecord(value: unknown): Record<string, unknown> {
return (value as Record<string, unknown>) || {}
}
function gitlabProjectHooksUrl(projectId: string, host: unknown): string {
return `${getGitLabApiBase(host)}/projects/${encodeURIComponent(projectId)}/hooks`
}
/**
* Best-effort cleanup that deletes any project hook pointing at `url`. Used to
* avoid orphaning a hook when the create response can't be parsed for its id.
*/
async function cleanupGitLabHookByUrl(
projectId: string,
accessToken: string,
url: string,
host: unknown
): Promise<void> {
const res = await secureFetchWithValidation(gitlabProjectHooksUrl(projectId, host), {
headers: { 'PRIVATE-TOKEN': accessToken },
}).catch(() => null)
if (!res || !res.ok) return
const hooks = (await res.json().catch(() => null)) as Array<{ id?: number; url?: string }> | null
if (!Array.isArray(hooks)) return
await Promise.all(
hooks
.filter((hook) => hook.url === url && hook.id != null)
.map((hook) =>
secureFetchWithValidation(`${gitlabProjectHooksUrl(projectId, host)}/${hook.id}`, {
method: 'DELETE',
headers: { 'PRIVATE-TOKEN': accessToken },
}).catch(() => null)
)
)
}
export const gitlabHandler: WebhookProviderHandler = {
/**
* GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token`
* header (plain equality, not an HMAC). The secret is generated during
* auto-registration, so a missing secret means misconfiguration — fail closed.
*/
verifyAuth({ request, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
logger.warn(`[${requestId}] GitLab webhook secret not configured`)
return new NextResponse('Unauthorized - Missing GitLab webhook secret', { status: 401 })
}
const token = request.headers.get('X-Gitlab-Token')
if (!token) {
logger.warn(`[${requestId}] GitLab webhook missing X-Gitlab-Token header`)
return new NextResponse('Unauthorized - Missing GitLab token', { status: 401 })
}
if (!safeCompare(token, secret)) {
logger.warn(`[${requestId}] GitLab token verification failed`)
return new NextResponse('Unauthorized - Invalid GitLab token', { status: 401 })
}
return null
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'gitlab_webhook') return true
const objectKind = asRecord(body).object_kind as string | undefined
const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils')
if (!isGitLabEventMatch(triggerId, objectKind || '')) {
logger.debug(
`[${requestId}] GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping`
)
return false
}
return true
},
/**
* GitLab 17.2+ adds a `type` field inside `object_attributes` on Issue Hook
* payloads (e.g. "Issue", "Incident", "Task"). `type` is a reserved
* TriggerOutput meta-key, so it can't be declared in the output schema
* under that name — exposed there as `work_item_type` instead. The raw
* `type` key is kept in the delivered data alongside it (this is plain
* passthrough data, not schema-constrained) so a workflow already
* referencing the undocumented raw path keeps working.
*/
async formatInput({ body, headers }: FormatInputContext): Promise<FormatInputResult> {
const b = asRecord(body)
const eventType = headers['x-gitlab-event'] || ''
const ref = (b.ref as string) || ''
const branch = ref.replace('refs/heads/', '')
const objectAttributes = b.object_attributes
let input: Record<string, unknown> = { ...b, event_type: eventType, branch }
if (
objectAttributes &&
typeof objectAttributes === 'object' &&
!Array.isArray(objectAttributes)
) {
const workItemType = (objectAttributes as Record<string, unknown>).type
if (workItemType !== undefined) {
input = {
...input,
object_attributes: { ...objectAttributes, work_item_type: workItemType },
}
}
}
return { input }
},
/**
* GitLab does not automatically retry a failed delivery — a failed request
* only counts toward auto-disabling the webhook (4 consecutive failures
* disables it temporarily, 40 permanently), and re-delivery only happens
* via a manual "Resend Request" (UI or API), which carries the same
* `webhook-id`/`Idempotency-Key`/`X-Gitlab-Event-UUID` headers as the
* original. Those headers are already in the shared idempotency service's
* allowlist and checked ahead of this method, which only receives the body.
* This is a content-derived fallback for the rare case those headers are
* stripped in transit (e.g. by an intermediary proxy). checkout_sha is
* null on branch/tag deletion (after falls back to the all-zeros SHA), so
* ref is included to keep unrelated deletions in one project from
* colliding onto the same key. Pipeline Hook payloads have no updated_at
* at all (confirmed against docs.gitlab.com — only Issue/Merge Request/
* Note hooks reliably include it), so status + finished_at/created_at is
* used there instead to keep each lifecycle transition of one pipeline
* (pending/running/success/failed) from colliding onto the same key.
*/
extractIdempotencyId(body: unknown): string | null {
const b = asRecord(body)
const objectKind = (b.object_kind as string) || ''
const project = asRecord(b.project)
const projectId = project.id != null ? String(project.id) : ''
if (objectKind === 'push' || objectKind === 'tag_push') {
const ref = (b.ref as string) || ''
const checkoutSha = (b.checkout_sha as string) || (b.after as string) || ''
if (!checkoutSha && !ref) return null
return `gitlab:${objectKind}:${projectId}:${ref}:${checkoutSha}`
}
const objectAttributes = asRecord(b.object_attributes)
const id = objectAttributes.id != null ? String(objectAttributes.id) : ''
if (!id) return null
const version =
(objectAttributes.updated_at as string) ||
[objectAttributes.status, objectAttributes.finished_at || objectAttributes.created_at]
.filter(Boolean)
.join(':') ||
''
return `gitlab:${objectKind || 'event'}:${projectId}:${id}:${version}`
},
/**
* Validates the optional self-managed host up front so a structurally
* unsafe value surfaces as a clear error instead of an unhandled
* UnsafeGitLabHostError.
*/
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const accessToken = config.accessToken as string | undefined
const projectId = config.projectId as string | undefined
const triggerId = config.triggerId as string | undefined
const host = config.host as string | undefined
if (!accessToken)
throw new Error('GitLab Personal Access Token is required to create the webhook.')
if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.')
try {
getGitLabApiBase(host)
} catch (error) {
if (error instanceof UnsafeGitLabHostError) {
throw new Error(
'GitLab host is invalid. Provide a domain like gitlab.example.com (no protocol, path, or credentials).'
)
}
throw error
}
const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils')
const secretToken = generateId()
const res = await secureFetchWithValidation(gitlabProjectHooksUrl(projectId, host), {
method: 'POST',
headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
url: getNotificationUrl(ctx.webhook),
token: secretToken,
enable_ssl_verification: true,
...getGitLabEventFlags(triggerId ?? 'gitlab_webhook'),
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail })
if (res.status === 401)
throw new Error(
'GitLab authentication failed. Verify your Personal Access Token has the api scope.'
)
if (res.status === 403)
throw new Error(
'GitLab access denied. You need the Maintainer or Owner role on the project.'
)
if (res.status === 404) throw new Error('GitLab project not found. Verify the Project ID.')
throw new Error(`Failed to create GitLab webhook: ${res.status}`)
}
const created = (await res.json().catch(() => ({}))) as { id?: number | string }
if (created.id === undefined || created.id === null) {
await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook), host)
throw new Error('GitLab webhook created but no hook ID was returned.')
}
logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`)
return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } }
},
/**
* A structurally unsafe host must not abort cleanup in non-strict mode —
* mirrors the graceful skip used for missing credentials below.
*/
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const accessToken = config.accessToken as string | undefined
const projectId = config.projectId as string | undefined
const externalId = config.externalId as string | undefined
const host = config.host as string | undefined
if (!accessToken || !projectId || !externalId) {
if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.')
logger.warn(
`[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID`
)
return
}
try {
getGitLabApiBase(host)
} catch (error) {
if (error instanceof UnsafeGitLabHostError) {
if (ctx.strict) {
throw new Error('Cannot delete GitLab webhook: the configured host is invalid.')
}
logger.warn(
`[${ctx.requestId}] Skipping GitLab webhook cleanup — configured host is invalid`
)
return
}
throw error
}
const res = await secureFetchWithValidation(
`${gitlabProjectHooksUrl(projectId, host)}/${externalId}`,
{
method: 'DELETE',
headers: { 'PRIVATE-TOKEN': accessToken },
}
)
if (!res.ok && res.status !== 404) {
if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`)
logger.warn(
`[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}`
)
return
}
logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`)
},
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { gmailHandler } from '@/lib/webhooks/providers/gmail'
import { gmailPollingTrigger } from '@/triggers/gmail'
const sampleEmail = {
id: 'msg-123',
threadId: 'thread-456',
subject: 'Test subject',
from: 'sender@example.com',
to: 'recipient@example.com',
cc: '',
date: '2026-07-08T00:00:00.000Z',
bodyText: 'plain text body',
bodyHtml: '<p>html body</p>',
labels: ['INBOX', 'UNREAD'],
hasAttachments: false,
attachments: [],
}
describe('Gmail webhook provider', () => {
it('formatInput passes through the polled email and timestamp unchanged', async () => {
const { input } = await gmailHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' },
headers: {},
requestId: 'test',
})
expect(input).toEqual({
email: sampleEmail,
timestamp: '2026-07-08T00:00:05.000Z',
})
})
it('passes the raw body through when it has no email key', async () => {
const { input } = await gmailHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: { foo: 'bar' },
headers: {},
requestId: 'test',
})
expect(input).toEqual({ foo: 'bar' })
})
it('every key formatInput can deliver on `email` matches a declared trigger output key', async () => {
const { input } = await gmailHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: { email: sampleEmail, timestamp: '2026-07-08T00:00:05.000Z' },
headers: {},
requestId: 'test',
})
const declaredEmailKeys = Object.keys(gmailPollingTrigger.outputs.email)
const deliveredEmailKeys = Object.keys((input as { email: object }).email)
expect(deliveredEmailKeys.sort()).toEqual(declaredEmailKeys.sort())
})
})
+117
View File
@@ -0,0 +1,117 @@
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type {
FormatInputContext,
FormatInputResult,
PollingConfigContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Gmail')
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 }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const credentialId = providerConfig.credentialId as string | undefined
if (!credentialId) {
logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`)
return false
}
const resolvedGmail = await resolveOAuthAccountId(credentialId)
if (!resolvedGmail) {
logger.error(
`[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}`
)
return false
}
const rows = await db
.select()
.from(account)
.where(eq(account.id, resolvedGmail.accountId))
.limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
const effectiveUserId = rows[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
resolvedGmail.accountId,
effectiveUserId,
requestId
)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
return false
}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: (providerConfig.maxEmailsPerPoll as number) || 25
const pollingInterval =
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: (providerConfig.pollingInterval as number) || 5
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
labelIds: providerConfig.labelIds || ['INBOX'],
labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE',
lastCheckedTimestamp:
(providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
logger.info(
`[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}`
)
return true
} catch (error: unknown) {
const err = error as Error
logger.error(`[${requestId}] Failed to configure Gmail polling`, {
webhookId: webhookData.id,
error: err.message,
stack: err.stack,
})
return false
}
},
}
@@ -0,0 +1,167 @@
import { createHash } from 'node:crypto'
import * as jose from 'jose'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import {
GONG_JWT_PUBLIC_KEY_CONFIG_KEY,
gongHandler,
normalizeGongPublicKeyPem,
verifyGongJwtAuth,
} from '@/lib/webhooks/providers/gong'
describe('normalizeGongPublicKeyPem', () => {
it('passes through PEM', () => {
const pem = '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----'
expect(normalizeGongPublicKeyPem(pem)).toBe(pem)
})
it('wraps raw base64', () => {
const raw = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3'
const out = normalizeGongPublicKeyPem(raw)
expect(out).toContain('BEGIN PUBLIC KEY')
expect(out).toContain('END PUBLIC KEY')
expect(out).toContain('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3')
})
it('returns null for garbage', () => {
expect(normalizeGongPublicKeyPem('not-base64!!!')).toBeNull()
})
})
describe('gongHandler formatInput', () => {
it('always returns callId as a string', async () => {
const { input } = await gongHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: { callData: { metaData: {} } },
headers: {},
requestId: 'gong-format',
})
expect((input as Record<string, unknown>).callId).toBe('')
})
it('exposes content topics and highlights alongside trackers', async () => {
const { input } = await gongHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: {
callData: {
metaData: { id: '99' },
content: {
trackers: [{ id: 't1', name: 'Competitor', count: 2 }],
topics: [{ name: 'Pricing', duration: 120 }],
highlights: [{ title: 'Action items' }],
},
},
},
headers: {},
requestId: 'gong-format-content',
})
const rec = input as Record<string, unknown>
expect(rec.callId).toBe('99')
expect(rec.trackers).toEqual([{ id: 't1', name: 'Competitor', count: 2 }])
expect(rec.topics).toEqual([{ name: 'Pricing', duration: 120 }])
expect(rec.highlights).toEqual([{ title: 'Action items' }])
})
})
describe('gongHandler verifyAuth (JWT)', () => {
it('returns null when JWT public key is not configured', async () => {
const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
method: 'POST',
body: '{}',
})
const rawBody = '{}'
const res = await verifyGongJwtAuth({
webhook: {},
workflow: {},
request,
rawBody,
requestId: 't1',
providerConfig: {},
})
expect(res).toBeNull()
})
it('returns 401 when key is configured but Authorization is missing', async () => {
const { publicKey } = await jose.generateKeyPair('RS256')
const spki = await jose.exportSPKI(publicKey)
const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
method: 'POST',
body: '{}',
})
const res = await verifyGongJwtAuth({
webhook: {},
workflow: {},
request,
rawBody: '{}',
requestId: 't2',
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
})
expect(res?.status).toBe(401)
})
it('accepts a valid Gong-style JWT', async () => {
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const spki = await jose.exportSPKI(publicKey)
const url = 'https://app.example.com/api/webhooks/trigger/test-path'
const rawBody = '{"callData":{}}'
const bodySha = createHash('sha256').update(rawBody, 'utf8').digest('hex')
const jwt = await new jose.SignJWT({
webhook_url: url,
body_sha256: bodySha,
})
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('1h')
.sign(privateKey)
const request = new NextRequest(url, {
method: 'POST',
body: rawBody,
headers: { Authorization: `Bearer ${jwt}` },
})
const res = await gongHandler.verifyAuth!({
webhook: {},
workflow: {},
request,
rawBody,
requestId: 't3',
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
})
expect(res).toBeNull()
})
it('rejects JWT when body hash does not match', async () => {
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const spki = await jose.exportSPKI(publicKey)
const url = 'https://app.example.com/api/webhooks/trigger/x'
const rawBody = '{"a":1}'
const jwt = await new jose.SignJWT({
webhook_url: url,
body_sha256: 'deadbeef',
})
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('1h')
.sign(privateKey)
const request = new NextRequest(url, {
method: 'POST',
body: rawBody,
headers: { Authorization: jwt },
})
const res = await verifyGongJwtAuth({
webhook: {},
workflow: {},
request,
rawBody,
requestId: 't4',
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
})
expect(res?.status).toBe(401)
})
})
+164
View File
@@ -0,0 +1,164 @@
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { toError } from '@sim/utils/errors'
import * as jose from 'jose'
import { NextResponse } from 'next/server'
import type {
AuthContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Gong')
/** providerConfig key: PEM or raw base64 RSA public key from Gong (Signed JWT header auth). */
export const GONG_JWT_PUBLIC_KEY_CONFIG_KEY = 'gongJwtPublicKeyPem'
/**
* Gong automation webhooks support either URL secrecy (token in path) or a signed JWT in
* `Authorization` (see https://help.gong.io/docs/create-a-webhook-rule).
* When {@link GONG_JWT_PUBLIC_KEY_CONFIG_KEY} is set, we verify RS256 per Gong's JWT guide.
* When unset, only the unguessable Sim webhook path authenticates the request (same as before).
*/
export function normalizeGongPublicKeyPem(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
if (trimmed.includes('BEGIN PUBLIC KEY')) {
return trimmed
}
const b64 = trimmed.replace(/\s/g, '')
if (!/^[A-Za-z0-9+/]+=*$/.test(b64)) {
return null
}
const chunked = b64.match(/.{1,64}/g)?.join('\n') ?? b64
return `-----BEGIN PUBLIC KEY-----\n${chunked}\n-----END PUBLIC KEY-----`
}
function normalizeUrlForGongJwtClaim(url: string): string {
try {
const u = new URL(url)
let path = u.pathname
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1)
}
return `${u.protocol}//${u.host.toLowerCase()}${path}`
} catch {
return url.trim()
}
}
function parseAuthorizationJwt(authHeader: string | null): string | null {
if (!authHeader) return null
const trimmed = authHeader.trim()
if (trimmed.toLowerCase().startsWith('bearer ')) {
return trimmed.slice(7).trim() || null
}
return trimmed || null
}
export async function verifyGongJwtAuth(ctx: AuthContext): Promise<NextResponse | null> {
const { request, rawBody, requestId, providerConfig } = ctx
const rawKey = providerConfig[GONG_JWT_PUBLIC_KEY_CONFIG_KEY]
if (typeof rawKey !== 'string') {
return null
}
const pem = normalizeGongPublicKeyPem(rawKey)
if (!pem) {
logger.warn(`[${requestId}] Gong JWT public key configured but could not be normalized`)
return new NextResponse('Unauthorized - Invalid Gong JWT public key configuration', {
status: 401,
})
}
const token = parseAuthorizationJwt(request.headers.get('authorization'))
if (!token) {
logger.warn(`[${requestId}] Gong JWT verification enabled but Authorization header missing`)
return new NextResponse('Unauthorized - Missing Gong JWT', { status: 401 })
}
let payload: jose.JWTPayload
try {
const key = await jose.importSPKI(pem, 'RS256')
const verified = await jose.jwtVerify(token, key, { algorithms: ['RS256'] })
payload = verified.payload
} catch (error) {
logger.warn(`[${requestId}] Gong JWT verification failed`, {
message: toError(error).message,
})
return new NextResponse('Unauthorized - Invalid Gong JWT', { status: 401 })
}
const claimUrl = payload.webhook_url
if (typeof claimUrl !== 'string' || !claimUrl) {
logger.warn(`[${requestId}] Gong JWT missing webhook_url claim`)
return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
}
const claimDigest = payload.body_sha256
if (typeof claimDigest !== 'string' || !claimDigest) {
logger.warn(`[${requestId}] Gong JWT missing body_sha256 claim`)
return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
}
const expectedDigest = sha256Hex(rawBody)
if (claimDigest !== expectedDigest) {
logger.warn(`[${requestId}] Gong JWT body_sha256 mismatch`)
return new NextResponse('Unauthorized - Gong JWT body mismatch', { status: 401 })
}
const receivedNorm = normalizeUrlForGongJwtClaim(request.url)
const claimNorm = normalizeUrlForGongJwtClaim(claimUrl)
if (receivedNorm !== claimNorm) {
logger.warn(`[${requestId}] Gong JWT webhook_url mismatch`, {
receivedNorm,
claimNorm,
})
return new NextResponse('Unauthorized - Gong JWT URL mismatch', { status: 401 })
}
return null
}
export const gongHandler: WebhookProviderHandler = {
verifyAuth: verifyGongJwtAuth,
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown>
const callData = obj.callData as Record<string, unknown> | undefined
const metaData = callData?.metaData as Record<string, unknown> | undefined
const id = metaData?.id
if (typeof id === 'string' && id) {
return `gong:${id}`
}
if (typeof id === 'number') {
return `gong:${id}`
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const callData = b.callData as Record<string, unknown> | undefined
const metaData = (callData?.metaData as Record<string, unknown>) || {}
const content = callData?.content as Record<string, unknown> | undefined
const callId =
typeof metaData.id === 'string' || typeof metaData.id === 'number' ? String(metaData.id) : ''
return {
input: {
isTest: b.isTest ?? false,
callData,
metaData,
parties: (callData?.parties as unknown[]) || [],
context: (callData?.context as unknown[]) || [],
trackers: (content?.trackers as unknown[]) || [],
topics: (content?.topics as unknown[]) || [],
highlights: (content?.highlights as unknown[]) || [],
eventType: 'gong.automation_rule',
callId,
},
}
},
}
@@ -0,0 +1,81 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
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
// triggerFormId is the current subBlock id; formId is the pre-#3141 id still
// present in provider_config for webhooks deployed before that rename.
const formId = (b?.formId ||
providerConfig.triggerFormId ||
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) {
logger.warn(`[${requestId}] Google Forms webhook secret not configured`)
return new NextResponse('Unauthorized - Missing Google Forms webhook secret', {
status: 401,
})
}
const secretHeaderName = providerConfig.secretHeaderName as string | undefined
if (!verifyTokenAuth(request, expectedToken, secretHeaderName)) {
logger.warn(`[${requestId}] Google Forms webhook authentication failed`)
return new NextResponse('Unauthorized - Invalid secret', { status: 401 })
}
return null
},
extractIdempotencyId(body: unknown): string | null {
const b = body as Record<string, unknown>
// Mirrors formatInput's responseId resolution. formId is deliberately not part
// of this key: the final key is already scoped by webhookId (one webhook per
// deployed form), and extractIdempotencyId has no access to providerConfig, so
// a body-only formId fallback would risk a bogus 'unknown' segment.
const responseId = (b?.responseId || b?.id) as string | undefined
if (typeof responseId !== 'string' || !responseId) {
return null
}
return `google_forms:${responseId}`
},
}
+255
View File
@@ -0,0 +1,255 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventFilterContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { skipByEventTypes } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Grain')
export const grainHandler: WebhookProviderHandler = {
handleReachabilityTest(body: unknown, requestId: string) {
const obj = body as Record<string, unknown> | null
const isVerificationRequest = !obj || Object.keys(obj).length === 0 || !obj.type
if (isVerificationRequest) {
logger.info(
`[${requestId}] Grain reachability test detected - returning 200 for webhook verification`
)
return NextResponse.json({
status: 'ok',
message: 'Webhook endpoint verified',
})
}
return null
},
shouldSkipEvent(ctx: EventFilterContext) {
return skipByEventTypes(ctx, 'Grain', logger)
},
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
if (obj.type && data?.id) {
return `${obj.type}:${data.id}`
}
return null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const viewId = providerConfig.viewId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}
if (!viewId) {
logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, {
webhookId: webhook.id,
triggerId,
})
throw new Error(
'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.'
)
}
const actionMap: Record<string, Array<'added' | 'updated' | 'removed'>> = {
grain_item_added: ['added'],
grain_item_updated: ['updated'],
grain_recording_created: ['added'],
grain_recording_updated: ['updated'],
grain_highlight_created: ['added'],
grain_highlight_updated: ['updated'],
grain_story_created: ['added'],
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_item_added: [],
grain_item_updated: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_added'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_added'],
}
const actions = actionMap[triggerId ?? ''] ?? []
const eventTypes = eventTypeMap[triggerId ?? ''] ?? []
if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`,
{
webhookId: webhook.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
viewId,
actions,
eventTypes,
webhookId: webhook.id,
})
const notificationUrl = getNotificationUrl(webhook)
const grainApiUrl = 'https://api.grain.com/_/public-api/hooks'
const requestBody: Record<string, unknown> = {
version: 2,
hook_url: notificationUrl,
view_id: viewId,
}
if (actions.length > 0) {
requestBody.actions = actions
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await grainResponse.json()) as Record<string, unknown>
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
const errors = responseBody.errors as Record<string, string> | undefined
const error = responseBody.error as Record<string, string> | string | undefined
const errorMessage =
errors?.detail ||
(typeof error === 'object' ? error?.message : undefined) ||
(typeof error === 'string' ? error : undefined) ||
(responseBody.message as string) ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const grainWebhookId = responseBody.id as string | undefined
if (!grainWebhookId) {
logger.error(
`[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`,
{
response: responseBody,
}
)
throw new Error(
'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.'
)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`,
{
grainWebhookId,
eventTypes,
}
)
return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Grain apiKey for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Grain externalId for webhook deletion')
return
}
const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}`
const grainResponse = await fetch(grainApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
})
if (!grainResponse.ok && grainResponse.status !== 404) {
const responseBody = await grainResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) throw new Error(`Failed to delete Grain webhook: ${grainResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,145 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Greenhouse')
/**
* Validates the Greenhouse HMAC-SHA256 signature.
* Greenhouse sends: `Signature: sha256 <hexdigest>`
*/
function validateGreenhouseSignature(secretKey: string, signature: string, body: string): boolean {
try {
if (!secretKey || !signature || !body) {
return false
}
const prefix = 'sha256 '
if (!signature.startsWith(prefix)) {
return false
}
const providedDigest = signature.substring(prefix.length)
const computedDigest = hmacSha256Hex(body, secretKey)
return safeCompare(computedDigest, providedDigest)
} catch {
logger.error('Error validating Greenhouse signature')
return false
}
}
export const greenhouseHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'secretKey',
headerName: 'signature',
validateFn: validateGreenhouseSignature,
providerLabel: 'Greenhouse',
}),
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const payload = (b.payload || {}) as Record<string, unknown>
const application = (payload.application || {}) as Record<string, unknown>
const candidate = (application.candidate || {}) as Record<string, unknown>
const jobNested = payload.job
let applicationId: number | null = null
if (typeof application.id === 'number') {
applicationId = application.id
} else if (typeof payload.application_id === 'number') {
applicationId = payload.application_id
}
const candidateId = typeof candidate.id === 'number' ? candidate.id : null
let jobId: number | null = null
if (
jobNested &&
typeof jobNested === 'object' &&
typeof (jobNested as Record<string, unknown>).id === 'number'
) {
jobId = (jobNested as Record<string, unknown>).id as number
} else if (typeof payload.job_id === 'number') {
jobId = payload.job_id
}
return {
input: {
action: b.action,
applicationId,
candidateId,
jobId,
payload: b.payload || {},
},
}
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const b = body as Record<string, unknown>
const action = b.action as string | undefined
if (triggerId && triggerId !== 'greenhouse_webhook') {
const { isGreenhouseEventMatch } = await import('@/triggers/greenhouse/utils')
if (!isGreenhouseEventMatch(triggerId, action || '')) {
logger.debug(
`[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedAction: action,
}
)
return false
}
}
return true
},
/**
* Fallback when Greenhouse-Event-ID is not available on headers (see idempotency service).
* Prefer stable resource keys; offer events include version for new versions.
*/
extractIdempotencyId(body: unknown) {
const b = body as Record<string, unknown>
const action = typeof b.action === 'string' ? b.action : ''
const payload = (b.payload || {}) as Record<string, unknown>
const application = (payload.application || {}) as Record<string, unknown>
const appId = application.id
if (appId !== undefined && appId !== null && appId !== '') {
return `greenhouse:${action}:application:${String(appId)}`
}
const offerId = payload.id
const offerVersion = payload.version
if (offerId !== undefined && offerId !== null && offerId !== '') {
const v = offerVersion !== undefined && offerVersion !== null ? String(offerVersion) : '0'
return `greenhouse:${action}:offer:${String(offerId)}:${v}`
}
const offer = (payload.offer || {}) as Record<string, unknown>
const nestedOfferId = offer.id
if (nestedOfferId !== undefined && nestedOfferId !== null && nestedOfferId !== '') {
const nestedVersion =
offer.version !== undefined && offer.version !== null ? String(offer.version) : '0'
return `greenhouse:${action}:offer:${String(nestedOfferId)}:${nestedVersion}`
}
const job = (payload.job || {}) as Record<string, unknown>
const jobId = job.id
if (jobId !== undefined && jobId !== null && jobId !== '') {
return `greenhouse:${action}:job:${String(jobId)}`
}
return null
},
}
+84
View File
@@ -0,0 +1,84 @@
import { db } from '@sim/db'
import { webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type {
FormatInputContext,
FormatInputResult,
PollingConfigContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Imap')
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 }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const now = new Date()
if (!providerConfig.host || !providerConfig.username || !providerConfig.password) {
logger.error(
`[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}`
)
return false
}
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
port: providerConfig.port || '993',
secure: providerConfig.secure !== false,
mailbox: providerConfig.mailbox || 'INBOX',
searchCriteria: providerConfig.searchCriteria || 'UNSEEN',
markAsRead: providerConfig.markAsRead || false,
includeAttachments: providerConfig.includeAttachments !== false,
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
logger.info(
`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`
)
return true
} catch (error: unknown) {
const err = error as Error
logger.error(`[${requestId}] Failed to configure IMAP polling`, {
webhookId: webhookData.id,
error: err.message,
})
return false
}
},
}
@@ -0,0 +1,283 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { incidentioHandler } from '@/lib/webhooks/providers/incidentio'
const SECRET_BYTES = Buffer.from('incidentio-test-secret-key-padding!!')
const SIGNING_SECRET = `whsec_${SECRET_BYTES.toString('base64')}`
function signIncidentioBody(msgId: string, timestamp: string, rawBody: string): string {
const toSign = `${msgId}.${timestamp}.${rawBody}`
const sig = crypto.createHmac('sha256', SECRET_BYTES).update(toSign, 'utf8').digest('base64')
return `v1,${sig}`
}
function requestWithSvixHeaders(
msgId: string,
timestamp: string,
rawBody: string,
signature?: string
): NextRequest {
const headers: Record<string, string> = {
'webhook-id': msgId,
'webhook-timestamp': timestamp,
}
if (signature !== undefined) {
headers['webhook-signature'] = signature
}
return new NextRequest('http://localhost/test', { headers })
}
const baseAuthCtx = {
webhook: {},
workflow: {},
rawBody: '',
}
describe('incident.io webhook provider', () => {
it('rejects requests when the signing secret is missing', async () => {
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', `${Math.floor(Date.now() / 1000)}`, '{}'),
rawBody: '{}',
requestId: 'incidentio-t1',
providerConfig: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests missing Svix signature headers', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody),
rawBody,
requestId: 'incidentio-t2',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('rejects requests with an invalid signature', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, 'v1,not-a-valid-signature'),
rawBody,
requestId: 'incidentio-t3',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('rejects requests when the timestamp skew is too large', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000) - 600}`
const signature = signIncidentioBody('msg_1', ts, rawBody)
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, signature),
rawBody,
requestId: 'incidentio-t4',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res?.status).toBe(401)
})
it('accepts a correctly signed request within the allowed window', async () => {
const rawBody = JSON.stringify({ event_type: 'public_incident.incident_created_v2' })
const ts = `${Math.floor(Date.now() / 1000)}`
const signature = signIncidentioBody('msg_1', ts, rawBody)
const res = await incidentioHandler.verifyAuth!({
...baseAuthCtx,
request: requestWithSvixHeaders('msg_1', ts, rawBody, signature),
rawBody,
requestId: 'incidentio-t5',
providerConfig: { signingSecret: SIGNING_SECRET },
})
expect(res).toBeNull()
})
it('matches events by event_type for a specific trigger', async () => {
const body = { event_type: 'public_incident.incident_created_v2' }
const matched = await incidentioHandler.matchEvent!({
body,
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t6',
providerConfig: { triggerId: 'incidentio_incident_created' },
})
expect(matched).toBe(true)
const mismatched = await incidentioHandler.matchEvent!({
body,
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t7',
providerConfig: { triggerId: 'incidentio_incident_updated' },
})
expect(mismatched).toBe(false)
})
it('formats incident_created input from the directly-nested wrapper', async () => {
const incident = {
id: 'inc_123',
reference: 'INC-123',
name: 'Database outage',
summary: 'DB is sad',
incident_status: { id: 'st_1', name: 'Investigating' },
severity: { id: 'sev_1', name: 'Major' },
mode: 'standard',
visibility: 'public',
permalink: 'https://app.incident.io/incidents/123',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
}
const body = {
event_type: 'public_incident.incident_created_v2',
'public_incident.incident_created_v2': incident,
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8',
})
expect(result.input).toEqual({
event_type: 'public_incident.incident_created_v2',
incident,
incident_id: 'inc_123',
name: 'Database outage',
reference: 'INC-123',
summary: 'DB is sad',
incident_status: { id: 'st_1', name: 'Investigating' },
severity: { id: 'sev_1', name: 'Major' },
mode: 'standard',
visibility: 'public',
permalink: 'https://app.incident.io/incidents/123',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
new_status: null,
previous_status: null,
update_message: null,
payload: body,
})
})
it('formats incident_status_updated input from the nested incident + status change fields', async () => {
const incident = { id: 'inc_123', reference: 'INC-123', name: 'Database outage' }
const new_status = { id: 'st_2', name: 'Resolved' }
const previous_status = { id: 'st_1', name: 'Investigating' }
const body = {
event_type: 'public_incident.incident_status_updated_v2',
'public_incident.incident_status_updated_v2': {
incident,
new_status,
previous_status,
message: 'Fixed it',
},
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8b',
})
expect(result.input).toMatchObject({
event_type: 'public_incident.incident_status_updated_v2',
incident,
incident_id: 'inc_123',
new_status,
previous_status,
update_message: 'Fixed it',
payload: body,
})
})
it('formats alert_created (v1) input from the directly-nested wrapper', async () => {
const alert = {
id: 'alrt_1',
title: 'CPU high',
description: 'CPU exceeded 75%',
status: 'firing',
alert_source_id: 'src_1',
deduplication_key: 'dedup_1',
source_url: 'https://alerts.example.com/1',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
resolved_at: '2021-08-17T14:28:57.801578Z',
}
const body = {
event_type: 'public_alert.alert_created_v1',
'public_alert.alert_created_v1': alert,
}
const result = await incidentioHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf_1', userId: 'user_1' },
headers: {},
requestId: 'incidentio-t8c',
})
expect(result.input).toEqual({
event_type: 'public_alert.alert_created_v1',
alert,
alert_id: 'alrt_1',
title: 'CPU high',
description: 'CPU exceeded 75%',
status: 'firing',
alert_source_id: 'src_1',
deduplication_key: 'dedup_1',
source_url: 'https://alerts.example.com/1',
created_at: '2021-08-17T13:28:57.801578Z',
updated_at: '2021-08-17T13:28:57.801578Z',
resolved_at: '2021-08-17T14:28:57.801578Z',
payload: body,
})
})
it('matches the alert_created trigger against public_alert.alert_created_v1', async () => {
const matched = await incidentioHandler.matchEvent!({
body: { event_type: 'public_alert.alert_created_v1' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
requestId: 'incidentio-t9b',
providerConfig: { triggerId: 'incidentio_alert_created' },
})
expect(matched).toBe(true)
})
it('extracts an idempotency id from event_type and entity id', () => {
const body = {
event_type: 'public_incident.incident_created_v2',
'public_incident.incident_created_v2': { incident: { id: 'inc_123' } },
}
expect(incidentioHandler.extractIdempotencyId!(body)).toBe(
'public_incident.incident_created_v2:inc_123'
)
expect(incidentioHandler.extractIdempotencyId!({})).toBeNull()
})
})
@@ -0,0 +1,217 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Incidentio')
const INCIDENTIO_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60
/**
* Verify an incident.io webhook signature using the Svix signing scheme.
* incident.io webhooks are powered by Svix: HMAC-SHA256 of
* `${webhook-id}.${webhook-timestamp}.${body}` signed with the base64-decoded
* `whsec_...` secret, compared against the `webhook-signature` header which may
* carry one or more space-separated `v1,<base64sig>` entries.
* @see https://docs.incident.io/integrations/webhooks
*/
function verifyIncidentioSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > INCIDENTIO_WEBHOOK_TIMESTAMP_SKEW_SECONDS) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
const providedSignatures = signatures.split(' ')
for (const versionedSig of providedSignatures) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
const sig = parts[1]
if (safeCompare(sig, expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying incident.io Svix signature:', error)
return false
}
}
function asObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return null
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
/**
* Locate a named entity (incident/alert) within an incident.io webhook body.
*
* Verified against the incident.io webhooks OpenAPI spec
* (https://docs.incident.io/openapi/webhooks.json). The body is a Svix
* envelope `{ event_type, [event_type]: <wrapper> }` where:
* - `incident_created_v2` / `incident_updated_v2` / `alert_created_v1`: the
* wrapper IS the entity (incident/alert fields directly under the key).
* - `incident_status_updated_v2`: the wrapper nests the incident under
* `.incident` alongside `new_status` / `previous_status` / `message`.
* Returns null when the entity cannot be found.
*/
function extractEntity(
body: Record<string, unknown>,
eventType: string,
key: 'incident' | 'alert'
): Record<string, unknown> | null {
const wrapper = eventType ? asObject(body[eventType]) : null
if (!wrapper) return null
return asObject(wrapper[key]) ?? wrapper
}
export const incidentioHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(
`[${requestId}] incident.io webhook missing signing secret in provider configuration`
)
return new NextResponse('Unauthorized - incident.io signing secret is required', {
status: 401,
})
}
const webhookId = request.headers.get('webhook-id')
const webhookTimestamp = request.headers.get('webhook-timestamp')
const webhookSignature = request.headers.get('webhook-signature')
if (!webhookId || !webhookTimestamp || !webhookSignature) {
logger.warn(`[${requestId}] incident.io webhook missing Svix signature headers`)
return new NextResponse('Unauthorized - Missing incident.io signature headers', {
status: 401,
})
}
if (
!verifyIncidentioSignature(
signingSecret,
webhookId,
webhookTimestamp,
webhookSignature,
rawBody
)
) {
logger.warn(`[${requestId}] incident.io Svix signature verification failed`)
return new NextResponse('Unauthorized - Invalid incident.io signature', { status: 401 })
}
return null
},
async matchEvent({ body, providerConfig, requestId }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isIncidentioEventMatch } = await import('@/triggers/incidentio/utils')
const eventType = (body as Record<string, unknown>)?.event_type as string | undefined
if (!isIncidentioEventMatch(triggerId, eventType || '')) {
logger.debug(
`[${requestId}] incident.io event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = (asObject(body) ?? {}) as Record<string, unknown>
const eventType = typeof b.event_type === 'string' ? b.event_type : ''
const wrapper = eventType ? asObject(b[eventType]) : null
const isAlert = eventType.startsWith('public_alert.')
if (isAlert) {
const alert = extractEntity(b, eventType, 'alert')
return {
input: {
event_type: eventType,
alert,
alert_id: asString(alert?.id),
title: asString(alert?.title),
description: asString(alert?.description),
status: asString(alert?.status),
alert_source_id: asString(alert?.alert_source_id),
deduplication_key: asString(alert?.deduplication_key),
source_url: asString(alert?.source_url),
created_at: asString(alert?.created_at),
updated_at: asString(alert?.updated_at),
resolved_at: asString(alert?.resolved_at),
payload: b,
},
}
}
const incident = extractEntity(b, eventType, 'incident')
return {
input: {
event_type: eventType,
incident,
incident_id: asString(incident?.id),
name: asString(incident?.name),
reference: asString(incident?.reference),
summary: asString(incident?.summary),
incident_status: asObject(incident?.incident_status),
severity: asObject(incident?.severity),
mode: asString(incident?.mode),
visibility: asString(incident?.visibility),
permalink: asString(incident?.permalink),
created_at: asString(incident?.created_at),
updated_at: asString(incident?.updated_at),
new_status: asObject(wrapper?.new_status),
previous_status: asObject(wrapper?.previous_status),
update_message: asString(wrapper?.message),
payload: b,
},
}
},
extractIdempotencyId(body: unknown) {
const b = asObject(body)
if (!b) return null
const eventType = typeof b.event_type === 'string' ? b.event_type : ''
const key = eventType.startsWith('public_alert.') ? 'alert' : 'incident'
const entity = extractEntity(b, eventType, key as 'incident' | 'alert')
const entityId = entity && typeof entity.id === 'string' ? entity.id : null
if (eventType && entityId) {
return `${eventType}:${entityId}`
}
return null
},
}
+21
View File
@@ -0,0 +1,21 @@
export { getProviderHandler } from '@/lib/webhooks/providers/registry'
import { getProviderHandler } from '@/lib/webhooks/providers/registry'
/**
* Extract a provider-specific unique identifier from the webhook body for idempotency.
*/
export function extractProviderIdentifierFromBody(provider: string, body: unknown): string | null {
if (!body || typeof body !== 'object') {
return null
}
const handler = getProviderHandler(provider)
return handler.extractIdempotencyId?.(body) ?? null
}
/** Returns whether a provider accepts deliveries through the generic per-webhook path route. */
export function acceptsPathWebhookDelivery(provider: string | null): boolean {
if (!provider) return true
return getProviderHandler(provider).ingressMode !== 'provider'
}
@@ -0,0 +1,405 @@
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { instantlyHandler } from '@/lib/webhooks/providers/instantly'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('Instantly webhook provider', () => {
it('verifyAuth rejects when secretToken is missing (fail-closed)', async () => {
const res = await instantlyHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects when the token header is missing', async () => {
const res = await instantlyHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { secretToken: 'expected-token' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects an incorrect token', async () => {
const res = await instantlyHandler.verifyAuth!({
request: reqWithHeaders({ 'x-sim-webhook-token': 'wrong-token' }),
rawBody: '{}',
requestId: 't3',
providerConfig: { secretToken: 'expected-token' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth accepts a matching token', async () => {
const res = await instantlyHandler.verifyAuth!({
request: reqWithHeaders({ 'x-sim-webhook-token': 'expected-token' }),
rawBody: '{}',
requestId: 't4',
providerConfig: { secretToken: 'expected-token' },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('matchEvent passes all events through for the generic webhook trigger', async () => {
const matched = await instantlyHandler.matchEvent!({
body: { event_type: 'lead_interested' },
requestId: 't5',
providerConfig: { triggerId: 'instantly_webhook' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(true)
})
it('matchEvent filters events that do not match the configured trigger', async () => {
const matched = await instantlyHandler.matchEvent!({
body: { event_type: 'email_opened' },
requestId: 't6',
providerConfig: { triggerId: 'instantly_email_sent' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(false)
})
it('matchEvent matches events for the configured trigger', async () => {
const matched = await instantlyHandler.matchEvent!({
body: { event_type: 'email_sent' },
requestId: 't7',
providerConfig: { triggerId: 'instantly_email_sent' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(true)
})
it('matchEvent accepts both link-click event type spellings', async () => {
const matchedA = await instantlyHandler.matchEvent!({
body: { event_type: 'link_clicked' },
requestId: 't8a',
providerConfig: { triggerId: 'instantly_link_clicked' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
const matchedB = await instantlyHandler.matchEvent!({
body: { event_type: 'email_link_clicked' },
requestId: 't8b',
providerConfig: { triggerId: 'instantly_link_clicked' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matchedA).toBe(true)
expect(matchedB).toBe(true)
})
it('formats input with keys matching the trigger outputs', async () => {
const body = {
timestamp: '2026-07-08T12:00:00.000Z',
event_type: 'reply_received',
workspace: 'ws-1',
campaign_id: 'camp-1',
campaign_name: 'Q3 Outreach',
lead_email: 'lead@example.com',
email_account: 'sender@example.com',
unibox_url: 'https://app.instantly.ai/unibox/1',
step: 2,
variant: 1,
is_first: true,
email_id: 'email-1',
reply_text_snippet: 'Sounds good',
reply_subject: 'Re: Q3 Outreach',
reply_text: 'Sounds good, thanks!',
reply_html: '<p>Sounds good, thanks!</p>',
}
const result = await instantlyHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
headers: {},
requestId: 't9',
})
expect(result.input).toEqual({
timestamp: '2026-07-08T12:00:00.000Z',
eventType: 'reply_received',
workspace: 'ws-1',
campaignId: 'camp-1',
campaignName: 'Q3 Outreach',
leadEmail: 'lead@example.com',
emailAccount: 'sender@example.com',
uniboxUrl: 'https://app.instantly.ai/unibox/1',
step: 2,
variant: 1,
isFirst: true,
emailId: 'email-1',
emailSubject: null,
emailText: null,
emailHtml: null,
replyTextSnippet: 'Sounds good',
replySubject: 'Re: Q3 Outreach',
replyText: 'Sounds good, thanks!',
replyHtml: '<p>Sounds good, thanks!</p>',
payload: body,
})
})
describe('extractIdempotencyId', () => {
it('prefers the email_id when present, qualified by timestamp', () => {
const id = instantlyHandler.extractIdempotencyId!({
event_type: 'email_sent',
email_id: 'email-123',
campaign_id: 'camp-1',
lead_email: 'lead@example.com',
timestamp: '2026-07-08T12:00:00.000Z',
})
expect(id).toBe('instantly:email_sent:email-123:2026-07-08T12:00:00.000Z')
})
it('returns null when email_id is present but timestamp is missing, rather than risk a false collision', () => {
const id = instantlyHandler.extractIdempotencyId!({
event_type: 'email_sent',
email_id: 'email-123',
})
expect(id).toBeNull()
})
it('falls back to a content-based key without an email_id', () => {
const id = instantlyHandler.extractIdempotencyId!({
event_type: 'lead_interested',
campaign_id: 'camp-1',
lead_email: 'lead@example.com',
timestamp: '2026-07-08T12:00:00.000Z',
})
expect(id).toBe('instantly:lead_interested:camp-1:lead@example.com:2026-07-08T12:00:00.000Z')
})
it('is stable across retries of the same delivery', () => {
const body = {
event_type: 'lead_interested',
campaign_id: 'camp-1',
lead_email: 'lead@example.com',
timestamp: '2026-07-08T12:00:00.000Z',
}
const first = instantlyHandler.extractIdempotencyId!(body)
const second = instantlyHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
})
it('is stable across retries of the same email_id-keyed delivery', () => {
const body = {
event_type: 'email_opened',
email_id: 'email-123',
timestamp: '2026-07-08T12:00:00.000Z',
}
const first = instantlyHandler.extractIdempotencyId!(body)
const second = instantlyHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
})
it('does not collide across distinct occurrences of the same email_id (e.g. repeat opens/clicks/replies)', () => {
const firstOpen = instantlyHandler.extractIdempotencyId!({
event_type: 'email_opened',
email_id: 'email-123',
timestamp: '2026-07-08T12:00:00.000Z',
})
const secondOpen = instantlyHandler.extractIdempotencyId!({
event_type: 'email_opened',
email_id: 'email-123',
timestamp: '2026-07-08T13:30:00.000Z',
})
expect(firstOpen).not.toBe(secondOpen)
})
it('returns null when there is not enough data to build a stable key', () => {
expect(instantlyHandler.extractIdempotencyId!({ event_type: 'account_error' })).toBeNull()
expect(instantlyHandler.extractIdempotencyId!({})).toBeNull()
expect(instantlyHandler.extractIdempotencyId!('not-an-object')).toBeNull()
})
})
describe('createSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test')
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
it('creates an Instantly webhook with the mapped event type', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'wh-123' }),
})
const result = await instantlyHandler.createSubscription!({
webhook: {
id: 'webhook-1',
path: 'abc-path',
providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_email_sent' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-1',
request: new NextRequest('http://localhost/test'),
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.instantly.ai/api/v2/webhooks')
expect(init.method).toBe('POST')
expect(init.headers.Authorization).toBe('Bearer instantly-key')
const sent = JSON.parse(init.body)
expect(sent.event_type).toBe('email_sent')
expect(sent.target_hook_url).toContain('/api/webhooks/trigger/abc-path')
expect(typeof sent.headers['X-Sim-Webhook-Token']).toBe('string')
expect(result?.providerConfigUpdates?.externalId).toBe('wh-123')
})
it('maps the link-clicked trigger to the email_link_clicked subscription event', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'wh-456' }) })
await instantlyHandler.createSubscription!({
webhook: {
id: 'webhook-2',
path: 'p2',
providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_link_clicked' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-2',
request: new NextRequest('http://localhost/test'),
})
const sent = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(sent.event_type).toBe('email_link_clicked')
})
it('throws a friendly error on 401', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ message: 'unauthorized' }),
})
await expect(
instantlyHandler.createSubscription!({
webhook: {
id: 'webhook-3',
path: 'p3',
providerConfig: { triggerApiKey: 'bad-key', triggerId: 'instantly_email_sent' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-3',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Invalid Instantly API Key/)
})
it('throws when the API key is missing', async () => {
await expect(
instantlyHandler.createSubscription!({
webhook: {
id: 'webhook-4',
path: 'p4',
providerConfig: { triggerId: 'instantly_email_sent' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-4',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Instantly API Key is required/)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('deleteSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the webhook by externalId', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, body: null })
await instantlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-1',
providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-123' },
},
workflow: {},
requestId: 'req-del-1',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.instantly.ai/api/v2/webhooks/wh-123')
expect(init.method).toBe('DELETE')
})
it('skips when apiKey or externalId is missing and does not throw', async () => {
await instantlyHandler.deleteSubscription!({
webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } },
workflow: {},
requestId: 'req-del-2',
})
await instantlyHandler.deleteSubscription!({
webhook: { id: 'webhook-3', providerConfig: { triggerApiKey: 'instantly-key' } },
workflow: {},
requestId: 'req-del-3',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not throw on a non-ok response in non-strict mode', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
await expect(
instantlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-4',
providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-9' },
},
workflow: {},
requestId: 'req-del-4',
})
).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,298 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
import { instantlyUrl } from '@/tools/instantly/utils'
const logger = createLogger('WebhookProvider:Instantly')
const SIM_WEBHOOK_TOKEN_HEADER = 'x-sim-webhook-token'
export const instantlyHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secretToken = providerConfig.secretToken as string | undefined
if (!secretToken) {
logger.warn(`[${requestId}] Instantly webhook secret token is missing`)
return new NextResponse('Unauthorized', { status: 401 })
}
if (!verifyTokenAuth(request, secretToken, SIM_WEBHOOK_TOKEN_HEADER)) {
logger.warn(`[${requestId}] Unauthorized Instantly webhook request`)
return new NextResponse('Unauthorized', { status: 401 })
}
return null
},
async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) return true
if (!isRecordLike(body)) {
logger.warn(`[${requestId}] Instantly webhook payload was not an object`)
return false
}
const { isInstantlyEventMatch } = await import('@/triggers/instantly/utils')
if (!isInstantlyEventMatch(triggerId, body)) {
logger.info(`[${requestId}] Instantly event did not match trigger`, {
triggerId,
eventType: body.event_type,
})
return false
}
return true
},
/**
* `email_id` (Instantly's `reply_to_uuid`) identifies the sent email, not
* one occurrence of an event on that email — the same event_type can fire
* more than once for one email_id (every open, every click, every reply in
* a thread), so email_id alone would collapse those distinct, legitimate
* deliveries into one idempotency slot. `timestamp` is fixed per event
* occurrence (not regenerated on retry), so appending it keeps the key
* both retry-stable and unique per occurrence — without it there's no way
* to distinguish occurrences, so dedup is skipped rather than risking a
* false collision.
*/
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
const eventType = typeof body.event_type === 'string' ? body.event_type : undefined
if (!eventType) return null
const timestamp = typeof body.timestamp === 'string' ? body.timestamp : undefined
const emailId = typeof body.email_id === 'string' ? body.email_id : undefined
if (emailId) {
return timestamp ? `instantly:${eventType}:${emailId}:${timestamp}` : null
}
const campaignId = typeof body.campaign_id === 'string' ? body.campaign_id : undefined
const leadEmail = typeof body.lead_email === 'string' ? body.lead_email : undefined
if (campaignId && leadEmail && timestamp) {
return `instantly:${eventType}:${campaignId}:${leadEmail}:${timestamp}`
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = isRecordLike(body) ? body : {}
return {
input: {
timestamp: toStringOrNull(payload.timestamp),
eventType: toStringOrNull(payload.event_type),
workspace: toStringOrNull(payload.workspace),
campaignId: toStringOrNull(payload.campaign_id),
campaignName: toStringOrNull(payload.campaign_name),
leadEmail: toStringOrNull(payload.lead_email),
emailAccount: toStringOrNull(payload.email_account),
uniboxUrl: toStringOrNull(payload.unibox_url),
step: toNumberOrNull(payload.step),
variant: toNumberOrNull(payload.variant),
isFirst: toBooleanOrNull(payload.is_first),
emailId: toStringOrNull(payload.email_id),
emailSubject: toStringOrNull(payload.email_subject),
emailText: toStringOrNull(payload.email_text),
emailHtml: toStringOrNull(payload.email_html),
replyTextSnippet: toStringOrNull(payload.reply_text_snippet),
replySubject: toStringOrNull(payload.reply_subject),
replyText: toStringOrNull(payload.reply_text),
replyHtml: toStringOrNull(payload.reply_html),
payload,
},
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const campaignId = optionalId(providerConfig.triggerCampaignId)
if (!apiKey?.trim()) {
throw new Error('Instantly API Key is required.')
}
if (!triggerId) {
throw new Error('Instantly trigger ID is required.')
}
const { getInstantlySubscriptionEventTypeForTrigger } = await import(
'@/triggers/instantly/utils'
)
const eventType = getInstantlySubscriptionEventTypeForTrigger(triggerId)
if (!eventType) {
throw new Error(`Unknown Instantly trigger type: ${triggerId}`)
}
const secretToken =
typeof providerConfig.secretToken === 'string' && providerConfig.secretToken.length > 0
? providerConfig.secretToken
: generateShortId(32)
const requestBody: Record<string, unknown> = {
name: `Sim - ${triggerId.replace(/^instantly_/, '').replace(/_/g, ' ')}`,
target_hook_url: getNotificationUrl(webhook),
event_type: eventType,
headers: {
'X-Sim-Webhook-Token': secretToken,
},
}
if (campaignId) {
requestBody.campaign = campaignId
}
logger.info(`[${requestId}] Creating Instantly webhook`, {
triggerId,
eventType,
hasCampaignId: Boolean(campaignId),
webhookId: webhook.id,
})
const response = await fetch(instantlyUrl('/api/v2/webhooks'), {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await parseJsonResponse(response)
if (!response.ok) {
const message = extractInstantlyError(responseBody)
logger.error(`[${requestId}] Failed to create Instantly webhook`, {
status: response.status,
message,
response: responseBody,
})
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Instantly API Key or missing webhook permissions.')
}
if (response.status === 402) {
throw new Error('Instantly webhook creation requires an active paid plan.')
}
throw new Error(
message ? `Instantly error: ${message}` : 'Failed to create Instantly webhook'
)
}
const externalId = responseBody?.id
if (typeof externalId !== 'string' || externalId.length === 0) {
throw new Error('Instantly webhook was created but the API response did not include an ID.')
}
logger.info(`[${requestId}] Successfully created Instantly webhook`, {
externalId,
webhookId: webhook.id,
})
return { providerConfigUpdates: { externalId, secretToken } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const externalId = providerConfig.externalId as string | undefined
if (!apiKey?.trim() || !externalId?.trim()) {
logger.warn(`[${requestId}] Missing Instantly webhook cleanup configuration`, {
webhookId: webhook.id,
hasApiKey: Boolean(apiKey),
hasExternalId: Boolean(externalId),
})
if (ctx.strict) throw new Error('Missing Instantly webhook cleanup configuration')
return
}
const response = await fetch(
instantlyUrl(`/api/v2/webhooks/${encodeURIComponent(externalId.trim())}`),
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
},
}
)
if (!response.ok && response.status !== 404) {
const responseBody = await parseJsonResponse(response)
logger.warn(`[${requestId}] Failed to delete Instantly webhook`, {
status: response.status,
response: responseBody,
})
if (ctx.strict) throw new Error(`Failed to delete Instantly webhook: ${response.status}`)
return
}
await response.body?.cancel()
logger.info(`[${requestId}] Successfully deleted Instantly webhook`, {
externalId,
webhookId: webhook.id,
})
} catch (error) {
logger.warn(`[${requestId}] Error deleting Instantly webhook`, {
message: toError(error).message,
})
if (ctx.strict) throw error
}
},
}
async function parseJsonResponse(response: Response): Promise<Record<string, unknown> | null> {
try {
const body: unknown = await response.json()
return isRecordLike(body) ? body : null
} catch {
return null
}
}
function extractInstantlyError(body: Record<string, unknown> | null): string | null {
if (!body) return null
if (typeof body.message === 'string') return body.message
if (typeof body.error === 'string') return body.error
return null
}
function toStringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function toBooleanOrNull(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null
}
function optionalId(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (trimmed === '' || trimmed === '-') return undefined
return trimmed
}
+117
View File
@@ -0,0 +1,117 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Intercom')
/**
* Validate Intercom webhook signature using HMAC-SHA1.
* Intercom signs payloads with the app's Client Secret and sends the
* signature in the X-Hub-Signature header as "sha1=<hex>".
*/
function validateIntercomSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Intercom signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
if (!signature.startsWith('sha1=')) {
logger.warn('Intercom signature has invalid format', {
signature: `${signature.substring(0, 10)}...`,
})
return false
}
const providedSignature = signature.substring(5)
const computedHash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex')
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Intercom signature:', error)
return false
}
}
export const intercomHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
return null
}
const signature = request.headers.get('X-Hub-Signature')
if (!signature) {
logger.warn(`[${requestId}] Intercom webhook missing X-Hub-Signature header`)
return new NextResponse('Unauthorized - Missing Intercom signature', { status: 401 })
}
if (!validateIntercomSignature(secret, signature, rawBody)) {
logger.warn(`[${requestId}] Intercom signature verification failed`)
return new NextResponse('Unauthorized - Invalid Intercom signature', { status: 401 })
}
return null
},
handleReachabilityTest(body: unknown, requestId: string) {
const obj = body as Record<string, unknown> | null
if (obj?.topic === 'ping') {
logger.info(
`[${requestId}] Intercom ping event detected - returning 200 without triggering workflow`
)
return NextResponse.json({
status: 'ok',
message: 'Webhook endpoint verified',
})
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
return { input: body }
},
async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
const topic = obj?.topic as string | undefined
if (triggerId && triggerId !== 'intercom_webhook') {
const { isIntercomEventMatch } = await import('@/triggers/intercom/utils')
if (!isIntercomEventMatch(triggerId, topic || '')) {
logger.debug(
`[${requestId}] Intercom event mismatch for trigger ${triggerId}. Topic: ${topic}. Skipping execution.`,
{
webhookId: webhook.id,
triggerId,
receivedTopic: topic,
}
)
return false
}
}
return true
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
if (obj?.id && obj?.type === 'notification_event') {
return String(obj.id)
}
return null
},
}
@@ -0,0 +1,196 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { jiraHandler } from '@/lib/webhooks/providers/jira'
import { isJiraEventMatch } from '@/triggers/jira/utils'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('Jira webhook provider', () => {
it('verifyAuth skips verification when no webhookSecret is configured (optional secret)', async () => {
const res = await jiraHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('verifyAuth rejects when a secret is configured but X-Hub-Signature is missing', async () => {
const res = await jiraHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects when the signature does not match', async () => {
const res = await jiraHandler.verifyAuth!({
request: reqWithHeaders({ 'X-Hub-Signature': 'sha256=wrong' }),
rawBody: '{"a":1}',
requestId: 't3',
providerConfig: { webhookSecret: 'my-secret' },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('isJiraEventMatch maps trigger ids to their real webhookEvent values', () => {
expect(isJiraEventMatch('jira_issue_created', 'jira:issue_created')).toBe(true)
expect(isJiraEventMatch('jira_issue_created', 'comment_created')).toBe(false)
expect(isJiraEventMatch('jira_issue_commented', 'comment_created')).toBe(true)
expect(isJiraEventMatch('jira_webhook', 'anything')).toBe(true)
})
it('matchEvent filters events that do not match the configured trigger', async () => {
const result = await jiraHandler.matchEvent!({
body: { webhookEvent: 'comment_created' },
requestId: 't4',
providerConfig: { triggerId: 'jira_issue_created' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
it('matchEvent passes through all events for the generic webhook trigger', async () => {
const result = await jiraHandler.matchEvent!({
body: { webhookEvent: 'anything' },
requestId: 't5',
providerConfig: { triggerId: 'jira_webhook' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('matchEvent degrades gracefully instead of throwing when body is null', async () => {
const result = await jiraHandler.matchEvent!({
body: null,
requestId: 't6',
providerConfig: { triggerId: 'jira_issue_created' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
it('matchEvent applies fieldFilters on issue_updated, matching a changed field', async () => {
const result = await jiraHandler.matchEvent!({
body: {
webhookEvent: 'jira:issue_updated',
changelog: { items: [{ field: 'status', from: 'Open', to: 'Done' }] },
},
requestId: 't7',
providerConfig: { triggerId: 'jira_issue_updated', fieldFilters: 'status, assignee' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('matchEvent applies fieldFilters on issue_updated, skipping when no filtered field changed', async () => {
const result = await jiraHandler.matchEvent!({
body: {
webhookEvent: 'jira:issue_updated',
changelog: { items: [{ field: 'description', from: 'a', to: 'b' }] },
},
requestId: 't8',
providerConfig: { triggerId: 'jira_issue_updated', fieldFilters: 'status, assignee' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
it('matchEvent ignores fieldFilters for other trigger types', async () => {
const result = await jiraHandler.matchEvent!({
body: { webhookEvent: 'jira:issue_created' },
requestId: 't9',
providerConfig: { triggerId: 'jira_issue_created', fieldFilters: 'status' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('matchEvent matches any field change when fieldFilters is empty', async () => {
const result = await jiraHandler.matchEvent!({
body: {
webhookEvent: 'jira:issue_updated',
changelog: { items: [{ field: 'description', from: 'a', to: 'b' }] },
},
requestId: 't10',
providerConfig: { triggerId: 'jira_issue_updated' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('formatInput extracts issue data for the issue_created trigger', async () => {
const { input } = await jiraHandler.formatInput!({
body: {
webhookEvent: 'jira:issue_created',
timestamp: 123,
issue: { id: '10001', key: 'PROJ-1' },
},
headers: {},
requestId: 't7',
webhook: { providerConfig: { triggerId: 'jira_issue_created' } },
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.webhookEvent).toBe('jira:issue_created')
const issue = i.issue as Record<string, unknown>
expect(issue.key).toBe('PROJ-1')
})
it('formatInput does not throw and degrades gracefully when body is null', async () => {
const { input } = await jiraHandler.formatInput!({
body: null,
headers: {},
requestId: 't8',
webhook: { providerConfig: { triggerId: 'jira_webhook' } },
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.webhookEvent).toBeUndefined()
expect(i.issue).toEqual({})
})
it('extractIdempotencyId derives a stable key from webhookEvent + entity id', () => {
const body = { webhookEvent: 'jira:issue_created', timestamp: 123, issue: { id: '10001' } }
const first = jiraHandler.extractIdempotencyId!(body)
const second = jiraHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
expect(first).toContain('10001')
})
it('extractIdempotencyId returns null when there is no stable identifier', () => {
expect(jiraHandler.extractIdempotencyId!({ webhookEvent: 'jira:issue_created' })).toBeNull()
})
it('extractIdempotencyId does not throw when body is null', () => {
expect(jiraHandler.extractIdempotencyId!(null)).toBeNull()
})
})
+190
View File
@@ -0,0 +1,190 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { isRecordLike } from '@sim/utils/object'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Jira')
/**
* `changelog.items[].field` lists the Jira field names that changed in this
* update (only present on issue_updated deliveries). Empty filter list means
* no restriction — match on any field change.
*/
function matchesFieldFilters(body: Record<string, unknown>, fieldFilters: string): boolean {
const wanted = fieldFilters
.split(',')
.map((f) => f.trim().toLowerCase())
.filter(Boolean)
if (wanted.length === 0) return true
const changelog = body.changelog as Record<string, unknown> | undefined
const items = Array.isArray(changelog?.items) ? changelog.items : []
const changedFields = items
.map((item) => (isRecordLike(item) && typeof item.field === 'string' ? item.field : ''))
.map((f) => f.toLowerCase())
return wanted.some((field) => changedFields.includes(field))
}
export function validateJiraSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Jira signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
if (!signature.startsWith('sha256=')) {
logger.warn('Jira signature has invalid format (expected sha256=)', {
signaturePrefix: signature.substring(0, 10),
})
return false
}
const providedSignature = signature.substring(7)
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Jira signature comparison', {
computedLength: computedHash.length,
providedLength: providedSignature.length,
})
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Jira signature:', error)
return false
}
}
export const jiraHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-Hub-Signature',
validateFn: validateJiraSignature,
providerLabel: 'Jira',
}),
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
const {
extractIssueData,
extractCommentData,
extractWorklogData,
extractSprintData,
extractProjectData,
extractVersionData,
} = 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' ||
triggerId === 'jira_comment_updated' ||
triggerId === 'jira_comment_deleted'
) {
return { input: extractCommentData(body) }
}
if (
triggerId === 'jira_worklog_created' ||
triggerId === 'jira_worklog_updated' ||
triggerId === 'jira_worklog_deleted'
) {
return { input: extractWorklogData(body) }
}
if (
triggerId === 'jira_sprint_created' ||
triggerId === 'jira_sprint_started' ||
triggerId === 'jira_sprint_closed'
) {
return { input: extractSprintData(body) }
}
if (triggerId === 'jira_project_created') {
return { input: extractProjectData(body) }
}
if (triggerId === 'jira_version_released') {
return { input: extractVersionData(body) }
}
if (!triggerId || triggerId === 'jira_webhook') {
const obj = isRecordLike(body) ? body : {}
return {
input: {
webhookEvent: obj.webhookEvent,
timestamp: obj.timestamp,
user: obj.user || null,
issue_event_type_name: obj.issue_event_type_name,
issue: obj.issue || {},
changelog: obj.changelog,
comment: obj.comment,
worklog: obj.worklog,
sprint: obj.sprint,
project: obj.project,
version: obj.version,
},
}
}
return { input: extractIssueData(body) }
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = isRecordLike(body) ? body : {}
if (triggerId && triggerId !== 'jira_webhook') {
const webhookEvent = obj.webhookEvent as string | undefined
const issueEventTypeName = obj.issue_event_type_name as string | undefined
const { isJiraEventMatch } = await import('@/triggers/jira/utils')
if (!isJiraEventMatch(triggerId, webhookEvent || '', issueEventTypeName)) {
logger.debug(
`[${requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedEvent: webhookEvent,
}
)
return false
}
const fieldFilters = providerConfig.fieldFilters as string | undefined
if (
triggerId === 'jira_issue_updated' &&
fieldFilters &&
!matchesFieldFilters(obj, fieldFilters)
) {
logger.debug(
`[${requestId}] Jira issue_updated field filter did not match for trigger ${triggerId}. Skipping execution.`,
{ webhookId: webhook.id, workflowId: workflow.id, fieldFilters }
)
return false
}
}
return true
},
extractIdempotencyId(body: unknown) {
const obj = isRecordLike(body) ? body : {}
const issue = obj.issue as Record<string, unknown> | undefined
const comment = obj.comment as Record<string, unknown> | undefined
const worklog = obj.worklog as Record<string, unknown> | undefined
const project = obj.project as Record<string, unknown> | undefined
const sprint = obj.sprint as Record<string, unknown> | undefined
const version = obj.version as Record<string, unknown> | undefined
const entityId =
comment?.id || worklog?.id || issue?.id || project?.id || sprint?.id || version?.id
if (obj.webhookEvent && entityId) {
const ts = obj.timestamp ?? ''
return `${obj.webhookEvent}:${entityId}:${ts}`
}
return null
},
}
+96
View File
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
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:JSM')
/**
* Jira Service Management webhook handler.
*
* JSM uses the Jira webhook infrastructure. The handler reuses the same HMAC
* signature validation as Jira and adds JSM-specific event matching logic
* to route events to the correct trigger based on event type and changelog context.
*/
export const jsmHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-Hub-Signature',
validateFn: validateJiraSignature,
providerLabel: 'JSM',
}),
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
const { extractRequestData, extractCommentData } = await import('@/triggers/jsm/utils')
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId === 'jsm_request_commented') {
return { input: extractCommentData(body as Record<string, unknown>) }
}
// For the generic webhook, pass through the full payload so no data is lost
if (!triggerId || triggerId === 'jsm_webhook') {
const obj = body as Record<string, unknown>
return {
input: {
webhookEvent: obj.webhookEvent,
timestamp: obj.timestamp,
user: obj.user || null,
issue_event_type_name: obj.issue_event_type_name,
issue: obj.issue || {},
changelog: obj.changelog,
comment: obj.comment,
},
}
}
return { input: extractRequestData(body as Record<string, unknown>) }
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
if (triggerId && triggerId !== 'jsm_webhook') {
const webhookEvent = obj.webhookEvent as string | undefined
const issueEventTypeName = obj.issue_event_type_name as string | undefined
const changelog = obj.changelog as
| { items?: Array<{ field?: string; toString?: string }> }
| undefined
const { isJsmEventMatch } = await import('@/triggers/jsm/utils')
if (!isJsmEventMatch(triggerId, webhookEvent || '', issueEventTypeName, changelog)) {
logger.debug(
`[${requestId}] JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedEvent: webhookEvent,
}
)
return false
}
}
return true
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
const comment = obj.comment as Record<string, unknown> | undefined
const issue = obj.issue as Record<string, unknown> | undefined
const entityId = comment?.id || issue?.id
if (obj.webhookEvent && entityId) {
const ts = obj.timestamp ?? ''
return `jsm:${obj.webhookEvent}:${entityId}:${ts}`
}
return null
},
}
+233
View File
@@ -0,0 +1,233 @@
import { createLogger } from '@sim/logger'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Lemlist')
export const lemlistHandler: WebhookProviderHandler = {
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const campaignId = providerConfig.campaignId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.'
)
}
const eventTypeMap: Record<string, string | undefined> = {
lemlist_email_replied: 'emailsReplied',
lemlist_linkedin_replied: 'linkedinReplied',
lemlist_interested: 'interested',
lemlist_not_interested: 'notInterested',
lemlist_email_opened: 'emailsOpened',
lemlist_email_clicked: 'emailsClicked',
lemlist_email_bounced: 'emailsBounced',
lemlist_email_sent: 'emailsSent',
lemlist_webhook: undefined,
}
const eventType = eventTypeMap[triggerId ?? '']
const notificationUrl = getNotificationUrl(webhook)
const authString = Buffer.from(`:${apiKey}`).toString('base64')
logger.info(`[${requestId}] Creating Lemlist webhook`, {
triggerId,
eventType,
hasCampaignId: !!campaignId,
webhookId: webhook.id,
})
const lemlistApiUrl = 'https://api.lemlist.com/api/hooks'
const requestBody: Record<string, unknown> = {
targetUrl: notificationUrl,
}
if (eventType) {
requestBody.type = eventType
}
if (campaignId) {
requestBody.campaignId = campaignId
}
const lemlistResponse = await fetch(lemlistApiUrl, {
method: 'POST',
headers: {
Authorization: `Basic ${authString}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await lemlistResponse.json()) as Record<string, unknown>
if (!lemlistResponse.ok || responseBody.error) {
const errorMessage =
(responseBody.message as string) ||
(responseBody.error as string) ||
'Unknown Lemlist API error'
logger.error(
`[${requestId}] Failed to create webhook in Lemlist for webhook ${webhook.id}. Status: ${lemlistResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist'
if (lemlistResponse.status === 401) {
userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.'
} else if (lemlistResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Lemlist API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') {
userFriendlyMessage = `Lemlist error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Lemlist for webhook ${webhook.id}.`,
{
lemlistWebhookId: responseBody._id,
}
)
return { providerConfigUpdates: { externalId: responseBody._id } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Lemlist webhook creation for webhook ${webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Lemlist apiKey for webhook deletion')
return
}
const authString = Buffer.from(`:${apiKey}`).toString('base64')
const deleteById = async (id: string) => {
const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50)
if (!validation.isValid) {
logger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, {
id: id.substring(0, 30),
})
if (ctx.strict) throw new Error('Invalid Lemlist hook ID for deletion')
return
}
const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}`
const lemlistResponse = await fetch(lemlistApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Basic ${authString}`,
},
})
if (!lemlistResponse.ok && lemlistResponse.status !== 404) {
const responseBody = await lemlistResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) {
throw new Error(`Failed to delete Lemlist webhook: ${lemlistResponse.status}`)
}
} else {
logger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`)
}
}
if (externalId) {
await deleteById(externalId)
return
}
if (ctx.strict) {
logger.warn(
`[${requestId}] Missing Lemlist externalId during strict cleanup; skipping unsafe URL-based remote deletion`,
{ webhookId: webhook.id }
)
throw new Error('Missing Lemlist externalId for strict cleanup')
}
const notificationUrl = getNotificationUrl(webhook)
const listResponse = await fetch('https://api.lemlist.com/api/hooks', {
method: 'GET',
headers: {
Authorization: `Basic ${authString}`,
},
})
if (!listResponse.ok) {
logger.warn(`[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, {
status: listResponse.status,
})
if (ctx.strict) throw new Error(`Failed to list Lemlist webhooks: ${listResponse.status}`)
return
}
const listBody = (await listResponse.json().catch(() => null)) as
| Record<string, unknown>
| Array<Record<string, unknown>>
| null
const hooks: Array<Record<string, unknown>> = Array.isArray(listBody)
? listBody
: ((listBody as Record<string, unknown>)?.hooks as Array<Record<string, unknown>>) ||
((listBody as Record<string, unknown>)?.data as Array<Record<string, unknown>>) ||
[]
const matches = hooks.filter((hook) => {
const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url
return typeof targetUrl === 'string' && targetUrl === notificationUrl
})
if (matches.length === 0) {
logger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, {
notificationUrl,
})
return
}
for (const hook of matches) {
const hookId = (hook?._id || hook?.id) as string | undefined
if (typeof hookId === 'string' && hookId.length > 0) {
await deleteById(hookId)
}
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,176 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { linearHandler } from '@/lib/webhooks/providers/linear'
function signLinearBody(secret: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
}
function requestWithLinearSignature(secret: string, rawBody: string): NextRequest {
const signature = signLinearBody(secret, rawBody)
return new NextRequest('http://localhost/test', {
headers: {
'Linear-Signature': signature,
},
})
}
describe('Linear webhook provider', () => {
it('rejects signed requests when webhookTimestamp is missing', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'create',
type: 'Issue',
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects signed requests when webhookTimestamp skew is too large', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now() - 600_000,
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t2',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts signed requests within the allowed timestamp window', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now(),
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t3',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('accepts signed requests within the 5-minute skew window (wider than Linears literal 60s suggestion, to tolerate retries)', async () => {
const secret = 'linear-secret'
const rawBody = JSON.stringify({
action: 'update',
type: 'Issue',
webhookTimestamp: Date.now() - 61_000,
})
const res = await linearHandler.verifyAuth!({
request: requestWithLinearSignature(secret, rawBody),
rawBody,
requestId: 'linear-t4',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('skips verification entirely when no webhookSecret is configured', async () => {
const rawBody = JSON.stringify({ action: 'create', type: 'Issue' })
const res = await linearHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'linear-t5',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
describe('matchEvent', () => {
it('returns null-body-safe result instead of throwing when body is null', async () => {
const result = await linearHandler.matchEvent!({
body: null,
requestId: 'linear-t6',
providerConfig: { triggerId: 'linear_issue_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(result).toBe(false)
})
})
describe('formatInput', () => {
it('does not throw when body is null', async () => {
const { input } = await linearHandler.formatInput!({ body: null } as any)
expect(input).toMatchObject({ action: '', type: '', actor: null })
})
})
describe('extractIdempotencyId', () => {
it('builds a stable key from type, action, entity id, and updatedAt', () => {
const key = linearHandler.extractIdempotencyId!({
type: 'Issue',
action: 'update',
data: { id: 'issue-1', updatedAt: '2026-07-08T00:00:00.000Z' },
})
expect(key).toBe('linear:Issue:update:issue-1:2026-07-08T00:00:00.000Z')
})
it('falls back to createdAt when updatedAt is absent (create events)', () => {
const key = linearHandler.extractIdempotencyId!({
type: 'Comment',
action: 'create',
data: { id: 'comment-1', createdAt: '2026-07-08T00:00:00.000Z' },
})
expect(key).toBe('linear:Comment:create:comment-1:2026-07-08T00:00:00.000Z')
})
it('returns null when the entity id is missing', () => {
const key = linearHandler.extractIdempotencyId!({ type: 'Issue', action: 'create' })
expect(key).toBeNull()
})
it('returns null when type is missing', () => {
const key = linearHandler.extractIdempotencyId!({ action: 'create', data: { id: 'x' } })
expect(key).toBeNull()
})
it('returns null instead of throwing when the body is null', () => {
expect(linearHandler.extractIdempotencyId!(null)).toBeNull()
})
it('returns null instead of throwing when the body is a non-object', () => {
expect(linearHandler.extractIdempotencyId!('not an object')).toBeNull()
expect(linearHandler.extractIdempotencyId!(42)).toBeNull()
expect(linearHandler.extractIdempotencyId!(['array'])).toBeNull()
})
})
})
+367
View File
@@ -0,0 +1,367 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Linear')
function validateLinearSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Linear signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Linear signature comparison', {
computedSignature: `${computedHash.substring(0, 10)}...`,
providedSignature: `${signature.substring(0, 10)}...`,
computedLength: computedHash.length,
providedLength: signature.length,
match: computedHash === signature,
})
return safeCompare(computedHash, signature)
} catch (error) {
logger.error('Error validating Linear signature:', error)
return false
}
}
/**
* Linear's docs recommend a 60s window ("Reject any webhooks not within 60 seconds of the
* current time to prevent replay attacks") but do NOT document whether `webhookTimestamp` is
* re-stamped per delivery attempt or fixed to the original event time. Linear's own retry policy
* resends failed deliveries after 1 minute, 1 hour, and 6 hours (@see
* https://linear.app/developers/webhooks) — if the timestamp is fixed rather than refreshed per
* attempt, a strict 60s window would silently and permanently drop every legitimate 1hr/6hr retry
* following any transient outage on our side, since Linear gives up after 3 failed attempts.
* We keep a wider 5-minute window: idempotency dedup (Linear-Delivery header / extractIdempotencyId
* fallback below) already prevents double-processing of any replayed or retried delivery within
* that window, so the incremental replay-protection benefit of matching Linear's 60s suggestion
* literally is marginal compared to the risk of dropping real business events.
*/
const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000
const verifyLinearSignature = createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'Linear-Signature',
validateFn: validateLinearSignature,
providerLabel: 'Linear',
})
export const linearHandler: WebhookProviderHandler = {
async verifyAuth(ctx: AuthContext): Promise<NextResponse | null> {
const { rawBody, requestId, providerConfig } = ctx
if (!providerConfig.webhookSecret) {
return null
}
const signatureError = await verifyLinearSignature(ctx)
if (signatureError) return signatureError
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>
const ts = parsed.webhookTimestamp
if (typeof ts !== 'number' || !Number.isFinite(ts)) {
logger.warn(`[${requestId}] Linear webhookTimestamp missing or invalid`)
return new NextResponse('Unauthorized - Invalid webhook timestamp', {
status: 401,
})
}
if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) {
logger.warn(
`[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)`
)
return new NextResponse('Unauthorized - Webhook timestamp skew too large', {
status: 401,
})
}
} catch (error) {
logger.warn(
`[${requestId}] Linear webhook body parse failed after signature verification`,
error
)
return new NextResponse('Unauthorized - Invalid webhook body', { status: 401 })
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
const rawActor = b.actor
let actor: unknown = null
if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) {
const a = rawActor as Record<string, unknown>
const { type: linearActorType, ...rest } = a
actor = {
...rest,
actorType: typeof linearActorType === 'string' ? linearActorType : null,
}
}
return {
input: {
action: b.action || '',
type: b.type || '',
webhookId: b.webhookId || '',
webhookTimestamp: b.webhookTimestamp || 0,
organizationId: b.organizationId || '',
createdAt: b.createdAt || '',
url: typeof b.url === 'string' ? b.url : '',
actor,
data: b.data || null,
updatedFrom: b.updatedFrom || null,
},
}
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && !triggerId.endsWith('_webhook') && !triggerId.endsWith('_webhook_v2')) {
const { isLinearEventMatch } = await import('@/triggers/linear/utils')
const obj = isRecordLike(body) ? body : {}
const action = typeof obj.action === 'string' ? obj.action : undefined
const type = typeof obj.type === 'string' ? obj.type : undefined
if (!isLinearEventMatch(triggerId, type || '', action)) {
logger.debug(
`[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.`
)
return false
}
}
return true
},
/**
* Fallback for dedup when the `Linear-Delivery` header (already handled generically by the
* idempotency service) is unavailable. Keys on the entity id plus its own updatedAt/createdAt,
* not a request-time timestamp, so retried deliveries of the same event still collapse.
*/
extractIdempotencyId(body: unknown): string | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null
const b = body as Record<string, unknown>
const type = typeof b.type === 'string' ? b.type : undefined
const action = typeof b.action === 'string' ? b.action : undefined
const data = b.data as Record<string, unknown> | undefined
const id = typeof data?.id === 'string' ? data.id : undefined
if (!type || !id) {
return null
}
const version = data?.updatedAt || data?.createdAt || b.createdAt
return [`linear:${type}`, action, id, version].filter(Boolean).join(':')
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const triggerId = config.triggerId as string | undefined
if (!triggerId || !triggerId.endsWith('_v2')) {
return undefined
}
const apiKey = config.apiKey as string | undefined
if (!apiKey) {
logger.warn(`[${ctx.requestId}] Missing API key for Linear webhook ${ctx.webhook.id}`)
throw new Error(
'Linear API key is required. Please provide a valid API key in the trigger configuration.'
)
}
const { LINEAR_RESOURCE_TYPE_MAP } = await import('@/triggers/linear/utils')
const resourceTypes = LINEAR_RESOURCE_TYPE_MAP[triggerId]
if (!resourceTypes) {
logger.warn(`[${ctx.requestId}] Unknown Linear trigger ID: ${triggerId}`)
throw new Error(`Unknown Linear trigger type: ${triggerId}`)
}
const notificationUrl = getNotificationUrl(ctx.webhook)
const webhookSecret = generateId()
const teamId = config.teamId as string | undefined
const input: Record<string, unknown> = {
url: notificationUrl,
resourceTypes,
secret: webhookSecret,
enabled: true,
}
if (teamId) {
input.teamId = teamId
} else {
input.allPublicTeams = true
}
try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: apiKey,
},
body: JSON.stringify({
query: `mutation WebhookCreate($input: WebhookCreateInput!) {
webhookCreate(input: $input) {
success
webhook { id enabled }
}
}`,
variables: { input },
}),
})
if (!response.ok) {
throw new Error(
`Linear API returned HTTP ${response.status}. Please verify your API key and try again.`
)
}
const data = await response.json()
const result = data?.data?.webhookCreate
if (!result?.success) {
const errors = data?.errors?.map((e: { message: string }) => e.message).join(', ')
logger.error(`[${ctx.requestId}] Failed to create Linear webhook`, {
errors,
webhookId: ctx.webhook.id,
})
throw new Error(errors || 'Failed to create Linear webhook. Please verify your API key.')
}
const externalId = result.webhook?.id
if (typeof externalId !== 'string' || !externalId.trim()) {
throw new Error(
'Linear webhook was created but the API response did not include a webhook id.'
)
}
logger.info(
`[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}`
)
return {
providerConfigUpdates: {
externalId,
webhookSecret,
},
}
} catch (error) {
if (error instanceof Error && error.message !== 'fetch failed') {
throw error
}
logger.error(`[${ctx.requestId}] Error creating Linear webhook`, {
error: toError(error).message,
})
throw new Error('Failed to create Linear webhook. Please verify your API key and try again.')
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const triggerId = config.triggerId as string | undefined
if (!triggerId || !triggerId.endsWith('_v2')) {
return
}
const externalId = config.externalId as string | undefined
const apiKey = config.apiKey as string | undefined
if (!externalId || !apiKey) {
if (ctx.strict) throw new Error('Missing Linear webhook deletion credentials')
return
}
try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: apiKey,
},
body: JSON.stringify({
query: `mutation WebhookDelete($id: String!) {
webhookDelete(id: $id) { success }
}`,
variables: { id: externalId },
}),
})
if (!response.ok) {
logger.warn(
`[${ctx.requestId}] Linear API returned HTTP ${response.status} during webhook deletion for ${externalId}`
)
if (ctx.strict) throw new Error(`Linear webhook deletion failed: ${response.status}`)
return
}
const data = await response.json()
if (data?.data?.webhookDelete?.success) {
logger.info(
`[${ctx.requestId}] Deleted Linear webhook ${externalId} for webhook ${ctx.webhook.id}`
)
} else {
const errorMessages = getGraphQLErrorMessages(data)
if (errorMessages.some(isAlreadyAbsentWebhookMessage)) {
logger.info(
`[${ctx.requestId}] Linear webhook ${externalId} was already absent during deletion`
)
return
}
logger.warn(
`[${ctx.requestId}] Linear webhook deletion returned unsuccessful for ${externalId}`
)
if (ctx.strict) throw new Error('Linear webhook deletion returned unsuccessful')
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Linear webhook ${externalId} (non-fatal)`, {
error: toError(error).message,
})
if (ctx.strict) throw error
}
},
}
function getGraphQLErrorMessages(data: unknown): string[] {
if (!data || typeof data !== 'object' || Array.isArray(data)) return []
const errors = (data as Record<string, unknown>).errors
if (!Array.isArray(errors)) return []
return errors
.map((error) => {
if (!error || typeof error !== 'object' || Array.isArray(error)) return null
const message = (error as Record<string, unknown>).message
return typeof message === 'string' ? message : null
})
.filter((message): message is string => Boolean(message))
}
function isAlreadyAbsentWebhookMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes('not found') ||
normalized.includes('not_found') ||
normalized.includes('does not exist') ||
normalized.includes('already deleted')
)
}
+266
View File
@@ -0,0 +1,266 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { LINQ_API_BASE, linqHeaders } from '@/tools/linq/utils'
import { LINQ_ALL_WEBHOOK_EVENT_TYPES, LINQ_TRIGGER_TO_EVENT_TYPE } from '@/triggers/linq/utils'
const logger = createLogger('WebhookProvider:Linq')
/** Max clock skew tolerated between the webhook timestamp and now (seconds). */
const MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60
/**
* Verify a Linq webhook signature using the Standard Webhooks scheme.
* Linq signs `${webhook-id}.${webhook-timestamp}.${rawBody}` with HMAC-SHA256 using
* the base64-decoded `whsec_...` signing secret, and delivers the result as one or
* more space-separated `v1,<base64>` signatures in the `webhook-signature` header.
*/
function verifyLinqSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > MAX_TIMESTAMP_SKEW_SECONDS) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
for (const versionedSig of signatures.split(' ')) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
if (safeCompare(parts[1], expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Linq webhook signature:', error)
return false
}
}
/** Parse a comma/whitespace-separated list of phone numbers into a clean array. */
function parsePhoneNumbers(value: unknown): string[] {
if (typeof value !== 'string') return []
return value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
export const linqHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Linq webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Linq signing secret is required', { status: 401 })
}
const webhookId = request.headers.get('webhook-id')
const webhookTimestamp = request.headers.get('webhook-timestamp')
const webhookSignature = request.headers.get('webhook-signature')
if (!webhookId || !webhookTimestamp || !webhookSignature) {
logger.warn(`[${requestId}] Linq webhook missing Standard Webhooks signature headers`)
return new NextResponse('Unauthorized - Missing Linq signature headers', { status: 401 })
}
if (
!verifyLinqSignature(signingSecret, webhookId, webhookTimestamp, webhookSignature, rawBody)
) {
logger.warn(`[${requestId}] Linq webhook signature verification failed`)
return new NextResponse('Unauthorized - Invalid Linq signature', { status: 401 })
}
return null
},
matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'linq_webhook') {
return true
}
const expectedType = LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]
if (!expectedType) {
logger.debug(`[${requestId}] Unknown Linq triggerId ${triggerId}, skipping.`)
return false
}
const actualType = (body as Record<string, unknown>)?.event_type as string | undefined
if (actualType !== expectedType) {
logger.debug(
`[${requestId}] Linq event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
return {
input: {
eventType: payload.event_type ?? null,
eventId: payload.event_id ?? null,
createdAt: payload.created_at ?? null,
webhookVersion: payload.webhook_version ?? null,
data: payload.data ?? null,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const eventId = (body as Record<string, unknown>)?.event_id
return typeof eventId === 'string' && eventId.length > 0 ? eventId : null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Linq webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Linq API Key is required. Please provide your Linq API Key in the trigger configuration.'
)
}
const events =
triggerId === 'linq_webhook'
? LINQ_ALL_WEBHOOK_EVENT_TYPES
: triggerId && LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]
? [LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]]
: null
if (!events?.length) {
throw new Error(`Unknown or unsupported Linq trigger type: ${triggerId ?? '(missing)'}`)
}
const phoneNumbers = parsePhoneNumbers(providerConfig.triggerPhoneNumbers)
const requestBody: Record<string, unknown> = {
target_url: getNotificationUrl(webhook),
subscribed_events: events,
}
if (phoneNumbers.length > 0) {
requestBody.phone_numbers = phoneNumbers
}
logger.info(`[${requestId}] Creating Linq webhook subscription`, {
triggerId,
events,
webhookId: webhook.id,
})
const response = await fetch(`${LINQ_API_BASE}/webhook-subscriptions`, {
method: 'POST',
headers: linqHeaders(apiKey),
body: JSON.stringify(requestBody),
})
const responseBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
if (!response.ok) {
const errorMessage =
((responseBody.error as Record<string, unknown>)?.message as string) ||
(responseBody.message as string) ||
'Unknown Linq API error'
logger.error(
`[${requestId}] Failed to create Linq webhook subscription for webhook ${webhook.id}. Status: ${response.status}`,
{ message: errorMessage }
)
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Linq API Key. Please verify your API Key is correct.')
}
throw new Error(`Linq error: ${errorMessage}`)
}
const externalId = responseBody.id
const signingSecret = responseBody.signing_secret
if (typeof externalId !== 'string' || !externalId.trim()) {
throw new Error('Linq webhook was created but the API response did not include a webhook id.')
}
if (typeof signingSecret !== 'string' || !signingSecret.trim()) {
throw new Error(
'Linq webhook was created but the API response did not include a signing secret.'
)
}
logger.info(`[${requestId}] Successfully created Linq webhook subscription ${externalId}.`)
return {
providerConfigUpdates: {
externalId,
signingSecret,
},
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.triggerApiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey || !externalId) {
logger.warn(
`[${requestId}] Missing apiKey or externalId for Linq webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Linq webhook deletion credentials')
return
}
const response = await fetch(`${LINQ_API_BASE}/webhook-subscriptions/${externalId}`, {
method: 'DELETE',
headers: linqHeaders(apiKey),
})
if (!response.ok && response.status !== 404) {
logger.warn(
`[${requestId}] Failed to delete Linq webhook subscription (non-fatal): ${response.status}`
)
if (ctx.strict) {
throw new Error(`Failed to delete Linq webhook subscription: ${response.status}`)
}
} else {
logger.info(`[${requestId}] Successfully deleted Linq webhook subscription ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Linq webhook subscription (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { loopsHandler } from '@/lib/webhooks/providers/loops'
const deliveredBody = {
eventName: 'email.delivered',
eventTime: 1734425918,
webhookSchemaVersion: '1.0.0',
sourceType: 'campaign',
campaignId: 'cm4t1suns001uw6atri87v54s',
email: {
id: 'cm4t1sseg004tje7982991nan',
emailMessageId: 'cm4ittv1v001oow9hruou8na8',
subject: 'Subject of the email',
},
contactIdentity: {
id: 'cm4ittmhq0011ow9h6fb460yw',
email: 'test@example.com',
userId: null,
},
}
const campaignSentBody = {
eventName: 'campaign.email.sent',
eventTime: 1734425918,
webhookSchemaVersion: '1.0.0',
contactIdentity: {
id: 'cm4ittmhq0011ow9h6fb460yw',
email: 'test@example.com',
userId: null,
},
campaignId: 'cm4t1suns001uw6atri87v54s',
campaignName: 'Test Campaign',
email: {
id: 'cm4t1sv84004yje79hawr1fi1',
emailMessageId: 'cm4t1suns001ww6atotin3bn1',
subject: 'Test Subject',
},
mailingLists: [
{
id: 'cm4ittp2k000l12j3lgrzvlxt',
name: 'test mailing list',
description: null,
isPublic: true,
},
],
}
describe('Loops webhook provider', () => {
it('formatInput flattens documented email and contactIdentity fields', async () => {
const { input } = await loopsHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: deliveredBody,
headers: {},
requestId: 'test',
})
expect(input).toMatchObject({
eventName: 'email.delivered',
eventTime: 1734425918,
webhookSchemaVersion: '1.0.0',
sourceType: 'campaign',
campaignId: 'cm4t1suns001uw6atri87v54s',
emailId: 'cm4t1sseg004tje7982991nan',
emailMessageId: 'cm4ittv1v001oow9hruou8na8',
subject: 'Subject of the email',
contactId: 'cm4ittmhq0011ow9h6fb460yw',
contactEmail: 'test@example.com',
userId: null,
})
})
it('formatInput flattens sent-event campaignName, loopName, and mailingLists fields', async () => {
const { input } = await loopsHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: campaignSentBody,
headers: {},
requestId: 'test',
})
expect(input).toMatchObject({
eventName: 'campaign.email.sent',
sourceType: null,
campaignId: 'cm4t1suns001uw6atri87v54s',
campaignName: 'Test Campaign',
loopId: null,
loopName: null,
transactionalId: null,
emailId: 'cm4t1sv84004yje79hawr1fi1',
emailMessageId: 'cm4t1suns001ww6atotin3bn1',
subject: 'Test Subject',
contactId: 'cm4ittmhq0011ow9h6fb460yw',
contactEmail: 'test@example.com',
mailingLists: campaignSentBody.mailingLists,
})
})
it('matchEvent returns true for a sent event matching the configured trigger', async () => {
const result = await loopsHandler.matchEvent!({
webhook: {},
workflow: {},
body: campaignSentBody,
request: {} as never,
requestId: 'test',
providerConfig: { triggerId: 'loops_campaign_email_sent' },
})
expect(result).toBe(true)
})
it('matchEvent returns true when eventName matches the configured trigger', async () => {
const result = await loopsHandler.matchEvent!({
webhook: {},
workflow: {},
body: deliveredBody,
request: {} as never,
requestId: 'test',
providerConfig: { triggerId: 'loops_email_delivered' },
})
expect(result).toBe(true)
})
it('matchEvent returns false when eventName does not match the configured trigger', async () => {
const result = await loopsHandler.matchEvent!({
webhook: {},
workflow: {},
body: deliveredBody,
request: {} as never,
requestId: 'test',
providerConfig: { triggerId: 'loops_email_opened' },
})
expect(result).toBe(false)
})
it('verifyAuth returns 401 when signing secret is missing', async () => {
const response = await loopsHandler.verifyAuth!({
webhook: {},
workflow: {},
request: { headers: new Headers() } as never,
rawBody: JSON.stringify(deliveredBody),
requestId: 'test',
providerConfig: {},
})
expect(response).not.toBeNull()
expect(response?.status).toBe(401)
})
it('extractIdempotencyId combines eventName, email id, and eventTime', () => {
expect(loopsHandler.extractIdempotencyId!(deliveredBody)).toBe(
'email.delivered:cm4t1sseg004tje7982991nan:1734425918'
)
})
})
+147
View File
@@ -0,0 +1,147 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Loops')
const LOOPS_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60
/**
* Verify a Loops webhook signature.
* Loops uses a Svix-compatible signing scheme (its own implementation, not Svix-hosted):
* HMAC-SHA256 of `${webhookId}.${timestamp}.${body}` signed with the base64-decoded signing
* secret (provided as `prefix_base64string`). Delivery metadata arrives in the `Webhook-Id` and
* `Webhook-Timestamp` headers, and the `Webhook-Signature` header carries one or more
* space-separated `version,signature` pairs (e.g. `v1,<base64>`).
* @see https://loops.so/docs/webhooks
*/
function verifyLoopsSignature(
secret: string,
webhookId: string,
timestamp: string,
signatureHeader: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > LOOPS_WEBHOOK_TIMESTAMP_SKEW_SECONDS) {
return false
}
const base64Secret = secret.includes('_') ? secret.slice(secret.indexOf('_') + 1) : secret
const secretBytes = Buffer.from(base64Secret, 'base64')
const toSign = `${webhookId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
const providedSignatures = signatureHeader.split(' ')
for (const versionedSig of providedSignatures) {
const parts = versionedSig.split(',')
const sig = parts.length === 2 ? parts[1] : versionedSig
if (sig && safeCompare(sig, expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Loops signature:', error)
return false
}
}
export const loopsHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Loops webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Loops signing secret is required', { status: 401 })
}
const webhookId = request.headers.get('webhook-id')
const timestamp = request.headers.get('webhook-timestamp')
const signature = request.headers.get('webhook-signature')
if (!webhookId || !timestamp || !signature) {
logger.warn(`[${requestId}] Loops webhook missing signature headers`)
return new NextResponse('Unauthorized - Missing Loops signature headers', { status: 401 })
}
if (!verifyLoopsSignature(signingSecret, webhookId, timestamp, signature, rawBody)) {
logger.warn(`[${requestId}] Loops signature verification failed`)
return new NextResponse('Unauthorized - Invalid Loops signature', { status: 401 })
}
return null
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isLoopsEventMatch } = await import('@/triggers/loops/utils')
if (!isLoopsEventMatch(triggerId, body as Record<string, unknown>)) {
const actualEvent = (body as Record<string, unknown>)?.eventName
logger.debug(
`[${requestId}] Loops event mismatch for trigger ${triggerId}. Got: ${String(actualEvent)}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
const email = payload.email as Record<string, unknown> | undefined
const contactIdentity = payload.contactIdentity as Record<string, unknown> | undefined
return {
input: {
eventName: payload.eventName ?? null,
eventTime: payload.eventTime ?? null,
webhookSchemaVersion: payload.webhookSchemaVersion ?? null,
sourceType: payload.sourceType ?? null,
campaignId: payload.campaignId ?? null,
campaignName: payload.campaignName ?? null,
loopId: payload.loopId ?? null,
loopName: payload.loopName ?? null,
transactionalId: payload.transactionalId ?? null,
mailingLists: payload.mailingLists ?? null,
email: email ?? null,
emailId: email?.id ?? null,
emailMessageId: email?.emailMessageId ?? null,
subject: email?.subject ?? null,
contactIdentity: contactIdentity ?? null,
contactId: contactIdentity?.id ?? null,
contactEmail: contactIdentity?.email ?? null,
userId: contactIdentity?.userId ?? null,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown>
const eventName = obj?.eventName as string | undefined
const eventTime = obj?.eventTime
const email = obj?.email as Record<string, unknown> | undefined
const emailId = email?.id as string | undefined
if (eventName && emailId && eventTime != null) {
return `${eventName}:${emailId}:${String(eventTime)}`
}
return null
},
}
@@ -0,0 +1,222 @@
/**
* @vitest-environment node
*/
import { hmacSha256Base64 } from '@sim/security/hmac'
import { authOAuthUtilsMock, inputValidationMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
const WEBHOOK_ID = 'webhook-uuid-1234'
function makeRequest(body: string): NextRequest {
return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
})
}
function makeNotificationBody(clientState?: unknown): string {
return JSON.stringify({
value: [
{
subscriptionId: 'sub-1',
changeType: 'created',
resource: 'chats/19:abc@thread.v2/messages/1700000000000',
resourceData: { id: '1700000000000' },
...(clientState !== undefined ? { clientState } : {}),
},
],
})
}
async function runVerifyAuth(rawBody: string, providerConfig: Record<string, unknown>) {
return microsoftTeamsHandler.verifyAuth!({
webhook: { id: WEBHOOK_ID },
workflow: {},
request: makeRequest(rawBody),
rawBody,
requestId: 'test-req',
providerConfig,
})
}
describe('microsoftTeamsHandler verifyAuth (chat subscription clientState)', () => {
const chatSubscriptionConfig = { triggerId: 'microsoftteams_chat_subscription' }
beforeEach(() => {
vi.clearAllMocks()
})
it('accepts notifications whose clientState matches the webhook id', async () => {
const res = await runVerifyAuth(makeNotificationBody(WEBHOOK_ID), chatSubscriptionConfig)
expect(res).toBeNull()
})
it('rejects notifications with a forged clientState', async () => {
const res = await runVerifyAuth(makeNotificationBody('forged'), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects notifications missing clientState', async () => {
const res = await runVerifyAuth(makeNotificationBody(), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects non-string clientState values', async () => {
const res = await runVerifyAuth(
makeNotificationBody({ nested: WEBHOOK_ID }),
chatSubscriptionConfig
)
expect(res?.status).toBe(401)
})
it('rejects payloads without a value array', async () => {
const res = await runVerifyAuth(JSON.stringify({ hello: 'world' }), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects payloads with an empty value array', async () => {
const res = await runVerifyAuth(JSON.stringify({ value: [] }), chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects unparseable bodies', async () => {
const res = await runVerifyAuth('not-json', chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('rejects batches where any notification has a mismatched clientState', async () => {
const rawBody = JSON.stringify({
value: [
{ subscriptionId: 'sub-1', resourceData: { id: '1' }, clientState: WEBHOOK_ID },
{ subscriptionId: 'sub-2', resourceData: { id: '2' }, clientState: 'forged' },
],
})
const res = await runVerifyAuth(rawBody, chatSubscriptionConfig)
expect(res?.status).toBe(401)
})
it('fails closed when the webhook record has no id', async () => {
const res = await microsoftTeamsHandler.verifyAuth!({
webhook: {},
workflow: {},
request: makeRequest(makeNotificationBody('')),
rawBody: makeNotificationBody(''),
requestId: 'test-req',
providerConfig: chatSubscriptionConfig,
})
expect(res?.status).toBe(401)
})
})
describe('microsoftTeamsHandler verifyAuth (outgoing webhook HMAC)', () => {
const secretBase64 = Buffer.from('super-secret').toString('base64')
const outgoingWebhookConfig = { triggerId: 'microsoftteams_webhook', hmacSecret: secretBase64 }
beforeEach(() => {
vi.clearAllMocks()
})
function signedRequest(rawBody: string): NextRequest {
const signature = hmacSha256Base64(rawBody, Buffer.from(secretBase64, 'base64'))
return new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
method: 'POST',
body: rawBody,
headers: { 'Content-Type': 'application/json', Authorization: `HMAC ${signature}` },
})
}
it('accepts a request with a valid HMAC signature', async () => {
const rawBody = JSON.stringify({ type: 'message', text: 'hi' })
const res = await microsoftTeamsHandler.verifyAuth!({
webhook: { id: WEBHOOK_ID },
workflow: {},
request: signedRequest(rawBody),
rawBody,
requestId: 'test-req',
providerConfig: outgoingWebhookConfig,
})
expect(res).toBeNull()
})
it('rejects a request with an invalid HMAC signature', async () => {
const rawBody = JSON.stringify({ type: 'message', text: 'hi' })
const res = await microsoftTeamsHandler.verifyAuth!({
webhook: { id: WEBHOOK_ID },
workflow: {},
request: makeRequest(rawBody),
rawBody,
requestId: 'test-req',
providerConfig: { ...outgoingWebhookConfig, hmacSecret: undefined },
})
expect(res?.status).toBe(401)
})
it('fails closed when no HMAC secret is configured for an outgoing webhook trigger', async () => {
const rawBody = JSON.stringify({ type: 'message', text: 'hi' })
const res = await runVerifyAuth(rawBody, { triggerId: 'microsoftteams_webhook' })
expect(res?.status).toBe(401)
})
})
describe('microsoftTeamsHandler extractIdempotencyId', () => {
it('derives a key from subscriptionId + messageId for Graph change notifications', () => {
const body = JSON.parse(makeNotificationBody(WEBHOOK_ID)) as unknown
expect(microsoftTeamsHandler.extractIdempotencyId!(body)).toBe(`sub-1:1700000000000`)
})
it('derives a key from the Activity id for outgoing webhook messages', () => {
const body = { id: 'activity-123', type: 'message', text: 'hi' }
expect(microsoftTeamsHandler.extractIdempotencyId!(body)).toBe('activity-123')
})
it('returns null when neither shape yields a stable identifier', () => {
expect(microsoftTeamsHandler.extractIdempotencyId!({ type: 'message' })).toBeNull()
})
it('returns null instead of throwing when body is null', () => {
expect(microsoftTeamsHandler.extractIdempotencyId!(null)).toBeNull()
})
it('returns null instead of throwing when body is a primitive', () => {
expect(microsoftTeamsHandler.extractIdempotencyId!('not-an-object')).toBeNull()
})
})
describe('microsoftTeamsHandler formatInput (outgoing webhook channelData)', () => {
it('populates teamsTeamId/teamsChannelId from nested team/channel ids', async () => {
const body = {
id: 'activity-123',
type: 'message',
text: 'hello',
channelData: {
team: { id: 'team-1' },
channel: { id: 'channel-1' },
tenant: { id: 'tenant-1' },
},
}
const result = await microsoftTeamsHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
headers: {},
requestId: 'test-req',
})
const input = result.input as {
message: { raw: { channelData: Record<string, unknown> } }
}
expect(input.message.raw.channelData).toEqual({
team: { id: 'team-1' },
tenant: { id: 'tenant-1' },
channel: { id: 'channel-1' },
teamsTeamId: 'team-1',
teamsChannelId: 'channel-1',
})
})
})
@@ -0,0 +1,869 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { isMicrosoftContentUrl } from '@/lib/core/security/input-validation'
import {
type SecureFetchResponse,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
} from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventFilterContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:MicrosoftTeams')
function validateMicrosoftTeamsSignature(
hmacSecret: string,
signature: string,
body: string
): boolean {
try {
if (!hmacSecret || !signature || !body) {
return false
}
if (!signature.startsWith('HMAC ')) {
return false
}
const providedSignature = signature.substring(5)
const secretBytes = Buffer.from(hmacSecret, 'base64')
const computedHash = hmacSha256Base64(body, secretBytes)
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Microsoft Teams signature:', error)
return false
}
}
/**
* Derive a stable per-delivery identifier from a Teams webhook body.
*
* Graph change notifications (chat subscriptions) are keyed by
* `subscriptionId:messageId`. Outgoing webhook activities (channel
* @mentions) carry no `value` array — they're keyed by the Bot Framework
* Activity's own unique `id` instead.
*/
function extractNotificationKey(body: unknown): string | null {
if (!isRecordLike(body)) {
return null
}
const value = body.value
if (Array.isArray(value) && value.length > 0) {
const notification = value[0]
if (!isRecordLike(notification)) {
return null
}
const subscriptionId = notification.subscriptionId
const resourceData = notification.resourceData
const messageId = isRecordLike(resourceData) ? resourceData.id : undefined
if (typeof subscriptionId === 'string' && typeof messageId === 'string') {
return `${subscriptionId}:${messageId}`
}
return null
}
const activityId = body.id
return typeof activityId === 'string' && activityId ? activityId : 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: toError(error).message,
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 }
): 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 parsedContentUrl: URL
try {
parsedContentUrl = new URL(contentUrl)
} catch {
continue
}
const contentHost = parsedContentUrl.hostname.toLowerCase()
let buffer: Buffer | null = null
let mimeType = 'application/octet-stream'
const isOneDriveShareLink =
contentHost === '1drv.ms' ||
contentHost === '1drv.com' ||
contentHost === 'microsoftpersonalcontent.com' ||
contentHost.endsWith('.microsoftpersonalcontent.com')
if (isMicrosoftContentUrl(contentUrl) && !isOneDriveShareLink) {
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 (isOneDriveShareLink) {
try {
let shareToken: string | null = null
if (contentHost === '1drv.ms') {
const lastSegment = parsedContentUrl.pathname.split('/').pop()
if (lastSegment) shareToken = lastSegment
} else if (parsedContentUrl.searchParams.has('resid')) {
shareToken = parsedContentUrl.searchParams.get('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 = {
handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) {
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
return null
},
verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) {
if (providerConfig.triggerId !== 'microsoftteams_chat_subscription') {
const hmacSecret = providerConfig.hmacSecret as string | undefined
if (!hmacSecret) {
logger.error(
`[${requestId}] Microsoft Teams outgoing webhook missing configured HMAC secret`
)
return new NextResponse('Unauthorized - Missing HMAC secret configuration', {
status: 401,
})
}
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('HMAC ')) {
logger.warn(
`[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header`
)
return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 })
}
if (!validateMicrosoftTeamsSignature(hmacSecret, authHeader, rawBody)) {
logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`)
return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 })
}
}
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
const expectedClientState = String(webhook.id ?? '')
if (!expectedClientState) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription webhook missing id for clientState verification`
)
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
}
let notifications: unknown[] = []
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>
if (Array.isArray(parsed?.value)) {
notifications = parsed.value
}
} catch {
notifications = []
}
if (notifications.length === 0) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription notification missing value array`
)
return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 })
}
for (const notification of notifications) {
const clientState = (notification as Record<string, unknown>)?.clientState
if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) {
logger.warn(
`[${requestId}] Microsoft Teams chat subscription clientState verification failed`
)
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
}
}
}
return null
},
formatErrorResponse(error: string, status: number) {
return NextResponse.json({ type: 'message', text: error }, { status })
},
enrichHeaders({ body }: EventFilterContext, headers: Record<string, string>) {
const key = extractNotificationKey(body)
if (key) {
headers['x-teams-notification-id'] = key
}
},
extractIdempotencyId(body: unknown) {
return extractNotificationKey(body)
},
formatSuccessResponse(providerConfig: Record<string, unknown>) {
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
return new NextResponse(null, { status: 202 })
}
return NextResponse.json({ type: 'message', text: 'Sim' })
},
formatQueueErrorResponse() {
return NextResponse.json(
{ type: 'message', text: 'Webhook processing failed' },
{ status: 500 }
)
},
async createSubscription({
webhook,
workflow,
userId,
requestId,
request,
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(webhook)
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return undefined
}
const credentialId = config.credentialId as string | undefined
const chatId = config.chatId as string | undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`)
throw new Error(
'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.'
)
}
if (!chatId) {
logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`)
throw new Error(
'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.'
)
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`)
throw new Error(
'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.'
)
}
const existingSubscriptionId = config.externalSubscriptionId as string | undefined
if (existingSubscriptionId) {
try {
const checkRes = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`,
{ method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }
)
if (checkRes.ok) {
const existingPayload = await checkRes.json()
logger.info(
`[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`
)
return {
providerConfigUpdates: {
externalSubscriptionId: existingSubscriptionId,
subscriptionExpiration: existingPayload.expirationDateTime,
},
}
}
} catch {
logger.debug(`[${requestId}] Existing subscription check failed, will create new one`)
}
}
const notificationUrl = getNotificationUrl(webhook)
const resource = `/chats/${chatId}/messages`
const maxLifetimeMinutes = 4230
const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString()
const body = {
changeType: 'created,updated',
notificationUrl,
lifecycleNotificationUrl: notificationUrl,
resource,
includeResourceData: false,
expirationDateTime,
clientState: webhook.id,
}
try {
const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
const payload = await res.json()
if (!res.ok) {
const errorMessage =
payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error'
logger.error(
`[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`,
{
status: res.status,
error: payload.error,
}
)
let userFriendlyMessage = 'Failed to create Teams subscription'
if (res.status === 401 || res.status === 403) {
userFriendlyMessage =
'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.'
} else if (res.status === 404) {
userFriendlyMessage =
'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.'
} else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') {
userFriendlyMessage = `Teams error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}`
)
return {
providerConfigUpdates: {
externalSubscriptionId: payload.id as string,
subscriptionExpiration: payload.expirationDateTime as string,
},
}
} catch (error: unknown) {
if (
error instanceof Error &&
(error.message.includes('credentials') ||
error.message.includes('Chat ID') ||
error.message.includes('authenticate'))
) {
throw error
}
logger.error(
`[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`,
error
)
throw new Error(
error instanceof Error
? error.message
: 'Failed to create Teams subscription. Please try again.'
)
}
},
async deleteSubscription({
webhook,
workflow,
requestId,
strict,
}: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(webhook)
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return
}
const externalSubscriptionId = config.externalSubscriptionId as string | undefined
const credentialId = config.credentialId as string | undefined
if (!externalSubscriptionId || !credentialId) {
logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`)
if (strict) throw new Error('Missing Teams subscription cleanup configuration')
return
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
logger.warn(
`[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}`
)
if (strict) throw new Error('Missing Teams access token for subscription deletion')
return
}
const res = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (res.ok || res.status === 404) {
logger.info(
`[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`
)
} else {
const errorBody = await res.text()
logger.warn(
`[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}`
)
if (strict) throw new Error(`Failed to delete Teams subscription: ${res.status}`)
}
} catch (error) {
logger.error(
`[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`,
error
)
if (strict) throw error
}
},
async formatInput({
body,
webhook,
workflow,
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 result = await formatTeamsGraphNotification(b, webhook, workflow)
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>
const channelData = (b?.channelData || {}) as Record<string, unknown>
const channelDataTeam = (channelData.team || {}) as Record<string, unknown>
const channelDataChannel = (channelData.channel || {}) as Record<string, unknown>
const channelDataTenant = (channelData.tenant || {}) 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: {
team: { id: (channelDataTeam.id || '') as string },
tenant: { id: (channelDataTenant.id || '') as string },
channel: { id: (channelDataChannel.id || '') as string },
teamsTeamId: (channelData.teamsTeamId || channelDataTeam.id || '') as string,
teamsChannelId: (channelData.teamsChannelId || channelDataChannel.id || '') as string,
},
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,
},
},
}
},
}
+357
View File
@@ -0,0 +1,357 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import { validateMondayNumericId } from '@/lib/core/security/input-validation'
import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
} from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Monday')
const MONDAY_API_URL = 'https://api.monday.com/v2'
/**
* Resolves an OAuth access token from the webhook's credential configuration.
* Follows the Airtable pattern: credentialId → getCredentialOwner → refreshAccessTokenIfNeeded.
*/
async function resolveAccessToken(
config: Record<string, unknown>,
userId: string,
requestId: string
): Promise<string> {
const credentialId = config.credentialId as string | undefined
if (credentialId) {
const credentialOwner = await getCredentialOwner(credentialId, requestId)
if (credentialOwner) {
const token = await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
if (token) return token
}
}
const fallbackToken = await getOAuthToken(userId, 'monday')
if (fallbackToken) return fallbackToken
throw new Error(
'Monday.com account connection required. Please connect your Monday.com account in the trigger configuration and try again.'
)
}
export const mondayHandler: WebhookProviderHandler = {
/**
* Handle Monday.com's webhook challenge verification.
* When a webhook is created, Monday.com sends a POST with `{"challenge": "..."}`.
* We must echo back `{"challenge": "..."}` with a 200 status.
*/
handleChallenge(body: unknown) {
const payload = body as Record<string, unknown>
// Monday.com challenges have a `challenge` string field but no `type` field
// (Slack challenges use `type: 'url_verification'`). Check both conditions
// to avoid intercepting challenges meant for other providers.
if (payload && typeof payload.challenge === 'string' && !('type' in payload)) {
logger.info('Monday.com webhook challenge received, echoing back')
return NextResponse.json({ challenge: payload.challenge }, { status: 200 })
}
return null
},
/**
* Create a Monday.com webhook subscription via their GraphQL API.
* Monday.com webhooks are board-scoped and event-type-specific.
*/
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const triggerId = config.triggerId as string | undefined
const boardId = config.boardId as string | undefined
if (!triggerId) {
logger.warn(`[${ctx.requestId}] Missing triggerId for Monday webhook ${ctx.webhook.id}`)
throw new Error('Trigger type is required for Monday.com webhook creation.')
}
if (!boardId) {
logger.warn(`[${ctx.requestId}] Missing boardId for Monday webhook ${ctx.webhook.id}`)
throw new Error(
'Board ID is required. Please provide a valid Monday.com board ID in the trigger configuration.'
)
}
const boardIdValidation = validateMondayNumericId(boardId, 'boardId')
if (!boardIdValidation.isValid) {
throw new Error(boardIdValidation.error!)
}
const { MONDAY_EVENT_TYPE_MAP } = await import('@/triggers/monday/utils')
const eventType = MONDAY_EVENT_TYPE_MAP[triggerId]
if (!eventType) {
logger.warn(`[${ctx.requestId}] Unknown Monday trigger ID: ${triggerId}`)
throw new Error(`Unknown Monday.com trigger type: ${triggerId}`)
}
const accessToken = await resolveAccessToken(config, ctx.userId, ctx.requestId)
const notificationUrl = getNotificationUrl(ctx.webhook)
try {
const response = await fetch(MONDAY_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'API-Version': '2024-10',
Authorization: accessToken,
},
body: JSON.stringify({
query: `mutation { create_webhook(board_id: ${boardIdValidation.sanitized}, url: ${JSON.stringify(notificationUrl)}, event: ${eventType}) { id board_id } }`,
}),
})
if (!response.ok) {
throw new Error(
`Monday.com API returned HTTP ${response.status}. Please verify your account connection and try again.`
)
}
const data = await response.json()
const errors = data.errors as Array<{ message: string }> | undefined
if (errors && errors.length > 0) {
const errorMsg = errors.map((e) => e.message).join(', ')
logger.error(`[${ctx.requestId}] Failed to create Monday webhook`, {
errors: errorMsg,
webhookId: ctx.webhook.id,
})
throw new Error(errorMsg || 'Failed to create Monday.com webhook.')
}
if (data.error_message) {
throw new Error(data.error_message as string)
}
const result = data.data?.create_webhook
if (!result?.id) {
throw new Error(
'Monday.com webhook was created but the API response did not include a webhook ID.'
)
}
const externalId = String(result.id)
logger.info(
`[${ctx.requestId}] Created Monday webhook ${externalId} for webhook ${ctx.webhook.id} (event: ${eventType}, board: ${boardId})`
)
return {
providerConfigUpdates: {
externalId,
},
}
} catch (error) {
if (error instanceof Error && error.message !== 'fetch failed') {
throw error
}
logger.error(`[${ctx.requestId}] Error creating Monday webhook`, {
error: toError(error).message,
})
throw new Error(
'Failed to create Monday.com webhook. Please verify your account connection and board ID, then try again.'
)
}
},
/**
* Delete a Monday.com webhook subscription via their GraphQL API.
* Errors are logged but not thrown (non-fatal cleanup).
*/
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const externalId = config.externalId as string | undefined
if (!externalId) {
if (ctx.strict) throw new Error('Missing Monday externalId for webhook deletion')
return
}
const externalIdValidation = validateMondayNumericId(externalId, 'webhookId')
if (!externalIdValidation.isValid) {
logger.warn(
`[${ctx.requestId}] Invalid externalId format for Monday webhook deletion: ${externalId}`
)
if (ctx.strict) throw new Error('Invalid Monday externalId for webhook deletion')
return
}
let accessToken: string | null = null
try {
const credentialId = config.credentialId as string | undefined
if (credentialId) {
const credentialOwner = await getCredentialOwner(credentialId, ctx.requestId)
if (credentialOwner) {
accessToken = await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
ctx.requestId
)
}
}
} catch (error) {
logger.warn(
`[${ctx.requestId}] Could not resolve credentials for Monday webhook deletion (non-fatal)`,
{ error: toError(error).message }
)
if (ctx.strict) throw error
}
if (!accessToken) {
logger.warn(
`[${ctx.requestId}] No access token available for Monday webhook deletion ${externalId} (non-fatal)`
)
if (ctx.strict) throw new Error('Missing Monday access token for webhook deletion')
return
}
try {
const response = await fetch(MONDAY_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'API-Version': '2024-10',
Authorization: accessToken,
},
body: JSON.stringify({
query: `mutation { delete_webhook(id: ${externalIdValidation.sanitized}) { id board_id } }`,
}),
})
if (!response.ok) {
logger.warn(
`[${ctx.requestId}] Monday API returned HTTP ${response.status} during webhook deletion for ${externalId}`
)
if (ctx.strict) throw new Error(`Monday webhook deletion failed: ${response.status}`)
return
}
const data = await response.json()
if (data.errors?.length > 0 || data.error_message) {
const errorMsg =
data.errors?.map((e: { message: string }) => e.message).join(', ') ||
data.error_message ||
'Unknown error'
if (isAlreadyAbsentWebhookMessage(errorMsg)) {
logger.info(
`[${ctx.requestId}] Monday webhook ${externalId} was already absent during deletion`
)
return
}
logger.warn(
`[${ctx.requestId}] Monday webhook deletion GraphQL error for ${externalId}: ${errorMsg}`
)
if (ctx.strict) throw new Error(`Monday webhook deletion failed: ${errorMsg}`)
return
}
if (data.data?.delete_webhook?.id) {
logger.info(
`[${ctx.requestId}] Deleted Monday webhook ${externalId} for webhook ${ctx.webhook.id}`
)
} else {
logger.warn(`[${ctx.requestId}] Monday webhook deletion returned no data for ${externalId}`)
if (ctx.strict) throw new Error('Monday webhook deletion returned no data')
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Monday webhook ${externalId} (non-fatal)`, {
error: toError(error).message,
})
if (ctx.strict) throw error
}
},
/**
* Transform Monday.com webhook payload into trigger output format.
* Extracts fields from the `event` object and flattens them to match trigger outputs.
*/
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
const event = payload.event as Record<string, unknown> | undefined
if (!event) {
return {
input: payload,
}
}
const input: Record<string, unknown> = {
boardId: event.boardId ? String(event.boardId) : null,
itemId: event.pulseId ? String(event.pulseId) : event.itemId ? String(event.itemId) : null,
itemName: (event.pulseName as string) ?? null,
groupId: (event.groupId as string) ?? null,
userId: event.userId ? String(event.userId) : null,
triggerTime: (event.triggerTime as string) ?? null,
triggerUuid: (event.triggerUuid as string) ?? null,
subscriptionId: event.subscriptionId ? String(event.subscriptionId) : null,
}
if (event.columnId !== undefined) {
input.columnId = (event.columnId as string) ?? null
input.columnType = (event.columnType as string) ?? null
input.columnTitle = (event.columnTitle as string) ?? null
input.value = event.value ?? null
input.previousValue = event.previousValue ?? null
}
if (event.destGroupId !== undefined) {
input.destGroupId = (event.destGroupId as string) ?? null
input.sourceGroupId = (event.sourceGroupId as string) ?? null
}
if (event.parentItemId !== undefined) {
input.parentItemId = event.parentItemId ? String(event.parentItemId) : null
input.parentItemBoardId = event.parentItemBoardId ? String(event.parentItemBoardId) : null
}
if (event.updateId !== undefined) {
input.updateId = event.updateId ? String(event.updateId) : null
input.body = (event.body as string) ?? null
input.textBody = (event.textBody as string) ?? null
}
return { input }
},
/**
* Extract idempotency ID from Monday.com webhook payload.
* Uses the unique triggerUuid provided by Monday.com.
*/
extractIdempotencyId(body: unknown): string | null {
const payload = body as Record<string, unknown>
const event = payload.event as Record<string, unknown> | undefined
if (event?.triggerUuid) {
return String(event.triggerUuid)
}
return null
},
}
function isAlreadyAbsentWebhookMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes('not found') ||
normalized.includes('not_found') ||
normalized.includes('does not exist') ||
normalized.includes('already deleted')
)
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { notionHandler } from '@/lib/webhooks/providers/notion'
import { isNotionPayloadMatch } from '@/triggers/notion/utils'
describe('Notion webhook provider', () => {
it('matches both legacy and newer schema updated event names', () => {
expect(
isNotionPayloadMatch('notion_database_schema_updated', {
type: 'database.schema_updated',
})
).toBe(true)
expect(
isNotionPayloadMatch('notion_database_schema_updated', {
type: 'data_source.schema_updated',
})
).toBe(true)
})
it('builds a stable idempotency key from event type and id', () => {
const key = notionHandler.extractIdempotencyId!({
id: 'evt_123',
type: 'page.created',
})
expect(key).toBe('notion:page.created:evt_123')
})
})
+151
View File
@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Notion')
/**
* Validates a Notion webhook signature using HMAC SHA-256.
* Notion sends X-Notion-Signature as "sha256=<hex>".
*/
function validateNotionSignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
logger.warn('Notion signature validation missing required fields', {
hasSecret: !!secret,
hasSignature: !!signature,
hasBody: !!body,
})
return false
}
const providedHash = signature.startsWith('sha256=') ? signature.slice(7) : signature
const computedHash = hmacSha256Hex(body, secret)
logger.debug('Notion signature comparison', {
computedSignature: `${computedHash.substring(0, 10)}...`,
providedSignature: `${providedHash.substring(0, 10)}...`,
computedLength: computedHash.length,
providedLength: providedHash.length,
match: computedHash === providedHash,
})
return safeCompare(computedHash, providedHash)
} catch (error) {
logger.error('Error validating Notion signature:', error)
return false
}
}
export const notionHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-Notion-Signature',
validateFn: validateNotionSignature,
providerLabel: 'Notion',
}),
handleReachabilityTest(body: unknown, requestId: string) {
const obj = body as Record<string, unknown> | null
const verificationToken = obj?.verification_token
if (typeof verificationToken === 'string' && verificationToken.length > 0) {
logger.info(`[${requestId}] Notion verification request detected - returning 200`)
return NextResponse.json({
status: 'ok',
message: 'Webhook endpoint verified',
})
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const rawEntity =
b.entity && typeof b.entity === 'object' ? (b.entity as Record<string, unknown>) : {}
const rawData = b.data && typeof b.data === 'object' ? (b.data as Record<string, unknown>) : {}
const rawParent =
rawData.parent && typeof rawData.parent === 'object'
? (rawData.parent as Record<string, unknown>)
: null
const { type: entityType, ...entityRest } = rawEntity
const { type: _rawParentType, ...parentRest } = rawParent ?? {}
return {
input: {
id: b.id,
type: b.type,
timestamp: b.timestamp,
api_version: b.api_version,
workspace_id: b.workspace_id,
workspace_name: b.workspace_name,
subscription_id: b.subscription_id,
integration_id: b.integration_id,
attempt_number: b.attempt_number,
authors: b.authors || [],
accessible_by: b.accessible_by || [],
entity: {
...entityRest,
entity_type: entityType,
},
data: {
...rawData,
...(rawParent
? {
parent: {
...parentRest,
parent_type: rawParent.type,
},
}
: {}),
},
},
}
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
if (triggerId && triggerId !== 'notion_webhook') {
const { isNotionPayloadMatch } = await import('@/triggers/notion/utils')
if (!isNotionPayloadMatch(triggerId, obj)) {
const eventType = obj.type as string | undefined
logger.debug(
`[${requestId}] Notion event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`,
{
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
receivedEvent: eventType,
}
)
return false
}
}
return true
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
const id = obj.id
const type = obj.type
if (
(typeof id === 'string' || typeof id === 'number') &&
(typeof type === 'string' || typeof type === 'number')
) {
return `notion:${String(type)}:${String(id)}`
}
return null
},
}
+113
View File
@@ -0,0 +1,113 @@
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type {
FormatInputContext,
FormatInputResult,
PollingConfigContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Outlook')
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 }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const credentialId = providerConfig.credentialId as string | undefined
if (!credentialId) {
logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`)
return false
}
const resolvedOutlook = await resolveOAuthAccountId(credentialId)
if (!resolvedOutlook) {
logger.error(
`[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}`
)
return false
}
const rows = await db
.select()
.from(account)
.where(eq(account.id, resolvedOutlook.accountId))
.limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
const effectiveUserId = rows[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
resolvedOutlook.accountId,
effectiveUserId,
requestId
)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
return false
}
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll:
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: (providerConfig.maxEmailsPerPoll as number) || 25,
pollingInterval:
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: (providerConfig.pollingInterval as number) || 5,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp:
(providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
logger.info(
`[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}`
)
return true
} catch (error: unknown) {
const err = error as Error
logger.error(`[${requestId}] Failed to configure Outlook polling`, {
webhookId: webhookData.id,
error: err.message,
stack: err.stack,
})
return false
}
},
}
@@ -0,0 +1,226 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:PagerDuty')
const PAGERDUTY_API_BASE = 'https://api.pagerduty.com'
/** Shared headers for PagerDuty REST API calls (the v2 Accept header is required). */
function pagerdutyHeaders(apiKey: string): Record<string, string> {
return {
Authorization: `Token token=${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.pagerduty+json;version=2',
}
}
/**
* PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the
* `X-PagerDuty-Signature` header as one or more comma-separated `v1=<hex>`
* values (multiple appear during signing-secret rotation). The delivery is
* valid when our computed signature matches any of them.
*/
function validatePagerDutySignature(secret: string, signature: string, body: string): boolean {
if (!secret || !signature || !body) return false
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
return signature
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('v1='))
.some((part) => safeCompare(part.slice(3), computed))
}
function asRecord(value: unknown): Record<string, unknown> {
return (value as Record<string, unknown>) || {}
}
/**
* Best-effort cleanup of a webhook subscription after a failed setup. Deletes by
* id when known, otherwise finds the subscription pointing at `url` and deletes
* it, so a created subscription is never orphaned in PagerDuty.
*/
async function cleanupPagerDutySubscription(
apiKey: string,
url: string,
subscriptionId?: string
): Promise<void> {
let id = subscriptionId
if (!id) {
const listRes = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, {
headers: pagerdutyHeaders(apiKey),
}).catch(() => null)
if (!listRes || !listRes.ok) return
const body = (await listRes.json().catch(() => null)) as {
webhook_subscriptions?: Array<{ id?: string; delivery_method?: { url?: string } }>
} | null
id = body?.webhook_subscriptions?.find((sub) => sub.delivery_method?.url === url)?.id
}
if (!id) return
await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${id}`, {
method: 'DELETE',
headers: pagerdutyHeaders(apiKey),
}).catch(() => null)
}
function referenceSummary(
value: unknown
): { id?: unknown; summary?: unknown; html_url?: unknown } | null {
if (!value || typeof value !== 'object') return null
const ref = value as Record<string, unknown>
return { id: ref.id, summary: ref.summary, html_url: ref.html_url }
}
export const pagerdutyHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-PagerDuty-Signature',
validateFn: validatePagerDutySignature,
providerLabel: 'PagerDuty',
// The signing secret is captured during auto-registration, so a missing
// secret means misconfiguration — fail closed rather than skip verification.
requireSecret: true,
}),
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'pagerduty_webhook') return true
const event = asRecord(asRecord(body).event)
const eventType = event.event_type as string | undefined
const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils')
if (!isPagerDutyEventMatch(triggerId, eventType || '')) {
logger.debug(
`[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const event = asRecord(asRecord(body).event)
const data = asRecord(event.data)
const priority = referenceSummary(data.priority)
return {
input: {
event_id: event.id,
event_type: event.event_type,
occurred_at: event.occurred_at,
agent: event.agent ?? null,
incident: {
id: data.id,
number: data.number,
title: data.title,
status: data.status,
urgency: data.urgency,
html_url: data.html_url,
created_at: data.created_at,
priority: priority?.summary ?? null,
service: referenceSummary(data.service),
escalation_policy: referenceSummary(data.escalation_policy),
assignees: Array.isArray(data.assignees) ? data.assignees : [],
},
},
}
},
extractIdempotencyId(body: unknown) {
const event = asRecord(asRecord(body).event)
return (event.id as string | undefined) || null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const triggerId = config.triggerId as string | undefined
if (!apiKey)
throw new Error('PagerDuty API Key is required to create the webhook subscription.')
const { getPagerDutyEvents } = await import('@/triggers/pagerduty/utils')
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, {
method: 'POST',
headers: pagerdutyHeaders(apiKey),
body: JSON.stringify({
webhook_subscription: {
type: 'webhook_subscription',
delivery_method: { type: 'http_delivery_method', url: getNotificationUrl(ctx.webhook) },
events: getPagerDutyEvents(triggerId ?? 'pagerduty_webhook'),
filter: { type: 'account_reference' },
},
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, {
detail,
})
if (res.status === 401)
throw new Error('PagerDuty authentication failed. Verify your REST API key.')
if (res.status === 403)
throw new Error('PagerDuty access denied. The API key must have read/write access.')
throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`)
}
const created = asRecord((await res.json().catch(() => ({}))) as unknown)
const subscription = asRecord(created.webhook_subscription)
const externalId = subscription.id as string | undefined
const secret = asRecord(subscription.delivery_method).secret as string | undefined
// The subscription exists once PagerDuty returns success; if it is missing
// its id or signing secret, delete it so it is not orphaned, then fail.
if (!externalId || !secret) {
await cleanupPagerDutySubscription(apiKey, getNotificationUrl(ctx.webhook), externalId)
if (!externalId) {
throw new Error('PagerDuty webhook created but no subscription ID was returned.')
}
throw new Error('PagerDuty webhook created but no signing secret was returned on creation.')
}
logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`)
return { providerConfigUpdates: { externalId, webhookSecret: secret } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey || !externalId) {
if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.')
logger.warn(
`[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID`
)
return
}
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${externalId}`, {
method: 'DELETE',
headers: pagerdutyHeaders(apiKey),
})
if (!res.ok && res.status !== 404) {
if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`)
logger.warn(
`[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}`
)
return
}
logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`)
},
}
+146
View File
@@ -0,0 +1,146 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { airtableHandler } from '@/lib/webhooks/providers/airtable'
import { ashbyHandler } from '@/lib/webhooks/providers/ashby'
import { attioHandler } from '@/lib/webhooks/providers/attio'
import { azureDevOpsHandler } from '@/lib/webhooks/providers/azure-devops'
import { calcomHandler } from '@/lib/webhooks/providers/calcom'
import { calendlyHandler } from '@/lib/webhooks/providers/calendly'
import { circlebackHandler } from '@/lib/webhooks/providers/circleback'
import { clerkHandler } from '@/lib/webhooks/providers/clerk'
import { confluenceHandler } from '@/lib/webhooks/providers/confluence'
import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison'
import { fathomHandler } from '@/lib/webhooks/providers/fathom'
import { firefliesHandler } from '@/lib/webhooks/providers/fireflies'
import { genericHandler } from '@/lib/webhooks/providers/generic'
import { githubHandler } from '@/lib/webhooks/providers/github'
import { gitlabHandler } from '@/lib/webhooks/providers/gitlab'
import { gmailHandler } from '@/lib/webhooks/providers/gmail'
import { gongHandler } from '@/lib/webhooks/providers/gong'
import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms'
import { grainHandler } from '@/lib/webhooks/providers/grain'
import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse'
import { imapHandler } from '@/lib/webhooks/providers/imap'
import { incidentioHandler } from '@/lib/webhooks/providers/incidentio'
import { instantlyHandler } from '@/lib/webhooks/providers/instantly'
import { intercomHandler } from '@/lib/webhooks/providers/intercom'
import { jiraHandler } from '@/lib/webhooks/providers/jira'
import { jsmHandler } from '@/lib/webhooks/providers/jsm'
import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
import { linearHandler } from '@/lib/webhooks/providers/linear'
import { linqHandler } from '@/lib/webhooks/providers/linq'
import { loopsHandler } from '@/lib/webhooks/providers/loops'
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
import { mondayHandler } from '@/lib/webhooks/providers/monday'
import { notionHandler } from '@/lib/webhooks/providers/notion'
import { outlookHandler } from '@/lib/webhooks/providers/outlook'
import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty'
import { resendHandler } from '@/lib/webhooks/providers/resend'
import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat'
import { rootlyHandler } from '@/lib/webhooks/providers/rootly'
import { rssHandler } from '@/lib/webhooks/providers/rss'
import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
import { sendblueHandler } from '@/lib/webhooks/providers/sendblue'
import { sentryHandler } from '@/lib/webhooks/providers/sentry'
import { servicenowHandler } from '@/lib/webhooks/providers/servicenow'
import { slackHandler } from '@/lib/webhooks/providers/slack'
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
import { tableProviderHandler } from '@/lib/webhooks/providers/table'
import { telegramHandler } from '@/lib/webhooks/providers/telegram'
import { tiktokHandler } from '@/lib/webhooks/providers/tiktok'
import { twilioHandler } from '@/lib/webhooks/providers/twilio'
import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice'
import { typeformHandler } from '@/lib/webhooks/providers/typeform'
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
import { vercelHandler } from '@/lib/webhooks/providers/vercel'
import { webflowHandler } from '@/lib/webhooks/providers/webflow'
import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp'
import { zendeskHandler } from '@/lib/webhooks/providers/zendesk'
import { zoomHandler } from '@/lib/webhooks/providers/zoom'
const logger = createLogger('WebhookProviderRegistry')
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
airtable: airtableHandler,
ashby: ashbyHandler,
attio: attioHandler,
azure_devops: azureDevOpsHandler,
calendly: calendlyHandler,
calcom: calcomHandler,
circleback: circlebackHandler,
clerk: clerkHandler,
confluence: confluenceHandler,
emailbison: emailBisonHandler,
fireflies: firefliesHandler,
generic: genericHandler,
gmail: gmailHandler,
github: githubHandler,
gitlab: gitlabHandler,
gong: gongHandler,
google_forms: googleFormsHandler,
fathom: fathomHandler,
grain: grainHandler,
greenhouse: greenhouseHandler,
imap: imapHandler,
incidentio: incidentioHandler,
intercom: intercomHandler,
instantly: instantlyHandler,
jira: jiraHandler,
jsm: jsmHandler,
lemlist: lemlistHandler,
linear: linearHandler,
linq: linqHandler,
loops: loopsHandler,
monday: mondayHandler,
resend: resendHandler,
revenuecat: revenueCatHandler,
rootly: rootlyHandler,
sentry: sentryHandler,
'microsoft-teams': microsoftTeamsHandler,
notion: notionHandler,
outlook: outlookHandler,
pagerduty: pagerdutyHandler,
rss: rssHandler,
salesforce: salesforceHandler,
sendblue: sendblueHandler,
servicenow: servicenowHandler,
slack: slackHandler,
// Native OAuth Slack trigger — inbound events are verified in the shared
// /api/webhooks/slack route; the handler reuses Slack payload normalization.
slack_app: slackHandler,
stripe: stripeHandler,
table: tableProviderHandler,
telegram: telegramHandler,
tiktok: tiktokHandler,
twilio: twilioHandler,
twilio_voice: twilioVoiceHandler,
typeform: typeformHandler,
vercel: vercelHandler,
webflow: webflowHandler,
whatsapp: whatsappHandler,
zendesk: zendeskHandler,
zoom: zoomHandler,
}
/**
* Default handler for unknown/future providers.
* Uses timing-safe comparison for bearer token validation.
*/
const defaultHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }) {
const token = providerConfig.token
if (typeof token === 'string') {
if (!verifyTokenAuth(request, token)) {
logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`)
return new NextResponse('Unauthorized', { status: 401 })
}
}
return null
},
}
/** Look up the provider handler, falling back to the default bearer token handler. */
export function getProviderHandler(provider: string): WebhookProviderHandler {
return PROVIDER_HANDLERS[provider] ?? defaultHandler
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { resendHandler } from '@/lib/webhooks/providers/resend'
describe('Resend webhook provider', () => {
it('formatInput exposes documented email metadata and distinct data.created_at', async () => {
const { input } = await resendHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body: {
type: 'email.bounced',
created_at: '2024-11-22T23:41:12.126Z',
data: {
broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216',
created_at: '2024-11-22T23:41:11.894719+00:00',
email_id: '56761188-7520-42d8-8898-ff6fc54ce618',
from: 'Acme <onboarding@resend.dev>',
to: ['delivered@resend.dev'],
subject: 'Sending this example',
template_id: '43f68331-0622-4e15-8202-246a0388854b',
tags: { category: 'confirm_email' },
bounce: {
message: 'Hard bounce',
subType: 'Suppressed',
type: 'Permanent',
},
},
},
headers: {},
requestId: 'test',
})
expect(input).toMatchObject({
type: 'email.bounced',
created_at: '2024-11-22T23:41:12.126Z',
data_created_at: '2024-11-22T23:41:11.894719+00:00',
email_id: '56761188-7520-42d8-8898-ff6fc54ce618',
broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216',
template_id: '43f68331-0622-4e15-8202-246a0388854b',
tags: { category: 'confirm_email' },
bounceType: 'Permanent',
bounceSubType: 'Suppressed',
bounceMessage: 'Hard bounce',
})
})
})
+299
View File
@@ -0,0 +1,299 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import {
RESEND_ALL_WEBHOOK_EVENT_TYPES,
RESEND_TRIGGER_TO_EVENT_TYPE,
} from '@/triggers/resend/utils'
const logger = createLogger('WebhookProvider:Resend')
/**
* Verify a Resend webhook signature using the Svix signing scheme.
* Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
* signed with the base64-decoded `whsec_...` secret.
*/
function verifySvixSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
const providedSignatures = signatures.split(' ')
for (const versionedSig of providedSignatures) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
const sig = parts[1]
if (safeCompare(sig, expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Resend Svix signature:', error)
return false
}
}
export const resendHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Resend webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Resend signing secret is required', { status: 401 })
}
const svixId = request.headers.get('svix-id')
const svixTimestamp = request.headers.get('svix-timestamp')
const svixSignature = request.headers.get('svix-signature')
if (!svixId || !svixTimestamp || !svixSignature) {
logger.warn(`[${requestId}] Resend webhook missing Svix signature headers`)
return new NextResponse('Unauthorized - Missing Resend signature headers', { status: 401 })
}
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
logger.warn(`[${requestId}] Resend Svix signature verification failed`)
return new NextResponse('Unauthorized - Invalid Resend signature', { status: 401 })
}
return null
},
matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'resend_webhook') {
return true
}
const expectedType = RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]
if (!expectedType) {
logger.debug(`[${requestId}] Unknown Resend triggerId ${triggerId}, skipping.`)
return false
}
const actualType = (body as Record<string, unknown>)?.type as string | undefined
if (actualType !== expectedType) {
logger.debug(
`[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
const data = payload.data as Record<string, unknown> | undefined
const bounce = data?.bounce as Record<string, unknown> | undefined
const click = data?.click as Record<string, unknown> | undefined
const dataCreatedAt = data?.created_at
const dataCreatedAtStr =
typeof dataCreatedAt === 'string'
? dataCreatedAt
: dataCreatedAt != null
? String(dataCreatedAt)
: null
return {
input: {
type: payload.type,
created_at: payload.created_at,
data_created_at: dataCreatedAtStr,
data: data ?? null,
email_id: data?.email_id ?? null,
broadcast_id: data?.broadcast_id ?? null,
template_id: data?.template_id ?? null,
tags: data?.tags ?? null,
from: data?.from ?? null,
to: data?.to ?? null,
subject: data?.subject ?? null,
bounceType: bounce?.type ?? null,
bounceSubType: bounce?.subType ?? null,
bounceMessage: bounce?.message ?? null,
clickIpAddress: click?.ipAddress ?? null,
clickLink: click?.link ?? null,
clickTimestamp: click?.timestamp ?? null,
clickUserAgent: click?.userAgent ?? null,
},
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Resend webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Resend API Key is required. Please provide your Resend API Key in the trigger configuration.'
)
}
const events =
triggerId === 'resend_webhook'
? RESEND_ALL_WEBHOOK_EVENT_TYPES
: triggerId && RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]
? [RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]]
: null
if (!events?.length) {
throw new Error(`Unknown or unsupported Resend trigger type: ${triggerId ?? '(missing)'}`)
}
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Resend webhook`, {
triggerId,
events,
webhookId: webhook.id,
})
const resendResponse = await fetch('https://api.resend.com/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: notificationUrl,
events,
}),
})
const responseBody = (await resendResponse.json()) as Record<string, unknown>
if (!resendResponse.ok) {
const errorMessage =
(responseBody.message as string) ||
(responseBody.name as string) ||
'Unknown Resend API error'
logger.error(
`[${requestId}] Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Resend'
if (resendResponse.status === 401 || resendResponse.status === 403) {
userFriendlyMessage = 'Invalid Resend API Key. Please verify your API Key is correct.'
} else if (errorMessage && errorMessage !== 'Unknown Resend API error') {
userFriendlyMessage = `Resend error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const externalId = responseBody.id
const signingSecretOut = responseBody.signing_secret
if (typeof externalId !== 'string' || !externalId.trim()) {
throw new Error(
'Resend webhook was created but the API response did not include a webhook id.'
)
}
if (typeof signingSecretOut !== 'string' || !signingSecretOut.trim()) {
throw new Error(
'Resend webhook was created but the API response did not include a signing secret.'
)
}
logger.info(
`[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`,
{
resendWebhookId: externalId,
}
)
return {
providerConfigUpdates: {
externalId,
signingSecret: signingSecretOut,
},
}
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Resend webhook creation for webhook ${webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey || !externalId) {
logger.warn(
`[${requestId}] Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Resend webhook deletion credentials')
return
}
const resendResponse = await fetch(`https://api.resend.com/webhooks/${externalId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!resendResponse.ok && resendResponse.status !== 404) {
const responseBody = await resendResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) throw new Error(`Failed to delete Resend webhook: ${resendResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -0,0 +1,305 @@
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat'
function requestWithAuth(authValue?: string): NextRequest {
return new NextRequest('http://localhost/test', {
headers: authValue ? { authorization: authValue } : {},
})
}
const sampleInitialPurchase = {
api_version: '1.0',
event: {
type: 'INITIAL_PURCHASE',
id: '12345678-1234-1234-1234-123456789012',
app_id: '1234567890',
event_timestamp_ms: 1658726378679,
app_user_id: '1234567890',
original_app_user_id: '$RCAnonymousID:abc',
aliases: ['$RCAnonymousID:abc'],
product_id: 'com.subscription.weekly',
period_type: 'NORMAL',
purchased_at_ms: 1658726374000,
expiration_at_ms: 1659331174000,
environment: 'PRODUCTION',
entitlement_id: null,
entitlement_ids: ['pro'],
presented_offering_id: null,
transaction_id: '123456789012345',
original_transaction_id: '123456789012345',
is_family_share: false,
country_code: 'US',
currency: 'USD',
price: 4.99,
price_in_purchased_currency: 4.99,
store: 'APP_STORE',
takehome_percentage: 0.7,
tax_percentage: 0.0,
commission_percentage: 0.3,
offer_code: null,
subscriber_attributes: { $email: { updated_at_ms: 1662955084635, value: 'a@b.com' } },
experiments: [],
},
}
describe('RevenueCat webhook provider', () => {
describe('verifyAuth', () => {
const secret = 'super-secret-header-value'
it('rejects requests when no secret is configured (fail-closed)', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(),
rawBody: '{}',
requestId: 'rc-1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests missing the Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(),
rawBody: '{}',
requestId: 'rc-2',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests with a mismatched Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth('wrong-value'),
rawBody: '{}',
requestId: 'rc-3',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts requests with a matching Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(secret),
rawBody: '{}',
requestId: 'rc-4',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('matchEvent', () => {
it('matches the configured event type', async () => {
const res = await revenueCatHandler.matchEvent!({
body: sampleInitialPurchase,
request: requestWithAuth(),
requestId: 'rc-5',
providerConfig: { triggerId: 'revenuecat_initial_purchase' },
webhook: {},
workflow: {},
})
expect(res).toBe(true)
})
it('skips events whose type does not match the trigger', async () => {
const res = await revenueCatHandler.matchEvent!({
body: sampleInitialPurchase,
request: requestWithAuth(),
requestId: 'rc-6',
providerConfig: { triggerId: 'revenuecat_cancellation' },
webhook: {},
workflow: {},
})
expect(res).toBe(false)
})
})
describe('formatInput', () => {
it('flattens the event wrapper into the trigger output keys', async () => {
const { input } = await revenueCatHandler.formatInput!({
body: sampleInitialPurchase,
webhook: {},
workflow: { id: 'wf', userId: 'user' },
headers: {},
requestId: 'rc-7',
})
const data = input as Record<string, unknown>
expect(data.type).toBe('INITIAL_PURCHASE')
expect(data.app_user_id).toBe('1234567890')
expect(data.product_id).toBe('com.subscription.weekly')
expect(data.price).toBe(4.99)
expect(data.entitlement_ids).toEqual(['pro'])
expect(data.cancel_reason).toBeNull()
expect(data.new_product_id).toBeNull()
expect(data.api_version).toBe('1.0')
expect(data.event).toEqual(sampleInitialPurchase.event)
})
})
describe('createSubscription', () => {
const baseCtx = {
webhook: {
id: 'wh-1',
path: 'abc123',
providerConfig: {
apiKey: 'sk_test',
projectId: 'proj1ab2c3d4',
triggerId: 'revenuecat_initial_purchase',
environment: 'all',
},
},
workflow: {},
userId: 'user-1',
requestId: 'rc-create',
request: requestWithAuth(),
}
beforeEach(() => {
vi.restoreAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://sim.example.com')
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('creates the integration and returns externalId + generated authHeaderSecret', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'wh_remote_1' }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock)
const result = await revenueCatHandler.createSubscription!(baseCtx)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.revenuecat.com/v2/projects/proj1ab2c3d4/integrations/webhooks')
expect(init.method).toBe('POST')
expect(init.headers.Authorization).toBe('Bearer sk_test')
const body = JSON.parse(init.body)
expect(body.event_types).toEqual(['initial_purchase'])
expect(body.url).toContain('/api/webhooks/trigger/abc123')
expect(typeof body.authorization_header).toBe('string')
expect(body.authorization_header.length).toBeGreaterThan(0)
expect(body.environment).toBeUndefined()
expect(result?.providerConfigUpdates?.externalId).toBe('wh_remote_1')
expect(result?.providerConfigUpdates?.authHeaderSecret).toBe(body.authorization_header)
})
it('forwards a concrete environment when set to production', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'wh_remote_2' }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock)
await revenueCatHandler.createSubscription!({
...baseCtx,
webhook: {
...baseCtx.webhook,
providerConfig: { ...baseCtx.webhook.providerConfig, environment: 'production' },
},
})
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.environment).toBe('production')
})
it('throws when the API key is missing', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
revenueCatHandler.createSubscription!({
...baseCtx,
webhook: { ...baseCtx.webhook, providerConfig: { projectId: 'proj1ab2c3d4' } },
})
).rejects.toThrow(/Secret API key/)
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws a friendly error on a 401 from RevenueCat', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 401 })))
await expect(revenueCatHandler.createSubscription!(baseCtx)).rejects.toThrow(
/authentication failed/i
)
})
})
describe('deleteSubscription', () => {
const baseCtx = {
webhook: {
id: 'wh-1',
providerConfig: { apiKey: 'sk_test', projectId: 'proj1ab2c3d4', externalId: 'wh_remote_1' },
},
workflow: {},
requestId: 'rc-delete',
}
afterEach(() => {
vi.restoreAllMocks()
})
it('issues a DELETE to the integration endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await revenueCatHandler.deleteSubscription!(baseCtx)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe(
'https://api.revenuecat.com/v2/projects/proj1ab2c3d4/integrations/webhooks/wh_remote_1'
)
expect(init.method).toBe('DELETE')
expect(init.headers.Authorization).toBe('Bearer sk_test')
})
it('does not throw on a 404 (already gone)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 404 })))
await expect(revenueCatHandler.deleteSubscription!(baseCtx)).resolves.toBeUndefined()
})
it('skips silently when credentials are missing (non-strict)', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
revenueCatHandler.deleteSubscription!({
...baseCtx,
webhook: { id: 'wh-1', providerConfig: {} },
})
).resolves.toBeUndefined()
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws on missing credentials when strict', async () => {
vi.stubGlobal('fetch', vi.fn())
await expect(
revenueCatHandler.deleteSubscription!({
...baseCtx,
webhook: { id: 'wh-1', providerConfig: {} },
strict: true,
})
).rejects.toThrow(/Missing RevenueCat credentials/)
})
})
describe('extractIdempotencyId', () => {
it('returns the event id', () => {
expect(revenueCatHandler.extractIdempotencyId!(sampleInitialPurchase)).toBe(
'12345678-1234-1234-1234-123456789012'
)
})
it('returns null when no event id is present', () => {
expect(revenueCatHandler.extractIdempotencyId!({ event: {} })).toBeNull()
})
})
})
@@ -0,0 +1,278 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { generateId } from '@sim/utils/id'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:RevenueCat')
/** Base URL for the RevenueCat REST API v2. */
const REVENUECAT_API_BASE = 'https://api.revenuecat.com/v2'
/**
* RevenueCat webhook handler.
*
* RevenueCat does not sign payloads. Instead, an Authorization header value is sent
* verbatim on every request. Sim generates this secret and sets it when it creates the
* webhook integration (see `createSubscription`), so it is always present once the
* trigger is deployed. We verify the incoming `Authorization` header against it using a
* timing-safe comparison and fail closed if the secret is missing from config.
*
* @see https://www.revenuecat.com/docs/integrations/webhooks
*/
export const revenueCatHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = providerConfig.authHeaderSecret as string | undefined
if (!secret) {
logger.warn(
`[${requestId}] RevenueCat webhook missing Authorization secret in provider configuration`
)
return new NextResponse('Unauthorized - RevenueCat Authorization secret is required', {
status: 401,
})
}
const authHeader = request.headers.get('authorization')
if (!authHeader) {
logger.warn(`[${requestId}] RevenueCat webhook missing Authorization header`)
return new NextResponse('Unauthorized - Missing RevenueCat Authorization header', {
status: 401,
})
}
if (!safeCompare(authHeader, secret)) {
logger.warn(`[${requestId}] RevenueCat Authorization header verification failed`)
return new NextResponse('Unauthorized - Invalid RevenueCat Authorization header', {
status: 401,
})
}
return null
},
/**
* Create the webhook integration in RevenueCat via the REST API v2.
*
* Sim generates the Authorization header secret, registers the integration
* with that secret, and stores both the returned integration id (`externalId`)
* and the secret (`authHeaderSecret`) so {@link verifyAuth} can authenticate
* incoming deliveries.
*
* @see https://www.revenuecat.com/docs/api-v2 (Integration > Create a webhook integration)
*/
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const { apiKey, projectId, triggerId, environment } = config as {
apiKey?: string
projectId?: string
triggerId?: string
environment?: string
}
if (!apiKey) {
throw new Error(
'RevenueCat Secret API key is required to create the webhook. Provide a v2 Secret API key with the project_configuration:integrations:read_write permission.'
)
}
if (!projectId) {
throw new Error('RevenueCat Project ID is required to create the webhook.')
}
const { REVENUECAT_TRIGGER_TO_API_EVENT_TYPE } = await import('@/triggers/revenuecat/utils')
const eventType = triggerId ? REVENUECAT_TRIGGER_TO_API_EVENT_TYPE[triggerId] : undefined
const authHeaderSecret = generateId()
const requestBody: Record<string, unknown> = {
name: `Sim webhook (${triggerId ?? 'revenuecat'})`,
url: getNotificationUrl(ctx.webhook),
authorization_header: authHeaderSecret,
}
if (eventType) {
requestBody.event_types = [eventType]
}
if (environment === 'production' || environment === 'sandbox') {
requestBody.environment = environment
}
const response = await fetch(
`${REVENUECAT_API_BASE}/projects/${encodeURIComponent(projectId)}/integrations/webhooks`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}
)
if (!response.ok) {
const errorBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
logger.error(
`[${ctx.requestId}] Failed to create RevenueCat webhook for webhook ${ctx.webhook.id}. Status: ${response.status}`,
{ response: errorBody }
)
let message = 'Failed to create webhook integration in RevenueCat'
if (response.status === 401) {
message = 'RevenueCat authentication failed. Verify your v2 Secret API key is correct.'
} else if (response.status === 403) {
message =
'RevenueCat access denied. Ensure the API key has the project_configuration:integrations:read_write permission.'
} else if (response.status === 404) {
message = 'RevenueCat project not found. Verify the Project ID is correct.'
} else if (typeof errorBody.message === 'string' && errorBody.message.length > 0) {
message = `RevenueCat error: ${errorBody.message}`
}
throw new Error(message)
}
const responseBody = (await response.json()) as Record<string, unknown>
const integrationId = responseBody.id as string | undefined
if (!integrationId) {
logger.error(
`[${ctx.requestId}] RevenueCat webhook created but no integration id was returned for webhook ${ctx.webhook.id}`,
{ response: responseBody }
)
throw new Error('RevenueCat webhook creation succeeded but no integration id was returned')
}
logger.info(
`[${ctx.requestId}] Created RevenueCat webhook integration ${integrationId} for webhook ${ctx.webhook.id}`
)
return { providerConfigUpdates: { externalId: integrationId, authHeaderSecret } }
},
/**
* Delete the webhook integration in RevenueCat during undeploy.
*
* Cleanup is best-effort: a missing integration (404) or a transient failure
* is logged non-fatally unless strict outbox cleanup is requested.
*
* @see https://www.revenuecat.com/docs/api-v2 (Integration > Delete a webhook integration)
*/
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const { apiKey, projectId, externalId } = config as {
apiKey?: string
projectId?: string
externalId?: string
}
if (!apiKey || !projectId || !externalId) {
logger.warn(
`[${ctx.requestId}] Missing apiKey/projectId/externalId for RevenueCat webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing RevenueCat credentials for webhook deletion')
return
}
const response = await fetch(
`${REVENUECAT_API_BASE}/projects/${encodeURIComponent(projectId)}/integrations/webhooks/${encodeURIComponent(externalId)}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
}
)
if (!response.ok && response.status !== 404) {
const errorBody = await response.json().catch(() => ({}))
logger.warn(
`[${ctx.requestId}] Failed to delete RevenueCat webhook (non-fatal): ${response.status}`,
{ response: errorBody }
)
if (ctx.strict) {
throw new Error(`Failed to delete RevenueCat webhook: ${response.status}`)
}
} else {
logger.info(`[${ctx.requestId}] Deleted RevenueCat webhook integration ${externalId}`)
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting RevenueCat webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isRevenueCatEventMatch } = await import('@/triggers/revenuecat/utils')
if (!isRevenueCatEventMatch(triggerId, (body as Record<string, unknown>) || {})) {
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
logger.debug(
`[${requestId}] RevenueCat event type '${event?.type as string | undefined}' does not match trigger ${triggerId}, skipping`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = (body as Record<string, unknown>) || {}
const event = (payload.event as Record<string, unknown>) || {}
return {
input: {
type: event.type ?? null,
id: event.id ?? null,
app_id: event.app_id ?? null,
event_timestamp_ms: event.event_timestamp_ms ?? null,
app_user_id: event.app_user_id ?? null,
original_app_user_id: event.original_app_user_id ?? null,
aliases: event.aliases ?? null,
product_id: event.product_id ?? null,
new_product_id: event.new_product_id ?? null,
period_type: event.period_type ?? null,
purchased_at_ms: event.purchased_at_ms ?? null,
expiration_at_ms: event.expiration_at_ms ?? null,
environment: event.environment ?? null,
entitlement_id: event.entitlement_id ?? null,
entitlement_ids: event.entitlement_ids ?? null,
presented_offering_id: event.presented_offering_id ?? null,
transaction_id: event.transaction_id ?? null,
original_transaction_id: event.original_transaction_id ?? null,
is_family_share: event.is_family_share ?? null,
country_code: event.country_code ?? null,
currency: event.currency ?? null,
price: event.price ?? null,
price_in_purchased_currency: event.price_in_purchased_currency ?? null,
store: event.store ?? null,
takehome_percentage: event.takehome_percentage ?? null,
tax_percentage: event.tax_percentage ?? null,
commission_percentage: event.commission_percentage ?? null,
offer_code: event.offer_code ?? null,
subscriber_attributes: event.subscriber_attributes ?? null,
experiments: event.experiments ?? null,
cancel_reason: event.cancel_reason ?? null,
expiration_reason: event.expiration_reason ?? null,
api_version: payload.api_version ?? null,
event,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
const id = event?.id
return typeof id === 'string' && id.length > 0 ? id : null
},
}
@@ -0,0 +1,342 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rootlyHandler } from '@/lib/webhooks/providers/rootly'
function signRootlyBody(secret: string, timestamp: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(`${timestamp}${rawBody}`, 'utf8').digest('hex')
}
function requestWithRootlySignature(
secret: string,
timestamp: string,
rawBody: string
): NextRequest {
const signature = signRootlyBody(secret, timestamp, rawBody)
return new NextRequest('http://localhost/test', {
headers: {
'X-Rootly-Signature': `t=${timestamp},v1=${signature}`,
},
})
}
describe('Rootly webhook provider', () => {
it('accepts a correctly signed request within the allowed timestamp window', async () => {
const secret = 'rootly-secret'
const timestamp = Math.floor(Date.now() / 1000).toString()
const rawBody = JSON.stringify({
event: { id: 'evt-1', type: 'incident.created', issued_at: '2022-11-27T19:44:33.633-08:00' },
data: { id: 'inc-1', title: 'Sparkling Frost' },
})
const res = await rootlyHandler.verifyAuth!({
request: requestWithRootlySignature(secret, timestamp, rawBody),
rawBody,
requestId: 'rootly-t1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('rejects when the signing secret is missing from config (fail-closed)', async () => {
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'rootly-t1b',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects a request with an invalid signature', async () => {
const secret = 'rootly-secret'
const timestamp = Math.floor(Date.now() / 1000).toString()
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const req = new NextRequest('http://localhost/test', {
headers: { 'X-Rootly-Signature': `t=${timestamp},v1=deadbeef` },
})
const res = await rootlyHandler.verifyAuth!({
request: req,
rawBody,
requestId: 'rootly-t2',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects when the signature header is missing', async () => {
const secret = 'rootly-secret'
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: new NextRequest('http://localhost/test'),
rawBody,
requestId: 'rootly-t3',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects when the timestamp skew is too large', async () => {
const secret = 'rootly-secret'
const timestamp = (Math.floor(Date.now() / 1000) - 600).toString()
const rawBody = JSON.stringify({ event: { id: 'evt-1', type: 'incident.created' }, data: {} })
const res = await rootlyHandler.verifyAuth!({
request: requestWithRootlySignature(secret, timestamp, rawBody),
rawBody,
requestId: 'rootly-t4',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('skips events that do not match the configured trigger', async () => {
const body = { event: { id: 'evt-1', type: 'incident.updated' }, data: { id: 'inc-1' } }
const matched = await rootlyHandler.matchEvent!({
body,
requestId: 'rootly-t5',
providerConfig: { triggerId: 'rootly_incident_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(false)
})
it('matches events that match the configured trigger', async () => {
const body = { event: { id: 'evt-1', type: 'incident.created' }, data: { id: 'inc-1' } }
const matched = await rootlyHandler.matchEvent!({
body,
requestId: 'rootly-t6',
providerConfig: { triggerId: 'rootly_incident_created' },
webhook: {},
workflow: {},
request: new NextRequest('http://localhost/test'),
})
expect(matched).toBe(true)
})
it('formats input with keys matching the trigger outputs', async () => {
const body = {
event: { id: 'evt-1', type: 'incident.created', issued_at: '2022-11-27T19:44:33.633-08:00' },
data: { id: 'inc-1', title: 'Sparkling Frost' },
}
const result = await rootlyHandler.formatInput!({
body,
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
headers: {},
requestId: 'rootly-t7',
})
expect(result.input).toEqual({
eventId: 'evt-1',
eventType: 'incident.created',
issuedAt: '2022-11-27T19:44:33.633-08:00',
data: { id: 'inc-1', title: 'Sparkling Frost' },
})
})
it('extracts the event id for idempotency', () => {
const id = rootlyHandler.extractIdempotencyId!({
event: { id: 'evt-1', type: 'incident.created' },
data: {},
})
expect(id).toBe('evt-1')
})
describe('createSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test')
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
it('creates a Rootly endpoint with a generated secret and the mapped event type', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ data: { id: 'wh-123', type: 'webhooks_endpoints' } }),
})
const result = await rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-1',
path: 'abc-path',
providerConfig: { apiKey: 'rootly-key', triggerId: 'rootly_incident_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-1',
request: new NextRequest('http://localhost/test'),
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.rootly.com/v1/webhooks/endpoints')
expect(init.method).toBe('POST')
const sent = JSON.parse(init.body)
expect(sent.data.type).toBe('webhooks_endpoints')
expect(sent.data.attributes.event_types).toEqual(['incident.created'])
expect(typeof sent.data.attributes.secret).toBe('string')
expect(sent.data.attributes.secret.length).toBeGreaterThan(0)
expect(sent.data.attributes.url).toContain('/api/webhooks/trigger/abc-path')
expect(result?.providerConfigUpdates?.externalId).toBe('wh-123')
expect(result?.providerConfigUpdates?.webhookSecret).toBe(sent.data.attributes.secret)
})
it('subscribes to all event types when triggerId is generic/unknown', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ data: { id: 'wh-456' } }),
})
await rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-2',
path: 'p2',
providerConfig: { apiKey: 'rootly-key', triggerId: 'rootly_webhook' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-2',
request: new NextRequest('http://localhost/test'),
})
const sent = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(sent.data.attributes.event_types).toEqual([
'incident.created',
'incident.updated',
'incident.resolved',
'alert.created',
])
})
it('throws a friendly error on 401', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ errors: [{ detail: 'unauthorized' }] }),
})
await expect(
rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-3',
path: 'p3',
providerConfig: { apiKey: 'bad-key', triggerId: 'rootly_incident_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-3',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Invalid Rootly API key/)
})
it('throws when apiKey is missing', async () => {
await expect(
rootlyHandler.createSubscription!({
webhook: {
id: 'webhook-4',
path: 'p4',
providerConfig: { triggerId: 'rootly_alert_created' },
},
workflow: {},
userId: 'user-1',
requestId: 'req-create-4',
request: new NextRequest('http://localhost/test'),
})
).rejects.toThrow(/Rootly API key is required/)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('deleteSubscription', () => {
const fetchMock = vi.fn()
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the endpoint by externalId', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200, body: null })
await rootlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-1',
providerConfig: { apiKey: 'rootly-key', externalId: 'wh-123' },
},
workflow: {},
requestId: 'req-del-1',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.rootly.com/v1/webhooks/endpoints/wh-123')
expect(init.method).toBe('DELETE')
})
it('skips when apiKey or externalId is missing and does not throw', async () => {
await rootlyHandler.deleteSubscription!({
webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } },
workflow: {},
requestId: 'req-del-2',
})
await rootlyHandler.deleteSubscription!({
webhook: { id: 'webhook-3', providerConfig: { apiKey: 'rootly-key' } },
workflow: {},
requestId: 'req-del-3',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not throw on a non-ok response in non-strict mode', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
await expect(
rootlyHandler.deleteSubscription!({
webhook: {
id: 'webhook-4',
providerConfig: { apiKey: 'rootly-key', externalId: 'wh-9' },
},
workflow: {},
requestId: 'req-del-4',
})
).resolves.toBeUndefined()
})
})
})
+286
View File
@@ -0,0 +1,286 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { generateId } from '@sim/utils/id'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Rootly')
const ROOTLY_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000
/**
* Parse a Rootly `X-Rootly-Signature` header of the form
* `t=<unix-seconds>,v1=<hex-hmac>` into its timestamp and signature parts.
*/
function parseRootlySignatureHeader(
header: string
): { timestamp: string; signature: string } | null {
let timestamp: string | undefined
let signature: string | undefined
for (const part of header.split(',')) {
const [key, value] = part.split('=')
if (key?.trim() === 't') timestamp = value?.trim()
else if (key?.trim() === 'v1') signature = value?.trim()
}
if (!timestamp || !signature) return null
return { timestamp, signature }
}
/**
* Validate a Rootly webhook signature. Rootly signs the concatenation of the
* header timestamp and the raw request body with HMAC-SHA256 (hex digest).
* See https://docs.rootly.com/configuration/webhooks.
*/
function validateRootlySignature(
secret: string,
timestamp: string,
signature: string,
body: string
): boolean {
try {
if (!secret || !timestamp || !signature || !body) return false
const computed = hmacSha256Hex(`${timestamp}${body}`, secret)
return safeCompare(computed, signature)
} catch (error) {
logger.error('Error validating Rootly signature:', error)
return false
}
}
export const rootlyHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
logger.warn(`[${requestId}] Rootly webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Rootly signing secret is required', { status: 401 })
}
const header = request.headers.get('X-Rootly-Signature')
if (!header) {
logger.warn(`[${requestId}] Rootly webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Rootly signature', { status: 401 })
}
const parsed = parseRootlySignatureHeader(header)
if (!parsed) {
logger.warn(`[${requestId}] Rootly signature header malformed`)
return new NextResponse('Unauthorized - Malformed Rootly signature', { status: 401 })
}
if (!validateRootlySignature(secret, parsed.timestamp, parsed.signature, rawBody)) {
logger.warn(`[${requestId}] Rootly signature verification failed`)
return new NextResponse('Unauthorized - Invalid Rootly signature', { status: 401 })
}
const tsSeconds = Number(parsed.timestamp)
if (!Number.isFinite(tsSeconds)) {
logger.warn(`[${requestId}] Rootly signature timestamp invalid`)
return new NextResponse('Unauthorized - Invalid Rootly timestamp', { status: 401 })
}
if (Math.abs(Date.now() - tsSeconds * 1000) > ROOTLY_WEBHOOK_TIMESTAMP_SKEW_MS) {
logger.warn(`[${requestId}] Rootly signature timestamp outside allowed skew`)
return new NextResponse('Unauthorized - Rootly timestamp skew too large', { status: 401 })
}
return null
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && triggerId !== 'rootly_webhook') {
const { isRootlyEventMatch } = await import('@/triggers/rootly/utils')
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
const eventType = typeof event?.type === 'string' ? event.type : ''
if (!isRootlyEventMatch(triggerId, eventType)) {
logger.debug(
`[${requestId}] Rootly event mismatch for trigger ${triggerId}. Type: ${eventType}. Skipping.`
)
return false
}
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
const event = (b?.event || {}) as Record<string, unknown>
return {
input: {
eventId: event.id || '',
eventType: event.type || '',
issuedAt: event.issued_at || '',
data: b?.data || null,
},
}
},
extractIdempotencyId(body: unknown) {
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
return typeof event?.id === 'string' && event.id ? event.id : null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const providerConfig = getProviderConfig(ctx.webhook)
const { apiKey, triggerId } = providerConfig as {
apiKey?: string
triggerId?: string
}
if (!apiKey) {
throw new Error(
'Rootly API key is required. Please provide your Rootly API key in the trigger configuration.'
)
}
const { rootlyEventTypesForTrigger } = await import('@/triggers/rootly/utils')
const eventTypes = rootlyEventTypesForTrigger(triggerId)
const notificationUrl = getNotificationUrl(ctx.webhook)
const signingSecret = generateId()
logger.info(`[${ctx.requestId}] Creating Rootly webhook endpoint`, {
triggerId,
eventTypes,
webhookId: ctx.webhook.id,
})
const requestBody = {
data: {
type: 'webhooks_endpoints',
attributes: {
name: `Sim (${triggerId || 'rootly'})`,
url: notificationUrl,
secret: signingSecret,
event_types: eventTypes,
enabled: true,
},
},
}
const response = await fetch('https://api.rootly.com/v1/webhooks/endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/vnd.api+json',
Accept: 'application/vnd.api+json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
if (!response.ok) {
const errors = responseBody.errors as Array<Record<string, unknown>> | undefined
const errorDetail =
(errors?.[0]?.detail as string | undefined) ||
(errors?.[0]?.title as string | undefined) ||
(responseBody.error as string | undefined)
let userFriendlyMessage = 'Failed to create Rootly webhook endpoint'
if (response.status === 401) {
userFriendlyMessage = 'Invalid Rootly API key. Please verify your API key is correct.'
} else if (response.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Rootly API key has permission to manage webhooks.'
} else if (errorDetail) {
userFriendlyMessage = `Rootly error: ${errorDetail}`
}
logger.error(
`[${ctx.requestId}] Failed to create Rootly webhook endpoint for webhook ${ctx.webhook.id}. Status: ${response.status}`,
{ response: responseBody }
)
throw new Error(userFriendlyMessage)
}
const data = responseBody.data as Record<string, unknown> | undefined
const externalId = data?.id as string | undefined
if (!externalId) {
throw new Error('Rootly webhook endpoint created but no endpoint ID was returned')
}
logger.info(
`[${ctx.requestId}] Successfully created Rootly webhook endpoint ${externalId} for webhook ${ctx.webhook.id}`
)
return { providerConfigUpdates: { externalId, webhookSecret: signingSecret } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${ctx.requestId}] Exception during Rootly webhook creation for webhook ${ctx.webhook.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
logger.warn(
`[${ctx.requestId}] Missing apiKey for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Rootly apiKey for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${ctx.requestId}] Missing externalId for Rootly webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Rootly externalId for webhook deletion')
return
}
const response = await fetch(`https://api.rootly.com/v1/webhooks/endpoints/${externalId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/vnd.api+json',
},
})
if (response.ok || response.status === 404) {
await response.body?.cancel()
logger.info(
`[${ctx.requestId}] Deleted Rootly webhook endpoint ${externalId} (status ${response.status})`
)
} else {
const responseBody = await response.json().catch(() => ({}))
logger.warn(
`[${ctx.requestId}] Failed to delete Rootly webhook endpoint (non-fatal): ${response.status}`,
{ response: responseBody }
)
if (ctx.strict) {
throw new Error(`Failed to delete Rootly webhook endpoint: ${response.status}`)
}
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Rootly webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
+65
View File
@@ -0,0 +1,65 @@
import { db } from '@sim/db'
import { webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type {
FormatInputContext,
FormatInputResult,
PollingConfigContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Rss')
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 }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
lastCheckedTimestamp: now.toISOString(),
lastSeenGuids: [],
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
logger.info(
`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`
)
return true
} catch (error: unknown) {
const err = error as Error
logger.error(`[${requestId}] Failed to configure RSS polling`, {
webhookId: webhookData.id,
error: err.message,
})
return false
}
},
}
@@ -0,0 +1,127 @@
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
import { isSalesforceEventMatch } from '@/triggers/salesforce/utils'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('Salesforce webhook provider', () => {
it('verifyAuth rejects when webhookSecret is missing', async () => {
const res = await salesforceHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth accepts Authorization Bearer secret', async () => {
const res = await salesforceHandler.verifyAuth!({
request: reqWithHeaders({ authorization: 'Bearer my-secret-value' }),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: 'my-secret-value' },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('verifyAuth accepts X-Sim-Webhook-Secret', async () => {
const res = await salesforceHandler.verifyAuth!({
request: reqWithHeaders({ 'x-sim-webhook-secret': 'abc' }),
rawBody: '{}',
requestId: 't3',
providerConfig: { webhookSecret: 'abc' },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('isSalesforceEventMatch filters record triggers by eventType', () => {
expect(
isSalesforceEventMatch('salesforce_record_created', { eventType: 'created' }, undefined)
).toBe(true)
expect(
isSalesforceEventMatch('salesforce_record_created', { eventType: 'updated' }, undefined)
).toBe(false)
expect(isSalesforceEventMatch('salesforce_record_created', {}, undefined)).toBe(false)
})
it('isSalesforceEventMatch enforces objectType config for generic webhook', () => {
expect(
isSalesforceEventMatch('salesforce_webhook', { objectType: 'Account', Id: 'x' }, 'Account')
).toBe(true)
expect(
isSalesforceEventMatch('salesforce_webhook', { objectType: 'Contact', Id: 'x' }, 'Account')
).toBe(false)
expect(isSalesforceEventMatch('salesforce_webhook', { Id: 'x' }, 'Account')).toBe(false)
})
it('isSalesforceEventMatch fails closed for record triggers when configured objectType is missing', () => {
expect(
isSalesforceEventMatch(
'salesforce_record_created',
{ eventType: 'created', Id: '001' },
'Account'
)
).toBe(false)
})
it('formatInput maps record trigger fields', async () => {
const { input } = await salesforceHandler.formatInput!({
body: {
eventType: 'created',
simEventType: 'after_insert',
objectType: 'Lead',
Id: '00Q1',
Name: 'Test',
OwnerId: '005OWNER',
SystemModstamp: '2024-01-01T00:00:00.000Z',
},
headers: {},
requestId: 't4',
webhook: { providerConfig: { triggerId: 'salesforce_record_created' } },
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.eventType).toBe('created')
expect(i.simEventType).toBe('after_insert')
expect(i.objectType).toBe('Lead')
expect(i.recordId).toBe('00Q1')
const rec = i.record as Record<string, unknown>
expect(rec.OwnerId).toBe('005OWNER')
expect(rec.SystemModstamp).toBe('2024-01-01T00:00:00.000Z')
})
it('extractIdempotencyId includes record id', () => {
const id = salesforceHandler.extractIdempotencyId!({
eventType: 'created',
Id: '001',
})
expect(id).toContain('001')
})
it('extractIdempotencyId is stable without timestamps for identical payloads', () => {
const body = {
eventType: 'updated',
objectType: 'Account',
Id: '001',
Name: 'Acme',
changedFields: ['Name'],
}
const first = salesforceHandler.extractIdempotencyId!(body)
const second = salesforceHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
expect(first).toContain('001')
expect(first).toContain('updated')
})
})
@@ -0,0 +1,320 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils'
export function extractSalesforceObjectTypeFromPayload(
body: Record<string, unknown>
): string | undefined {
const direct =
(typeof body.objectType === 'string' && body.objectType) ||
(typeof body.sobjectType === 'string' && body.sobjectType) ||
undefined
if (direct) {
return direct
}
const attrs = body.attributes as Record<string, unknown> | undefined
if (typeof attrs?.type === 'string') {
return attrs.type
}
const record = body.record
if (record && typeof record === 'object' && !Array.isArray(record)) {
const r = record as Record<string, unknown>
if (typeof r.sobjectType === 'string') {
return r.sobjectType
}
const ra = r.attributes as Record<string, unknown> | undefined
if (typeof ra?.type === 'string') {
return ra.type
}
}
return undefined
}
const logger = createLogger('WebhookProvider:Salesforce')
function verifySalesforceSharedSecret(request: Request, secret: string): boolean {
if (verifyTokenAuth(request, secret, 'x-sim-webhook-secret')) {
return true
}
return verifyTokenAuth(request, secret)
}
function asRecord(body: unknown): Record<string, unknown> {
return body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {}
}
function extractRecordCore(body: Record<string, unknown>): Record<string, unknown> {
const nested = body.record
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
return { ...(nested as Record<string, unknown>) }
}
const skip = new Set([
'eventType',
'simEventType',
'changedFields',
'previousStage',
'newStage',
'previousStatus',
'newStatus',
'payload',
'record',
])
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
if (!skip.has(k)) {
out[k] = v
}
}
return out
}
function pickTimestamp(body: Record<string, unknown>, record: Record<string, unknown>): string {
const candidates = [
body.timestamp,
body.time,
record.SystemModstamp,
record.LastModifiedDate,
record.CreatedDate,
]
for (const c of candidates) {
if (typeof c === 'string' && c.length > 0) {
return c
}
}
return ''
}
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {
const id =
(typeof body.recordId === 'string' && body.recordId) ||
(typeof record.Id === 'string' && record.Id) ||
(typeof body.Id === 'string' && body.Id) ||
''
return id
}
function pickStr(record: Record<string, unknown>, key: string): string {
const v = record[key]
return typeof v === 'string' ? v : ''
}
export const salesforceHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret?.trim()) {
logger.warn(`[${requestId}] Salesforce webhook missing webhookSecret — rejecting`)
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
}
if (!verifySalesforceSharedSecret(request, secret.trim())) {
logger.warn(`[${requestId}] Salesforce webhook secret verification failed`)
return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isSalesforceEventMatch } = await import('@/triggers/salesforce/utils')
const configuredObjectType = providerConfig.objectType as string | undefined
const obj = asRecord(body)
if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) {
logger.debug(
`[${requestId}] Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`,
{ webhookId: webhook.id, workflowId: workflow.id, triggerId }
)
return false
}
return true
},
async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> {
const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig
const pc =
rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc)
? (rawPc as Record<string, unknown>)
: {}
const id = typeof pc.triggerId === 'string' ? pc.triggerId : ''
const body = asRecord(ctx.body)
const record = extractRecordCore(body)
const objectType =
extractSalesforceObjectTypeFromPayload(body) ||
(typeof record.attributes === 'object' &&
record.attributes &&
typeof (record.attributes as Record<string, unknown>).type === 'string'
? String((record.attributes as Record<string, unknown>).type)
: '') ||
(typeof record.sobjectType === 'string' ? record.sobjectType : '')
const recordId = pickRecordId(body, record)
const timestamp = pickTimestamp(body, record)
const eventTypeRaw =
(typeof body.eventType === 'string' && body.eventType) ||
(typeof body.simEventType === 'string' && body.simEventType) ||
''
const simEventTypeRaw = typeof body.simEventType === 'string' ? body.simEventType : ''
if (id === 'salesforce_webhook') {
return {
input: {
eventType: eventTypeRaw || 'webhook',
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: Object.keys(record).length > 0 ? record : body,
payload: ctx.body,
},
}
}
if (
id === 'salesforce_record_created' ||
id === 'salesforce_record_updated' ||
id === 'salesforce_record_deleted'
) {
const changedFields = body.changedFields
return {
input: {
eventType: eventTypeRaw || id.replace('salesforce_', '').replace(/_/g, ' '),
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Name: typeof record.Name === 'string' ? record.Name : '',
CreatedDate: typeof record.CreatedDate === 'string' ? record.CreatedDate : '',
LastModifiedDate:
typeof record.LastModifiedDate === 'string' ? record.LastModifiedDate : '',
OwnerId: pickStr(record, 'OwnerId'),
SystemModstamp: pickStr(record, 'SystemModstamp'),
},
changedFields: changedFields !== undefined ? changedFields : null,
payload: ctx.body,
},
}
}
if (id === 'salesforce_opportunity_stage_changed') {
return {
input: {
eventType: eventTypeRaw || 'opportunity_stage_changed',
objectType: objectType || 'Opportunity',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Name: typeof record.Name === 'string' ? record.Name : '',
StageName: typeof record.StageName === 'string' ? record.StageName : '',
Amount: record.Amount !== undefined ? String(record.Amount) : '',
CloseDate: typeof record.CloseDate === 'string' ? record.CloseDate : '',
Probability: record.Probability !== undefined ? String(record.Probability) : '',
AccountId: pickStr(record, 'AccountId'),
OwnerId: pickStr(record, 'OwnerId'),
},
previousStage:
typeof body.previousStage === 'string'
? body.previousStage
: typeof body.PriorStage === 'string'
? body.PriorStage
: '',
newStage:
typeof body.newStage === 'string'
? body.newStage
: typeof record.StageName === 'string'
? record.StageName
: '',
payload: ctx.body,
},
}
}
if (id === 'salesforce_case_status_changed') {
return {
input: {
eventType: eventTypeRaw || 'case_status_changed',
objectType: objectType || 'Case',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: {
Id: typeof record.Id === 'string' ? record.Id : recordId,
Subject: typeof record.Subject === 'string' ? record.Subject : '',
Status: typeof record.Status === 'string' ? record.Status : '',
Priority: typeof record.Priority === 'string' ? record.Priority : '',
CaseNumber: typeof record.CaseNumber === 'string' ? record.CaseNumber : '',
AccountId: pickStr(record, 'AccountId'),
ContactId: pickStr(record, 'ContactId'),
OwnerId: pickStr(record, 'OwnerId'),
},
previousStatus:
typeof body.previousStatus === 'string'
? body.previousStatus
: typeof body.PriorStatus === 'string'
? body.PriorStatus
: '',
newStatus:
typeof body.newStatus === 'string'
? body.newStatus
: typeof record.Status === 'string'
? record.Status
: '',
payload: ctx.body,
},
}
}
return {
input: {
eventType: eventTypeRaw || 'webhook',
objectType: objectType || '',
recordId,
timestamp,
simEventType: simEventTypeRaw,
record: Object.keys(record).length > 0 ? record : body,
payload: ctx.body,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const b = asRecord(body)
const record = extractRecordCore(b)
const id = pickRecordId(b, record)
const et =
(typeof b.eventType === 'string' && b.eventType) ||
(typeof b.simEventType === 'string' && b.simEventType) ||
''
const ts = pickTimestamp(b, record)
if (!id) {
return null
}
if (ts) {
return `salesforce:${et || 'event'}:${id}:${ts}`
}
return `salesforce:${et || 'event'}:${id}:${buildFallbackDeliveryFingerprint(b)}`
},
}
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sendblueHandler } from '@/lib/webhooks/providers/sendblue'
const inboundBody = {
accountEmail: 'me@example.com',
content: 'hello',
media_url: '',
is_outbound: false,
status: 'RECEIVED',
message_handle: 'handle-123',
from_number: '+19998887777',
number: '+18887776666',
group_id: '',
}
const outboundBody = {
...inboundBody,
is_outbound: true,
status: 'SENT',
}
describe('sendblueHandler', () => {
describe('matchEvent', () => {
it('matches an inbound message for the message_received trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: inboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(true)
})
it('rejects an outbound event for the message_received trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: outboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(false)
})
it('matches an outbound status update for the message_status_updated trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: outboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_status_updated' } },
requestId: 'r1',
} as any)
).toBe(true)
})
it('passes through when the triggerId is unknown or unset', () => {
expect(
sendblueHandler.matchEvent!({
body: inboundBody,
webhook: {},
requestId: 'r1',
} as any)
).toBe(true)
})
it('rejects a non-object payload for a known trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: 'not-an-object',
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(false)
})
})
describe('extractIdempotencyId', () => {
it('uses the message handle alone when no status is present', () => {
expect(sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123' })).toBe(
'handle-123'
)
})
it('suffixes the status so SENT and DELIVERED on one handle stay distinct', () => {
expect(
sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123', status: 'DELIVERED' })
).toBe('handle-123:DELIVERED')
})
it('returns null when no message handle is present', () => {
expect(sendblueHandler.extractIdempotencyId!({})).toBeNull()
expect(sendblueHandler.extractIdempotencyId!('nope')).toBeNull()
})
})
describe('formatInput', () => {
it('returns the payload under input with empty strings normalized to null', async () => {
const result = await sendblueHandler.formatInput!({ body: inboundBody } as any)
expect(result.input.account_email).toBe('me@example.com')
expect(result.input.media_url).toBeNull()
expect(result.input.group_id).toBeNull()
expect(result.input.is_outbound).toBe(false)
expect(result.input.participants).toEqual([])
expect(result.input.raw).toBe(JSON.stringify(inboundBody))
})
it('defaults missing fields to null and tolerates a non-object body', async () => {
const result = await sendblueHandler.formatInput!({ body: undefined } as any)
expect(result.input.message_handle).toBeNull()
expect(result.input.content).toBeNull()
expect(result.input.participants).toEqual([])
})
})
})
+104
View File
@@ -0,0 +1,104 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import { getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Sendblue')
/**
* Maps Sendblue trigger IDs to the expected value of the webhook payload's
* `is_outbound` flag, used to route inbound vs. outbound status events.
* The handler is the only runtime consumer, so the map lives here (single
* source of truth) rather than crossing from the triggers graph into the
* webhook-providers graph.
*/
const SENDBLUE_TRIGGER_IS_OUTBOUND: Record<string, boolean> = {
sendblue_message_received: false,
sendblue_message_status_updated: true,
}
/**
* Sendblue webhook handler.
*
* No `verifyAuth` is implemented: Sendblue supports an optional per-webhook
* `secret`/`globalSecret` that it "includes in the webhook request headers,"
* but the official docs never name the header or specify whether the value is
* a plain token echo or an HMAC signature. Implementing verification today
* would require guessing the header name, so it is deferred. When Sendblue
* documents the scheme, wire `verifyTokenAuth` (plain token) or
* `createHmacVerifier` (HMAC) from `@/lib/webhooks/providers/utils` and add a
* secret sub-block to the block definition.
*/
export const sendblueHandler: WebhookProviderHandler = {
matchEvent({ body, webhook, requestId }: EventMatchContext): boolean {
const providerConfig = getProviderConfig(webhook)
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || !(triggerId in SENDBLUE_TRIGGER_IS_OUTBOUND)) return true
if (!isRecordLike(body)) {
logger.warn(`[${requestId}] Sendblue webhook payload was not an object`)
return false
}
const expected = SENDBLUE_TRIGGER_IS_OUTBOUND[triggerId]
const isOutbound = body.is_outbound === true
if (isOutbound !== expected) {
logger.info(`[${requestId}] Sendblue event did not match trigger`, { triggerId, isOutbound })
return false
}
return true
},
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
const handle = body.message_handle
if (typeof handle !== 'string' || handle.length === 0) return null
// A single outbound message emits multiple status callbacks (e.g. SENT then
// DELIVERED) that share one message_handle, so the status is part of the key
// to keep distinct transitions from being deduped as retries.
const status = typeof body.status === 'string' && body.status.length > 0 ? body.status : null
return status ? `${handle}:${status}` : handle
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
return {
input: {
account_email: b.accountEmail ?? b.account_email ?? null,
content: b.content ?? null,
media_url: (typeof b.media_url === 'string' && b.media_url) || null,
is_outbound: b.is_outbound ?? null,
status: b.status ?? null,
error_code: b.error_code ?? null,
error_message: b.error_message ?? null,
error_reason: b.error_reason ?? null,
error_detail: b.error_detail ?? null,
message_handle: b.message_handle ?? null,
date_sent: b.date_sent ?? null,
date_updated: b.date_updated ?? null,
from_number: b.from_number ?? null,
number: b.number ?? null,
to_number: b.to_number ?? null,
was_downgraded: b.was_downgraded ?? null,
plan: b.plan ?? null,
message_type: b.message_type ?? null,
group_id: (typeof b.group_id === 'string' && b.group_id) || null,
participants: b.participants ?? [],
send_style: b.send_style ?? null,
opted_out: b.opted_out ?? null,
sendblue_number: b.sendblue_number ?? null,
service: b.service ?? null,
group_display_name: b.group_display_name ?? null,
sender_email: b.sender_email ?? null,
seat_id: b.seat_id ?? null,
raw: JSON.stringify(b),
},
}
},
}
@@ -0,0 +1,223 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { sentryHandler } from '@/lib/webhooks/providers/sentry'
function signSentryBody(secret: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
}
function requestWithSentrySignature(
secret: string,
rawBody: string,
resource = 'issue'
): NextRequest {
const signature = signSentryBody(secret, rawBody)
return new NextRequest('http://localhost/test', {
headers: {
'Sentry-Hook-Signature': signature,
'Sentry-Hook-Resource': resource,
},
})
}
describe('Sentry webhook provider', () => {
it('accepts requests with a valid HMAC signature', async () => {
const secret = 'sentry-client-secret'
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
const res = await sentryHandler.verifyAuth!({
request: requestWithSentrySignature(secret, rawBody),
rawBody,
requestId: 'sentry-t1',
providerConfig: { clientSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('rejects requests with an invalid signature', async () => {
const secret = 'sentry-client-secret'
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
const request = new NextRequest('http://localhost/test', {
headers: {
'Sentry-Hook-Signature': 'deadbeef',
'Sentry-Hook-Resource': 'issue',
},
})
const res = await sentryHandler.verifyAuth!({
request,
rawBody,
requestId: 'sentry-t2',
providerConfig: { clientSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests missing the signature header', async () => {
const secret = 'sentry-client-secret'
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
const request = new NextRequest('http://localhost/test', {
headers: { 'Sentry-Hook-Resource': 'issue' },
})
const res = await sentryHandler.verifyAuth!({
request,
rawBody,
requestId: 'sentry-t3',
providerConfig: { clientSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests when the client secret is not configured (fail-closed)', async () => {
const secret = 'sentry-client-secret'
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
const res = await sentryHandler.verifyAuth!({
request: requestWithSentrySignature(secret, rawBody),
rawBody,
requestId: 'sentry-t3b',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('matches an issue created event by resource header and action', async () => {
const rawBody = JSON.stringify({ action: 'created', data: { issue: { id: '1' } } })
const request = new NextRequest('http://localhost/test', {
headers: { 'Sentry-Hook-Resource': 'issue' },
})
const matched = await sentryHandler.matchEvent!({
body: JSON.parse(rawBody),
request,
requestId: 'sentry-t4',
providerConfig: { triggerId: 'sentry_issue_created' },
webhook: {},
workflow: {},
})
expect(matched).toBe(true)
})
it('skips an issue created trigger when the action is resolved', async () => {
const rawBody = JSON.stringify({ action: 'resolved', data: { issue: { id: '1' } } })
const request = new NextRequest('http://localhost/test', {
headers: { 'Sentry-Hook-Resource': 'issue' },
})
const matched = await sentryHandler.matchEvent!({
body: JSON.parse(rawBody),
request,
requestId: 'sentry-t5',
providerConfig: { triggerId: 'sentry_issue_created' },
webhook: {},
workflow: {},
})
expect(matched).toBe(false)
})
it('skips when the resource header does not match the trigger', async () => {
const rawBody = JSON.stringify({ action: 'created', data: { error: { event_id: 'abc' } } })
const request = new NextRequest('http://localhost/test', {
headers: { 'Sentry-Hook-Resource': 'error' },
})
const matched = await sentryHandler.matchEvent!({
body: JSON.parse(rawBody),
request,
requestId: 'sentry-t6',
providerConfig: { triggerId: 'sentry_issue_created' },
webhook: {},
workflow: {},
})
expect(matched).toBe(false)
})
it('formats issue input with keys matching the trigger outputs', async () => {
const body = {
action: 'created',
installation: { uuid: 'inst-1' },
actor: { type: 'application', id: 'app-1', name: 'Test' },
data: { issue: { id: '42', title: 'Boom', type: 'error' } },
}
const result = await sentryHandler.formatInput!({
body,
headers: { 'sentry-hook-resource': 'issue' },
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
requestId: 'sentry-t7',
})
expect(result.input).toEqual({
action: 'created',
installation: { uuid: 'inst-1' },
actor: { type: 'application', id: 'app-1', name: 'Test' },
issue: { id: '42', title: 'Boom', type: 'error', eventType: 'error' },
})
})
it('extracts an idempotency id for issue events', () => {
const id = sentryHandler.extractIdempotencyId!({
action: 'created',
data: { issue: { id: '42' } },
})
expect(id).toBe('sentry:issue:42:created')
})
it('does not match when the body is null', async () => {
const request = new NextRequest('http://localhost/test', {
headers: { 'Sentry-Hook-Resource': 'issue' },
})
const matched = await sentryHandler.matchEvent!({
body: null,
request,
requestId: 'sentry-t8',
providerConfig: { triggerId: 'sentry_issue_created' },
webhook: {},
workflow: {},
})
expect(matched).toBe(false)
})
it('formats input with an empty envelope when the body is null', async () => {
const result = await sentryHandler.formatInput!({
body: null,
headers: { 'sentry-hook-resource': 'issue' },
webhook: {},
workflow: { id: 'wf-1', userId: 'user-1' },
requestId: 'sentry-t9',
})
expect(result.input).toEqual({
action: '',
installation: null,
actor: null,
issue: null,
})
})
it('returns null idempotency id when the body is null', () => {
expect(sentryHandler.extractIdempotencyId!(null)).toBeNull()
})
})
+143
View File
@@ -0,0 +1,143 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { isRecordLike } from '@sim/utils/object'
import type {
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Sentry')
/**
* Sentry signs webhooks with the Internal Integration's Client Secret using
* HMAC-SHA256 over the raw request body, delivered in the
* `sentry-hook-signature` header as a hex digest.
*
* @see https://docs.sentry.io/organization/integrations/integration-platform/webhooks/
*/
function validateSentrySignature(secret: string, signature: string, body: string): boolean {
try {
if (!secret || !signature || !body) {
return false
}
const computedHash = hmacSha256Hex(body, secret)
return safeCompare(computedHash, signature)
} catch (error) {
logger.error('Error validating Sentry signature:', error)
return false
}
}
/** Header carrying the resource type that triggered the webhook. */
const SENTRY_RESOURCE_HEADER = 'sentry-hook-resource'
/**
* Exposes the payload's `type` field as `eventType`. `TriggerOutput` reserves
* the `type` key, so nested `type` fields are surfaced under an alias for the
* tag dropdown (the original `type` is preserved on the passthrough object).
*/
function aliasEventType(entity: unknown): Record<string, unknown> | null {
if (!isRecordLike(entity)) return null
return { ...entity, eventType: entity.type ?? null }
}
export const sentryHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'clientSecret',
headerName: 'Sentry-Hook-Signature',
validateFn: validateSentrySignature,
providerLabel: 'Sentry',
requireSecret: true,
}),
async matchEvent({ body, request, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId) {
const resource = request.headers.get(SENTRY_RESOURCE_HEADER)
const obj = isRecordLike(body) ? body : {}
const action = typeof obj.action === 'string' ? obj.action : undefined
const { isSentryEventMatch } = await import('@/triggers/sentry/utils')
if (!isSentryEventMatch(triggerId, resource, action)) {
logger.debug(
`[${requestId}] Sentry event mismatch for trigger ${triggerId}. Resource: ${resource}, Action: ${action}. Skipping.`
)
return false
}
}
return true
},
async formatInput({ body, headers }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
const data = isRecordLike(b.data) ? b.data : {}
const resource = headers[SENTRY_RESOURCE_HEADER] || ''
const envelope = {
action: (b.action as string) || '',
installation: b.installation ?? null,
actor: b.actor ?? null,
}
switch (resource) {
case 'issue':
return { input: { ...envelope, issue: aliasEventType(data.issue) } }
case 'error':
return { input: { ...envelope, error: aliasEventType(data.error) } }
case 'event_alert':
return {
input: {
...envelope,
event: data.event ?? null,
triggered_rule: (data.triggered_rule as string) ?? '',
issue_alert: data.issue_alert ?? null,
},
}
case 'metric_alert':
return {
input: {
...envelope,
metric_alert: data.metric_alert ?? null,
description_text: (data.description_text as string) ?? '',
description_title: (data.description_title as string) ?? '',
web_url: (data.web_url as string) ?? '',
},
}
default:
return { input: { ...envelope, data } }
}
},
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
const data = isRecordLike(body.data) ? body.data : {}
const action = typeof body.action === 'string' ? body.action : ''
const issue = isRecordLike(data.issue) ? data.issue : undefined
if (issue?.id) {
return `sentry:issue:${issue.id}:${action}`
}
const error = isRecordLike(data.error) ? data.error : undefined
if (error?.event_id) {
return `sentry:error:${error.event_id}`
}
const event = isRecordLike(data.event) ? data.event : undefined
if (event?.event_id) {
return `sentry:event_alert:${event.event_id}`
}
const metricAlert = isRecordLike(data.metric_alert) ? data.metric_alert : undefined
if (metricAlert?.id) {
return `sentry:metric_alert:${metricAlert.id}:${action}`
}
return null
},
}
@@ -0,0 +1,57 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:ServiceNow')
function asRecord(body: unknown): Record<string, unknown> {
return body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {}
}
export const servicenowHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret?.trim()) {
logger.warn(`[${requestId}] ServiceNow webhook missing webhookSecret — rejecting`)
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
}
if (
!verifyTokenAuth(request, secret.trim(), 'x-sim-webhook-secret') &&
!verifyTokenAuth(request, secret.trim())
) {
logger.warn(`[${requestId}] ServiceNow webhook secret verification failed`)
return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) {
return true
}
const { isServiceNowEventMatch } = await import('@/triggers/servicenow/utils')
const configuredTableName = providerConfig.tableName as string | undefined
const obj = asRecord(body)
if (!isServiceNowEventMatch(triggerId, obj, configuredTableName)) {
logger.debug(
`[${requestId}] ServiceNow event mismatch for trigger ${triggerId}. Skipping execution.`,
{ webhookId: webhook.id, workflowId: workflow.id, triggerId }
)
return false
}
return true
},
}
@@ -0,0 +1,597 @@
import { describe, expect, it } from 'vitest'
import {
handleSlackChallenge,
resolveSlackEventKey,
shouldSkipSlackTriggerEvent,
slackHandler,
} from '@/lib/webhooks/providers/slack'
const ctx = (body: unknown) => ({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body,
headers: {},
requestId: 'slack-test',
})
const eventOf = (input: unknown) =>
(input as { event: Record<string, unknown> }).event as Record<string, unknown>
describe('slackHandler formatInput - Events API', () => {
it('maps an app_mention event', async () => {
const { input } = await slackHandler.formatInput!(
ctx({
team_id: 'T1',
event_id: 'Ev1',
event: {
type: 'app_mention',
channel: 'C1',
user: 'U1',
text: 'hey <@bot> hello',
ts: '111.222',
thread_ts: '111.000',
},
})
)
const event = eventOf(input)
expect(event.event_type).toBe('app_mention')
expect(event.channel).toBe('C1')
expect(event.user).toBe('U1')
expect(event.text).toBe('hey <@bot> hello')
expect(event.timestamp).toBe('111.222')
expect(event.thread_ts).toBe('111.000')
expect(event.team_id).toBe('T1')
expect(event.event_id).toBe('Ev1')
expect(event.command).toBe('')
expect(event.action_value).toBe('')
expect(event.actions).toEqual([])
})
})
describe('slackHandler formatInput - interactivity (block_actions)', () => {
it('carries the button action value, channel, user, and response_url through', async () => {
const { input } = await slackHandler.formatInput!(
ctx({
type: 'block_actions',
api_app_id: 'A123',
team: { id: 'T1', domain: 'acme' },
user: { id: 'U1', username: 'alice' },
channel: { id: 'C1', name: 'general' },
trigger_id: 'trigger-1',
response_url: 'https://hooks.slack.com/actions/abc',
container: { message_ts: '999.000' },
message: {
ts: '999.000',
text: 'Approve this?',
thread_ts: '999.aaa',
blocks: [{ type: 'section', block_id: 'b1', text: { type: 'mrkdwn', text: 'Approve?' } }],
},
state: { values: { reason_block: { reason_input: { value: 'looks good' } } } },
actions: [
{
action_id: 'approve_btn',
block_id: 'b1',
value: 'approve_42',
action_ts: '1234.5678',
},
],
})
)
const event = eventOf(input)
expect(event.event_type).toBe('block_actions')
expect(event.channel).toBe('C1')
expect(event.channel_name).toBe('general')
expect(event.user).toBe('U1')
expect(event.user_name).toBe('alice')
expect(event.team_id).toBe('T1')
expect(event.action_id).toBe('approve_btn')
expect(event.action_value).toBe('approve_42')
expect(event.text).toBe('Approve this?')
expect(event.message_ts).toBe('999.000')
expect(event.timestamp).toBe('999.000')
expect(event.thread_ts).toBe('999.aaa')
expect(event.response_url).toBe('https://hooks.slack.com/actions/abc')
expect(event.trigger_id).toBe('trigger-1')
expect(event.api_app_id).toBe('A123')
expect(Array.isArray(event.actions)).toBe(true)
expect((event.actions as unknown[]).length).toBe(1)
const message = event.message as Record<string, unknown>
expect(message).not.toBeNull()
expect(Array.isArray(message.blocks)).toBe(true)
expect((message.blocks as unknown[]).length).toBe(1)
expect(event.view).toBeNull()
const state = event.state as { values: Record<string, Record<string, { value: string }>> }
expect(state).not.toBeNull()
expect(state.values.reason_block.reason_input.value).toBe('looks good')
})
it('carries the full view (state.values + private_metadata) through for a view_submission', async () => {
const { input } = await slackHandler.formatInput!(
ctx({
type: 'view_submission',
user: { id: 'U1', username: 'alice' },
team: { id: 'T1' },
trigger_id: 'trigger-2',
view: {
id: 'V123',
callback_id: 'create_ticket',
private_metadata: '{"thread_ts":"999.aaa"}',
hash: 'abc.def',
state: {
values: {
summary_block: { summary_input: { type: 'plain_text_input', value: 'Printer down' } },
},
},
},
})
)
const event = eventOf(input)
expect(event.event_type).toBe('view_submission')
expect(event.callback_id).toBe('create_ticket')
const view = event.view as Record<string, unknown>
expect(view).not.toBeNull()
expect(view.private_metadata).toBe('{"thread_ts":"999.aaa"}')
const values = (view.state as Record<string, unknown>).values as Record<
string,
Record<string, Record<string, unknown>>
>
expect(values.summary_block.summary_input.value).toBe('Printer down')
expect(event.message).toBeNull()
expect(event.state).toBeNull()
})
it('normalizes a static_select value and falls back to action value for text', async () => {
const { input } = await slackHandler.formatInput!(
ctx({
type: 'block_actions',
user: { id: 'U2', name: 'bob' },
channel: { id: 'C9' },
actions: [
{
action_id: 'pick',
type: 'static_select',
selected_option: { value: 'opt_b', text: { type: 'plain_text', text: 'Option B' } },
},
],
})
)
const event = eventOf(input)
expect(event.action_value).toBe('opt_b')
expect(event.text).toBe('opt_b')
expect(event.user_name).toBe('bob')
})
})
describe('slackHandler formatInput - block_suggestion', () => {
it('skips execution instead of triggering the workflow', async () => {
const { input, skip } = await slackHandler.formatInput!(
ctx({
type: 'block_suggestion',
action_id: 'external_select',
block_id: 'b1',
value: 'sea',
team: { id: 'T1' },
user: { id: 'U1' },
})
)
expect(input).toBeNull()
expect(skip?.message).toBeTruthy()
})
})
describe('slackHandler formatInput - slash commands', () => {
it('maps flat slash-command form fields', async () => {
const { input } = await slackHandler.formatInput!(
ctx({
command: '/deploy',
text: 'staging now',
team_id: 'T1',
channel_id: 'C1',
channel_name: 'ops',
user_id: 'U1',
user_name: 'alice',
api_app_id: 'A123',
response_url: 'https://hooks.slack.com/commands/abc',
trigger_id: 'trigger-2',
})
)
const event = eventOf(input)
expect(event.event_type).toBe('slash_command')
expect(event.command).toBe('/deploy')
expect(event.text).toBe('staging now')
expect(event.channel).toBe('C1')
expect(event.channel_name).toBe('ops')
expect(event.user).toBe('U1')
expect(event.user_name).toBe('alice')
expect(event.team_id).toBe('T1')
expect(event.response_url).toBe('https://hooks.slack.com/commands/abc')
expect(event.trigger_id).toBe('trigger-2')
expect(event.api_app_id).toBe('A123')
})
})
describe('slackHandler extractIdempotencyId', () => {
it('uses event_id for Events API payloads', () => {
expect(slackHandler.extractIdempotencyId!({ event_id: 'Ev1' })).toBe('Ev1')
})
it('uses trigger_id for interactivity and slash-command payloads', () => {
expect(
slackHandler.extractIdempotencyId!({ type: 'block_actions', trigger_id: 'trigger-1' })
).toBe('trigger-1')
expect(
slackHandler.extractIdempotencyId!({ command: '/deploy', trigger_id: 'trigger-2' })
).toBe('trigger-2')
})
it('returns null when no identifier is present', () => {
expect(slackHandler.extractIdempotencyId!({})).toBeNull()
})
it('degrades gracefully instead of throwing for a null or non-object body', () => {
expect(slackHandler.extractIdempotencyId!(null)).toBeNull()
expect(slackHandler.extractIdempotencyId!(undefined)).toBeNull()
expect(slackHandler.extractIdempotencyId!('not-an-object')).toBeNull()
})
})
describe('handleSlackChallenge', () => {
it('echoes the challenge for a url_verification payload', () => {
const response = handleSlackChallenge({ type: 'url_verification', challenge: 'abc123' })
expect(response).not.toBeNull()
})
it('returns null for non-challenge payloads', () => {
expect(handleSlackChallenge({ type: 'event_callback' })).toBeNull()
})
it('degrades gracefully instead of throwing for a null or non-object body', () => {
expect(handleSlackChallenge(null)).toBeNull()
expect(handleSlackChallenge(undefined)).toBeNull()
expect(handleSlackChallenge('not-an-object')).toBeNull()
expect(handleSlackChallenge([1, 2, 3])).toBeNull()
})
})
describe('slackHandler formatInput - null/non-object body', () => {
it('degrades gracefully instead of throwing', async () => {
const { input } = await slackHandler.formatInput!(ctx(null))
const event = eventOf(input)
expect(event.event_type).toBe('unknown')
})
})
const API_APP_ID = 'A_SELF'
function slackBody(event: Record<string, unknown>, extra: Record<string, unknown> = {}) {
return { team_id: 'T1', api_app_id: API_APP_ID, event, ...extra }
}
/** True when the event fires (i.e. is not skipped) for the given config. */
function fires(config: Record<string, unknown>, event: Record<string, unknown>): boolean {
return !shouldSkipSlackTriggerEvent(slackBody(event), config)
}
describe('shouldSkipSlackTriggerEvent', () => {
it('fires a message event matching source=channel in a public channel', () => {
expect(
fires(
{ eventType: 'message', source: ['channel'] },
{
type: 'message',
channel_type: 'channel',
channel: 'C1',
ts: '1.1',
}
)
).toBe(true)
})
it('drops a DM when source is restricted to public channels', () => {
expect(
fires(
{ eventType: 'message', source: ['channel'] },
{
type: 'message',
channel_type: 'im',
channel: 'D1',
ts: '1.1',
}
)
).toBe(false)
})
it('source=[public,private] fires on both channel types but drops DMs', () => {
const source = ['channel', 'group']
expect(
fires(
{ eventType: 'message', source },
{
type: 'message',
channel_type: 'channel',
channel: 'C1',
ts: '1.2',
}
)
).toBe(true)
expect(
fires(
{ eventType: 'message', source },
{
type: 'message',
channel_type: 'group',
channel: 'G1',
ts: '1.3',
}
)
).toBe(true)
expect(
fires(
{ eventType: 'message', source },
{
type: 'message',
channel_type: 'im',
channel: 'D1',
ts: '1.4',
}
)
).toBe(false)
})
it('empty source matches any channel type', () => {
expect(
fires(
{ eventType: 'message', source: [] },
{
type: 'message',
channel_type: 'im',
channel: 'D1',
ts: '1.5',
}
)
).toBe(true)
})
it('a channel filter never drops a DM allowed by Source', () => {
const config = { eventType: 'message', source: ['im', 'channel'], channelFilter: ['C1'] }
expect(fires(config, { type: 'message', channel_type: 'im', channel: 'D1', ts: '1.6' })).toBe(
true
)
expect(
fires(config, { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.7' })
).toBe(true)
expect(
fires(config, { type: 'message', channel_type: 'channel', channel: 'C2', ts: '1.8' })
).toBe(false)
})
it('app_mention Threads=Only fires only on threaded mentions', () => {
expect(
fires(
{ eventType: 'app_mention', threads: 'only' },
{
type: 'app_mention',
channel: 'C1',
ts: '2.0',
}
)
).toBe(false)
expect(
fires(
{ eventType: 'app_mention', threads: 'only' },
{
type: 'app_mention',
channel: 'C1',
ts: '2.1',
thread_ts: '2.0',
}
)
).toBe(true)
})
it('maps message_changed to message_edited and not to message', () => {
const edit = {
type: 'message',
subtype: 'message_changed',
channel_type: 'channel',
channel: 'C1',
ts: '3.1',
}
expect(fires({ eventType: 'message_edited' }, edit)).toBe(true)
expect(fires({ eventType: 'message' }, edit)).toBe(false)
})
it('does not drop an edit event that omits channel_type when a Source is selected', () => {
// message_changed payloads often omit channel_type; a Source selection must
// not silently swallow them.
const edit = {
type: 'message',
subtype: 'message_changed',
channel: 'C1',
ts: '3.2',
}
expect(fires({ eventType: 'message_edited', source: ['channel'] }, edit)).toBe(true)
})
it("self-drops the app's own message unless includeOwnMessages is set", () => {
const own = {
type: 'message',
channel_type: 'channel',
channel: 'C1',
ts: '4.1',
app_id: API_APP_ID,
bot_id: 'B1',
}
expect(fires({ eventType: 'message' }, own)).toBe(false)
expect(fires({ eventType: 'message', includeOwnMessages: true }, own)).toBe(true)
})
it("self-drops the app's own reaction via stored bot_user_id", () => {
const event = {
type: 'reaction_added',
reaction: 'thumbsup',
user: 'U_BOT',
item: { channel: 'C1', ts: '5.0' },
}
expect(fires({ eventType: 'reaction_added', bot_user_id: 'U_BOT' }, event)).toBe(false)
expect(fires({ eventType: 'reaction_added', bot_user_id: 'U_OTHER' }, event)).toBe(true)
})
it('matches the channel filter for object-form channels (channel_rename)', () => {
// channel_created / channel_rename deliver `channel` as an object, not a string.
const rename = {
type: 'channel_rename',
channel: { id: 'C1', name: 'renamed-channel', created: 1 },
}
expect(fires({ eventType: 'channel_rename', channelFilter: ['C1'] }, rename)).toBe(true)
expect(fires({ eventType: 'channel_rename', channelFilter: ['C2'] }, rename)).toBe(false)
})
it('applies the emoji filter to reaction events', () => {
const event = {
type: 'reaction_added',
reaction: 'eyes',
user: 'U1',
item: { channel: 'C1', ts: '6.0' },
}
expect(fires({ eventType: 'reaction_added', emoji: 'thumbsup' }, event)).toBe(false)
expect(fires({ eventType: 'reaction_added', emoji: 'eyes, thumbsup' }, event)).toBe(true)
})
it('fails closed when no eventType and the legacy events selection is empty or missing', () => {
const event = { type: 'message', channel_type: 'channel', channel: 'C1', ts: '7.0' }
expect(fires({}, event)).toBe(false)
expect(fires({ events: [] }, event)).toBe(false)
})
it('honors the legacy events array for pre-redesign webhooks', () => {
expect(
fires(
{ events: ['message.channels'] },
{
type: 'message',
channel_type: 'channel',
channel: 'C1',
ts: '7.1',
}
)
).toBe(true)
})
it('ignores other bots unless filterBotMessages is off', () => {
const otherBot = {
type: 'message',
channel_type: 'channel',
channel: 'C1',
ts: '8.1',
bot_id: 'B_OTHER',
app_id: 'A_OTHER',
}
expect(fires({ eventType: 'message' }, otherBot)).toBe(false)
expect(fires({ eventType: 'message', filterBotMessages: false }, otherBot)).toBe(true)
})
})
describe('resolveSlackEventKey - interactions', () => {
it('maps a top-level block_actions / view_submission payload (no event envelope)', () => {
expect(resolveSlackEventKey({ type: 'block_actions', actions: [] })).toBe('block_actions')
expect(resolveSlackEventKey({ type: 'view_submission', view: {} })).toBe('view_submission')
})
it('does not surface unsupported interaction types or Events API without an event', () => {
expect(resolveSlackEventKey({ type: 'shortcut' })).toBeNull()
expect(resolveSlackEventKey({ type: 'view_closed' })).toBeNull()
expect(resolveSlackEventKey({})).toBeNull()
})
})
/** True when an interaction (top-level payload, no event envelope) fires. */
function interactionFires(config: Record<string, unknown>, body: Record<string, unknown>): boolean {
return !shouldSkipSlackTriggerEvent(
{ team: { id: 'T1' }, api_app_id: API_APP_ID, ...body },
config
)
}
describe('shouldSkipSlackTriggerEvent - interactions', () => {
const blockActions = {
type: 'block_actions',
user: { id: 'U1' },
actions: [{ action_id: 'approve_btn', value: 'v' }],
}
const viewSubmission = {
type: 'view_submission',
user: { id: 'U1' },
view: { callback_id: 'create_ticket' },
}
it('fires a block_actions event when eventType matches and no filter is set', () => {
expect(interactionFires({ eventType: 'block_actions' }, blockActions)).toBe(true)
})
it('drops an interaction when the configured eventType is a different event', () => {
expect(interactionFires({ eventType: 'message' }, blockActions)).toBe(false)
expect(interactionFires({ eventType: 'view_submission' }, blockActions)).toBe(false)
})
it('scopes block_actions to matching action_ids', () => {
expect(
interactionFires({ eventType: 'block_actions', interactionFilter: 'deny_btn' }, blockActions)
).toBe(false)
expect(
interactionFires(
{ eventType: 'block_actions', interactionFilter: 'approve_btn, deny_btn' },
blockActions
)
).toBe(true)
})
it('scopes view_submission to matching callback_ids', () => {
expect(
interactionFires(
{ eventType: 'view_submission', interactionFilter: 'other_modal' },
viewSubmission
)
).toBe(false)
expect(
interactionFires(
{ eventType: 'view_submission', interactionFilter: 'create_ticket' },
viewSubmission
)
).toBe(true)
})
})
describe('slackHandler.shouldSkipEvent (custom-app path)', () => {
const message = slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '9.1' })
const skipCtx = (providerConfig: Record<string, unknown>, body: unknown) => ({
webhook: {},
body,
requestId: 'r',
providerConfig,
})
it('applies the trigger filter for a slack_oauth webhook', () => {
// Configured for reactions, but a message arrives -> skip.
expect(
slackHandler.shouldSkipEvent!(
skipCtx({ triggerId: 'slack_oauth', eventType: 'reaction_added' }, message)
)
).toBe(true)
// Configured for messages -> fire.
expect(
slackHandler.shouldSkipEvent!(
skipCtx({ triggerId: 'slack_oauth', eventType: 'message' }, message)
)
).toBe(false)
})
it('never skips the legacy slack_webhook trigger (unfiltered)', () => {
expect(
slackHandler.shouldSkipEvent!(
skipCtx({ triggerId: 'slack_webhook', eventType: 'reaction_added' }, message)
)
).toBe(false)
expect(slackHandler.shouldSkipEvent!(skipCtx({}, message))).toBe(false)
})
})
+983
View File
@@ -0,0 +1,983 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import type {
AuthContext,
EventFilterContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import {
getSlackBotCredential,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared'
const logger = createLogger('WebhookProvider:Slack')
/** 50 MB */
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024
const SLACK_MAX_FILES = 15
const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed'])
/**
* Interactivity payload types Slack POSTs to the request URL as a form-encoded
* `payload` field (button clicks, selects, shortcuts, modal submits). These have
* no Events-API `event` envelope, so they need their own mapping.
* See https://api.slack.com/interactivity/handling#payloads
*
* `block_suggestion` (external select option loading) is deliberately excluded:
* Slack requires a synchronous JSON `options` response within 3 seconds, which
* this trigger's fire-and-forget webhook execution model cannot provide — it is
* skipped explicitly in `formatInput` instead of being routed here.
*/
const SLACK_INTERACTIVE_TYPES = new Set([
'block_actions',
'interactive_message',
'message_action',
'shortcut',
'view_submission',
'view_closed',
])
/**
* Interaction payload types surfaced as selectable trigger events. A subset of
* SLACK_INTERACTIVE_TYPES — these are the only interactions that map to an
* eventType a trigger can subscribe to (see SLACK_EVENT_CATALOG).
*/
const SLACK_INTERACTION_EVENT_KEYS = new Set(['block_actions', 'view_submission'])
interface SlackDownloadedFile {
name: string
data: string
mimeType: string
size: number
}
/**
* Unified output shape for the Slack trigger across all three payload families
* (Events API, interactivity, slash commands). Every key is always present so
* downstream blocks never resolve to `undefined`.
*/
interface SlackTriggerEvent {
event_type: string
subtype: string
channel: string
channel_name: string
channel_type: string
user: string
user_name: string
bot_id: string
text: string
timestamp: string
thread_ts: string
team_id: string
event_id: string
reaction: string
item_user: string
command: string
action_id: string
action_value: string
actions: unknown[]
response_url: string
trigger_id: string
callback_id: string
api_app_id: string
app_id: string
message_ts: string
/**
* Full Slack view object for modal interactions (view_submission/view_closed):
* `state.values` (submitted input values), `private_metadata`, `id`,
* `callback_id`, `hash`, etc. Null for non-modal interactions and Events API.
*/
view: Record<string, unknown> | null
/**
* Full Slack message object the interaction originated from (block_actions):
* `blocks`, `text`, `ts`, etc. — needed to rewrite the source message's blocks.
* Null when the interaction has no source message and for slash/Events API.
*/
message: Record<string, unknown> | null
/**
* Top-level interactivity `state` for block_actions: the current values of all
* stateful elements in the surface (`state.values`), e.g. inputs read on a
* button click without a modal submit. Distinct from `view.state` (modal
* submissions). Null for non-block_actions payloads.
*/
state: Record<string, unknown> | null
hasFiles: boolean
files: SlackDownloadedFile[]
}
function createSlackEvent(): SlackTriggerEvent {
return {
event_type: 'unknown',
subtype: '',
channel: '',
channel_name: '',
channel_type: '',
user: '',
user_name: '',
bot_id: '',
text: '',
timestamp: '',
thread_ts: '',
team_id: '',
event_id: '',
reaction: '',
item_user: '',
command: '',
action_id: '',
action_value: '',
actions: [],
response_url: '',
trigger_id: '',
callback_id: '',
api_app_id: '',
app_id: '',
message_ts: '',
view: null,
message: null,
state: null,
hasFiles: false,
files: [],
}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/**
* Normalize the "value" carried by a Slack interactive action across the
* different element types (button, static/multi/external select, datepicker,
* timepicker, overflow, radio/checkbox, conversations/channels/users select).
*/
function extractActionValue(action: Record<string, unknown> | undefined): string {
if (!action) return ''
if (typeof action.value === 'string') return action.value
const selectedOption = action.selected_option as Record<string, unknown> | undefined
if (selectedOption && typeof selectedOption.value === 'string') {
return selectedOption.value
}
const selectedOptions = action.selected_options as Array<Record<string, unknown>> | undefined
if (Array.isArray(selectedOptions)) {
return selectedOptions
.map((o) => (typeof o?.value === 'string' ? o.value : ''))
.filter(Boolean)
.join(',')
}
for (const key of [
'selected_date',
'selected_time',
'selected_date_time',
'selected_conversation',
'selected_channel',
'selected_user',
] as const) {
if (typeof action[key] === 'string') {
return action[key] as string
}
}
return ''
}
/**
* Slash commands arrive as flat `application/x-www-form-urlencoded` fields
* (no JSON `payload`, no `event` envelope), identified by a leading-slash
* `command`. See https://api.slack.com/interactivity/slash-commands
*/
function formatSlackSlashCommand(b: Record<string, unknown>): SlackTriggerEvent {
const event = createSlackEvent()
event.event_type = 'slash_command'
event.command = asString(b.command)
event.text = asString(b.text)
event.channel = asString(b.channel_id)
event.channel_name = asString(b.channel_name)
event.user = asString(b.user_id)
event.user_name = asString(b.user_name)
event.team_id = asString(b.team_id)
event.response_url = asString(b.response_url)
event.trigger_id = asString(b.trigger_id)
event.api_app_id = asString(b.api_app_id)
return event
}
/**
* Interactivity payloads (button clicks, selects, shortcuts, modal submits).
* The actionable data lives in `actions[]` / `view`, plus `response_url` and
* `trigger_id` which are needed to respond to or follow up on the interaction.
* `text` prefers the source message text, falling back to the triggering
* action's value so a blocks-only message still surfaces something useful.
*/
function formatSlackInteractive(b: Record<string, unknown>): SlackTriggerEvent {
const event = createSlackEvent()
event.event_type = asString(b.type) || 'block_actions'
const actions = Array.isArray(b.actions) ? (b.actions as Array<Record<string, unknown>>) : []
event.actions = actions
const firstAction = actions[0]
event.action_id = asString(firstAction?.action_id)
event.action_value = extractActionValue(firstAction)
const channel = b.channel as Record<string, unknown> | undefined
event.channel = asString(channel?.id)
event.channel_name = asString(channel?.name)
const user = b.user as Record<string, unknown> | undefined
event.user = asString(user?.id)
event.user_name = asString(user?.username) || asString(user?.name)
const team = b.team as Record<string, unknown> | undefined
event.team_id = asString(team?.id) || asString(user?.team_id)
const container = b.container as Record<string, unknown> | undefined
const message = b.message as Record<string, unknown> | undefined
event.message_ts = asString(message?.ts) || asString(container?.message_ts)
event.timestamp = event.message_ts || asString(firstAction?.action_ts)
event.thread_ts = asString(message?.thread_ts)
event.text = asString(message?.text) || event.action_value
event.message = message ?? null
event.response_url = asString(b.response_url)
event.trigger_id = asString(b.trigger_id)
const view = b.view as Record<string, unknown> | undefined
event.callback_id = asString(b.callback_id) || asString(view?.callback_id)
event.view = view ?? null
event.state = (b.state as Record<string, unknown>) ?? null
event.api_app_id = asString(b.api_app_id)
return event
}
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: toError(error).message,
})
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: toError(error).message,
})
}
}
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: toError(error).message,
})
return ''
}
}
/** Maximum allowed timestamp skew (5 minutes) per Slack docs. */
const SLACK_TIMESTAMP_MAX_SKEW = 300
/**
* Resolve the Slack `team_id` and bot `user_id` for a bot token via `auth.test`.
* Used at deploy time to derive the tenant routing key for the native
* (`slack_app`) trigger and to store the bot user id for reaction self-drop —
* both are Slack-attested, never taken from user input. Throws on failure so
* deploy fails fast rather than registering an unroutable trigger.
*/
export async function fetchSlackTeamId(botToken: string): Promise<{
teamId: string
userId: string | undefined
teamName: string | undefined
}> {
const response = await fetch('https://slack.com/api/auth.test', {
headers: { Authorization: `Bearer ${botToken}` },
})
const data = (await response.json()) as {
ok?: boolean
team_id?: string
user_id?: string
team?: string
error?: string
}
if (!data.ok || !data.team_id) {
throw new Error(`Slack auth.test failed: ${data.error || 'unknown error'}`)
}
return { teamId: data.team_id, userId: data.user_id, teamName: data.team }
}
/**
* Validate Slack request signature using HMAC-SHA256.
* Basestring format: `v0:{timestamp}:{rawBody}`
* Signature header format: `v0={hex}`
*/
export function validateSlackSignature(
signingSecret: string,
signature: string,
timestamp: string,
rawBody: string
): boolean {
try {
if (!signingSecret || !signature || !rawBody) {
return false
}
if (!signature.startsWith('v0=')) {
logger.warn('Slack signature has invalid format (missing v0= prefix)')
return false
}
const providedSignature = signature.substring(3)
const basestring = `v0:${timestamp}:${rawBody}`
const computedHash = hmacSha256Hex(basestring, signingSecret)
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Slack signature:', error)
return false
}
}
/**
* Channel a Slack event occurred in. Message/mention events carry it under
* `channel`, reaction events under `item.channel`, and file/pin events under
* `channel_id`.
*/
export function resolveSlackEventChannel(
event: Record<string, unknown> | undefined
): string | undefined {
if (!event) return undefined
if (typeof event.channel === 'string') return event.channel
// channel_created / channel_rename deliver `channel` as an object.
if (isRecordLike(event.channel) && typeof event.channel.id === 'string') {
return event.channel.id
}
const item = event.item as Record<string, unknown> | undefined
if (typeof item?.channel === 'string') return item.channel
return typeof event.channel_id === 'string' ? event.channel_id : undefined
}
/**
* Handle Slack verification challenges
*/
export function handleSlackChallenge(body: unknown): NextResponse | null {
if (!isRecordLike(body)) {
return null
}
if (body.type === 'url_verification' && body.challenge) {
return NextResponse.json({ challenge: body.challenge })
}
return null
}
/**
* Verify a Slack request's timestamp + HMAC signature against a signing secret.
* Returns a 401 `NextResponse` on failure, or `null` when valid. Shared by the
* per-workflow webhook handler (secret from providerConfig) and the native app
* ingest route (secret from `SLACK_SIGNING_SECRET`).
*/
export function verifySlackRequestSignature(
signingSecret: string,
request: Request,
rawBody: string,
requestId: string
): NextResponse | null {
const signature = request.headers.get('x-slack-signature')
const timestamp = request.headers.get('x-slack-request-timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Slack webhook missing signature or timestamp header`)
return new NextResponse('Unauthorized - Missing Slack signature', { status: 401 })
}
const now = Math.floor(Date.now() / 1000)
const parsedTimestamp = Number(timestamp)
if (Number.isNaN(parsedTimestamp)) {
logger.warn(`[${requestId}] Slack webhook timestamp is not a valid number`, { timestamp })
return new NextResponse('Unauthorized - Invalid timestamp', { status: 401 })
}
const skew = Math.abs(now - parsedTimestamp)
if (skew > SLACK_TIMESTAMP_MAX_SKEW) {
logger.warn(`[${requestId}] Slack webhook timestamp too old`, { timestamp, now, skew })
return new NextResponse('Unauthorized - Request timestamp too old', { status: 401 })
}
if (!validateSlackSignature(signingSecret, signature, timestamp, rawBody)) {
logger.warn(`[${requestId}] Slack signature verification failed`)
return new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 })
}
return null
}
/** Message subtypes that carry real content (vs system/join/topic messages). */
const CONTENT_MESSAGE_SUBTYPES = new Set([
'file_share',
'me_message',
'thread_broadcast',
'bot_message',
])
/**
* Maps an inbound Slack Events API payload to the trigger `eventType` id it
* satisfies (see SLACK_EVENT_CATALOG). Returns null for payloads we do not
* surface. For most events the Slack `event.type` is the id verbatim; `message`
* fans out to `message` / `message_edited` / `message_deleted` by subtype.
*/
export function resolveSlackEventKey(body: Record<string, unknown>): string | null {
const event = body.event as Record<string, unknown> | undefined
if (!event) {
// Interactivity payloads (button clicks, modal submits) have no `event`
// envelope — the family is the top-level `type`. Surface only the ones a
// trigger can subscribe to.
const interactionType = body.type as string | undefined
if (interactionType && SLACK_INTERACTION_EVENT_KEYS.has(interactionType)) {
return interactionType
}
return null
}
const type = event.type as string | undefined
switch (type) {
case 'app_mention':
case 'reaction_added':
case 'reaction_removed':
case 'file_shared':
case 'member_joined_channel':
case 'member_left_channel':
case 'channel_created':
case 'channel_archive':
case 'channel_rename':
case 'pin_added':
case 'pin_removed':
case 'team_join':
case 'app_home_opened':
case 'assistant_thread_started':
case 'assistant_thread_context_changed':
return type
case 'message': {
const subtype = event.subtype as string | undefined
if (subtype === 'message_changed') return 'message_edited'
if (subtype === 'message_deleted') return 'message_deleted'
// Edits/deletes are handled above; other non-content subtypes (joins,
// topic/name changes, etc.) are not surfaced. `bot_message` is content so
// the "Ignore bot messages" toggle can decide, rather than being dropped.
if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) return null
return 'message'
}
default:
return null
}
}
/** True when the message originated from a bot (used to ignore other bots). */
function isBotEvent(event: Record<string, unknown> | undefined): boolean {
if (!event) return false
return Boolean(event.bot_id) || event.subtype === 'bot_message'
}
/**
* True when the event was produced by this Slack app itself, identified by
* matching the producing `app_id` against the payload's `api_app_id`.
*/
function isOwnAppEvent(
event: Record<string, unknown> | undefined,
apiAppId: string | undefined
): boolean {
if (!event || !apiAppId) return false
const appId =
(event.app_id as string | undefined) ??
((event.bot_profile as Record<string, unknown> | undefined)?.app_id as string | undefined)
return appId === apiAppId
}
/** True when the event is a thread reply (Slack-canonical: thread_ts set and != ts). */
function isThreadReply(event: Record<string, unknown> | undefined): boolean {
if (!event) return false
const threadTs = event.thread_ts as string | undefined
const ts = event.ts as string | undefined
return typeof threadTs === 'string' && threadTs.length > 0 && threadTs !== ts
}
function normalizeSelection(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String)
if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim())
return []
}
/**
* Back-compat matcher for pre-redesign webhooks that stored a multi-select
* `events` array of old ids (`message.im`, `message.channels`, ...). Maps the
* new `eventKey` back onto the legacy selection so existing deployments keep
* firing until they are re-deployed onto the single-event model.
*/
function matchesLegacyEvents(
rawEvents: unknown,
eventKey: string | null,
channelType: string | undefined
): boolean {
const events = normalizeSelection(rawEvents)
if (events.length === 0 || !eventKey) return false
for (const legacy of events) {
if (legacy === eventKey) return true
if (eventKey === 'message') {
if (legacy === 'message.im' && channelType === 'im') return true
if (legacy === 'message.channels' && channelType === 'channel') return true
if (legacy === 'message.groups' && channelType === 'group') return true
}
}
return false
}
/**
* Decides whether an inbound Slack event should be dropped for a `slack_oauth`
* trigger webhook, applying the configured event, source, threads, emoji, name,
* channel, self-drop, and bot filters. Shared by the native app ingest route
* (`/api/webhooks/slack`) and the custom-app path route (via
* `slackHandler.shouldSkipEvent`) so both backends filter identically. Returns
* true to skip.
*/
export function shouldSkipSlackTriggerEvent(
body: Record<string, unknown>,
providerConfig: Record<string, unknown>
): boolean {
const rawEvent = body.event as Record<string, unknown> | undefined
const eventKey = resolveSlackEventKey(body)
const channelType = rawEvent?.channel_type as string | undefined
const configuredEvent =
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
// Match the single configured event. Pre-redesign webhooks fall back to the
// legacy multi-select `events` array.
if (configuredEvent) {
if (!eventKey || eventKey !== configuredEvent) return true
} else if (!matchesLegacyEvents(providerConfig.events, eventKey, channelType)) {
return true
}
const supports = (filter: SlackEventFilter): boolean =>
configuredEvent !== null && slackEventSupportsFilter(configuredEvent, filter)
// Source — restrict a message event to any of DM / public / private
// (multiselect by `channel_type`). Empty means any source. Only filter when
// the channel_type is actually known: `message_changed` / `message_deleted`
// payloads often omit it, and dropping those on an unknown type would silently
// swallow every edit/delete.
if (supports('source')) {
const sources = normalizeSelection(providerConfig.source)
if (sources.length > 0 && channelType && !sources.includes(channelType)) return true
}
// Threads — include / exclude / only.
if (supports('threads')) {
const threads = typeof providerConfig.threads === 'string' ? providerConfig.threads : 'include'
const reply = isThreadReply(rawEvent)
if (threads === 'exclude' && reply) return true
if (threads === 'only' && !reply) return true
}
// Emoji — restrict a reaction event to specific emoji names.
if (supports('emoji')) {
const emojis = normalizeSelection(providerConfig.emoji)
const reaction = rawEvent?.reaction as string | undefined
if (emojis.length > 0 && (!reaction || !emojis.includes(reaction))) return true
}
// Name-contains — restrict channel_created to matching names.
if (supports('name')) {
const needle =
typeof providerConfig.nameContains === 'string' ? providerConfig.nameContains.trim() : ''
if (needle) {
const channel = rawEvent?.channel as Record<string, unknown> | undefined
const name = typeof channel?.name === 'string' ? channel.name : ''
if (!name.includes(needle)) return true
}
}
// Interaction — restrict a block_actions / view_submission event to specific
// action_id / callback_id values. Interaction fields live on the top-level
// body, not `rawEvent` (which is undefined for interactions). Empty = any.
if (supports('interaction')) {
const ids = normalizeSelection(providerConfig.interactionFilter)
if (ids.length > 0) {
const actions = Array.isArray(body.actions)
? (body.actions as Array<Record<string, unknown>>)
: []
const view = body.view as Record<string, unknown> | undefined
const interactionId =
eventKey === 'view_submission'
? ((view?.callback_id as string | undefined) ?? (body.callback_id as string | undefined))
: (actions[0]?.action_id as string | undefined)
if (!interactionId || !ids.includes(interactionId)) return true
}
}
// Channels — picker or manual IDs, the basic/advanced sides of one canonical
// field. DMs always skip it: a DM's channel can't be picked, so a DM allowed
// by Source must not be dropped by a channel filter meant for real channels.
const eventChannel = resolveSlackEventChannel(rawEvent)
const channelScoped =
channelType !== 'im' && (configuredEvent ? supports('channels') : Boolean(eventChannel))
if (channelScoped) {
const pickerChannels = normalizeSelection(providerConfig.channelFilter)
const selectedChannels =
pickerChannels.length > 0
? pickerChannels
: normalizeSelection(providerConfig.manualChannelFilter)
if (
selectedChannels.length > 0 &&
(!eventChannel || !selectedChannels.includes(eventChannel))
) {
return true
}
}
// Self-drop (invariant): never fire on this app's own output unless the
// advanced opt-in is set. Reactions identify self by the stored bot user id,
// messages by app_id. Other bots are dropped by the "Ignore bot messages"
// toggle, but never our own output.
const includeOwn = providerConfig.includeOwnMessages === true
let ownEvent = isOwnAppEvent(
rawEvent,
typeof body.api_app_id === 'string' ? body.api_app_id : undefined
)
if (eventKey === 'reaction_added' || eventKey === 'reaction_removed') {
const botUserId =
typeof providerConfig.bot_user_id === 'string' ? providerConfig.bot_user_id : undefined
ownEvent = Boolean(botUserId) && (rawEvent?.user as string | undefined) === botUserId
}
if (ownEvent) {
if (!includeOwn) return true
} else if (isBotEvent(rawEvent) && providerConfig.filterBotMessages !== false) {
return true
}
return false
}
export const slackHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret) {
return null
}
return verifySlackRequestSignature(signingSecret, request, rawBody, requestId)
},
handleChallenge(body: unknown) {
return handleSlackChallenge(body)
},
shouldSkipEvent({ body, providerConfig }: EventFilterContext) {
// Only the unified `slack_oauth` trigger carries event/filter config on this
// (custom-app) path; the legacy `slack_webhook` trigger is unfiltered.
if (providerConfig.triggerId !== 'slack_oauth') return false
return shouldSkipSlackTriggerEvent(body as Record<string, unknown>, providerConfig)
},
/**
* `event_id` (Events API) and `team_id:event.ts` are the primary keys.
* `trigger_id` is the fallback for interactivity and slash-command payloads,
* which carry no `event_id` but reuse the same `trigger_id` across Slack's
* retries of a given interaction.
*/
extractIdempotencyId(body: unknown) {
if (!isRecordLike(body)) {
return null
}
if (body.event_id) {
return String(body.event_id)
}
const event = isRecordLike(body.event) ? body.event : undefined
if (event?.ts && body.team_id) {
return `${body.team_id}:${event.ts}`
}
if (body.trigger_id) {
return String(body.trigger_id)
}
return null
},
formatSuccessResponse() {
return new NextResponse(null, { status: 200 })
},
formatQueueErrorResponse() {
return new NextResponse(null, { status: 200 })
},
/**
* Routes across Slack's three distinct payload families, each identified by
* a different shape: slash commands (flat form fields with a leading-slash
* `command`), interactivity (a JSON `payload` with an interactive `type` or
* `actions[]` and no Events-API `event` envelope), and the Events API
* (app_mention, message, reaction_added, ... nested under `event`).
*/
async formatInput({ body, webhook, requestId }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
let botToken = providerConfig.botToken as string | undefined
// Reusable custom Slack bot credential: use its stored bot token directly.
if (!botToken && typeof providerConfig.credentialId === 'string') {
const botCredential = await getSlackBotCredential(providerConfig.credentialId)
if (botCredential) botToken = botCredential.botToken
}
// Native (slack_app) triggers carry an OAuth credential rather than a pasted
// bot token; resolve it via the credential's OWNER (not the execution actor
// in workflow.userId, who may not own the credential) so reaction-message
// text and file downloads work.
if (!botToken && typeof providerConfig.credentialId === 'string') {
const credentialId = providerConfig.credentialId
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.accountId) {
const [owner] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (owner?.userId) {
botToken =
(await refreshAccessTokenIfNeeded(credentialId, owner.userId, requestId)) ?? undefined
}
}
}
const includeFiles = Boolean(providerConfig.includeFiles)
if (typeof b?.command === 'string' && b.command.startsWith('/')) {
return { input: { event: formatSlackSlashCommand(b) } }
}
if (b?.type === 'block_suggestion') {
return {
input: null,
skip: {
message:
'Slack block_suggestion payloads require a synchronous options response and cannot be served by an async workflow trigger',
},
}
}
if (
!b?.event &&
((typeof b?.type === 'string' && SLACK_INTERACTIVE_TYPES.has(b.type)) ||
Array.isArray(b?.actions))
) {
return { input: { event: formatSlackInteractive(b) } }
}
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 = resolveSlackEventChannel(rawEvent) || ''
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: SlackDownloadedFile[] = []
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')
}
const event = createSlackEvent()
event.event_type = eventType
event.subtype = asString(rawEvent?.subtype)
event.channel = channel
event.channel_type = asString(rawEvent?.channel_type)
event.user = asString(rawEvent?.user)
event.bot_id = asString(rawEvent?.bot_id)
event.text = text
event.timestamp = messageTs
event.thread_ts = asString(rawEvent?.thread_ts)
event.team_id = asString(b?.team_id) || asString(rawEvent?.team)
event.event_id = asString(b?.event_id)
event.api_app_id = asString(b?.api_app_id)
event.app_id =
asString(rawEvent?.app_id) ||
asString((rawEvent?.bot_profile as Record<string, unknown> | undefined)?.app_id)
event.reaction = asString(rawEvent?.reaction)
event.item_user = asString(rawEvent?.item_user)
event.message_ts = messageTs
event.hasFiles = hasFiles
event.files = files
return { input: { event } }
},
}
@@ -0,0 +1,151 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import Stripe from 'stripe'
import { describe, expect, it } from 'vitest'
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
const WEBHOOK_SECRET = 'whsec_test_secret'
function signedRequest(rawBody: string, secret = WEBHOOK_SECRET) {
const header = Stripe.webhooks.generateTestHeaderString({
payload: rawBody,
secret,
})
return reqWithHeaders({ 'stripe-signature': header })
}
describe('Stripe webhook provider', () => {
it('verifyAuth rejects when webhookSecret is not configured', async () => {
const res = await stripeHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects when the Stripe-Signature header is missing', async () => {
const res = await stripeHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: WEBHOOK_SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth rejects an invalid signature', async () => {
const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' })
const res = await stripeHandler.verifyAuth!({
request: reqWithHeaders({ 'stripe-signature': 't=1,v1=bad' }),
rawBody,
requestId: 't3',
providerConfig: { webhookSecret: WEBHOOK_SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('verifyAuth accepts a validly signed payload', async () => {
const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' })
const res = await stripeHandler.verifyAuth!({
request: signedRequest(rawBody),
rawBody,
requestId: 't4',
providerConfig: { webhookSecret: WEBHOOK_SECRET },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('verifyAuth rejects a signature signed with the wrong secret', async () => {
const rawBody = JSON.stringify({ id: 'evt_1', object: 'event', type: 'customer.created' })
const res = await stripeHandler.verifyAuth!({
request: signedRequest(rawBody, 'whsec_other_secret'),
rawBody,
requestId: 't5',
providerConfig: { webhookSecret: WEBHOOK_SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('shouldSkipEvent filters events outside the configured eventTypes allowlist', () => {
const skip = stripeHandler.shouldSkipEvent!({
webhook: { id: 'w1' },
body: { type: 'invoice.paid' },
requestId: 't6',
providerConfig: { eventTypes: ['customer.created'] },
})
expect(skip).toBe(true)
})
it('shouldSkipEvent passes through matching events', () => {
const skip = stripeHandler.shouldSkipEvent!({
webhook: { id: 'w1' },
body: { type: 'customer.created' },
requestId: 't7',
providerConfig: { eventTypes: ['customer.created'] },
})
expect(skip).toBe(false)
})
it('shouldSkipEvent passes through everything when no allowlist is configured', () => {
const skip = stripeHandler.shouldSkipEvent!({
webhook: { id: 'w1' },
body: { type: 'invoice.paid' },
requestId: 't8',
providerConfig: {},
})
expect(skip).toBe(false)
})
it('formatInput passes the raw Stripe event body through unchanged', async () => {
const body = { id: 'evt_1', object: 'event', type: 'customer.created', data: { object: {} } }
const { input } = await stripeHandler.formatInput!({
body,
headers: {},
requestId: 't9',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
expect(input).toEqual(body)
})
it('extractIdempotencyId returns the Stripe event id for event objects', () => {
const id = stripeHandler.extractIdempotencyId!({ id: 'evt_123', object: 'event' })
expect(id).toBe('evt_123')
})
it('extractIdempotencyId is stable across retried deliveries of the same event', () => {
const body = { id: 'evt_123', object: 'event', type: 'customer.created' }
const first = stripeHandler.extractIdempotencyId!(body)
const second = stripeHandler.extractIdempotencyId!({ ...body })
expect(first).toBe(second)
})
it('extractIdempotencyId returns null for non-event objects', () => {
expect(stripeHandler.extractIdempotencyId!({ id: 'obj_1', object: 'customer' })).toBeNull()
})
it('extractIdempotencyId degrades gracefully for null or non-object bodies', () => {
expect(stripeHandler.extractIdempotencyId!(null)).toBeNull()
expect(stripeHandler.extractIdempotencyId!(undefined)).toBeNull()
expect(stripeHandler.extractIdempotencyId!('not-an-object')).toBeNull()
expect(stripeHandler.extractIdempotencyId!(['array'])).toBeNull()
})
})
+65
View File
@@ -0,0 +1,65 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import type {
AuthContext,
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 = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
logger.warn(
`[${requestId}] Stripe webhook missing webhookSecret in providerConfig — rejecting request`
)
return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
}
const signature = request.headers.get('stripe-signature')
if (!signature) {
logger.warn(`[${requestId}] Stripe webhook missing Stripe-Signature header`)
return new NextResponse('Unauthorized - Missing Stripe signature', { status: 401 })
}
try {
Stripe.webhooks.constructEvent(rawBody, signature, secret)
} catch (error) {
logger.warn(`[${requestId}] Stripe signature verification failed`, {
error: getErrorMessage(error),
})
return new NextResponse('Unauthorized - Invalid Stripe signature', { status: 401 })
}
return null
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
return { input: body }
},
shouldSkipEvent(ctx: EventFilterContext) {
return skipByEventTypes(ctx, 'Stripe', logger)
},
/**
* Stripe event ids (evt_...) are globally unique and stable across retries —
* Stripe resends the same event id on delivery retries, so keying on it
* directly is sufficient without a content-derived fallback.
*/
extractIdempotencyId(body: unknown): string | null {
if (!isRecordLike(body)) return null
if (body.id && body.object === 'event') {
return String(body.id)
}
return null
},
}
+13
View File
@@ -0,0 +1,13 @@
import type { FormatInputContext, FormatInputResult, WebhookProviderHandler } from './types'
/**
* Provider handler for table triggers.
*
* Tables use direct triggering (fired from the insert codepath),
* so this handler only needs formatInput to pass the payload through.
*/
export const tableProviderHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
return { input: body }
},
}
+259
View File
@@ -0,0 +1,259 @@
import { db, webhook, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Telegram')
export const telegramHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId }: AuthContext) {
const userAgent = request.headers.get('user-agent')
if (!userAgent) {
logger.warn(
`[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.`
)
}
return null
},
extractIdempotencyId(body: unknown): string | null {
const obj = body as Record<string, unknown>
const updateId = obj.update_id
if (typeof updateId === 'number') {
return `telegram:${updateId}`
}
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,
},
}
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const botToken = config.botToken as string | undefined
if (!botToken) {
logger.warn(`[${ctx.requestId}] Missing botToken for Telegram webhook ${ctx.webhook.id}`)
throw new Error(
'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.'
)
}
const notificationUrl = getNotificationUrl(ctx.webhook)
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
try {
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'TelegramBot/1.0',
},
body: JSON.stringify({ url: notificationUrl }),
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to create Telegram webhook. Status: ${telegramResponse.status}`
logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody })
let userFriendlyMessage = 'Failed to create Telegram webhook'
if (telegramResponse.status === 401) {
userFriendlyMessage =
'Invalid bot token. Please verify that the bot token is correct and try again.'
} else if (responseBody.description) {
userFriendlyMessage = `Telegram error: ${responseBody.description}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${ctx.requestId}] Successfully created Telegram webhook for webhook ${ctx.webhook.id}`
)
return {}
} catch (error: unknown) {
if (
error instanceof Error &&
(error.message.includes('Bot token') || error.message.includes('Telegram error'))
) {
throw error
}
logger.error(
`[${ctx.requestId}] Error creating Telegram webhook for webhook ${ctx.webhook.id}`,
error
)
throw new Error(
error instanceof Error
? error.message
: 'Failed to create Telegram webhook. Please try again.'
)
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const botToken = config.botToken as string | undefined
if (!botToken) {
logger.warn(
`[${ctx.requestId}] Missing botToken for Telegram webhook deletion ${ctx.webhook.id}`
)
if (ctx.strict) throw new Error('Missing Telegram botToken for webhook deletion')
return
}
if (await activeTelegramWebhookUsesBot(ctx.webhook, botToken)) {
logger.info(
`[${ctx.requestId}] Skipping Telegram webhook deletion because an active deployment uses the same bot token`,
{ webhookId: ctx.webhook.id }
)
return
}
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to delete Telegram webhook. Status: ${telegramResponse.status}`
logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody })
if (ctx.strict) throw new Error(errorMessage)
} else {
logger.info(
`[${ctx.requestId}] Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}`
)
}
} catch (error) {
logger.error(
`[${ctx.requestId}] Error deleting Telegram webhook for webhook ${ctx.webhook.id}`,
error
)
if (ctx.strict) throw error
}
},
}
async function activeTelegramWebhookUsesBot(
webhookRecord: Record<string, unknown>,
botToken: string
): Promise<boolean> {
const workflowId = webhookRecord.workflowId
const webhookId = webhookRecord.id
if (typeof workflowId !== 'string' || typeof webhookId !== 'string') return false
const activeWebhooks = await db
.select({ id: webhook.id, providerConfig: webhook.providerConfig })
.from(webhook)
.innerJoin(
workflowDeploymentVersion,
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id)
)
.where(
and(
eq(webhook.workflowId, workflowId),
ne(webhook.id, webhookId),
eq(webhook.provider, 'telegram'),
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true),
isNull(webhook.archivedAt)
)
)
return activeWebhooks.some((activeWebhook) => {
const config = getProviderConfig({ providerConfig: activeWebhook.providerConfig })
return config.botToken === botToken
})
}
@@ -0,0 +1,119 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCredentialExpression, mockEq, mockSelect, queryRows } = vi.hoisted(() => ({
mockCredentialExpression: vi.fn(() => 'webhook.credentialId'),
mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })),
mockSelect: vi.fn(),
queryRows: {
rows: [] as Array<{
accountId: string
webhook: Record<string, unknown>
workflow: Record<string, unknown>
}>,
},
}))
vi.mock('@sim/db', () => {
const chain = {
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
leftJoin: vi.fn(() => chain),
where: vi.fn(() => Promise.resolve(queryRows.rows)),
}
mockSelect.mockImplementation(() => chain)
return {
account: {
id: 'account.id',
accountId: 'account.accountId',
providerId: 'account.providerId',
},
credential: {
id: 'credential.id',
accountId: 'credential.accountId',
providerId: 'credential.providerId',
type: 'credential.type',
workspaceId: 'credential.workspaceId',
},
db: { select: mockSelect },
webhookCredentialIdExpression: mockCredentialExpression,
webhook: {
deploymentVersionId: 'webhook.deploymentVersionId',
isActive: 'webhook.isActive',
archivedAt: 'webhook.archivedAt',
provider: 'webhook.provider',
providerConfig: 'webhook.providerConfig',
workflowId: 'webhook.workflowId',
},
workflow: {
id: 'workflow.id',
workspaceId: 'workflow.workspaceId',
archivedAt: 'workflow.archivedAt',
},
workflowDeploymentVersion: {
id: 'workflowDeploymentVersion.id',
workflowId: 'workflowDeploymentVersion.workflowId',
isActive: 'workflowDeploymentVersion.isActive',
},
}
})
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => conditions),
eq: mockEq,
isNull: vi.fn((value: unknown) => ({ isNull: value })),
like: vi.fn((left: unknown, right: unknown) => ({ left, right })),
or: vi.fn((...conditions: unknown[]) => conditions),
}))
import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets'
describe('findTikTokWebhookTargets', () => {
beforeEach(() => {
vi.clearAllMocks()
queryRows.rows = []
})
it('returns only rows whose stored account ID exactly matches user_openid', async () => {
queryRows.rows = [
{
accountId: 'act.user-11111111-2222-3333-4444-555555555555',
webhook: { id: 'webhook-1' },
workflow: { id: 'workflow-1' },
},
{
accountId: 'act.user-other-11111111-2222-3333-4444-555555555555',
webhook: { id: 'webhook-2' },
workflow: { id: 'workflow-2' },
},
]
const targets = await findTikTokWebhookTargets('act.user', 'request-1')
expect(targets).toEqual([
{
webhook: { id: 'webhook-1' },
workflow: { id: 'workflow-1' },
},
])
})
it('enforces provider and workspace bindings in the database query', async () => {
await findTikTokWebhookTargets('act.user', 'request-2')
expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'tiktok')
expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok')
expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId')
expect(mockCredentialExpression).toHaveBeenCalledWith('webhook.providerConfig')
expect(mockEq).toHaveBeenCalledWith('webhook.credentialId', 'credential.id')
})
it('does not query for an empty user_openid', async () => {
expect(await findTikTokWebhookTargets('', 'request-3')).toEqual([])
expect(mockSelect).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,102 @@
import {
account,
credential,
db,
webhook,
webhookCredentialIdExpression,
workflow,
workflowDeploymentVersion,
} from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like, or } from 'drizzle-orm'
const logger = createLogger('TikTokWebhookTargets')
const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export interface TikTokWebhookTarget {
webhook: typeof webhook.$inferSelect
workflow: typeof workflow.$inferSelect
}
function escapeLikePattern(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
}
function openIdFromAccountId(accountId: string): string {
return accountId.replace(ACCOUNT_ID_UUID_SUFFIX, '')
}
/**
* Resolves a TikTok user_openid to active webhook targets through the credential ID persisted in
* providerConfig. The workflow-workspace equality prevents cross-tenant event routing.
*/
export async function findTikTokWebhookTargets(
userOpenId: string,
requestId: string
): Promise<TikTokWebhookTarget[]> {
if (!userOpenId) return []
const rows = await db
.select({
accountId: account.accountId,
webhook,
workflow,
})
.from(account)
.innerJoin(
credential,
and(
eq(credential.accountId, account.id),
eq(credential.type, 'oauth'),
eq(credential.providerId, 'tiktok')
)
)
.innerJoin(
webhook,
and(
eq(webhookCredentialIdExpression(webhook.providerConfig), credential.id),
eq(webhook.provider, 'tiktok'),
eq(webhook.isActive, true),
isNull(webhook.archivedAt)
)
)
.innerJoin(
workflow,
and(
eq(workflow.id, webhook.workflowId),
eq(workflow.workspaceId, credential.workspaceId),
isNull(workflow.archivedAt)
)
)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(account.providerId, 'tiktok'),
like(account.accountId, `${escapeLikePattern(userOpenId)}-%`),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
const targets = rows
.filter((row) => openIdFromAccountId(row.accountId) === userOpenId)
.map(({ webhook: webhookRecord, workflow: workflowRecord }) => ({
webhook: webhookRecord,
workflow: workflowRecord,
}))
logger.info(`[${requestId}] Resolved TikTok webhook targets`, {
userOpenIdPrefix: userOpenId.slice(0, 12),
webhookCount: targets.length,
})
return targets
}
@@ -0,0 +1,298 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import {
parseTikTokContent,
parseTikTokSignatureHeader,
tiktokHandler,
verifyTikTokSignature,
} from '@/lib/webhooks/providers/tiktok'
import { isTikTokEventMatch } from '@/triggers/tiktok/utils'
function signTikTokBody(secret: string, timestamp: string, rawBody: string): string {
return crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex')
}
function requestWithTikTokSignature(signatureHeader: string): NextRequest {
return new NextRequest('http://localhost/api/webhooks/tiktok', {
headers: {
'TikTok-Signature': signatureHeader,
},
})
}
describe('parseTikTokSignatureHeader', () => {
it('parses t and s from the header', () => {
expect(
parseTikTokSignatureHeader(
't=1633174587,s=18494715036ac4416a1d0a673871a2edbcfc94d94bd88ccd2c5ec9b3425afe66'
)
).toEqual({
timestamp: '1633174587',
signature: '18494715036ac4416a1d0a673871a2edbcfc94d94bd88ccd2c5ec9b3425afe66',
})
})
it('returns null for missing or malformed headers', () => {
expect(parseTikTokSignatureHeader(null)).toBeNull()
expect(parseTikTokSignatureHeader('')).toBeNull()
expect(parseTikTokSignatureHeader('t=123')).toBeNull()
expect(parseTikTokSignatureHeader('s=abc')).toBeNull()
})
})
describe('verifyTikTokSignature', () => {
const secret = 'tiktok-client-secret'
const rawBody = JSON.stringify({
client_key: 'key',
event: 'post.publish.complete',
create_time: 1615338610,
user_openid: 'act.example',
content: '{"publish_id":"p1","publish_type":"DIRECT_POST"}',
})
it('accepts a valid signature within the skew window', () => {
const now = Math.floor(Date.now() / 1000)
const timestamp = String(now)
const signature = signTikTokBody(secret, timestamp, rawBody)
const result = verifyTikTokSignature(
rawBody,
`t=${timestamp},s=${signature}`,
'tt-1',
secret,
now
)
expect(result).toBeNull()
})
it('rejects an invalid signature', () => {
const now = Math.floor(Date.now() / 1000)
const result = verifyTikTokSignature(
rawBody,
`t=${now},s=${'0'.repeat(64)}`,
'tt-2',
secret,
now
)
expect(result?.status).toBe(401)
})
it('rejects when the client secret is missing', () => {
const now = Math.floor(Date.now() / 1000)
const result = verifyTikTokSignature(rawBody, `t=${now},s=abc`, 'tt-3', undefined, now)
expect(result?.status).toBe(401)
})
it('rejects a stale timestamp', () => {
const now = Math.floor(Date.now() / 1000)
const stale = String(now - 600)
const signature = signTikTokBody(secret, stale, rawBody)
const result = verifyTikTokSignature(rawBody, `t=${stale},s=${signature}`, 'tt-4', secret, now)
expect(result?.status).toBe(401)
})
it('rejects a missing signature header', () => {
const result = verifyTikTokSignature(rawBody, null, 'tt-5', secret)
expect(result?.status).toBe(401)
})
})
describe('parseTikTokContent', () => {
it('parses a JSON string content field', () => {
expect(parseTikTokContent('{"publish_id":"p1","publish_type":"DIRECT_POST"}')).toEqual({
publish_id: 'p1',
publish_type: 'DIRECT_POST',
})
})
it('returns an empty object for invalid JSON', () => {
expect(parseTikTokContent('{not-json')).toEqual({})
})
})
describe('isTikTokEventMatch', () => {
it('matches documented event names including TikTok typo', () => {
expect(isTikTokEventMatch('tiktok_post_publish_complete', 'post.publish.complete')).toBe(true)
expect(
isTikTokEventMatch(
'tiktok_post_no_longer_public',
'post.publish.no_longer_publicaly_available'
)
).toBe(true)
expect(isTikTokEventMatch('tiktok_post_publish_complete', 'post.publish.failed')).toBe(false)
})
})
describe('tiktokHandler', () => {
it('verifyAuth delegates to signature verification', async () => {
const secret = 'tiktok-client-secret'
const rawBody = '{"event":"authorization.removed"}'
const now = Math.floor(Date.now() / 1000)
const timestamp = String(now)
const signature = signTikTokBody(secret, timestamp, rawBody)
// verifyAuth uses env.TIKTOK_CLIENT_SECRET; exercise via verifyTikTokSignature path above.
// Handler still requires a request with the header for the dedicated ingress.
const res = await tiktokHandler.verifyAuth!({
request: requestWithTikTokSignature(`t=${timestamp},s=${signature}`),
rawBody,
requestId: 'tt-handler',
providerConfig: {},
webhook: {},
workflow: {},
})
// Without env secret this may 401; signature path is covered above.
expect(res === null || res.status === 401).toBe(true)
})
it('matchEvent filters by trigger id', async () => {
const match = await tiktokHandler.matchEvent!({
body: { event: 'post.publish.complete' },
request: new NextRequest('http://localhost'),
requestId: 'tt-match',
providerConfig: { triggerId: 'tiktok_post_publish_complete' },
webhook: {},
workflow: {},
})
expect(match).toBe(true)
const skip = await tiktokHandler.matchEvent!({
body: { event: 'post.publish.failed' },
request: new NextRequest('http://localhost'),
requestId: 'tt-skip',
providerConfig: { triggerId: 'tiktok_post_publish_complete' },
webhook: {},
workflow: {},
})
expect(skip).toBe(false)
})
it('formatInput flattens envelope and content fields', async () => {
const { input } = await tiktokHandler.formatInput!({
body: {
client_key: 'ck',
event: 'post.publish.failed',
create_time: 1615338610,
user_openid: 'act.user',
content: '{"publish_id":"pub-1","publish_type":"DIRECT_POST","reason":"spam_risk"}',
},
webhook: {},
workflow: { id: 'w1', userId: 'u1' },
headers: {},
requestId: 'tt-fmt',
})
expect(input).toEqual({
event: 'post.publish.failed',
createTime: 1615338610,
userOpenId: 'act.user',
clientKey: 'ck',
publishId: 'pub-1',
publishType: 'DIRECT_POST',
failReason: 'spam_risk',
})
})
it('formatInput maps authorization.removed reason', async () => {
const { input } = await tiktokHandler.formatInput!({
body: {
client_key: 'ck',
event: 'authorization.removed',
create_time: 1615338610,
user_openid: 'act.user',
content: '{"reason": 1 }',
},
webhook: {},
workflow: { id: 'w1', userId: 'u1' },
headers: {},
requestId: 'tt-auth',
})
expect(input).toEqual({
event: 'authorization.removed',
createTime: 1615338610,
userOpenId: 'act.user',
clientKey: 'ck',
reason: 1,
})
})
it('formatInput emits only the selected event output shape with null optional values', async () => {
const { input } = await tiktokHandler.formatInput!({
body: {
client_key: 'ck',
event: 'post.publish.publicly_available',
create_time: 1615338610,
user_openid: 'act.user',
content: '{"publish_id":"pub-1"}',
},
webhook: {},
workflow: { id: 'w1', userId: 'u1' },
headers: {},
requestId: 'tt-public',
})
expect(input).toEqual({
event: 'post.publish.publicly_available',
createTime: 1615338610,
userOpenId: 'act.user',
clientKey: 'ck',
publishId: 'pub-1',
publishType: null,
postId: null,
})
expect(input).not.toHaveProperty('shareId')
expect(input).not.toHaveProperty('failReason')
})
it('distinguishes multiple completed posts created from one publish_id', () => {
expect(
tiktokHandler.extractIdempotencyId!({
event: 'post.publish.complete',
user_openid: 'act.user',
create_time: 1,
content: '{"publish_id":"pub-1"}',
})
).toBe('post.publish.complete:act.user:pub-1:1')
expect(
tiktokHandler.extractIdempotencyId!({
event: 'post.publish.complete',
user_openid: 'act.user',
create_time: 2,
content: '{"publish_id":"pub-1"}',
})
).toBe('post.publish.complete:act.user:pub-1:2')
})
it('uses post_id to distinguish public availability events for the same publish_id', () => {
expect(
tiktokHandler.extractIdempotencyId!({
event: 'post.publish.publicly_available',
user_openid: 'act.user',
create_time: 3,
content: '{"publish_id":"pub-1","post_id":"post-1"}',
})
).toBe('post.publish.publicly_available:act.user:pub-1:post-1')
})
it('extractIdempotencyId falls back to share_id then create_time', () => {
expect(
tiktokHandler.extractIdempotencyId!({
event: 'video.publish.completed',
user_openid: 'act.user',
create_time: 99,
content: '{"share_id":"share-1"}',
})
).toBe('video.publish.completed:act.user:share-1')
expect(
tiktokHandler.extractIdempotencyId!({
event: 'authorization.removed',
user_openid: 'act.user',
create_time: 42,
content: '{"reason":1}',
})
).toBe('authorization.removed:act.user:42')
})
})
+245
View File
@@ -0,0 +1,245 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:TikTok')
/** TikTok recommends rejecting replayed signatures; 5 minutes matches Linear/common practice. */
export const TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60
export interface TikTokSignatureParts {
timestamp: string
signature: string
}
/**
* Parse `TikTok-Signature: t=<unix>,s=<hex>` (comma-separated prefix=value pairs).
*/
export function parseTikTokSignatureHeader(header: string | null): TikTokSignatureParts | null {
if (!header) return null
let timestamp: string | undefined
let signature: string | undefined
for (const part of header.split(',')) {
const trimmed = part.trim()
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
const prefix = trimmed.slice(0, eq)
const value = trimmed.slice(eq + 1)
if (prefix === 't') timestamp = value
if (prefix === 's') signature = value
}
if (!timestamp || !signature) return null
return { timestamp, signature }
}
/**
* Verify TikTok webhook HMAC-SHA256 of `${t}.${rawBody}` with the app client secret.
* Returns null on success, or a 401 NextResponse on failure.
*/
export function verifyTikTokSignature(
rawBody: string,
signatureHeader: string | null,
requestId: string,
clientSecret: string | undefined = env.TIKTOK_CLIENT_SECRET,
nowSeconds: number = Math.floor(Date.now() / 1000)
): NextResponse | null {
if (!clientSecret) {
logger.warn(`[${requestId}] TikTok webhook missing TIKTOK_CLIENT_SECRET`)
return new NextResponse('Unauthorized - TikTok client secret not configured', { status: 401 })
}
const parts = parseTikTokSignatureHeader(signatureHeader)
if (!parts) {
logger.warn(`[${requestId}] TikTok webhook missing or malformed TikTok-Signature header`)
return new NextResponse('Unauthorized - Missing TikTok signature', { status: 401 })
}
const timestampSeconds = Number(parts.timestamp)
if (!Number.isFinite(timestampSeconds)) {
logger.warn(`[${requestId}] TikTok webhook signature timestamp is not a number`)
return new NextResponse('Unauthorized - Invalid TikTok signature timestamp', { status: 401 })
}
if (Math.abs(nowSeconds - timestampSeconds) > TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS) {
logger.warn(`[${requestId}] TikTok webhook signature timestamp outside allowed skew`, {
skewSeconds: TIKTOK_WEBHOOK_TIMESTAMP_SKEW_SECONDS,
timestampSeconds,
nowSeconds,
})
return new NextResponse('Unauthorized - TikTok signature timestamp skew too large', {
status: 401,
})
}
const signedPayload = `${parts.timestamp}.${rawBody}`
const computed = hmacSha256Hex(signedPayload, clientSecret)
if (!safeCompare(computed, parts.signature)) {
logger.warn(`[${requestId}] TikTok signature verification failed`)
return new NextResponse('Unauthorized - Invalid TikTok signature', { status: 401 })
}
return null
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
/**
* Parse the TikTok envelope `content` field (a JSON string) into an object.
*/
export function parseTikTokContent(content: unknown): Record<string, unknown> {
if (typeof content !== 'string' || content.length === 0) {
return asRecord(content) ?? {}
}
try {
return asRecord(JSON.parse(content)) ?? {}
} catch {
logger.warn('Failed to parse TikTok webhook content JSON string')
return {}
}
}
function stringField(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = obj[key]
if (typeof value === 'string' && value.length > 0) return value
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
}
return undefined
}
function numberField(value: unknown): number | null {
const number = typeof value === 'number' ? value : Number(value)
return Number.isFinite(number) ? number : null
}
export const tiktokHandler: WebhookProviderHandler = {
ingressMode: 'provider',
executionMode: 'queue',
async verifyAuth({ request, rawBody, requestId }: AuthContext): Promise<NextResponse | null> {
return verifyTikTokSignature(rawBody, request.headers.get('TikTok-Signature'), requestId)
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId =
typeof providerConfig.triggerId === 'string' ? providerConfig.triggerId : undefined
if (!triggerId) return true
const { isTikTokEventMatch } = await import('@/triggers/tiktok/utils')
const event = stringField(asRecord(body) ?? {}, 'event')
if (!isTikTokEventMatch(triggerId, event)) {
logger.debug(
`[${requestId}] TikTok event mismatch for trigger ${triggerId}. Event: ${event}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const envelope = asRecord(body) ?? {}
const content = parseTikTokContent(envelope.content)
const event = typeof envelope.event === 'string' ? envelope.event : ''
const commonInput: Record<string, unknown> = {
event,
createTime: numberField(envelope.create_time),
userOpenId: typeof envelope.user_openid === 'string' ? envelope.user_openid : null,
clientKey: typeof envelope.client_key === 'string' ? envelope.client_key : null,
}
const postingInput = {
...commonInput,
publishId: stringField(content, 'publish_id') ?? null,
publishType: stringField(content, 'publish_type') ?? null,
}
if (event === 'post.publish.failed') {
return {
input: {
...postingInput,
failReason: stringField(content, 'fail_reason', 'reason') ?? null,
},
}
}
if (event === 'post.publish.complete' || event === 'post.publish.inbox_delivered') {
return { input: postingInput }
}
if (
event === 'post.publish.publicly_available' ||
event === 'post.publish.no_longer_publicaly_available'
) {
return {
input: {
...postingInput,
postId: stringField(content, 'post_id') ?? null,
},
}
}
if (event === 'authorization.removed') {
return {
input: {
...commonInput,
reason: numberField(content.reason),
},
}
}
if (event === 'video.publish.completed' || event === 'video.upload.failed') {
return {
input: {
...commonInput,
shareId: stringField(content, 'share_id') ?? null,
},
}
}
return { input: commonInput }
},
extractIdempotencyId(body: unknown) {
const envelope = asRecord(body)
if (!envelope) return null
const event = typeof envelope.event === 'string' ? envelope.event : null
const userOpenId = typeof envelope.user_openid === 'string' ? envelope.user_openid : null
if (!event || !userOpenId) return null
const content = parseTikTokContent(envelope.content)
const publishId = stringField(content, 'publish_id')
const postId = stringField(content, 'post_id')
const shareId = stringField(content, 'share_id')
const createTime =
typeof envelope.create_time === 'number' || typeof envelope.create_time === 'string'
? String(envelope.create_time)
: null
let unique: string | null = null
if (publishId && postId) {
unique = `${publishId}:${postId}`
} else if (event === 'post.publish.complete' && publishId && createTime) {
unique = `${publishId}:${createTime}`
} else {
unique = publishId ?? shareId ?? createTime
}
if (!unique) return null
return `${event}:${userOpenId}:${unique}`
},
}
@@ -0,0 +1,121 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { NextResponse } from 'next/server'
import type { AuthContext } from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:TwilioSignature')
/**
* Validate `X-Twilio-Signature`: HMAC-SHA1 over the callback URL plus each POST
* param key/value sorted alphabetically.
* @see https://www.twilio.com/docs/usage/security#validating-requests
*/
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,
})
return false
}
const sortedKeys = Object.keys(params).sort()
let data = url
for (const key of sortedKeys) {
data += key + params[key]
}
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(authToken),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
)
const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data))
const signatureArray = Array.from(new Uint8Array(signatureBytes))
const signatureBase64 = btoa(String.fromCharCode(...signatureArray))
return safeCompare(signatureBase64, signature)
} catch (error) {
logger.error('Error validating Twilio signature:', error)
return false
}
}
/**
* Reconstruct the public callback URL Twilio signed, recovering the original
* host/proto from forwarding headers when Sim runs behind a proxy. Forged headers
* don't help an attacker: without the auth token they can't match the signature.
*/
function getExternalUrl(request: Request): string {
const proto = request.headers.get('x-forwarded-proto') || 'https'
const host = request.headers.get('x-forwarded-host') || request.headers.get('host')
if (host) {
const url = new URL(request.url)
return `${proto}://${host}${url.pathname}${url.search}`
}
return request.url
}
/**
* Shared `verifyAuth` for Twilio webhook providers (SMS and Voice). Enforces a
* valid `X-Twilio-Signature` when an auth token is configured; skips verification
* when none is set (the provider-wide "optional secret" convention).
*/
export async function verifyTwilioAuth(
{ request, rawBody, requestId, providerConfig }: AuthContext,
providerLabel: string
): Promise<NextResponse | null> {
const authToken = providerConfig.authToken as string | undefined
if (!authToken) {
logger.warn(
`[${requestId}] ${providerLabel} webhook has no auth token configured — accepting request without signature verification. Configure an auth token to require signed requests.`
)
return null
}
const signature = request.headers.get('x-twilio-signature')
if (!signature) {
logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Twilio signature', { status: 401 })
}
let params: Record<string, string> = {}
try {
if (typeof rawBody === 'string') {
const urlParams = new URLSearchParams(rawBody)
params = Object.fromEntries(urlParams.entries())
}
} catch (error) {
logger.error(
`[${requestId}] Error parsing ${providerLabel} webhook body for signature validation:`,
error
)
return new NextResponse('Bad Request - Invalid body format', { status: 400 })
}
const fullUrl = getExternalUrl(request)
const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params)
if (!isValidSignature) {
logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, {
url: fullUrl,
signatureLength: signature.length,
paramsCount: Object.keys(params).length,
authTokenLength: authToken.length,
})
return new NextResponse('Unauthorized - Invalid Twilio signature', { status: 401 })
}
return null
}
@@ -0,0 +1,204 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice'
/** Twilio canonical signature: HMAC-SHA1(authToken, url + sorted(key+value)) base64. */
function signTwilio(authToken: string, url: string, params: Record<string, string>): string {
const data = Object.keys(params)
.sort()
.reduce((acc, key) => acc + key + params[key], url)
return crypto.createHmac('sha1', authToken).update(Buffer.from(data, 'utf8')).digest('base64')
}
describe('twilioVoiceHandler', () => {
describe('verifyAuth', () => {
const authToken = 'voice-auth-token'
const url = 'http://localhost:3000/api/test'
const params = { CallSid: 'CA123', From: '+15551234567', To: '+15557654321' }
const rawBody = new URLSearchParams(params).toString()
const signature = signTwilio(authToken, url, params)
it('skips verification when no auth token is configured', async () => {
const request = createMockRequest('POST', undefined, {})
const res = await twilioVoiceHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('returns 401 when the signature header is missing', async () => {
const request = createMockRequest('POST', undefined, {})
const res = await twilioVoiceHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns 401 when the signature is invalid', async () => {
const request = createMockRequest('POST', undefined, { 'x-twilio-signature': 'bad' })
const res = await twilioVoiceHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns null when the signature is valid', async () => {
const request = createMockRequest('POST', undefined, { 'x-twilio-signature': signature })
const res = await twilioVoiceHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('extractIdempotencyId', () => {
it('prefers MessageSid, falls back to CallSid', () => {
expect(twilioVoiceHandler.extractIdempotencyId!({ MessageSid: 'SM1' })).toBe('SM1')
expect(twilioVoiceHandler.extractIdempotencyId!({ CallSid: 'CA1' })).toBe('CA1')
expect(twilioVoiceHandler.extractIdempotencyId!({})).toBeNull()
})
it('returns null instead of throwing when body is not a record', () => {
expect(twilioVoiceHandler.extractIdempotencyId!(null)).toBeNull()
expect(twilioVoiceHandler.extractIdempotencyId!(undefined)).toBeNull()
expect(twilioVoiceHandler.extractIdempotencyId!('not-an-object')).toBeNull()
expect(twilioVoiceHandler.extractIdempotencyId!([1, 2, 3])).toBeNull()
})
it('distinguishes each CallStatus transition for the same CallSid', () => {
const ringing = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'ringing',
})
const inProgress = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
})
const completed = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'completed',
})
expect(ringing).not.toBeNull()
expect(ringing).not.toBe(inProgress)
expect(inProgress).not.toBe(completed)
})
it('dedupes a retried delivery of the same CallStatus transition', () => {
const first = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'completed',
})
const retry = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'completed',
})
expect(first).toBe(retry)
})
it('distinguishes recording and transcription status callbacks from CallStatus callbacks on the same CallSid', () => {
const callCompleted = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'completed',
})
const recordingCompleted = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
RecordingStatus: 'completed',
})
const transcriptionCompleted = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
TranscriptionStatus: 'completed',
})
expect(callCompleted).not.toBeNull()
expect(recordingCompleted).not.toBeNull()
expect(transcriptionCompleted).not.toBeNull()
expect(callCompleted).not.toBe(recordingCompleted)
expect(recordingCompleted).not.toBe(transcriptionCompleted)
})
it('distinguishes multiple Gather turns that share the same CallSid and CallStatus', () => {
const firstDigits = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
Digits: '1',
})
const secondDigits = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
Digits: '2',
})
const speech = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
SpeechResult: 'sales',
})
expect(firstDigits).not.toBe(secondDigits)
expect(firstDigits).not.toBe(speech)
expect(secondDigits).not.toBe(speech)
})
it('distinguishes recording status callbacks by RecordingSid for the same CallSid', () => {
const recordingOne = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
RecordingSid: 'RE1',
})
const recordingTwo = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
RecordingSid: 'RE2',
})
expect(recordingOne).not.toBe(recordingTwo)
})
it('dedupes a retried delivery of the same Gather turn', () => {
const first = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
Digits: '1',
})
const retry = twilioVoiceHandler.extractIdempotencyId!({
CallSid: 'CA1',
CallStatus: 'in-progress',
Digits: '1',
})
expect(first).toBe(retry)
})
})
describe('formatInput', () => {
it('degrades to empty output instead of throwing when body is not a record', async () => {
const { input } = await twilioVoiceHandler.formatInput!({
webhook: {},
workflow: { id: 'wf1', userId: 'u1' },
body: null,
headers: {},
requestId: 'r1',
})
const i = input as Record<string, unknown>
expect(i.callSid).toBeUndefined()
expect(i.raw).toBe('{}')
})
})
})
@@ -0,0 +1,137 @@
import { isRecordLike } from '@sim/utils/object'
import { NextResponse } from 'next/server'
import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature'
import type {
AuthContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
export const twilioVoiceHandler: WebhookProviderHandler = {
verifyAuth(ctx: AuthContext) {
return verifyTwilioAuth(ctx, 'Twilio Voice')
},
/**
* A call fires many independent callbacks against the same CallSid as it
* progresses: CallStatus transitions (queued -> ringing -> in-progress ->
* completed/...), repeated Gather turns while CallStatus stays
* "in-progress" (differentiated only by Digits or SpeechResult), and
* separate recording/transcription completions via RecordingStatus/
* TranscriptionStatus. The discriminator is built from field=value pairs
* (not bare values) so callbacks of different kinds can never collide even
* when they share a value — e.g. CallStatus=completed vs
* RecordingStatus=completed are distinct strings — while a retried
* delivery of the identical payload still produces the identical key.
*/
extractIdempotencyId(body: unknown) {
if (!isRecordLike(body)) return null
const sid = (body.MessageSid as string) || (body.CallSid as string)
if (!sid) return null
const discriminatorFields = [
'CallStatus',
'Digits',
'SpeechResult',
'RecordingSid',
'RecordingStatus',
'TranscriptionSid',
'TranscriptionStatus',
] as const
const discriminator = discriminatorFields
.map((field) => {
const value = body[field]
return typeof value === 'string' && value ? `${field}=${value.toLowerCase()}` : null
})
.filter(Boolean)
.join('&')
return discriminator ? `${sid}:${discriminator}` : sid
},
formatSuccessResponse(providerConfig: Record<string, unknown>) {
const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim()
if (twimlResponse && twimlResponse.length > 0) {
const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse)
return new NextResponse(convertedTwiml, {
status: 200,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
},
})
}
const defaultTwiml = `<?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',
},
})
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
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>
<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',
},
})
},
}
@@ -0,0 +1,276 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { twilioHandler } from '@/lib/webhooks/providers/twilio'
/** Twilio canonical signature: HMAC-SHA1(authToken, url + sorted(key+value)) base64. */
function signTwilio(authToken: string, url: string, params: Record<string, string>): string {
const data = Object.keys(params)
.sort()
.reduce((acc, key) => acc + key + params[key], url)
return crypto.createHmac('sha1', authToken).update(Buffer.from(data, 'utf8')).digest('base64')
}
describe('twilioHandler', () => {
describe('verifyAuth', () => {
const authToken = 'test-auth-token'
const url = 'http://localhost:3000/api/test'
const params = { From: '+15551234567', To: '+15557654321', Body: 'hello', MessageSid: 'SM123' }
const rawBody = new URLSearchParams(params).toString()
const signature = signTwilio(authToken, url, params)
it('rejects a forged request with no signature header', async () => {
const request = createMockRequest('POST', undefined, {
'content-type': 'application/x-www-form-urlencoded',
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects a request with an invalid signature', async () => {
const request = createMockRequest('POST', undefined, {
'x-twilio-signature': 'not-the-real-signature',
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts a request with a valid signature', async () => {
const request = createMockRequest('POST', undefined, {
'x-twilio-signature': signature,
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('skips verification when no auth token is configured (optional-secret convention)', async () => {
const request = createMockRequest('POST', undefined, {})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('reconstructs the public URL from forwarding headers when validating', async () => {
const publicUrl = 'https://sim.ai/api/webhooks/trigger/twilio-sms-abc123'
const fwdSignature = signTwilio(authToken, publicUrl, params)
const request = createMockRequest(
'POST',
undefined,
{
'x-twilio-signature': fwdSignature,
'x-forwarded-proto': 'https',
'x-forwarded-host': 'sim.ai',
},
'http://internal-host:3000/api/webhooks/trigger/twilio-sms-abc123'
)
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('rejects a forged body even with a forwarded host (no valid token)', async () => {
const request = createMockRequest(
'POST',
undefined,
{
'x-twilio-signature': signTwilio('attacker-guess', url, params),
'x-forwarded-host': 'sim.ai',
},
url
)
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
})
describe('extractIdempotencyId', () => {
it('prefers MessageSid, falls back to CallSid', () => {
expect(
twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', SmsStatus: 'received' })
).toBe('SM1')
expect(twilioHandler.extractIdempotencyId!({ CallSid: 'CA1' })).toBe('CA1')
expect(twilioHandler.extractIdempotencyId!({})).toBeNull()
})
it('returns null instead of throwing when body is not a record', () => {
expect(twilioHandler.extractIdempotencyId!(null)).toBeNull()
expect(twilioHandler.extractIdempotencyId!(undefined)).toBeNull()
expect(twilioHandler.extractIdempotencyId!('not-an-object')).toBeNull()
expect(twilioHandler.extractIdempotencyId!([1, 2, 3])).toBeNull()
})
it('keys status callbacks by SID + status so each delivery state is distinct', () => {
const sent = twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', MessageStatus: 'sent' })
const delivered = twilioHandler.extractIdempotencyId!({
MessageSid: 'SM1',
MessageStatus: 'delivered',
})
expect(sent).toBe('SM1:sent')
expect(delivered).toBe('SM1:delivered')
expect(sent).not.toBe(delivered)
})
})
describe('matchEvent', () => {
const match = (triggerId: string, body: Record<string, unknown>) =>
twilioHandler.matchEvent!({
body,
request: new Request('http://localhost/test') as never,
rawBody: '',
requestId: 'r1',
providerConfig: { triggerId },
webhook: {},
workflow: {},
})
const inbound = { MessageSid: 'SM1', From: '+1', Body: 'hi', SmsStatus: 'received' }
const status = { MessageSid: 'SM1', MessageStatus: 'delivered', SmsStatus: 'delivered' }
it('routes inbound messages only to the received trigger', () => {
expect(match('twilio_sms_received', inbound)).toBe(true)
expect(match('twilio_sms_status', inbound)).toBe(false)
})
it('routes delivery callbacks only to the status trigger', () => {
expect(match('twilio_sms_status', status)).toBe(true)
expect(match('twilio_sms_received', status)).toBe(false)
})
it('passes through when no triggerId is configured', () => {
expect(match('', inbound)).toBe(true)
})
it('matches neither trigger for an ambiguous payload missing status fields', () => {
const ambiguous = { MessageSid: 'SM1', From: '+1' }
expect(match('twilio_sms_received', ambiguous)).toBe(false)
expect(match('twilio_sms_status', ambiguous)).toBe(false)
})
it('matches neither trigger instead of throwing when body is not a record', () => {
expect(match('twilio_sms_received', null as never)).toBe(false)
expect(match('twilio_sms_status', null as never)).toBe(false)
})
})
describe('formatInput', () => {
const ctx = (body: Record<string, unknown>) => ({
webhook: {},
workflow: { id: 'wf1', userId: 'u1' },
body,
headers: {},
requestId: 'r1',
})
it('maps inbound SMS params to aligned output keys', async () => {
const body = {
MessageSid: 'SM123',
AccountSid: 'AC123',
From: '+15551234567',
To: '+15557654321',
Body: 'hello world',
NumMedia: '0',
NumSegments: '1',
SmsStatus: 'received',
ApiVersion: '2010-04-01',
FromCity: 'SAN FRANCISCO',
FromState: 'CA',
FromCountry: 'US',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.messageSid).toBe('SM123')
expect(i.from).toBe('+15551234567')
expect(i.to).toBe('+15557654321')
expect(i.body).toBe('hello world')
expect(i.smsStatus).toBe('received')
expect(i.numMedia).toBe('0')
expect(i.media).toEqual([])
expect(i.fromCity).toBe('SAN FRANCISCO')
expect(i.raw).toBe(JSON.stringify(body))
})
it('extracts MMS media items from NumMedia / MediaUrl{N}', async () => {
const body = {
MessageSid: 'MM123',
NumMedia: '2',
MediaUrl0: 'https://api.twilio.com/media/0',
MediaContentType0: 'image/jpeg',
MediaUrl1: 'https://api.twilio.com/media/1',
MediaContentType1: 'image/png',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.media).toEqual([
{ url: 'https://api.twilio.com/media/0', contentType: 'image/jpeg' },
{ url: 'https://api.twilio.com/media/1', contentType: 'image/png' },
])
})
it('maps status-callback params including ErrorCode on failure', async () => {
const body = {
MessageSid: 'SM999',
MessageStatus: 'failed',
SmsStatus: 'failed',
ErrorCode: '30008',
From: '+15550000000',
To: '+15551111111',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.messageStatus).toBe('failed')
expect(i.errorCode).toBe('30008')
expect(i.media).toEqual([])
})
it('degrades to empty output instead of throwing when body is not a record', async () => {
const { input } = await twilioHandler.formatInput!(ctx(null as never))
const i = input as Record<string, unknown>
expect(i.messageSid).toBeUndefined()
expect(i.media).toEqual([])
expect(i.raw).toBe('{}')
})
})
})
+98
View File
@@ -0,0 +1,98 @@
import { isRecordLike } from '@sim/utils/object'
import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
/**
* Build the media array from Twilio's `NumMedia` / `MediaUrl{N}` /
* `MediaContentType{N}` params (MMS messages).
*/
function extractMedia(b: Record<string, unknown>): Array<{ url: unknown; contentType: unknown }> {
const numMedia = Number.parseInt((b.NumMedia as string) ?? '0', 10) || 0
const media: Array<{ url: unknown; contentType: unknown }> = []
for (let i = 0; i < numMedia; i++) {
media.push({ url: b[`MediaUrl${i}`], contentType: b[`MediaContentType${i}`] })
}
return media
}
export const twilioHandler: WebhookProviderHandler = {
verifyAuth(ctx: AuthContext) {
return verifyTwilioAuth(ctx, 'Twilio SMS')
},
/**
* Distinguish an inbound SMS from a delivery status callback so the two
* triggers don't fire on each other's deliveries when they share a URL.
* Twilio reports status `received` for inbound messages; delivery callbacks
* carry a non-`received` `MessageStatus` (queued/sent/delivered/…). Each side
* requires a positive signal, so an ambiguous payload missing both fields
* matches neither trigger rather than misrouting.
*/
matchEvent({ body, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) return true
const b = isRecordLike(body) ? body : {}
const messageStatus = ((b.MessageStatus as string) ?? '').toLowerCase()
const smsStatus = ((b.SmsStatus as string) ?? '').toLowerCase()
const isInbound = smsStatus === 'received' || messageStatus === 'received'
const isStatusCallback = !isInbound && (messageStatus !== '' || smsStatus !== '')
if (triggerId === 'twilio_sms_received') return isInbound
if (triggerId === 'twilio_sms_status') return isStatusCallback
return true
},
/**
* Status callbacks repeat for the same SID as the message progresses
* (sent -> delivered -> ...), so the delivery status is part of the key to
* keep each distinct callback (while still deduping Twilio's retries of
* the same status). Inbound messages fire once (SmsStatus 'received'),
* keyed by SID alone.
*/
extractIdempotencyId(body: unknown) {
if (!isRecordLike(body)) return null
const obj = body
const sid = (obj.MessageSid as string) || (obj.CallSid as string)
if (!sid) return null
const status = (
((obj.MessageStatus as string) || (obj.SmsStatus as string)) ??
''
).toLowerCase()
return status && status !== 'received' ? `${sid}:${status}` : sid
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = isRecordLike(body) ? body : {}
return {
input: {
messageSid: b.MessageSid,
accountSid: b.AccountSid,
messagingServiceSid: b.MessagingServiceSid,
from: b.From,
to: b.To,
body: b.Body,
numMedia: b.NumMedia,
numSegments: b.NumSegments,
media: extractMedia(b),
smsStatus: b.SmsStatus,
messageStatus: b.MessageStatus,
errorCode: b.ErrorCode,
apiVersion: b.ApiVersion,
fromCity: b.FromCity,
fromState: b.FromState,
fromZip: b.FromZip,
fromCountry: b.FromCountry,
toCity: b.ToCity,
toState: b.ToState,
toZip: b.ToZip,
toCountry: b.ToCountry,
raw: JSON.stringify(b),
},
}
},
}
+218
View File
@@ -0,0 +1,218 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
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
}
const providedSignature = signature.substring(7)
const computedHash = hmacSha256Base64(body, secret)
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Typeform signature:', error)
return false
}
}
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',
validateFn: validateTypeformSignature,
providerLabel: 'Typeform',
}),
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const formId = config.formId as string | undefined
const apiKey = config.apiKey as string | undefined
const webhookTag = config.webhookTag as string | undefined
const secret = config.secret as string | undefined
if (!formId) {
logger.warn(`[${ctx.requestId}] Missing formId for Typeform webhook ${ctx.webhook.id}`)
throw new Error(
'Form ID is required to create a Typeform webhook. Please provide a valid form ID.'
)
}
if (!apiKey) {
logger.warn(`[${ctx.requestId}] Missing apiKey for Typeform webhook ${ctx.webhook.id}`)
throw new Error(
'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.'
)
}
const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}`
const notificationUrl = getNotificationUrl(ctx.webhook)
try {
const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}`
const requestBody: Record<string, unknown> = {
url: notificationUrl,
enabled: true,
verify_ssl: true,
event_types: {
form_response: true,
},
}
if (secret) {
requestBody.secret = secret
}
const typeformResponse = await fetch(typeformApiUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!typeformResponse.ok) {
const responseBody = await typeformResponse.json().catch(() => ({}))
const errorMessage =
(responseBody as Record<string, string>).description ||
(responseBody as Record<string, string>).message ||
'Unknown error'
logger.error(`[${ctx.requestId}] Typeform API error: ${errorMessage}`, {
status: typeformResponse.status,
response: responseBody,
})
let userFriendlyMessage = 'Failed to create Typeform webhook'
if (typeformResponse.status === 401) {
userFriendlyMessage =
'Invalid Personal Access Token. Please verify your Typeform API key and try again.'
} else if (typeformResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.'
} else if (typeformResponse.status === 404) {
userFriendlyMessage = 'Form not found. Please verify the form ID is correct.'
} else if (
(responseBody as Record<string, string>).description ||
(responseBody as Record<string, string>).message
) {
userFriendlyMessage = `Typeform error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const responseBody = await typeformResponse.json()
logger.info(
`[${ctx.requestId}] Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`,
{ webhookId: (responseBody as Record<string, unknown>).id }
)
if (!webhookTag && tag) {
return { providerConfigUpdates: { webhookTag: tag } }
}
return {}
} catch (error: unknown) {
if (
error instanceof Error &&
(error.message.includes('Form ID') ||
error.message.includes('Personal Access Token') ||
error.message.includes('Typeform error'))
) {
throw error
}
logger.error(
`[${ctx.requestId}] Error creating Typeform webhook for webhook ${ctx.webhook.id}`,
error
)
throw new Error(
error instanceof Error
? error.message
: 'Failed to create Typeform webhook. Please try again.'
)
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(ctx.webhook)
const formId = config.formId as string | undefined
const apiKey = config.apiKey as string | undefined
const webhookTag = config.webhookTag as string | undefined
if (!formId || !apiKey) {
logger.warn(
`[${ctx.requestId}] Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Typeform webhook deletion credentials')
return
}
const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}`
const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}`
const typeformResponse = await fetch(typeformApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!typeformResponse.ok && typeformResponse.status !== 404) {
logger.warn(
`[${ctx.requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}`
)
if (ctx.strict) {
throw new Error(`Failed to delete Typeform webhook: ${typeformResponse.status}`)
}
} else {
logger.info(`[${ctx.requestId}] Successfully deleted Typeform webhook with tag ${tag}`)
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Typeform webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
+158
View File
@@ -0,0 +1,158 @@
import type { NextRequest, NextResponse } from 'next/server'
/** Context for signature/token verification. */
export interface AuthContext {
webhook: Record<string, unknown>
workflow: Record<string, unknown>
request: NextRequest
rawBody: string
requestId: string
providerConfig: Record<string, unknown>
}
/** Context for event matching against trigger configuration. */
export interface EventMatchContext {
webhook: Record<string, unknown>
workflow: Record<string, unknown>
body: unknown
request: NextRequest
requestId: string
providerConfig: Record<string, unknown>
}
/** Context for event filtering and header enrichment. */
export interface EventFilterContext {
webhook: Record<string, unknown>
body: unknown
requestId: string
providerConfig: Record<string, unknown>
}
/** Context for custom input preparation during execution. */
export interface FormatInputContext {
webhook: Record<string, unknown>
workflow: { id: string; userId: string }
body: unknown
headers: Record<string, string>
requestId: string
}
/** Result of custom input preparation. */
export interface FormatInputResult {
input: unknown
skip?: { message: string }
}
/** Context for provider-specific file processing before execution. */
export interface ProcessFilesContext {
input: Record<string, unknown>
blocks: Record<string, unknown>
blockId: string
workspaceId: string
workflowId: string
executionId: string
requestId: string
userId: string
}
/** Context for creating an external webhook subscription during deployment. */
export interface SubscriptionContext {
webhook: Record<string, unknown>
workflow: Record<string, unknown>
userId: string
requestId: string
request: NextRequest
}
/** Result of creating an external webhook subscription. */
export interface SubscriptionResult {
/** Fields to merge into providerConfig (externalId, webhookSecret, etc.) */
providerConfigUpdates?: Record<string, unknown>
}
/** Context for deleting an external webhook subscription during undeployment. */
export interface DeleteSubscriptionContext {
webhook: Record<string, unknown>
workflow: Record<string, unknown>
requestId: string
strict?: boolean
}
/** Context for configuring polling after webhook creation. */
export interface PollingConfigContext {
webhook: Record<string, unknown>
requestId: string
}
/**
* Strategy interface for provider-specific webhook behavior.
* Each provider implements only the methods it needs — all methods are optional.
*/
export interface WebhookProviderHandler {
/**
* Restrict deliveries to a provider-owned ingress route instead of the generic per-webhook
* path route. Use when the provider sends all app events to one callback before target lookup.
*/
ingressMode?: 'path' | 'provider'
/**
* Queue workflow execution through the configured durable backend instead of the low-latency
* in-process path. Use for providers whose ingress is acknowledged before target processing.
*/
executionMode?: 'inline' | 'queue'
/** Verify signature/auth. Return NextResponse(401/403) on failure, null on success. */
verifyAuth?(ctx: AuthContext): Promise<NextResponse | null> | NextResponse | null
/** Handle reachability/verification probes after webhook lookup. */
handleReachabilityTest?(body: unknown, requestId: string): NextResponse | null
/** Format error responses (some providers need special formats). */
formatErrorResponse?(error: string, status: number): NextResponse
/** Return true to skip this event (filtering by event type, collection, etc.). */
shouldSkipEvent?(ctx: EventFilterContext): boolean
/** Return true if event matches, false or NextResponse to skip with a custom response. */
matchEvent?(ctx: EventMatchContext): Promise<boolean | NextResponse> | boolean | NextResponse
/** Add provider-specific headers (idempotency keys, notification IDs, etc.). */
enrichHeaders?(ctx: EventFilterContext, headers: Record<string, string>): void
/** Extract unique identifier for idempotency dedup. */
extractIdempotencyId?(body: unknown): string | null
/** Custom success response after queuing. Return null for default `{message: "Webhook processed"}`. */
formatSuccessResponse?(providerConfig: Record<string, unknown>): NextResponse | null
/** Custom error response when queuing fails. Return null for default 500. */
formatQueueErrorResponse?(): NextResponse | null
/** Custom input preparation. When defined, replaces the default pass-through of the raw body. */
formatInput?(ctx: FormatInputContext): Promise<FormatInputResult>
/** Called when input is null after formatting. Return skip message or null to proceed. */
handleEmptyInput?(requestId: string): { message: string } | null
/** Post-process input to handle file uploads before execution. */
processInputFiles?(ctx: ProcessFilesContext): Promise<void>
/** Create an external webhook subscription (e.g., register with Telegram, Airtable, etc.). */
createSubscription?(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined>
/** Delete an external webhook subscription during cleanup. Strict outbox cleanup should throw. */
deleteSubscription?(ctx: DeleteSubscriptionContext): Promise<void>
/** Configure polling after webhook creation (gmail, outlook, rss, imap). */
configurePolling?(ctx: PollingConfigContext): Promise<boolean>
/** Handle verification challenges before webhook lookup (Slack url_verification, WhatsApp hub.verify_token, Teams validationToken). */
handleChallenge?(
body: unknown,
request: NextRequest,
requestId: string,
path: string,
/** Raw request body bytes (when available); required for signature checks that must match the provider (e.g. Zoom). */
rawBody?: string
): Promise<NextResponse | null> | NextResponse | null
}
+145
View File
@@ -0,0 +1,145 @@
import type { Logger } from '@sim/logger'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import { NextResponse } from 'next/server'
import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProviderAuth')
/**
* Deterministic JSON serialization with object keys sorted, so structurally
* identical payloads produce identical output regardless of key order.
*/
function stableSerialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
.join(',')}}`
}
return JSON.stringify(value)
}
/**
* Fallback idempotency fingerprint for payloads with no stable delivery id
* or content timestamp to key on. A provider retry resends identical bytes,
* so this hash is stable across retries of the same delivery while still
* differentiating distinct events.
*/
export function buildFallbackDeliveryFingerprint(body: unknown): string {
return sha256Hex(stableSerialize(body))
}
interface HmacVerifierOptions {
configKey: string
headerName: string
validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise<boolean>
providerLabel: string
/**
* When true, reject (401) if no secret is configured instead of skipping
* verification. Use for providers where the secret is always present (e.g.
* auto-registered webhooks) so a missing secret fails closed.
*/
requireSecret?: boolean
}
/**
* Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers.
* Covers the common pattern: get secret → check header → validate signature → return 401 or null.
*/
export function createHmacVerifier({
configKey,
headerName,
validateFn,
providerLabel,
requireSecret = false,
}: HmacVerifierOptions) {
return async ({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> => {
const secret = providerConfig[configKey] as string | undefined
if (!secret) {
if (requireSecret) {
logger.warn(`[${requestId}] ${providerLabel} webhook secret not configured`)
return new NextResponse(`Unauthorized - Missing ${providerLabel} webhook secret`, {
status: 401,
})
}
return null
}
const signature = request.headers.get(headerName)
if (!signature) {
logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`)
return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 })
}
const isValid = await validateFn(secret, signature, rawBody)
if (!isValid) {
logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, {
signatureLength: signature.length,
secretLength: secret.length,
})
return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 })
}
return null
}
}
/**
* Verify a bearer token or custom header token using timing-safe comparison.
* Used by generic webhooks, Google Forms, and the default handler.
*/
export function verifyTokenAuth(
request: Request,
expectedToken: string,
secretHeaderName?: string
): boolean {
if (secretHeaderName) {
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
return !!headerValue && safeCompare(headerValue, expectedToken)
}
const authHeader = request.headers.get('authorization')
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7)
return safeCompare(token, expectedToken)
}
return false
}
/**
* Skip events whose `body.type` is not in the `providerConfig.eventTypes` allowlist.
* Shared by providers that use a simple event-type filter (Stripe, Grain, etc.).
*/
export function skipByEventTypes(
{ webhook, body, requestId, providerConfig }: EventFilterContext,
providerLabel: string,
eventLogger: Logger
): boolean {
const eventTypes = providerConfig.eventTypes
if (!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0) {
return false
}
const eventType = (body as Record<string, unknown>)?.type as string | undefined
if (eventType && !eventTypes.includes(eventType)) {
eventLogger.info(
`[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping`
)
return true
}
return false
}
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { vercelHandler } from '@/lib/webhooks/providers/vercel'
describe('vercelHandler', () => {
describe('verifyAuth', () => {
const secret = 'test-signing-secret'
const rawBody = JSON.stringify({ type: 'deployment.created', id: 'del_1' })
const signature = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
it('returns 401 when webhookSecret is missing', async () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'x-vercel-signature': signature,
})
const res = await vercelHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns 401 when signature header is missing', async () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {})
const res = await vercelHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('returns null when signature is valid', async () => {
const request = createMockRequest('POST', JSON.parse(rawBody), {
'x-vercel-signature': signature,
})
const res = await vercelHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { webhookSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('extractIdempotencyId', () => {
it('uses top-level delivery id from Vercel payload', () => {
expect(vercelHandler.extractIdempotencyId!({ id: 'abc123' })).toBe('vercel:abc123')
expect(vercelHandler.extractIdempotencyId!({})).toBeNull()
})
})
})
+362
View File
@@ -0,0 +1,362 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Vercel')
function verifyVercelSignature(secret: string, signature: string, rawBody: string): boolean {
const hash = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
return safeCompare(hash, signature)
}
export const vercelHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = (providerConfig.webhookSecret as string | undefined)?.trim()
if (!secret) {
logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`)
return new NextResponse(
'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
{ status: 401 }
)
}
const signature = request.headers.get('x-vercel-signature')
if (!signature) {
logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`)
return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 })
}
if (!verifyVercelSignature(secret, signature, rawBody)) {
logger.warn(`[${requestId}] Vercel signature verification failed`)
return new NextResponse('Unauthorized - Invalid Vercel signature', { status: 401 })
}
return null
},
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
const eventType = obj.type as string | undefined
if (triggerId && triggerId !== 'vercel_webhook') {
const { isVercelEventMatch } = await import('@/triggers/vercel/utils')
if (!isVercelEventMatch(triggerId, eventType)) {
logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, {
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
eventType,
})
return false
}
}
return true
},
extractIdempotencyId(body: unknown) {
const id = (body as Record<string, unknown>)?.id
if (id === undefined || id === null || id === '') {
return null
}
return `vercel:${String(id)}`
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const teamId = providerConfig.teamId as string | undefined
const filterProjectIds = providerConfig.filterProjectIds as string | undefined
if (!apiKey) {
throw new Error(
'Vercel Access Token is required. Please provide your access token in the trigger configuration.'
)
}
const { VERCEL_GENERIC_TRIGGER_EVENT_TYPES, VERCEL_TRIGGER_EVENT_TYPES } = await import(
'@/triggers/vercel/utils'
)
if (
triggerId &&
triggerId !== 'vercel_webhook' &&
!(triggerId in VERCEL_TRIGGER_EVENT_TYPES)
) {
throw new Error(
`Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.`
)
}
const events =
triggerId && triggerId !== 'vercel_webhook'
? [VERCEL_TRIGGER_EVENT_TYPES[triggerId]]
: undefined
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Vercel webhook`, {
triggerId,
events,
hasTeamId: !!teamId,
hasProjectIds: !!filterProjectIds,
webhookId: webhook.id,
})
/**
* Vercel requires an explicit events list — there is no "subscribe to all" option.
* For the generic webhook trigger, we subscribe to the most commonly useful events.
* Full list: https://vercel.com/docs/webhooks/webhooks-api#event-types
*/
const requestBody: Record<string, unknown> = {
url: notificationUrl,
events: events || [...VERCEL_GENERIC_TRIGGER_EVENT_TYPES],
}
if (filterProjectIds) {
const projectIds = String(filterProjectIds)
.split(',')
.map((id: string) => id.trim())
.filter(Boolean)
if (projectIds.length > 0) {
requestBody.projectIds = projectIds
}
}
const apiUrl = teamId
? `https://api.vercel.com/v1/webhooks?teamId=${encodeURIComponent(teamId)}`
: 'https://api.vercel.com/v1/webhooks'
const vercelResponse = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await vercelResponse.json().catch(() => ({}))) as Record<
string,
unknown
>
if (!vercelResponse.ok) {
const errorObj = responseBody.error as Record<string, unknown> | undefined
const errorMessage =
(errorObj?.message as string) ||
(responseBody.message as string) ||
'Unknown Vercel API error'
let userFriendlyMessage = 'Failed to create webhook subscription in Vercel'
if (vercelResponse.status === 401 || vercelResponse.status === 403) {
userFriendlyMessage =
'Invalid or insufficient Vercel Access Token. Please verify your token has the correct permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Vercel API error') {
userFriendlyMessage = `Vercel error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const externalId = responseBody.id as string | undefined
if (!externalId) {
throw new Error('Vercel webhook creation succeeded but no webhook ID was returned')
}
logger.info(
`[${requestId}] Successfully created webhook in Vercel for webhook ${webhook.id}.`,
{ vercelWebhookId: externalId }
)
const signingSecret = responseBody.secret as string | undefined
if (!signingSecret) {
throw new Error(
'Vercel webhook was created but no signing secret was returned. Delete the webhook in Vercel and save this trigger again.'
)
}
return {
providerConfigUpdates: {
externalId,
webhookSecret: signingSecret,
},
}
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Vercel webhook creation for webhook ${webhook.id}.`,
{ message: err.message, stack: err.stack }
)
throw error
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
const teamId = config.teamId as string | undefined
if (!apiKey || !externalId) {
logger.warn(
`[${requestId}] Missing apiKey or externalId for Vercel webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Vercel webhook deletion credentials')
return
}
const apiUrl = teamId
? `https://api.vercel.com/v1/webhooks/${encodeURIComponent(externalId)}?teamId=${encodeURIComponent(teamId)}`
: `https://api.vercel.com/v1/webhooks/${encodeURIComponent(externalId)}`
const response = await fetch(apiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok && response.status !== 404) {
logger.warn(
`[${requestId}] Failed to delete Vercel webhook (non-fatal): ${response.status}`
)
if (ctx.strict) throw new Error(`Failed to delete Vercel webhook: ${response.status}`)
} else {
await response.body?.cancel()
logger.info(`[${requestId}] Successfully deleted Vercel webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Vercel webhook (non-fatal)`, error)
if (ctx.strict) throw error
}
},
async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> {
const body = ctx.body as Record<string, unknown>
const payload = (body.payload || {}) as Record<string, unknown>
const deployment = payload.deployment ?? null
const project = payload.project ?? null
const team = payload.team ?? null
const user = payload.user ?? null
const domain = payload.domain ?? null
const linksRaw = payload.links
let links: { deployment: string; project: string } | null = null
if (linksRaw && typeof linksRaw === 'object' && !Array.isArray(linksRaw)) {
const L = linksRaw as Record<string, unknown>
const dep = L.deployment
const proj = L.project
if (typeof dep === 'string' || typeof proj === 'string') {
links = {
deployment: typeof dep === 'string' ? dep : '',
project: typeof proj === 'string' ? proj : '',
}
}
}
const regionsRaw = payload.regions
const regions = Array.isArray(regionsRaw) ? regionsRaw : null
let deploymentMeta: Record<string, unknown> | null = null
if (deployment && typeof deployment === 'object') {
const meta = (deployment as Record<string, unknown>).meta
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
deploymentMeta = meta as Record<string, unknown>
}
}
return {
input: {
type: body.type ?? '',
id: body.id != null ? String(body.id) : '',
createdAt: (() => {
const v = body.createdAt
if (typeof v === 'number' && !Number.isNaN(v)) {
return v
}
if (typeof v === 'string') {
const parsed = Date.parse(v)
return Number.isNaN(parsed) ? 0 : parsed
}
const n = Number(v)
return Number.isNaN(n) ? 0 : n
})(),
region: body.region != null ? String(body.region) : null,
payload,
links,
regions,
deployment:
deployment && typeof deployment === 'object'
? {
id:
(deployment as Record<string, unknown>).id != null
? String((deployment as Record<string, unknown>).id)
: '',
url: ((deployment as Record<string, unknown>).url as string) ?? '',
name: ((deployment as Record<string, unknown>).name as string) ?? '',
meta: deploymentMeta,
}
: null,
project:
project && typeof project === 'object'
? {
id:
(project as Record<string, unknown>).id != null
? String((project as Record<string, unknown>).id)
: '',
name: ((project as Record<string, unknown>).name as string) ?? '',
}
: null,
team:
team && typeof team === 'object'
? {
id:
(team as Record<string, unknown>).id != null
? String((team as Record<string, unknown>).id)
: '',
}
: null,
user:
user && typeof user === 'object'
? {
id:
(user as Record<string, unknown>).id != null
? String((user as Record<string, unknown>).id)
: '',
}
: null,
target: payload.target != null ? String(payload.target) : null,
plan: payload.plan != null ? String(payload.plan) : null,
domain:
domain && typeof domain === 'object'
? {
name: ((domain as Record<string, unknown>).name as string) ?? '',
delegated:
typeof (domain as Record<string, unknown>).delegated === 'boolean'
? ((domain as Record<string, unknown>).delegated as boolean)
: null,
}
: null,
},
}
},
}
+316
View File
@@ -0,0 +1,316 @@
import { createLogger } from '@sim/logger'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventFilterContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProvider:Webflow')
export const webflowHandler: WebhookProviderHandler = {
async createSubscription({
webhook: webhookRecord,
workflow,
userId,
requestId,
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
const { path, providerConfig } = webhookRecord as Record<string, unknown>
const config = (providerConfig as Record<string, unknown>) || {}
const { siteId, triggerId, collectionId, formName, credentialId } = config as {
siteId?: string
triggerId?: string
collectionId?: string
formName?: string
credentialId?: string
}
if (!siteId) {
logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, {
webhookId: webhookRecord.id,
})
throw new Error('Site ID is required to create Webflow webhook')
}
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
if (!siteIdValidation.isValid) {
throw new Error(siteIdValidation.error)
}
if (!triggerId) {
logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, {
webhookId: webhookRecord.id,
})
throw new Error('Trigger type is required to create Webflow webhook')
}
const credentialOwner = credentialId
? await getCredentialOwner(credentialId, requestId)
: null
const accessToken = credentialId
? credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
: await getOAuthToken(userId, 'webflow')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.`
)
throw new Error(
'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const triggerTypeMap: Record<string, string> = {
webflow_collection_item_created: 'collection_item_created',
webflow_collection_item_changed: 'collection_item_changed',
webflow_collection_item_deleted: 'collection_item_deleted',
webflow_form_submission: 'form_submission',
}
const webflowTriggerType = triggerTypeMap[triggerId]
if (!webflowTriggerType) {
logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, {
webhookId: webhookRecord.id,
})
throw new Error(`Invalid Webflow trigger type: ${triggerId}`)
}
const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks`
const requestBody: Record<string, unknown> = {
triggerType: webflowTriggerType,
url: notificationUrl,
}
if (formName && webflowTriggerType === 'form_submission') {
requestBody.filter = {
name: formName,
}
}
const webflowResponse = await fetch(webflowApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await webflowResponse.json()
if (!webflowResponse.ok || responseBody.error) {
const errorMessage =
responseBody.message || responseBody.error || 'Unknown Webflow API error'
logger.error(
`[${requestId}] Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`,
{ message: errorMessage, response: responseBody }
)
throw new Error(errorMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`,
{
webflowWebhookId: responseBody.id || responseBody._id,
}
)
return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } }
} catch (error: unknown) {
const err = error as Error
logger.error(
`[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`,
{
message: err.message,
stack: err.stack,
}
)
throw error
}
},
async deleteSubscription({
webhook: webhookRecord,
workflow,
requestId,
strict,
}: DeleteSubscriptionContext): Promise<void> {
try {
const config = getProviderConfig(webhookRecord)
const siteId = config.siteId as string | undefined
const externalId = config.externalId as string | undefined
if (!siteId) {
logger.warn(
`[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup`
)
if (strict) throw new Error('Missing Webflow siteId for webhook deletion')
return
}
if (!externalId) {
logger.warn(
`[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup`
)
if (strict) throw new Error('Missing Webflow externalId for webhook deletion')
return
}
const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100)
if (!siteIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, {
webhookId: webhookRecord.id,
siteId: siteId.substring(0, 30),
})
if (strict) throw new Error('Invalid Webflow siteId for webhook deletion')
return
}
const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100)
if (!webhookIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, {
webhookId: webhookRecord.id,
externalId: externalId.substring(0, 30),
})
if (strict) throw new Error('Invalid Webflow webhook ID for deletion')
return
}
const credentialId = config.credentialId as string | undefined
if (!credentialId) {
logger.warn(
`[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}`
)
if (strict) throw new Error('Missing Webflow credentialId for webhook deletion')
return
}
const credentialOwner = await getCredentialOwner(credentialId, requestId)
const accessToken = credentialOwner
? await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
requestId
)
: null
if (!accessToken) {
const message = `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`
logger.warn(message, { webhookId: webhookRecord.id })
if (strict) throw new Error(message)
return
}
const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}`
const webflowResponse = await fetch(webflowApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
accept: 'application/json',
},
})
if (!webflowResponse.ok && webflowResponse.status !== 404) {
const responseBody = await webflowResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`,
{ response: responseBody }
)
if (strict) {
throw new Error(`Failed to delete Webflow webhook: ${webflowResponse.status}`)
}
} else {
logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error)
if (strict) throw error
}
},
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) {
const obj = body as Record<string, unknown>
const payload = obj.payload as Record<string, unknown> | undefined
const payloadCollectionId = (payload?.collectionId ?? obj.collectionId) as string | undefined
if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) {
logger.info(
`[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping`
)
return true
}
}
return false
},
}
@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { createHmac } from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({
db: {},
workflowDeploymentVersion: {},
}))
import { whatsappHandler } from './whatsapp'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('WhatsApp webhook provider', () => {
it('rejects deliveries when the app secret is not configured', async () => {
const response = await whatsappHandler.verifyAuth!({
webhook: { id: 'wh_1' },
workflow: { id: 'wf_1' },
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 'wa-auth-missing-secret',
providerConfig: {},
})
expect(response?.status).toBe(401)
await expect(response?.text()).resolves.toBe(
'Unauthorized - WhatsApp app secret not configured'
)
})
it('accepts a valid X-Hub-Signature-256 header for the exact raw payload', async () => {
const secret = 'test-secret'
const rawBody =
'{"entry":[{"changes":[{"field":"messages","value":{"messages":[{"id":"wamid.1"}]}}]}]}'
const signature = `sha256=${createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`
const response = await whatsappHandler.verifyAuth!({
webhook: { id: 'wh_2' },
workflow: { id: 'wf_2' },
request: reqWithHeaders({ 'x-hub-signature-256': signature }),
rawBody,
requestId: 'wa-auth-valid-signature',
providerConfig: { appSecret: secret },
})
expect(response).toBeNull()
})
it('builds a stable idempotency key for batched message and status payloads', () => {
const key = whatsappHandler.extractIdempotencyId!({
entry: [
{
changes: [
{
field: 'messages',
value: {
messages: [{ id: 'wamid.message.1' }],
statuses: [
{
id: 'wamid.status.1',
status: 'delivered',
timestamp: '1700000001',
},
],
},
},
],
},
],
})
expect(key).toMatch(/^whatsapp:2:[a-f0-9]{64}$/)
})
it('flattens batched messages and statuses into trigger-friendly outputs', async () => {
const result = await whatsappHandler.formatInput!({
webhook: { id: 'wh_3', providerConfig: {} },
workflow: { id: 'wf_3', userId: 'user_3' },
body: {
object: 'whatsapp_business_account',
entry: [
{
changes: [
{
field: 'messages',
value: {
metadata: {
phone_number_id: '12345',
display_phone_number: '+1 555 0100',
},
contacts: [
{
wa_id: '15550101',
profile: { name: 'Alice' },
},
],
messages: [
{
id: 'wamid.message.1',
from: '15550101',
timestamp: '1700000000',
type: 'text',
text: { body: 'hello' },
},
],
},
},
{
field: 'messages',
value: {
metadata: {
phone_number_id: '12345',
display_phone_number: '+1 555 0100',
},
statuses: [
{
id: 'wamid.status.1',
recipient_id: '15550102',
status: 'delivered',
timestamp: '1700000001',
conversation: { id: 'conv_1' },
pricing: { category: 'utility' },
},
],
},
},
],
},
],
},
headers: {},
requestId: 'wa-format-batch',
})
const input = result.input as Record<string, unknown>
expect(input.eventType).toBe('mixed')
expect(input.messageId).toBe('wamid.message.1')
expect(input.phoneNumberId).toBe('12345')
expect(input.displayPhoneNumber).toBe('+1 555 0100')
expect(input.text).toBe('hello')
expect(input.status).toBe('delivered')
expect(input.contact).toEqual({
wa_id: '15550101',
profile: { name: 'Alice' },
})
expect(input.webhookContacts).toEqual([
{
wa_id: '15550101',
profile: { name: 'Alice' },
},
])
expect(input.messages).toEqual([
{
messageId: 'wamid.message.1',
from: '15550101',
phoneNumberId: '12345',
displayPhoneNumber: '+1 555 0100',
text: 'hello',
timestamp: '1700000000',
messageType: 'text',
raw: {
id: 'wamid.message.1',
from: '15550101',
timestamp: '1700000000',
type: 'text',
text: { body: 'hello' },
},
},
])
expect(input.statuses).toEqual([
{
messageId: 'wamid.status.1',
recipientId: '15550102',
phoneNumberId: '12345',
displayPhoneNumber: '+1 555 0100',
status: 'delivered',
timestamp: '1700000001',
conversation: { id: 'conv_1' },
pricing: { category: 'utility' },
raw: {
id: 'wamid.status.1',
recipient_id: '15550102',
status: 'delivered',
timestamp: '1700000001',
conversation: { id: 'conv_1' },
pricing: { category: 'utility' },
},
},
])
})
})
+376
View File
@@ -0,0 +1,376 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import { hmacSha256Hex } from '@sim/security/hmac'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:WhatsApp')
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getWhatsAppChanges(
body: unknown
): Array<{ field?: string; value: Record<string, unknown> }> {
if (!isRecord(body) || !Array.isArray(body.entry)) {
return []
}
const changes: Array<{ field?: string; value: Record<string, unknown> }> = []
for (const entry of body.entry) {
if (!isRecord(entry) || !Array.isArray(entry.changes)) {
continue
}
for (const change of entry.changes) {
if (!isRecord(change) || !isRecord(change.value)) {
continue
}
changes.push({
field: typeof change.field === 'string' ? change.field : undefined,
value: change.value,
})
}
}
return changes
}
function normalizeWhatsAppContact(contact: Record<string, unknown>) {
const profile = isRecord(contact.profile) ? contact.profile : undefined
return {
wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : undefined,
profile: profile
? {
name: typeof profile.name === 'string' ? profile.name : undefined,
}
: undefined,
}
}
function normalizeWhatsAppMessage(
message: Record<string, unknown>,
metadata?: Record<string, unknown>
) {
const text = isRecord(message.text) ? message.text : undefined
return {
messageId: typeof message.id === 'string' ? message.id : undefined,
from: typeof message.from === 'string' ? message.from : undefined,
phoneNumberId:
typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined,
displayPhoneNumber:
typeof metadata?.display_phone_number === 'string'
? metadata.display_phone_number
: undefined,
text: typeof text?.body === 'string' ? text.body : undefined,
timestamp: typeof message.timestamp === 'string' ? message.timestamp : undefined,
messageType: typeof message.type === 'string' ? message.type : undefined,
raw: message,
}
}
function normalizeWhatsAppStatus(
status: Record<string, unknown>,
metadata?: Record<string, unknown>
) {
return {
messageId: typeof status.id === 'string' ? status.id : undefined,
recipientId: typeof status.recipient_id === 'string' ? status.recipient_id : undefined,
phoneNumberId:
typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined,
displayPhoneNumber:
typeof metadata?.display_phone_number === 'string'
? metadata.display_phone_number
: undefined,
status: typeof status.status === 'string' ? status.status : undefined,
timestamp: typeof status.timestamp === 'string' ? status.timestamp : undefined,
conversation: isRecord(status.conversation) ? status.conversation : undefined,
pricing: isRecord(status.pricing) ? status.pricing : undefined,
raw: status,
}
}
function validateWhatsAppSignature(secret: string, signature: string, body: string): boolean {
try {
if (!signature.startsWith('sha256=')) {
logger.warn('WhatsApp signature has invalid format')
return false
}
const providedSignature = signature.substring(7)
const computedSignature = hmacSha256Hex(body, secret)
return safeCompare(computedSignature, providedSignature)
} catch (error) {
logger.error('Error validating WhatsApp signature:', error)
return false
}
}
function buildWhatsAppIdempotencyKey(keys: Set<string>): string | null {
if (keys.size === 0) {
return null
}
const sortedKeys = Array.from(keys).sort()
const digest = sha256Hex(sortedKeys.join('|'))
return `whatsapp:${sortedKeys.length}:${digest}`
}
/**
* Handle WhatsApp verification requests
*/
async function handleWhatsAppVerification(
requestId: string,
path: string,
mode: string | null,
token: string | null,
challenge: string | null
): Promise<NextResponse | null> {
if (mode && token && challenge) {
logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`)
if (mode !== 'subscribe') {
logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`)
return new NextResponse('Invalid mode', { status: 400 })
}
const webhooks = await db
.select({ webhook })
.from(webhook)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, webhook.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.provider, 'whatsapp'),
eq(webhook.path, path),
eq(webhook.isActive, true),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
for (const row of webhooks) {
const wh = row.webhook
const providerConfig = (wh.providerConfig as Record<string, unknown>) || {}
const verificationToken = providerConfig.verificationToken
if (!verificationToken) {
continue
}
if (safeCompare(token, verificationToken as string)) {
logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`)
return new NextResponse(challenge, {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
logger.warn(`[${requestId}] No matching WhatsApp verification token found`)
return new NextResponse('Verification failed', { status: 403 })
}
return null
}
export const whatsappHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }) {
const appSecret = providerConfig.appSecret as string | undefined
if (!appSecret) {
logger.warn(
`[${requestId}] WhatsApp webhook missing appSecret in providerConfig — rejecting request`
)
return new NextResponse('Unauthorized - WhatsApp app secret not configured', { status: 401 })
}
const signature = request.headers.get('x-hub-signature-256')
if (!signature) {
logger.warn(`[${requestId}] WhatsApp webhook missing signature header`)
return new NextResponse('Unauthorized - Missing WhatsApp signature', { status: 401 })
}
if (!validateWhatsAppSignature(appSecret, signature, rawBody)) {
logger.warn(`[${requestId}] WhatsApp signature verification failed`)
return new NextResponse('Unauthorized - Invalid WhatsApp signature', { status: 401 })
}
return null
},
async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) {
const url = new URL(request.url)
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
return handleWhatsAppVerification(requestId, path, mode, token, challenge)
},
extractIdempotencyId(body: unknown) {
const keys = new Set<string>()
for (const { field, value } of getWhatsAppChanges(body)) {
if (Array.isArray(value.messages)) {
for (const message of value.messages) {
if (!isRecord(message) || typeof message.id !== 'string') {
continue
}
keys.add(`${field ?? 'messages'}:message:${message.id}`)
}
}
if (Array.isArray(value.statuses)) {
for (const status of value.statuses) {
if (!isRecord(status) || typeof status.id !== 'string') {
continue
}
const statusValue = typeof status.status === 'string' ? status.status : ''
const timestamp = typeof status.timestamp === 'string' ? status.timestamp : ''
keys.add(`${field ?? 'messages'}:status:${status.id}:${statusValue}:${timestamp}`)
}
}
if (Array.isArray(value.groups)) {
for (const group of value.groups) {
if (!isRecord(group) || typeof group.request_id !== 'string') {
continue
}
keys.add(`${field ?? 'groups'}:group:${group.request_id}`)
}
}
}
return buildWhatsAppIdempotencyKey(keys)
},
formatSuccessResponse() {
return new NextResponse(null, { status: 200 })
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = isRecord(body) ? body : undefined
const contacts: Array<{ wa_id?: string; profile?: { name?: string } }> = []
const messages: Array<{
messageId?: string
from?: string
phoneNumberId?: string
displayPhoneNumber?: string
text?: string
timestamp?: string
messageType?: string
raw: Record<string, unknown>
}> = []
const statuses: Array<{
messageId?: string
recipientId?: string
phoneNumberId?: string
displayPhoneNumber?: string
status?: string
timestamp?: string
conversation?: Record<string, unknown>
pricing?: Record<string, unknown>
raw: Record<string, unknown>
}> = []
for (const { value } of getWhatsAppChanges(body)) {
const metadata = isRecord(value.metadata) ? value.metadata : undefined
if (Array.isArray(value.contacts)) {
for (const contact of value.contacts) {
if (!isRecord(contact)) {
continue
}
contacts.push(normalizeWhatsAppContact(contact))
}
}
if (Array.isArray(value.messages)) {
for (const message of value.messages) {
if (!isRecord(message)) {
continue
}
messages.push(normalizeWhatsAppMessage(message, metadata))
}
}
if (Array.isArray(value.statuses)) {
for (const status of value.statuses) {
if (!isRecord(status)) {
continue
}
statuses.push(normalizeWhatsAppStatus(status, metadata))
}
}
}
if (messages.length === 0 && statuses.length === 0) {
return { input: null }
}
const firstMessage = messages[0]
const firstStatus = statuses[0]
return {
input: {
eventType:
messages.length > 0 && statuses.length > 0
? 'mixed'
: messages.length > 0
? 'incoming_message'
: 'message_status',
messageId: firstMessage?.messageId ?? firstStatus?.messageId,
from: firstMessage?.from,
recipientId: firstStatus?.recipientId,
phoneNumberId: firstMessage?.phoneNumberId ?? firstStatus?.phoneNumberId,
displayPhoneNumber: firstMessage?.displayPhoneNumber ?? firstStatus?.displayPhoneNumber,
text: firstMessage?.text,
timestamp: firstMessage?.timestamp ?? firstStatus?.timestamp,
messageType: firstMessage?.messageType,
status: firstStatus?.status,
contact: contacts[0],
webhookContacts: contacts,
messages,
statuses,
conversation: firstStatus?.conversation,
pricing: firstStatus?.pricing,
raw: payload ?? body,
},
}
},
handleEmptyInput(requestId: string) {
logger.info(
`[${requestId}] No messages or status updates in WhatsApp payload, skipping execution`
)
return { message: 'No messages or status updates in WhatsApp payload' }
},
}
@@ -0,0 +1,299 @@
/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { zendeskHandler } from '@/lib/webhooks/providers/zendesk'
import { isZendeskEventMatch } from '@/triggers/zendesk/utils'
const SECRET = 'my-signing-secret'
function sign(secret: string, timestamp: string, body: string): string {
return crypto
.createHmac('sha256', secret)
.update(timestamp + body, 'utf8')
.digest('base64')
}
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('Zendesk webhook provider', () => {
describe('verifyAuth', () => {
it('rejects when webhookSecret is missing', async () => {
const res = await zendeskHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects when signature headers are missing', async () => {
const res = await zendeskHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't2',
providerConfig: { webhookSecret: SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects a stale timestamp outside the allowed skew window', async () => {
const body = '{}'
const timestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const signature = sign(SECRET, timestamp, body)
const res = await zendeskHandler.verifyAuth!({
request: reqWithHeaders({
'X-Zendesk-Webhook-Signature': signature,
'X-Zendesk-Webhook-Signature-Timestamp': timestamp,
}),
rawBody: body,
requestId: 't3',
providerConfig: { webhookSecret: SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects an invalid signature', async () => {
const body = '{}'
const timestamp = new Date().toISOString()
const res = await zendeskHandler.verifyAuth!({
request: reqWithHeaders({
'X-Zendesk-Webhook-Signature': 'not-a-real-signature',
'X-Zendesk-Webhook-Signature-Timestamp': timestamp,
}),
rawBody: body,
requestId: 't4',
providerConfig: { webhookSecret: SECRET },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts a valid base64 HMAC-SHA256 signature over timestamp + body', async () => {
const body = JSON.stringify({ id: 'evt-1', type: 'zen:event-type:ticket.created' })
const timestamp = new Date().toISOString()
const signature = sign(SECRET, timestamp, body)
const res = await zendeskHandler.verifyAuth!({
request: reqWithHeaders({
'X-Zendesk-Webhook-Signature': signature,
'X-Zendesk-Webhook-Signature-Timestamp': timestamp,
}),
rawBody: body,
requestId: 't5',
providerConfig: { webhookSecret: SECRET },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('isZendeskEventMatch', () => {
it('matches the configured trigger to its native event type', () => {
expect(isZendeskEventMatch('zendesk_ticket_created', 'zen:event-type:ticket.created')).toBe(
true
)
expect(
isZendeskEventMatch('zendesk_ticket_created', 'zen:event-type:ticket.status_changed')
).toBe(false)
expect(isZendeskEventMatch('zendesk_webhook', 'zen:event-type:ticket.created')).toBe(true)
})
})
describe('matchEvent', () => {
it('passes through all events for the all-events trigger', async () => {
const result = await zendeskHandler.matchEvent!({
body: { type: 'zen:event-type:ticket.status_changed' },
requestId: 't6',
providerConfig: { triggerId: 'zendesk_webhook' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(true)
})
it('filters events that do not match the configured trigger', async () => {
const result = await zendeskHandler.matchEvent!({
body: { type: 'zen:event-type:ticket.status_changed' },
requestId: 't7',
providerConfig: { triggerId: 'zendesk_ticket_created' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
it('does not throw when body is null', async () => {
const result = await zendeskHandler.matchEvent!({
body: null,
requestId: 't8',
providerConfig: { triggerId: 'zendesk_ticket_created' },
webhook: {},
workflow: {},
request: reqWithHeaders({}),
})
expect(result).toBe(false)
})
})
describe('formatInput', () => {
it('maps the event-subscription envelope to the declared output schema', async () => {
const { input } = await zendeskHandler.formatInput!({
body: {
id: 'evt-1',
type: 'zen:event-type:ticket.created',
time: '2026-01-01T00:00:00Z',
account_id: 123,
detail: {
id: '456',
subject: 'Help',
status: 'new',
priority: 'high',
type: 'incident',
description: 'desc',
requester_id: '1',
assignee_id: '2',
group_id: '3',
organization_id: '4',
tags: ['a', 'b'],
via: { channel: 'web' },
is_public: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
event: { current: 'high', previous: 'normal' },
},
headers: {},
requestId: 't9',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.event_id).toBe('evt-1')
expect(i.event_type).toBe('zen:event-type:ticket.created')
expect(i.account_id).toBe(123)
const ticket = i.ticket as Record<string, unknown>
expect(ticket.id).toBe('456')
expect(ticket.ticket_type).toBe('incident')
expect(ticket.via_channel).toBe('web')
expect(ticket.tags).toEqual(['a', 'b'])
expect(i.event).toEqual({ current: 'high', previous: 'normal' })
})
it('does not throw and degrades gracefully when body is null', async () => {
const { input } = await zendeskHandler.formatInput!({
body: null,
headers: {},
requestId: 't10',
webhook: {},
workflow: { id: 'w', userId: 'u' },
})
const i = input as Record<string, unknown>
expect(i.event_id).toBeUndefined()
const ticket = i.ticket as Record<string, unknown>
expect(ticket.id).toBeUndefined()
expect(ticket.tags).toEqual([])
})
})
describe('createSubscription', () => {
it('rejects a subdomain containing a path separator to prevent host-boundary escape', async () => {
await expect(
zendeskHandler.createSubscription!({
webhook: {
id: 'wh-1',
path: 'p',
providerConfig: {
subdomain: 'evil.example.com/x',
email: 'admin@example.com',
apiToken: 'token',
},
},
workflow: {},
userId: 'u1',
requestId: 't11',
request: reqWithHeaders({}),
})
).rejects.toThrow(/subdomain must contain only letters, numbers, and hyphens/)
})
it('rejects a subdomain that is missing entirely', async () => {
await expect(
zendeskHandler.createSubscription!({
webhook: { id: 'wh-2', path: 'p', providerConfig: {} },
workflow: {},
userId: 'u1',
requestId: 't12',
request: reqWithHeaders({}),
})
).rejects.toThrow(/subdomain is required/)
})
})
describe('deleteSubscription', () => {
it('skips (non-strict) when the stored subdomain is invalid', async () => {
await expect(
zendeskHandler.deleteSubscription!({
webhook: {
id: 'wh-3',
providerConfig: {
subdomain: 'evil.example.com/x',
email: 'admin@example.com',
apiToken: 'token',
externalId: 'ext-1',
},
},
workflow: {},
requestId: 't13',
})
).resolves.toBeUndefined()
})
it('throws (strict) when the stored subdomain is invalid', async () => {
await expect(
zendeskHandler.deleteSubscription!({
webhook: {
id: 'wh-4',
providerConfig: {
subdomain: 'evil.example.com/x',
email: 'admin@example.com',
apiToken: 'token',
externalId: 'ext-1',
},
},
workflow: {},
requestId: 't14',
strict: true,
})
).rejects.toThrow(/Invalid Zendesk subdomain/)
})
})
describe('extractIdempotencyId', () => {
it('returns the stable event id', () => {
expect(zendeskHandler.extractIdempotencyId!({ id: 'evt-1' })).toBe('evt-1')
})
it('returns null when there is no id', () => {
expect(zendeskHandler.extractIdempotencyId!({})).toBeNull()
})
it('does not throw when body is null', () => {
expect(zendeskHandler.extractIdempotencyId!(null)).toBeNull()
})
})
})
+284
View File
@@ -0,0 +1,284 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Zendesk')
function asRecord(value: unknown): Record<string, unknown> {
return (value as Record<string, unknown>) || {}
}
/** Zendesk API base for a subdomain. */
function zendeskApiBase(subdomain: string): string {
return `https://${subdomain}.zendesk.com/api/v2`
}
/**
* Zendesk subdomains are bare hostname labels (letters, digits, internal hyphens).
* Rejecting anything else prevents the value from escaping the host portion of the
* interpolated URL (e.g. a subdomain containing `/` would redirect the request —
* and its Basic-auth admin credentials — to an attacker-controlled host).
*/
const ZENDESK_SUBDOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i
function isValidZendeskSubdomain(subdomain: string): boolean {
return ZENDESK_SUBDOMAIN_PATTERN.test(subdomain)
}
/** Basic auth header for the Zendesk API-token scheme (`email/token:apiToken`). */
function zendeskAuthHeader(email: string, apiToken: string): string {
return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}`
}
/** Best-effort delete used to avoid orphaning a webhook when post-create setup fails. */
async function deleteZendeskWebhookQuietly(
apiBase: string,
authHeader: string,
webhookId: string
): Promise<void> {
await fetch(`${apiBase}/webhooks/${webhookId}`, {
method: 'DELETE',
headers: { Authorization: authHeader },
}).catch(() => {})
}
/** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */
const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000
/**
* Verify the signed timestamp is recent to prevent replay of captured deliveries.
* Zendesk sends `X-Zendesk-Webhook-Signature-Timestamp` as an ISO-8601 string
* (e.g. `2025-01-24T15:30:00.000Z`), so it is parsed with `Date.parse`.
*/
function isZendeskTimestampFresh(timestamp: string): boolean {
const signedAt = Date.parse(timestamp)
if (Number.isNaN(signedAt)) return false
return Math.abs(Date.now() - signedAt) <= ZENDESK_TIMESTAMP_MAX_SKEW_MS
}
/**
* Zendesk signs `timestamp + rawBody` (no separator) with HMAC-SHA256 keyed by
* the webhook's signing secret, then base64-encodes it into
* `X-Zendesk-Webhook-Signature`. The timestamp is sent in a separate header.
*/
function validateZendeskSignature(
secret: string,
signature: string,
timestamp: string,
body: string
): boolean {
if (!secret || !signature || !timestamp) return false
const computed = crypto
.createHmac('sha256', secret)
.update(timestamp + body, 'utf8')
.digest('base64')
return safeCompare(computed, signature)
}
export const zendeskHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
logger.warn(`[${requestId}] Zendesk webhook secret not configured`)
return new NextResponse('Unauthorized - Missing Zendesk webhook secret', { status: 401 })
}
const signature = request.headers.get('X-Zendesk-Webhook-Signature')
const timestamp = request.headers.get('X-Zendesk-Webhook-Signature-Timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Zendesk webhook missing signature headers`)
return new NextResponse('Unauthorized - Missing Zendesk signature', { status: 401 })
}
if (!isZendeskTimestampFresh(timestamp)) {
logger.warn(`[${requestId}] Zendesk webhook timestamp outside the allowed window`, {
timestamp,
})
return new NextResponse('Unauthorized - Stale Zendesk timestamp', { status: 401 })
}
if (!validateZendeskSignature(secret, signature, timestamp, rawBody)) {
logger.warn(`[${requestId}] Zendesk signature verification failed`)
return new NextResponse('Unauthorized - Invalid Zendesk signature', { status: 401 })
}
return null
},
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'zendesk_webhook') return true
const eventType = asRecord(body).type as string | undefined
const { isZendeskEventMatch } = await import('@/triggers/zendesk/utils')
if (!isZendeskEventMatch(triggerId, eventType || '')) {
logger.debug(
`[${requestId}] Zendesk event '${eventType}' does not match trigger ${triggerId}, skipping`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = asRecord(body)
const detail = asRecord(b.detail)
const via = asRecord(detail.via)
return {
input: {
event_id: b.id,
event_type: b.type,
time: b.time,
account_id: b.account_id,
ticket: {
id: detail.id,
subject: detail.subject,
status: detail.status,
priority: detail.priority,
ticket_type: detail.type,
description: detail.description,
requester_id: detail.requester_id,
assignee_id: detail.assignee_id,
group_id: detail.group_id,
organization_id: detail.organization_id,
tags: Array.isArray(detail.tags) ? detail.tags : [],
via_channel: via.channel,
is_public: detail.is_public,
created_at: detail.created_at,
updated_at: detail.updated_at,
},
event: b.event ?? null,
},
}
},
extractIdempotencyId(body: unknown) {
return (asRecord(body).id as string | undefined) || null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const subdomain = config.subdomain as string | undefined
const email = config.email as string | undefined
const apiToken = config.apiToken as string | undefined
const triggerId = config.triggerId as string | undefined
if (!subdomain) throw new Error('Zendesk subdomain is required to create the webhook.')
if (!isValidZendeskSubdomain(subdomain)) {
throw new Error(
'Zendesk subdomain must contain only letters, numbers, and hyphens (e.g. "yourcompany").'
)
}
if (!email) throw new Error('Zendesk admin email is required to create the webhook.')
if (!apiToken) throw new Error('Zendesk API token is required to create the webhook.')
const { getZendeskSubscriptions } = await import('@/triggers/zendesk/utils')
const apiBase = zendeskApiBase(subdomain)
const authHeader = zendeskAuthHeader(email, apiToken)
const createRes = await fetch(`${apiBase}/webhooks`, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({
webhook: {
name: `Sim webhook (${ctx.webhook.id})`,
endpoint: getNotificationUrl(ctx.webhook),
http_method: 'POST',
request_format: 'json',
status: 'active',
subscriptions: getZendeskSubscriptions(triggerId ?? 'zendesk_webhook'),
},
}),
})
if (!createRes.ok) {
const detail = await createRes.text().catch(() => '')
logger.error(`[${ctx.requestId}] Failed to create Zendesk webhook (${createRes.status})`, {
detail,
})
if (createRes.status === 401 || createRes.status === 403) {
throw new Error(
'Zendesk authentication failed. Verify the subdomain, admin email, and API token.'
)
}
throw new Error(`Failed to create Zendesk webhook: ${createRes.status}`)
}
const created = asRecord((await createRes.json().catch(() => ({}))) as unknown)
const externalId = asRecord(created.webhook).id as string | undefined
if (!externalId) throw new Error('Zendesk webhook created but no webhook ID was returned.')
const secretRes = await fetch(`${apiBase}/webhooks/${externalId}/signing_secret`, {
headers: { Authorization: authHeader },
})
if (!secretRes.ok) {
const detail = await secretRes.text().catch(() => '')
logger.error(
`[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`,
{ detail }
)
await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId)
throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`)
}
const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown)
const secret = asRecord(secretBody.signing_secret).secret as string | undefined
if (!secret) {
await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId)
throw new Error('Zendesk did not return a signing secret for the webhook.')
}
logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`)
return { providerConfigUpdates: { externalId, webhookSecret: secret } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const subdomain = config.subdomain as string | undefined
const email = config.email as string | undefined
const apiToken = config.apiToken as string | undefined
const externalId = config.externalId as string | undefined
if (!subdomain || !email || !apiToken || !externalId) {
if (ctx.strict) throw new Error('Missing Zendesk credentials or webhook ID for deletion.')
logger.warn(
`[${ctx.requestId}] Skipping Zendesk webhook cleanup — missing credentials or webhook ID`
)
return
}
if (!isValidZendeskSubdomain(subdomain)) {
if (ctx.strict) throw new Error('Invalid Zendesk subdomain for deletion.')
logger.warn(`[${ctx.requestId}] Skipping Zendesk webhook cleanup — invalid subdomain`)
return
}
const res = await fetch(`${zendeskApiBase(subdomain)}/webhooks/${externalId}`, {
method: 'DELETE',
headers: { Authorization: zendeskAuthHeader(email, apiToken) },
})
if (!res.ok && res.status !== 404) {
if (ctx.strict) throw new Error(`Failed to delete Zendesk webhook: ${res.status}`)
logger.warn(
`[${ctx.requestId}] Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}`
)
return
}
logger.info(`[${ctx.requestId}] Deleted Zendesk webhook ${externalId}`)
},
}
@@ -0,0 +1,61 @@
import crypto from 'node:crypto'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { validateZoomSignature, zoomHandler } from '@/lib/webhooks/providers/zoom'
import { isZoomEventMatch } from '@/triggers/zoom/utils'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
return new NextRequest('http://localhost/test', { headers })
}
describe('Zoom webhook provider', () => {
it('isZoomEventMatch rejects empty event for specialized triggers', () => {
expect(isZoomEventMatch('zoom_meeting_started', '')).toBe(false)
expect(isZoomEventMatch('zoom_meeting_started', ' ')).toBe(false)
expect(isZoomEventMatch('zoom_meeting_started', 'meeting.started')).toBe(true)
expect(isZoomEventMatch('zoom_webhook', '')).toBe(true)
})
it('validateZoomSignature uses raw body bytes, not a re-serialized variant', () => {
const secret = 'test-secret'
const timestamp = String(Math.floor(Date.now() / 1000))
const rawA = '{"a":1,"b":2}'
const rawB = '{"b":2,"a":1}'
const computed = crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${rawA}`)
const hashA = `v0=${computed.digest('hex')}`
expect(validateZoomSignature(secret, hashA, timestamp, rawA)).toBe(true)
expect(validateZoomSignature(secret, hashA, timestamp, rawB)).toBe(false)
})
it('does not implement extractIdempotencyId (x-zm-request-id handled at service level)', () => {
expect(zoomHandler.extractIdempotencyId).toBeUndefined()
})
it('formatInput passes through the Zoom webhook envelope', async () => {
const body = {
event: 'meeting.started',
event_ts: 1700000000000,
payload: { account_id: 'acct', object: { id: 1 } },
}
const { input } = await zoomHandler.formatInput!({
webhook: {},
workflow: { id: 'wf', userId: 'u' },
body,
headers: {},
requestId: 'zoom-format',
})
expect(input).toBe(body)
})
it('matchEvent never executes endpoint validation payloads', async () => {
const result = await zoomHandler.matchEvent!({
webhook: { id: 'w' },
workflow: { id: 'wf' },
body: { event: 'endpoint.url_validation' },
request: reqWithHeaders({}),
requestId: 't5',
providerConfig: { triggerId: 'zoom_webhook' },
})
expect(result).toBe(false)
})
})
+223
View File
@@ -0,0 +1,223 @@
import { db, webhook, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Zoom')
/**
* Validate Zoom webhook signature using HMAC-SHA256.
* Zoom sends `x-zm-signature` as `v0=<hex>` and `x-zm-request-timestamp`.
* The message to hash is `v0:{timestamp}:{rawBody}`.
*/
/** Exported for tests — Zoom signs `v0:{timestamp}:{rawBody}`. */
export function validateZoomSignature(
secretToken: string,
signature: string,
timestamp: string,
body: string
): boolean {
try {
if (!secretToken || !signature || !timestamp || !body) {
return false
}
const nowSeconds = Math.floor(Date.now() / 1000)
const requestSeconds = Number.parseInt(timestamp, 10)
if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 300) {
return false
}
const message = `v0:${timestamp}:${body}`
const computedHash = hmacSha256Hex(message, secretToken)
const expectedSignature = `v0=${computedHash}`
return safeCompare(expectedSignature, signature)
} catch (err) {
logger.error('Zoom signature validation error', err)
return false
}
}
async function resolveZoomChallengeSecrets(
path: string,
requestId: string
): Promise<Array<{ secretToken: string }>> {
const rows = await db
.select({
providerConfig: webhook.providerConfig,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)))
const resolvedRows = await Promise.all(
rows.map(async (row) => {
const rawConfig =
row.providerConfig &&
typeof row.providerConfig === 'object' &&
!Array.isArray(row.providerConfig)
? (row.providerConfig as Record<string, unknown>)
: {}
try {
const config = await resolveEnvVarsInObject(
rawConfig,
row.userId,
row.workspaceId ?? undefined
)
const secretToken = typeof config.secretToken === 'string' ? config.secretToken : ''
return { secretToken }
} catch (error) {
logger.warn(`[${requestId}] Failed to resolve Zoom webhook secret for challenge`, {
error: toError(error).message,
path,
})
return { secretToken: '' }
}
})
)
return resolvedRows.filter((row) => row.secretToken)
}
export const zoomHandler: WebhookProviderHandler = {
/**
* Zoom delivers the standard app webhook envelope (`event`, `event_ts`, `payload`).
* Pass through unchanged so trigger outputs match runtime input.
*/
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
return { input: body }
},
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secretToken = providerConfig.secretToken as string | undefined
if (!secretToken) {
logger.warn(
`[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request`
)
return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 })
}
const signature = request.headers.get('x-zm-signature')
const timestamp = request.headers.get('x-zm-request-timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`)
return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 })
}
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
logger.warn(`[${requestId}] Zoom webhook signature verification failed`)
return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 })
}
return null
},
async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
const event = typeof obj.event === 'string' ? obj.event : ''
if (event === 'endpoint.url_validation') {
return false
}
if (triggerId) {
const { isZoomEventMatch } = await import('@/triggers/zoom/utils')
if (!isZoomEventMatch(triggerId, event)) {
logger.debug(
`[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
{
webhookId: wh.id,
workflowId: workflow.id,
triggerId,
receivedEvent: event,
}
)
return false
}
}
return true
},
/**
* Handle Zoom endpoint URL validation challenges.
* Zoom sends an `endpoint.url_validation` event with a `plainToken` that must
* be hashed with the app's secret token and returned alongside the original token.
*/
async handleChallenge(
body: unknown,
request: NextRequest,
requestId: string,
path: string,
rawBody?: string
) {
const obj = body as Record<string, unknown> | null
if (obj?.event !== 'endpoint.url_validation') {
return null
}
const payload = obj.payload as Record<string, unknown> | undefined
const plainToken = payload?.plainToken as string | undefined
if (!plainToken) {
return null
}
logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`)
const signature = request.headers.get('x-zm-signature')
const timestamp = request.headers.get('x-zm-request-timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`)
return null
}
const bodyForSignature =
rawBody !== undefined && rawBody !== null ? rawBody : JSON.stringify(body)
let rows: Array<{ secretToken: string }> = []
try {
rows = await resolveZoomChallengeSecrets(path, requestId)
} catch (err) {
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
return null
}
for (const row of rows) {
const secretToken = row.secretToken
if (
secretToken &&
validateZoomSignature(secretToken, signature, timestamp, bodyForSignature)
) {
const hashForValidate = hmacSha256Hex(plainToken, secretToken)
return NextResponse.json({
plainToken,
encryptedToken: hashForValidate,
})
}
}
logger.warn(
`[${requestId}] Zoom challenge: no matching secret for path ${path} (${rows.length} webhook row(s))`
)
return null
},
}