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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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'}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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 Linear’s 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('{}')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user