Files
simstudioai--sim/apps/sim/app/api/webhooks/trigger/[path]/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

221 lines
7.3 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks'
import { parseRequest } from '@/lib/api/server'
import {
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
isWorkspaceApiExecutionEntitled,
} from '@/lib/billing/core/api-access'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
dispatchResolvedWebhookTarget,
findAllWebhooksForPath,
handlePreLookupWebhookVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { acceptsPathWebhookDelivery } from '@/lib/webhooks/providers'
import { isInternalTriggerProvider } from '@/triggers/constants'
const logger = createLogger('WebhookTriggerAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 60
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ path: string }> }) => {
const requestId = generateRequestId()
const parsed = await parseRequest(webhookTriggerGetContract, request, context)
if (!parsed.success) return parsed.response
const { path } = parsed.data.params
// Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.)
const challengeResponse = await handleProviderChallenges({}, request, requestId, path)
if (challengeResponse) {
return challengeResponse
}
return (
(await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) ||
new NextResponse('Method not allowed', { status: 405 })
)
}
)
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ path: string }> }) => {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleWebhookPost(request, context)
} finally {
ticket.release()
}
}
)
async function handleWebhookPost(
request: NextRequest,
context: { params: Promise<{ path: string }> }
): Promise<NextResponse> {
const receivedAt = Date.now()
/**
* Slack signs every interactive request with the originating interaction time.
* Capturing it lets the executor surface the true trigger_id age (the window
* that expires at 3s) instead of only the in-workflow block timings.
*/
const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp')
const triggerTimestampMs = slackRequestTimestamp
? Number(slackRequestTimestamp) * 1000
: undefined
const requestId = generateRequestId()
const parsed = await parseRequest(webhookTriggerPostContract, request, context)
if (!parsed.success) return parsed.response
const { path } = parsed.data.params
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
}
const parseResult = await parseWebhookBody(request, requestId)
// Check if parseWebhookBody returned an error response
if (parseResult instanceof NextResponse) {
return parseResult
}
const { body, rawBody } = parseResult
const challengeResponse = await handleProviderChallenges(body, request, requestId, path, rawBody)
if (challengeResponse) {
return challengeResponse
}
// Find all webhooks for this path (multiple webhooks in one workflow may share a path)
const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path })
/** Exclude in-process triggers and providers that own an app-level ingress route. */
const webhooksForPath = allWebhooksForPath.filter(
({ webhook: foundWebhook }) =>
!isInternalTriggerProvider(foundWebhook.provider) &&
acceptsPathWebhookDelivery(foundWebhook.provider)
)
if (allWebhooksForPath.length > 0 && webhooksForPath.length === 0) {
logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}
if (webhooksForPath.length === 0) {
const verificationResponse = await handlePreLookupWebhookVerification(
request.method,
body as Record<string, unknown> | undefined,
requestId,
path
)
if (verificationResponse) {
return verificationResponse
}
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}
// Process each webhook matched on this path
const responses: NextResponse[] = []
let billingBlocked = false
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
// Generic ("custom") webhooks are an unauthenticated programmatic execution
// surface, so they fall under the same paid-plan gate as the API. Provider
// webhooks (slack, github, ...) are unaffected.
if (
foundWebhook.provider === 'generic' &&
!(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId))
) {
logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`)
billingBlocked = true
if (webhooksForPath.length > 1) continue
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
}
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
request,
rawBody,
requestId
)
if (authError) {
if (webhooksForPath.length > 1) {
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
continue
}
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
const dispatchResult = await dispatchResolvedWebhookTarget(
foundWebhook,
foundWorkflow,
body,
request,
{
requestId,
path,
receivedAt,
triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
}
)
if (dispatchResult.reason === 'filtered') {
continue
}
if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`,
{ reason: dispatchResult.reason, status: dispatchResult.response.status }
)
continue
}
return dispatchResult.response
}
responses.push(dispatchResult.response)
}
if (responses.length === 0) {
if (billingBlocked) {
return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
}
return new NextResponse('No webhooks processed successfully', { status: 500 })
}
if (responses.length === 1) {
return responses[0]
}
// For multiple webhooks, return success if at least one succeeded
logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`)
return NextResponse.json({
success: true,
webhooksProcessed: responses.length,
})
}