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,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
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user