Skip to content
Merged
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
87 changes: 85 additions & 2 deletions packages/components/nodes/agentflow/Start/Start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,71 @@ class Start_Agentflow implements INode {
}
]
},
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might need to add client type to avoid showing this in internal app

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be fine since these fields will only show if webhooks get selected, but webhooks cannot be selected because it's hidden from the options on the dropdown

Image

label: 'HTTP Method',
name: 'webhookMethod',
type: 'options',
options: [
{ label: 'GET', name: 'GET' },
{ label: 'POST', name: 'POST' },
{ label: 'PUT', name: 'PUT' },
{ label: 'PATCH', name: 'PATCH' },
{ label: 'DELETE', name: 'DELETE' }
],
default: 'POST',
show: {
startInputType: 'webhookTrigger'
}
},
{
label: 'Content Type',
name: 'webhookContentType',
type: 'options',
description:
'Expected Content-Type of incoming requests. For application/x-www-form-urlencoded, if the entire payload is a JSON string in a "payload" field (e.g. GitHub webhooks), it is automatically parsed — use $webhook.body.* as normal.',
options: [
{ label: 'application/json', name: 'application/json' },
{ label: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded' }
],
default: 'application/json',
optional: true,
show: {
startInputType: 'webhookTrigger'
}
},
{
label: 'Webhook URL',
name: 'webhookURL',
type: 'string',
description: 'Send a POST request to this URL to trigger the workflow',
description: 'Send a request to this URL to trigger the workflow',
optional: true,
show: {
startInputType: 'webhookTrigger'
}
},
{
label: 'Expected Query Parameters',
name: 'webhookQueryParams',
description: 'Declare expected query parameters. Leave empty to accept any.',
type: 'array',
optional: true,
show: {
startInputType: 'webhookTrigger'
},
array: [
{
label: 'Variable Name',
name: 'name',
type: 'string',
placeholder: 'e.g. page'
},
{
label: 'Required',
name: 'required',
type: 'boolean'
}
]
},
{
label: 'Expected Body Parameters',
name: 'webhookBodyParams',
Expand Down Expand Up @@ -185,6 +240,29 @@ class Start_Agentflow implements INode {
}
]
},
{
label: 'Expected Headers',
name: 'webhookHeaderParams',
description: 'Declare expected request headers. Leave empty to accept any.',
type: 'array',
optional: true,
show: {
startInputType: 'webhookTrigger'
},
array: [
{
label: 'Header Name',
name: 'name',
type: 'string',
placeholder: 'e.g. x-github-event'
},
{
label: 'Required',
name: 'required',
type: 'boolean'
}
]
},
{
label: 'Ephemeral Memory',
name: 'startEphemeralMemory',
Expand Down Expand Up @@ -275,7 +353,12 @@ class Start_Agentflow implements INode {

if (startInputType === 'webhookTrigger') {
inputData.webhook = input
let webhookOutput = input
let webhookOutput: string | Record<string, any> = input
try {
webhookOutput = typeof input === 'string' ? JSON.parse(input) : input
} catch (_) {
/* keep as-is */
}
if (options.agentflowRuntime?.webhook && Object.keys(options.agentflowRuntime.webhook).length) {
webhookOutput = options.agentflowRuntime.webhook
}
Expand Down
51 changes: 49 additions & 2 deletions packages/server/src/controllers/webhook/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const mockReq = (overrides: Partial<Request> = {}): Request =>
({
params: { id: 'chatflow-123' },
body: { foo: 'bar' },
method: 'POST',
headers: { 'content-type': 'application/json' },
query: { page: '1' },
user: undefined,
...overrides
} as unknown as Request)
Expand Down Expand Up @@ -77,7 +80,44 @@ describe('createWebhook', () => {

await webhookController.createWebhook(req, res, next)

expect(mockBuildChatflow).toHaveBeenCalledWith(expect.objectContaining({ body: { webhook: { body: originalBody } } }))
expect(mockBuildChatflow).toHaveBeenCalledWith(
expect.objectContaining({
body: {
webhook: {
body: originalBody,
headers: expect.any(Object),
query: expect.any(Object)
}
}
})
)
})

it('builds namespaced webhook payload with body, headers, and query', async () => {
mockValidateWebhookChatflow.mockResolvedValue(undefined)
mockBuildChatflow.mockResolvedValue({})

const req = mockReq({
body: { action: 'push' },
headers: { 'x-github-event': 'push' } as any,
query: { page: '2' } as any
})
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(mockBuildChatflow).toHaveBeenCalledWith(
expect.objectContaining({
body: {
webhook: {
body: { action: 'push' },
headers: expect.objectContaining({ 'x-github-event': 'push' }),
query: { page: '2' }
}
}
})
)
})

it('returns buildChatflow result as JSON response', async () => {
Expand Down Expand Up @@ -119,6 +159,13 @@ describe('createWebhook', () => {

await webhookController.createWebhook(req, res, next)

expect(mockValidateWebhookChatflow).toHaveBeenCalledWith('chatflow-123', undefined, { foo: 'bar' })
expect(mockValidateWebhookChatflow).toHaveBeenCalledWith(
'chatflow-123',
undefined,
{ foo: 'bar' },
'POST',
expect.any(Object),
expect.any(Object)
)
})
})
22 changes: 20 additions & 2 deletions packages/server/src/controllers/webhook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,28 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) =>

const workspaceId = req.user?.activeWorkspaceId

await webhookService.validateWebhookChatflow(req.params.id, workspaceId, req.body)
// For form-encoded requests, unwrap JSON encoded in a `payload` field (e.g. GitHub webhooks)
// so $webhook.body.* resolves against the actual payload fields.
const contentType = (req.headers['content-type'] ?? '').toLowerCase()
let body = req.body
if (contentType.startsWith('application/x-www-form-urlencoded') && typeof body?.payload === 'string') {
try {
body = JSON.parse(body.payload)
} catch {
// leave body as-is if payload isn't valid JSON
}
}

await webhookService.validateWebhookChatflow(req.params.id, workspaceId, body, req.method, req.headers, req.query)

// Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist
req.body = { webhook: { body: req.body } }
req.body = {
webhook: {
body,
headers: req.headers,
query: req.query
}
}

const apiResponse = await predictionsServices.buildChatflow(req)
return res.json(apiResponse)
Expand Down
1 change: 0 additions & 1 deletion packages/server/src/routes/webhook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import webhookController from '../../controllers/webhook'
const router = express.Router()

// Unauthenticated at route level — API key validation happens downstream in utilBuildChatflow.
// Method filtering (GET/POST/etc.) TODO: will be enforced in the service once the Start node config is read.
router.all('/:id', webhookController.getRateLimiterMiddleware, webhookController.createWebhook)

export default router
126 changes: 120 additions & 6 deletions packages/server/src/services/webhook/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ jest.mock('../chatflows', () => ({

import webhookService from './index'

const makeChatflow = (startInputType: string, webhookBodyParams?: unknown) => ({
const makeChatflow = (
startInputType: string,
inputs?: {
webhookBodyParams?: unknown
webhookMethod?: string
webhookContentType?: string
webhookHeaderParams?: unknown
webhookQueryParams?: unknown
}
) => ({
id: 'test-id',
flowData: JSON.stringify({
nodes: [
Expand All @@ -20,7 +29,7 @@ const makeChatflow = (startInputType: string, webhookBodyParams?: unknown) => ({
name: 'startAgentflow',
inputs: {
startInputType,
...(webhookBodyParams !== undefined && { webhookBodyParams })
...inputs
}
}
}
Expand Down Expand Up @@ -71,16 +80,83 @@ describe('validateWebhookChatflow', () => {
await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toBe(original)
})

// --- Method validation ---

it('throws 405 when HTTP method does not match configured webhookMethod', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookMethod: 'POST' }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'GET')).rejects.toMatchObject({
statusCode: StatusCodes.METHOD_NOT_ALLOWED
})
})

it('resolves for any method when webhookMethod is not configured', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'DELETE')).resolves.toBeUndefined()
})

// --- Content-Type validation ---

it('throws 415 when Content-Type does not match configured webhookContentType', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))

await expect(
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' })
).rejects.toMatchObject({ statusCode: StatusCodes.UNSUPPORTED_MEDIA_TYPE })
})

it('resolves when Content-Type starts with configured value (handles charset suffix)', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))

await expect(
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'application/json; charset=utf-8' })
).resolves.toBeUndefined()
})

it('resolves when webhookContentType is not configured', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))

await expect(
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' })
).resolves.toBeUndefined()
})

// --- Header validation ---

it('throws 400 when a required header is missing', async () => {
mockGetChatflowById.mockResolvedValue(
makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
)

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).rejects.toMatchObject({
statusCode: StatusCodes.BAD_REQUEST,
message: expect.stringContaining('x-api-key')
})
})

it('resolves when all required headers are present', async () => {
mockGetChatflowById.mockResolvedValue(
makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
)

await expect(
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'x-api-key': 'secret' })
).resolves.toBeUndefined()
})

// --- Body param validation ---

it('throws 400 when a required param is missing from body', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
statusCode: StatusCodes.BAD_REQUEST
})
})

it('includes the missing field name in the 400 error message', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
statusCode: StatusCodes.BAD_REQUEST,
Expand All @@ -89,13 +165,13 @@ describe('validateWebhookChatflow', () => {
})

it('resolves when all required params are present in body', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toBeUndefined()
})

it('resolves when webhookBodyParams is empty string (DB default)', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', ''))
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: '' }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toBeUndefined()
})
Expand All @@ -105,4 +181,42 @@ describe('validateWebhookChatflow', () => {

await expect(webhookService.validateWebhookChatflow('some-id', undefined, { anything: 'goes' })).resolves.toBeUndefined()
})

// --- Body type validation ---

it('throws 400 when a declared body param has wrong type', async () => {
mockGetChatflowById.mockResolvedValue(
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] })
)

await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 'not-a-number' })).rejects.toMatchObject({
statusCode: StatusCodes.BAD_REQUEST,
message: expect.stringContaining('count')
})
})

it('resolves when declared body param has correct type', async () => {
mockGetChatflowById.mockResolvedValue(
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] })
)

await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toBeUndefined()
})

// --- Query param validation ---

it('throws 400 when a required query param is missing', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {})).rejects.toMatchObject({
statusCode: StatusCodes.BAD_REQUEST,
message: expect.stringContaining('page')
})
})

it('resolves when all required query params are present', async () => {
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] }))

await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toBeUndefined()
})
})
Loading