Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({}))

process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'

import { GET, POST } from '@/app/api/webhooks/trigger/[path]/route'
import { DELETE, GET, PATCH, POST, PUT } from '@/app/api/webhooks/trigger/[path]/route'

describe('Webhook Trigger API Route', () => {
beforeEach(() => {
Expand Down Expand Up @@ -683,6 +683,123 @@ describe('Webhook Trigger API Route', () => {
})
})

describe('GET deliveries', () => {
it('dispatches a GET delivery to a generic webhook', async () => {
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'get-path',
isActive: true,
providerConfig: { requireAuth: false },
workflowId: 'test-workflow-id',
})

const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/get-path?srcId=123'
)

const response = await GET(req, { params: Promise.resolve({ path: 'get-path' }) })

expect(response.status).toBe(200)
expect(dispatchResolvedWebhookTargetMock).toHaveBeenCalledOnce()
})

it('rejects a GET delivery to a provider that only accepts POST', async () => {
testData.webhooks.push({
id: 'stripe-webhook-id',
provider: 'stripe',
path: 'post-only-path',
isActive: true,
providerConfig: {},
workflowId: 'test-workflow-id',
})

const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/post-only-path'
)

const response = await GET(req, { params: Promise.resolve({ path: 'post-only-path' }) })

expect(response.status).toBe(405)
expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled()
})
})

describe('PUT, PATCH and DELETE deliveries', () => {
const handlers = { PUT, PATCH, DELETE }

it.each(Object.keys(handlers) as Array<keyof typeof handlers>)(
'dispatches a %s delivery to a generic webhook',
async (method) => {
testData.webhooks.push({
id: 'generic-webhook-id',
provider: 'generic',
path: 'any-method-path',
isActive: true,
providerConfig: { requireAuth: false },
workflowId: 'test-workflow-id',
})

const req = createMockRequest(
method,
{ event: 'test' },
{},
'http://localhost:3000/api/webhooks/trigger/any-method-path?srcId=123'
)

const response = await handlers[method](req, {
params: Promise.resolve({ path: 'any-method-path' }),
})

expect(response.status).toBe(200)
expect(dispatchResolvedWebhookTargetMock).toHaveBeenCalledOnce()
}
)

it('rejects a PUT delivery to a provider that only accepts POST', async () => {
testData.webhooks.push({
id: 'stripe-webhook-id',
provider: 'stripe',
path: 'post-only-path',
isActive: true,
providerConfig: {},
workflowId: 'test-workflow-id',
})

const req = createMockRequest(
'PUT',
{ event: 'test' },
{},
'http://localhost:3000/api/webhooks/trigger/post-only-path'
)

const response = await PUT(req, { params: Promise.resolve({ path: 'post-only-path' }) })

expect(response.status).toBe(405)
expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled()
})

it('returns 405 for a DELETE to an unknown path', async () => {
const req = createMockRequest(
'DELETE',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/unknown-path'
)

const response = await DELETE(req, { params: Promise.resolve({ path: 'unknown-path' }) })

expect(response.status).toBe(405)
expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled()
})
})

describe('Reservation-free filtering', () => {
it('skips filtered webhook events before preprocessing reserves a slot', async () => {
testData.webhooks.push({
Expand Down
66 changes: 54 additions & 12 deletions apps/sim/app/api/webhooks/trigger/[path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
parseWebhookBody,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { acceptsPathWebhookDelivery } from '@/lib/webhooks/providers'
import { acceptsPathWebhookDelivery, acceptsWebhookDeliveryMethod } from '@/lib/webhooks/providers'

const logger = createLogger('WebhookTriggerAPI')

Expand All @@ -35,31 +35,58 @@ export const GET = withRouteHandler(
return challengeResponse
}

return (
(await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) ||
new NextResponse('Method not allowed', { status: 405 })
const verificationResponse = await handlePreLookupWebhookVerification(
Comment thread
cursor[bot] marked this conversation as resolved.
request.method,
undefined,
requestId,
path
)
if (verificationResponse) {
return verificationResponse
}

const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}

try {
return await handleWebhookDelivery(request, context, webhookTriggerGetContract)
} finally {
ticket.release()
}
}
)

export const POST = withRouteHandler(
const handleBodyDelivery = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ path: string }> }) => {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}

try {
return await handleWebhookPost(request, context)
return await handleWebhookDelivery(request, context, webhookTriggerPostContract)
} finally {
ticket.release()
}
}
)

async function handleWebhookPost(
export const POST = handleBodyDelivery

/**
* Methods a provider must opt into via `extraDeliveryMethods`. A delivery to a path whose
* triggers have not opted in gets a 405 from `handleWebhookDelivery`.
*/
export const PUT = handleBodyDelivery
export const PATCH = handleBodyDelivery
export const DELETE = handleBodyDelivery

async function handleWebhookDelivery(
request: NextRequest,
context: { params: Promise<{ path: string }> }
context: { params: Promise<{ path: string }> },
contract: typeof webhookTriggerGetContract | typeof webhookTriggerPostContract
): Promise<NextResponse> {
const receivedAt = Date.now()
/**
Expand All @@ -73,7 +100,7 @@ async function handleWebhookPost(
: undefined

const requestId = generateRequestId()
const parsed = await parseRequest(webhookTriggerPostContract, request, context)
const parsed = await parseRequest(contract, request, context)
if (!parsed.success) return parsed.response
const { path } = parsed.data.params

Expand All @@ -99,15 +126,26 @@ async function handleWebhookPost(
// Find all webhooks for this path (multiple webhooks in one workflow may share a path)
const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path })

const webhooksForPath = allWebhooksForPath.filter(({ webhook: foundWebhook }) =>
const pathWebhooks = allWebhooksForPath.filter(({ webhook: foundWebhook }) =>
acceptsPathWebhookDelivery(foundWebhook.provider)
)

if (allWebhooksForPath.length > 0 && webhooksForPath.length === 0) {
if (allWebhooksForPath.length > 0 && pathWebhooks.length === 0) {
logger.warn(`[${requestId}] Rejected HTTP delivery to non-path trigger: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}

const webhooksForPath = pathWebhooks.filter(({ webhook: foundWebhook }) =>
acceptsWebhookDeliveryMethod(foundWebhook.provider, request.method)
)

if (pathWebhooks.length > 0 && webhooksForPath.length === 0) {
logger.warn(
`[${requestId}] Rejected ${request.method} delivery to path ${path}: no trigger on this path accepts that method`
)
return new NextResponse('Method not allowed', { status: 405 })
}

if (webhooksForPath.length === 0) {
const verificationResponse = await handlePreLookupWebhookVerification(
request.method,
Expand All @@ -120,7 +158,11 @@ async function handleWebhookPost(
}

logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
return new NextResponse('Not Found', { status: 404 })
// Unknown paths keep answering 405 on GET so probes cannot tell an unknown path
// from one whose trigger only accepts POST.
return request.method === 'POST'
? new NextResponse('Not Found', { status: 404 })
: new NextResponse('Method not allowed', { status: 405 })
}

// Process each webhook matched on this path
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/background/webhook-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ export type WebhookExecutionPayload = {
provider: string
body: unknown
headers: Record<string, string>
/** Request URL query parameters; absent when the request had none or on legacy queued jobs. */
query?: Record<string, string>
/** HTTP method the delivery arrived with; absent on legacy queued jobs. */
method?: string
path: string
blockId?: string
/** Immutable deployment admitted by webhook ingress; absent on legacy queued jobs. */
Expand Down Expand Up @@ -622,6 +626,8 @@ async function executeWebhookJobInternal(
workflow: { id: payload.workflowId, userId: payload.userId },
body: payload.body,
headers: payload.headers,
query: payload.query ?? {},
method: payload.method ?? '',
requestId,
})
input = result.input as Record<string, unknown> | null
Expand Down
38 changes: 38 additions & 0 deletions apps/sim/lib/webhooks/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,44 @@ describe('webhook processor execution identity', () => {
expect(mockReleaseExecutionSlot).not.toHaveBeenCalled()
})

it('carries request query parameters into the queued payload', async () => {
await dispatchResolvedWebhookTarget(
makeWebhookRecord({ path: 'incoming/hook', provider: 'generic' }),
makeWorkflowRecord({}),
{},
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/incoming/hook?srcId=123&title=Hello%20World'
) as NextRequest,
{ requestId: 'request-1', path: 'incoming/hook' }
)

expect(mockEnqueue).toHaveBeenCalledWith(
'webhook-execution',
expect.objectContaining({ query: { srcId: '123', title: 'Hello World' } }),
expect.anything()
)
})

it('omits query from the queued payload when the request has none', async () => {
await dispatchResolvedWebhookTarget(
makeWebhookRecord({ path: 'incoming/hook', provider: 'generic' }),
makeWorkflowRecord({}),
{ event: 'test' },
createMockRequest(
'POST',
{ event: 'test' },
{},
'http://localhost:3000/api/webhooks/trigger/incoming/hook'
) as NextRequest,
{ requestId: 'request-1', path: 'incoming/hook' }
)

expect(mockEnqueue.mock.calls[0]?.[1]).not.toHaveProperty('query')
})

it('runs database-inline webhook jobs through the queue cancellation signal', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const result = await dispatchResolvedWebhookTarget(
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/webhooks/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ async function queueWebhookExecutionWithResult(
}

const credentialId = getCredentialId(providerConfig)
const query = Object.fromEntries(new URL(request.url).searchParams)

const actorUserId = options.actorUserId
const billingAttribution = options.billingAttribution
Expand Down Expand Up @@ -722,6 +723,8 @@ async function queueWebhookExecutionWithResult(
provider: foundWebhook.provider,
body,
headers,
method: request.method,
...(Object.keys(query).length > 0 ? { query } : {}),
path: options.path || foundWebhook.path || '',
blockId: foundWebhook.blockId ?? undefined,
...(foundWebhook.deploymentVersionId
Expand Down
Loading