From c3ad232a4aeb930c8ec45450e9c64b974cb1c274 Mon Sep 17 00:00:00 2001 From: jchui-wd Date: Thu, 2 Apr 2026 09:39:13 -0700 Subject: [PATCH 01/13] feat(ambient-agents): Add webhook trigger UI on start node (#6068) * feat(ambient-agents): Add webhook trigger UI on start node, handles in both canvas, agentflow is out of scope but shows temporary ui * fix: resolve webhookURL copy button not appearing after first save useParams() does not update when window.history.replaceState() is used on first save (bypasses React Router). Fall back to Redux canvas.chatflow.id so NodeInputHandler re-renders reactively when SET_CHATFLOW is dispatched. --- .../src/atoms/NodeInputHandler.test.tsx | 30 +++++++++ .../agentflow/src/atoms/NodeInputHandler.tsx | 14 ++++ .../components/nodes/agentflow/Start/Start.ts | 65 +++++++++++++++++- packages/server/src/Interface.ts | 1 + packages/server/src/utils/buildAgentflow.ts | 3 + .../ui/src/views/canvas/NodeInputHandler.jsx | 66 ++++++++++++++++++- 6 files changed, 175 insertions(+), 4 deletions(-) diff --git a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx index 5f2aea542bd..3ba740789e8 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx @@ -505,6 +505,36 @@ describe('NodeInputHandler – variable popover', () => { }) }) +describe('NodeInputHandler – webhookURL field', () => { + it('renders a disabled text field with static webhook URL placeholder', () => { + render( + + ) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input).toBeDisabled() + expect(input.value).toBe('POST /api/v1/webhook/{chatflowId}') + }) + + it('does not call onDataChange for webhookURL (read-only)', () => { + render( + + ) + + expect(mockOnDataChange).not.toHaveBeenCalled() + }) +}) + describe('NodeInputHandler – credential type rendering', () => { const StubAsyncInput: ComponentType = ({ onChange }) => ( + )} + + ) : ( + // Configured — show masked or plaintext field with actions + + {webhookSecretPlaintext && ( + + { + navigator.clipboard.writeText(webhookSecretPlaintext).then( + () => + enqueueSnackbar({ + message: 'Secret copied!', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success' + } + }), + () => + enqueueSnackbar({ + message: 'Failed to copy secret.', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error' + } + }) + ) + }} + > + + + + )} + + + + + + + + + + + + ) + }} + /> + )} + + )} + {inputParam.name !== 'webhookURL' && + inputParam.name !== 'webhookSecret' && (inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && (inputParam?.acceptVariable && (window.location.href.includes('v2/agentcanvas') || window.location.href.includes('v2/marketplace')) ? ( From bf2e368e77a069afa7ef78650d38894b7a1d0509 Mon Sep 17 00:00:00 2001 From: jchui-wd Date: Fri, 24 Apr 2026 13:44:24 -0700 Subject: [PATCH 05/13] feat:367 | Webhooks - Human Input + Callback Support (#6263) * Added HITL support for webhooks * feat: add async callback URL to webhook trigger Optional Callback URL / Secret on the Start node (or x-callback-url header). Webhook now returns 202 immediately and POSTs SUCCESS, STOPPED (HITL), or ERROR to the callback URL, signed with HMAC-SHA256 when a secret is set. Retries 3x with 0s/3s/6s backoff. * used getErrorMessage for error messages --- .../components/nodes/agentflow/Start/Start.ts | 23 ++ .../src/controllers/webhook/index.test.ts | 234 +++++++++++++++++- .../server/src/controllers/webhook/index.ts | 57 ++++- .../server/src/services/webhook/index.test.ts | 53 ++-- packages/server/src/services/webhook/index.ts | 12 +- packages/server/src/utils/buildAgentflow.ts | 17 ++ .../src/utils/callbackDispatcher.test.ts | 89 +++++++ .../server/src/utils/callbackDispatcher.ts | 32 +++ 8 files changed, 491 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/utils/callbackDispatcher.test.ts create mode 100644 packages/server/src/utils/callbackDispatcher.ts diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 3a33e430789..a384c01b329 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -213,6 +213,29 @@ class Start_Agentflow implements INode { startInputType: 'webhookTrigger' } }, + { + label: 'Callback URL', + name: 'callbackUrl', + type: 'string', + description: + 'If set, Flowise returns 202 immediately and POSTs the result to this URL when the flow finishes. Useful for platforms with strict HTTP timeout windows (GitHub, Slack, Zapier).', + placeholder: 'https://example.com/flowise-callback', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Callback Secret', + name: 'callbackSecret', + type: 'string', + description: + 'If set, outgoing callback POSTs are signed with HMAC-SHA256. The signature is sent as X-Flowise-Signature: sha256= so your callback endpoint can verify the request came from Flowise.', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, { label: 'Expected Query Parameters', name: 'webhookQueryParams', diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index f08235c1f1d..017a689379d 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express' const mockValidateWebhookChatflow = jest.fn() const mockBuildChatflow = jest.fn() +const mockDispatchCallback = jest.fn() jest.mock('../../services/webhook', () => ({ __esModule: true, @@ -19,6 +20,10 @@ jest.mock('../../utils/rateLimit', () => ({ }) } })) +jest.mock('../../utils/callbackDispatcher', () => ({ + dispatchCallback: mockDispatchCallback +})) +jest.mock('uuid', () => ({ v4: () => 'generated-uuid' })) import webhookController from './index' @@ -36,6 +41,7 @@ const mockReq = (overrides: Partial = {}): Request => const mockRes = (): Response => { const res = {} as Response res.json = jest.fn().mockReturnValue(res) + res.status = jest.fn().mockReturnValue(res) return res } @@ -44,6 +50,8 @@ const mockNext = (): NextFunction => jest.fn() describe('createWebhook', () => { beforeEach(() => { jest.clearAllMocks() + // Default: no callback config on Start node + mockValidateWebhookChatflow.mockResolvedValue({}) }) it('calls next with PRECONDITION_FAILED when id is missing', async () => { @@ -70,7 +78,6 @@ describe('createWebhook', () => { }) it('wraps req.body under webhook key before calling buildChatflow', async () => { - mockValidateWebhookChatflow.mockResolvedValue(undefined) mockBuildChatflow.mockResolvedValue({}) const originalBody = { foo: 'bar' } @@ -94,7 +101,6 @@ describe('createWebhook', () => { }) it('builds namespaced webhook payload with body, headers, and query', async () => { - mockValidateWebhookChatflow.mockResolvedValue(undefined) mockBuildChatflow.mockResolvedValue({}) const req = mockReq({ @@ -121,7 +127,6 @@ describe('createWebhook', () => { }) it('returns buildChatflow result as JSON response', async () => { - mockValidateWebhookChatflow.mockResolvedValue(undefined) const apiResult = { output: 'ok' } mockBuildChatflow.mockResolvedValue(apiResult) @@ -136,7 +141,6 @@ describe('createWebhook', () => { }) it('calls next with error when buildChatflow rejects', async () => { - mockValidateWebhookChatflow.mockResolvedValue(undefined) const error = new Error('execution failed') mockBuildChatflow.mockRejectedValue(error) @@ -150,7 +154,6 @@ describe('createWebhook', () => { }) it('passes the original body to validateWebhookChatflow before mutation', async () => { - mockValidateWebhookChatflow.mockResolvedValue(undefined) mockBuildChatflow.mockResolvedValue({}) const req = mockReq({ body: { foo: 'bar' } }) @@ -166,7 +169,226 @@ describe('createWebhook', () => { 'POST', expect.any(Object), expect.any(Object), - undefined // rawBody — not set on mock request + undefined, // rawBody — not set on mock request + undefined // options — not a resume call + ) + }) + + it('passes skipFieldValidation option when body contains humanInput (resume call)', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const req = mockReq({ body: { chatId: 'abc', humanInput: { type: 'proceed', startNodeId: 'humanInputAgentflow_0' } } }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockValidateWebhookChatflow).toHaveBeenCalledWith( + 'chatflow-123', + undefined, + expect.objectContaining({ humanInput: expect.any(Object) }), + 'POST', + expect.any(Object), + expect.any(Object), + undefined, + { skipFieldValidation: true } + ) + }) + + it('includes humanInput and chatId at top level of req.body on resume', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const humanInput = { type: 'proceed', startNodeId: 'humanInputAgentflow_0' } + const req = mockReq({ body: { chatId: 'abc123', humanInput } }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockBuildChatflow).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + humanInput, + chatId: 'abc123', + webhook: expect.any(Object) + }) + }) + ) + }) + + // --- Async callback (FLOWISE-367) --- + + it('returns 202 immediately when X-Callback-Url header is present', async () => { + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.status).toHaveBeenCalledWith(202) + expect(res.json).toHaveBeenCalledWith({ chatId: expect.any(String), status: 'PROCESSING' }) + expect(mockBuildChatflow).toHaveBeenCalled() + }) + + it('returns 202 with chatId from body when already provided', async () => { + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ + body: { chatId: 'existing-id', foo: 'bar' }, + headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any + }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.json).toHaveBeenCalledWith({ chatId: 'existing-id', status: 'PROCESSING' }) + }) + + it('generates a chatId when not in body and callback URL is present', async () => { + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.json).toHaveBeenCalledWith({ chatId: 'generated-uuid', status: 'PROCESSING' }) + }) + + it('dispatches SUCCESS callback when flow completes without action', async () => { + const apiResponse = { text: 'hello', executionId: 'exec-1' } + mockBuildChatflow.mockResolvedValue(apiResponse) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(mockDispatchCallback).toHaveBeenCalledWith( + 'https://cb.example.com', + { status: 'SUCCESS', chatId: expect.any(String), data: apiResponse }, + undefined ) }) + + it('dispatches STOPPED callback when flow has action (HITL pause)', async () => { + const action = { id: 'act-1', mapping: { approve: 'Proceed', reject: 'Reject' }, elements: [] } + const apiResponse = { text: 'waiting', executionId: 'exec-2', action } + mockBuildChatflow.mockResolvedValue(apiResponse) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(mockDispatchCallback).toHaveBeenCalledWith( + 'https://cb.example.com', + { + status: 'STOPPED', + chatId: expect.any(String), + data: { text: 'waiting', executionId: 'exec-2', action } + }, + undefined + ) + }) + + it('dispatches ERROR callback when flow throws', async () => { + mockBuildChatflow.mockRejectedValue(new Error('flow exploded')) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(mockDispatchCallback).toHaveBeenCalledWith( + 'https://cb.example.com', + { status: 'ERROR', chatId: expect.any(String), error: 'flow exploded' }, + undefined + ) + }) + + it('uses callbackSecret from Start node config when signing', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackSecret: 'node-secret' }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(mockDispatchCallback).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'node-secret') + }) + + it('uses callbackUrl from Start node config when no header is present', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node-configured.example.com/cb' }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq() + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(res.status).toHaveBeenCalledWith(202) + expect(mockDispatchCallback).toHaveBeenCalledWith('https://node-configured.example.com/cb', expect.any(Object), undefined) + }) + + it('header callbackUrl takes priority over Start node config', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node.example.com/cb' }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://header.example.com/cb' } as any }) + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + expect(mockDispatchCallback).toHaveBeenCalledWith('https://header.example.com/cb', expect.any(Object), undefined) + }) + + it('calls next with BAD_REQUEST when callbackUrl is not a valid http/https URL', async () => { + const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'ftp://bad.example.com' } as any }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST })) + expect(mockBuildChatflow).not.toHaveBeenCalled() + }) + + it('falls back to synchronous response when no callbackUrl is configured', async () => { + const apiResult = { text: 'sync result' } + mockBuildChatflow.mockResolvedValue(apiResult) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.status).not.toHaveBeenCalledWith(202) + expect(res.json).toHaveBeenCalledWith(apiResult) + expect(mockDispatchCallback).not.toHaveBeenCalled() + }) }) diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index caf56d74a5f..168ae699c70 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -1,9 +1,12 @@ import { Request, Response, NextFunction } from 'express' import { StatusCodes } from 'http-status-codes' +import { v4 as uuidv4 } from 'uuid' import { RateLimiterManager } from '../../utils/rateLimit' import predictionsServices from '../../services/predictions' import webhookService from '../../services/webhook' import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { dispatchCallback } from '../../utils/callbackDispatcher' +import { getErrorMessage } from '../../errors/utils' const createWebhook = async (req: Request, res: Response, next: NextFunction) => { try { @@ -25,16 +28,22 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => } } - await webhookService.validateWebhookChatflow( + const isResume = body?.humanInput != null + + const { callbackUrl: nodeCallbackUrl, callbackSecret } = await webhookService.validateWebhookChatflow( req.params.id, workspaceId, body, req.method, req.headers, req.query, - (req as any).rawBody + (req as any).rawBody, + isResume ? { skipFieldValidation: true } : undefined ) + // Header takes priority over Start node config + const callbackUrl: string | undefined = (req.headers['x-callback-url'] as string | undefined) || nodeCallbackUrl + // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist req.body = { webhook: { @@ -44,6 +53,50 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => } } + const { humanInput, chatId: bodyChatId, sessionId } = body ?? {} + if (humanInput != null) req.body.humanInput = humanInput + if (bodyChatId != null) req.body.chatId = bodyChatId + if (sessionId != null) req.body.sessionId = sessionId + + if (callbackUrl) { + try { + const parsed = new URL(callbackUrl) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() + } catch { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid http or https URL`) + } + + // Pre-assign chatId so the 202 response and the background execution share the same ID + const chatId: string = (bodyChatId as string | undefined) ?? uuidv4() + req.body.chatId = chatId + + res.status(202).json({ chatId, status: 'PROCESSING' }) + + setImmediate(async () => { + try { + const apiResponse = await predictionsServices.buildChatflow(req) + + // apiResponse.action is the parsed humanInputAction — only present when flow is STOPPED (FLOWISE-387) + if (apiResponse.action) { + await dispatchCallback( + callbackUrl, + { + status: 'STOPPED', + chatId, + data: { text: apiResponse.text, executionId: apiResponse.executionId, action: apiResponse.action } + }, + callbackSecret + ) + } else { + await dispatchCallback(callbackUrl, { status: 'SUCCESS', chatId, data: apiResponse }, callbackSecret) + } + } catch (err: any) { + await dispatchCallback(callbackUrl, { status: 'ERROR', chatId, error: getErrorMessage(err) }, callbackSecret) + } + }) + return + } + const apiResponse = await predictionsServices.buildChatflow(req) return res.json(apiResponse) } catch (error) { diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts index 198c26d7048..df941f83f0e 100644 --- a/packages/server/src/services/webhook/index.test.ts +++ b/packages/server/src/services/webhook/index.test.ts @@ -65,7 +65,7 @@ describe('validateWebhookChatflow', () => { it('resolves without error for a valid webhook chatflow', async () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) - await expect(webhookService.validateWebhookChatflow('some-id')).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id')).resolves.toMatchObject({}) }) it('throws 500 for unexpected errors from getChatflowById', async () => { @@ -96,7 +96,7 @@ describe('validateWebhookChatflow', () => { 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() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'DELETE')).resolves.toMatchObject({}) }) // --- Content-Type validation --- @@ -114,7 +114,7 @@ describe('validateWebhookChatflow', () => { await expect( webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'application/json; charset=utf-8' }) - ).resolves.toBeUndefined() + ).resolves.toMatchObject({}) }) it('resolves when webhookContentType is not configured', async () => { @@ -122,7 +122,7 @@ describe('validateWebhookChatflow', () => { await expect( webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' }) - ).resolves.toBeUndefined() + ).resolves.toMatchObject({}) }) // --- Header validation --- @@ -145,7 +145,7 @@ describe('validateWebhookChatflow', () => { await expect( webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'x-api-key': 'secret' }) - ).resolves.toBeUndefined() + ).resolves.toMatchObject({}) }) // --- Body param validation --- @@ -170,19 +170,19 @@ describe('validateWebhookChatflow', () => { it('resolves when all required params are present in body', async () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toMatchObject({}) }) it('resolves when webhookBodyParams is empty string (DB default)', async () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: '' })) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toMatchObject({}) }) it('resolves when no params declared but body has arbitrary fields', async () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { anything: 'goes' })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { anything: 'goes' })).resolves.toMatchObject({}) }) // --- Body type validation --- @@ -203,7 +203,7 @@ describe('validateWebhookChatflow', () => { makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) ) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toMatchObject({}) }) it('resolves when number param is sent as a numeric string (form-encoded)', async () => { @@ -211,7 +211,7 @@ describe('validateWebhookChatflow', () => { makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) ) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toMatchObject({}) }) it('throws 400 when number param is an empty string (form-encoded)', async () => { @@ -230,7 +230,7 @@ describe('validateWebhookChatflow', () => { makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) ) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toMatchObject({}) }) it('resolves when boolean param is the string "true" (form-encoded)', async () => { @@ -238,7 +238,7 @@ describe('validateWebhookChatflow', () => { makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) ) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toMatchObject({}) }) it('resolves when boolean param is the string "false" (form-encoded)', async () => { @@ -246,7 +246,7 @@ describe('validateWebhookChatflow', () => { makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) ) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'false' })).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'false' })).resolves.toMatchObject({}) }) it('throws 400 when boolean param is an invalid string like "yes" (form-encoded)', async () => { @@ -274,7 +274,7 @@ describe('validateWebhookChatflow', () => { 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() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toMatchObject({}) }) // --- HMAC signature verification --- @@ -290,7 +290,7 @@ describe('validateWebhookChatflow', () => { it('resolves without signature check when no webhookSecret is configured', async () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) - await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).resolves.toBeUndefined() + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).resolves.toMatchObject({}) }) it('resolves when secret is set and signature is valid', async () => { @@ -300,7 +300,7 @@ describe('validateWebhookChatflow', () => { await expect( webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY) - ).resolves.toBeUndefined() + ).resolves.toMatchObject({}) }) it('throws 401 when secret is set but X-Webhook-Signature header is missing', async () => { @@ -344,4 +344,25 @@ describe('validateWebhookChatflow', () => { webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, undefined) ).rejects.toMatchObject({ statusCode: 401 }) }) + + // --- skipFieldValidation option (resume calls) --- + + it('skips field validation when skipFieldValidation is true', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) + + // Missing required body param 'action' — would normally throw 400, but not on resume + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, undefined, { skipFieldValidation: true }) + ).resolves.toMatchObject({}) + }) + + it('still runs signature check when skipFieldValidation is true', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + + // No signature header — should still 401 even with skipFieldValidation + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY, { skipFieldValidation: true }) + ).rejects.toMatchObject({ statusCode: 401 }) + }) }) diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts index e5c6917e3e6..6cd593ba59f 100644 --- a/packages/server/src/services/webhook/index.ts +++ b/packages/server/src/services/webhook/index.ts @@ -12,8 +12,9 @@ const validateWebhookChatflow = async ( method?: string, headers?: Record, query?: Record, - rawBody?: Buffer -): Promise => { + rawBody?: Buffer, + options?: { skipFieldValidation?: boolean } +): Promise<{ callbackUrl?: string; callbackSecret?: string }> => { try { const chatflow = await chatflowsService.getChatflowById(chatflowId, workspaceId) if (!chatflow) { @@ -28,6 +29,9 @@ const validateWebhookChatflow = async ( throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} is not configured as a webhook trigger`) } + const callbackUrl = (startNode?.data?.inputs?.callbackUrl as string | undefined) || undefined + const callbackSecret = (startNode?.data?.inputs?.callbackSecret as string | undefined) || undefined + // Signature verification (runs before any other validation to fail-fast on bad auth) if (chatflow.webhookSecretConfigured) { const sigHeader = ((startNode?.data?.inputs?.webhookSignatureHeader as string) || 'x-webhook-signature').toLowerCase() @@ -49,6 +53,8 @@ const validateWebhookChatflow = async ( } } + if (options?.skipFieldValidation) return { callbackUrl, callbackSecret } + // Method validation const webhookMethod = startNode?.data?.inputs?.webhookMethod if (webhookMethod && method?.toUpperCase() !== webhookMethod.toUpperCase()) { @@ -105,6 +111,8 @@ const validateWebhookChatflow = async ( if (missingQueryParams.length > 0) { throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required query parameters: ${missingQueryParams.join(', ')}`) } + + return { callbackUrl, callbackSecret } } catch (error) { if (error instanceof InternalFlowiseError) throw error throw new InternalFlowiseError( diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index ba959c17a06..5954250b618 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -1701,6 +1701,23 @@ export const executeAgentFlow = async ({ } } + // On webhook humanInput resume, restore the original trigger's webhook data so $webhook.body.*, + // $webhook.headers.*, $webhook.query.* and $flow.input resolve to the original trigger values. + // incomingInput.webhook is always present on webhook calls so we overwrite it directly rather + // than relying on the agentflowRuntime.webhook fallback (unlike the formInput pattern). + if (startInputType === 'webhookTrigger' && humanInput && previousExecution) { + const previousExecutionData = (JSON.parse(previousExecution.executionData) as IAgentflowExecutedData[]) ?? [] + + const previousStartAgent = previousExecutionData.find((execData) => execData.data.name === 'startAgentflow') + + if (previousStartAgent) { + const previousStartAgentOutput = previousStartAgent.data.output + if (previousStartAgentOutput && typeof previousStartAgentOutput === 'object' && 'webhook' in previousStartAgentOutput) { + incomingInput.webhook = previousStartAgentOutput.webhook as Record + } + } + } + // If it is human input, find the last checkpoint and resume // Skip human input resumption for recursive iteration calls - they should start fresh if (humanInput && !(isRecursive && iterationContext)) { diff --git a/packages/server/src/utils/callbackDispatcher.test.ts b/packages/server/src/utils/callbackDispatcher.test.ts new file mode 100644 index 00000000000..bff70ab46fd --- /dev/null +++ b/packages/server/src/utils/callbackDispatcher.test.ts @@ -0,0 +1,89 @@ +import { createHmac } from 'crypto' + +const mockAxiosPost = jest.fn() +const mockLoggerError = jest.fn() + +jest.mock('axios', () => ({ post: mockAxiosPost })) +jest.mock('./logger', () => ({ error: mockLoggerError })) + +import { dispatchCallback } from './callbackDispatcher' + +const URL = 'https://example.com/callback' +const PAYLOAD = { status: 'SUCCESS', chatId: 'abc-123', data: { text: 'hello' } } + +function expectedSignature(body: string, secret: string): string { + return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') +} + +describe('dispatchCallback', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('POSTs JSON payload to the callback URL', async () => { + mockAxiosPost.mockResolvedValue({ status: 200 }) + + await dispatchCallback(URL, PAYLOAD) + + expect(mockAxiosPost).toHaveBeenCalledTimes(1) + expect(mockAxiosPost).toHaveBeenCalledWith(URL, JSON.stringify(PAYLOAD), { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + }) + }) + + it('includes X-Flowise-Signature header when secret is provided', async () => { + mockAxiosPost.mockResolvedValue({ status: 200 }) + const secret = 'my-secret' + const body = JSON.stringify(PAYLOAD) + + await dispatchCallback(URL, PAYLOAD, secret) + + expect(mockAxiosPost).toHaveBeenCalledWith( + URL, + body, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Flowise-Signature': expectedSignature(body, secret) + }) + }) + ) + }) + + it('does not include X-Flowise-Signature when no secret is provided', async () => { + mockAxiosPost.mockResolvedValue({ status: 200 }) + + await dispatchCallback(URL, PAYLOAD) + + const call = mockAxiosPost.mock.calls[0] + expect(call[2].headers).not.toHaveProperty('X-Flowise-Signature') + }) + + it('retries on failure and succeeds on second attempt', async () => { + mockAxiosPost.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ status: 200 }) + + const promise = dispatchCallback(URL, PAYLOAD) + await jest.advanceTimersByTimeAsync(3000) + await promise + + expect(mockAxiosPost).toHaveBeenCalledTimes(2) + expect(mockLoggerError).not.toHaveBeenCalled() + }) + + it('logs an error after all 3 attempts fail and does not throw', async () => { + mockAxiosPost.mockRejectedValue(new Error('unreachable')) + + const promise = dispatchCallback(URL, PAYLOAD) + await jest.advanceTimersByTimeAsync(3000) + await jest.advanceTimersByTimeAsync(6000) + await promise + + expect(mockAxiosPost).toHaveBeenCalledTimes(3) + expect(mockLoggerError).toHaveBeenCalledWith(expect.stringContaining('Failed to deliver callback')) + }) +}) diff --git a/packages/server/src/utils/callbackDispatcher.ts b/packages/server/src/utils/callbackDispatcher.ts new file mode 100644 index 00000000000..d32c560cc8d --- /dev/null +++ b/packages/server/src/utils/callbackDispatcher.ts @@ -0,0 +1,32 @@ +import axios from 'axios' +import { createHmac } from 'crypto' +import logger from './logger' + +// Delays in ms before each attempt: attempt 1 is immediate, attempt 2 waits 3s, attempt 3 waits 6s +const RETRY_DELAYS = [0, 3000, 6000] + +function sign(body: string, secret: string): string { + return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') +} + +export async function dispatchCallback(url: string, payload: Record, secret?: string): Promise { + const body = JSON.stringify(payload) + const headers: Record = { 'Content-Type': 'application/json' } + if (secret) headers['X-Flowise-Signature'] = sign(body, secret) + + for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) { + if (RETRY_DELAYS[attempt] > 0) { + await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt])) + } + try { + await axios.post(url, body, { headers, timeout: 10000 }) + return + } catch (err: any) { + if (attempt === RETRY_DELAYS.length - 1) { + logger.error( + `[callbackDispatcher] Failed to deliver callback to ${url} after ${RETRY_DELAYS.length} attempts: ${err.message}` + ) + } + } + } +} From 0a50bf91977c60e321df604ea68d68bc90b75265 Mon Sep 17 00:00:00 2001 From: jchui-wd Date: Fri, 24 Apr 2026 14:06:23 -0700 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20add=20object/array=20body=20param?= =?UTF-8?q?=20types=20and=20per-option=20show/hide=20on=20d=E2=80=A6=20(#6?= =?UTF-8?q?273)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add webhook secret & HMAC signature verification to webhook trigger Adds server-side webhook secret management (generate/clear/verify) and a UI control in the Start node for configuring the secret, signature header, and signature type (HMAC-SHA256 or plain token). Raw request body is now captured before JSON parsing so HMAC signatures can be verified against the original bytes. Migrations added for all four supported databases. * fix: accept string-coerced numbers and booleans in webhook body type validation application/x-www-form-urlencoded payloads deliver all values as strings, so the strict typeof check was incorrectly rejecting valid numeric ("42") and boolean ("true"/"false") values. Updated the filter to coerce and validate instead, with tests covering both JSON and form-encoded cases. * Added HITL support for webhooks * feat: add async callback URL to webhook trigger Optional Callback URL / Secret on the Start node (or x-callback-url header). Webhook now returns 202 immediately and POSTs SUCCESS, STOPPED (HITL), or ERROR to the callback URL, signed with HMAC-SHA256 when a secret is set. Retries 3x with 0s/3s/6s backoff. * feat: add object/array body param types and per-option show/hide on dropdowns - Add object, array[string/number/boolean/object] as webhook body param types, available when content type is application/json - Extend options fields with show/hide conditions so individual dropdown choices can be hidden based on other param values --- packages/agentflow/src/core/types/node.ts | 12 +- .../src/core/utils/fieldVisibility.test.ts | 98 ++++++++++++ .../src/core/utils/fieldVisibility.ts | 22 ++- .../components/nodes/agentflow/Start/Start.ts | 25 ++++ packages/components/src/Interface.ts | 2 + .../server/src/services/webhook/index.test.ts | 140 ++++++++++++++++++ packages/server/src/services/webhook/index.ts | 8 + packages/ui/src/utils/genericHelper.js | 11 ++ 8 files changed, 313 insertions(+), 5 deletions(-) diff --git a/packages/agentflow/src/core/types/node.ts b/packages/agentflow/src/core/types/node.ts index 97dbd647fa0..3789b4e9b5d 100644 --- a/packages/agentflow/src/core/types/node.ts +++ b/packages/agentflow/src/core/types/node.ts @@ -84,7 +84,17 @@ export interface InputParam { type: string default?: unknown optional?: boolean - options?: Array<{ label: string; name: string; description?: string; client?: Array } | string> + options?: Array< + | { + label: string + name: string + description?: string + client?: Array + show?: Record + hide?: Record + } + | string + > placeholder?: string rows?: number description?: string diff --git a/packages/agentflow/src/core/utils/fieldVisibility.test.ts b/packages/agentflow/src/core/utils/fieldVisibility.test.ts index 45bf66488d9..695a142cf2e 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.test.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.test.ts @@ -194,6 +194,104 @@ describe('evaluateFieldVisibility', () => { expect(params[0].display).toBeUndefined() expect(params[1].display).toBeUndefined() }) + + describe('option-level show/hide filtering', () => { + it('removes options whose hide condition matches', () => { + const param = makeParam({ + type: 'options', + options: [ + { label: 'String', name: 'string' }, + { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } } + ] as any + }) + + const result = evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' }) + expect(result[0].options).toHaveLength(1) + expect(result[0].options![0]).toMatchObject({ name: 'string' }) + }) + + it('keeps options whose hide condition does not match', () => { + const param = makeParam({ + type: 'options', + options: [ + { label: 'String', name: 'string' }, + { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } } + ] as any + }) + + const result = evaluateFieldVisibility([param], { contentType: 'application/json' }) + expect(result[0].options).toHaveLength(2) + }) + + it('removes options whose show condition does not match', () => { + const param = makeParam({ + type: 'options', + options: [ + { label: 'Basic', name: 'basic' }, + { label: 'Advanced', name: 'advanced', show: { mode: 'expert' } } + ] as any + }) + + const result = evaluateFieldVisibility([param], { mode: 'beginner' }) + expect(result[0].options).toHaveLength(1) + expect(result[0].options![0]).toMatchObject({ name: 'basic' }) + }) + + it('keeps options whose show condition matches', () => { + const param = makeParam({ + type: 'options', + options: [ + { label: 'Basic', name: 'basic' }, + { label: 'Advanced', name: 'advanced', show: { mode: 'expert' } } + ] as any + }) + + const result = evaluateFieldVisibility([param], { mode: 'expert' }) + expect(result[0].options).toHaveLength(2) + }) + + it('passes through string options unchanged', () => { + const param = makeParam({ + type: 'options', + options: ['one', 'two', 'three'] as any + }) + + const result = evaluateFieldVisibility([param], {}) + expect(result[0].options).toHaveLength(3) + }) + + it('passes through options with no show/hide unchanged', () => { + const param = makeParam({ + type: 'options', + options: [ + { label: 'A', name: 'a' }, + { label: 'B', name: 'b' } + ] as any + }) + + const result = evaluateFieldVisibility([param], {}) + expect(result[0].options).toHaveLength(2) + }) + + it('does not mutate the original options array', () => { + const options = [ + { label: 'String', name: 'string' }, + { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } } + ] as any + const param = makeParam({ type: 'options', options }) + + evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' }) + + // Original options array is untouched + expect(options).toHaveLength(2) + }) + + it('does not affect non-options params', () => { + const param = makeParam({ type: 'string' }) + const result = evaluateFieldVisibility([param], {}) + expect(result[0].options).toBeUndefined() + }) + }) }) describe('evaluateFieldVisibility – nested array $index pattern (Start node formInputTypes)', () => { diff --git a/packages/agentflow/src/core/utils/fieldVisibility.ts b/packages/agentflow/src/core/utils/fieldVisibility.ts index c2b6c8fd950..1413ad2bb99 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.ts @@ -129,13 +129,27 @@ export function evaluateParamVisibility(param: InputParam, inputValues: Record, arrayIndex?: number): InputParam[] { - return params.map((param) => ({ - ...param, - display: evaluateParamVisibility(param, inputValues, arrayIndex) - })) + return params.map((param) => { + const withDisplay = { ...param, display: evaluateParamVisibility(param, inputValues, arrayIndex) } + + if (withDisplay.type === 'options' && withDisplay.options) { + const filteredOptions = withDisplay.options.filter((opt) => { + if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true + return evaluateParamVisibility( + { id: '', name: '', label: '', type: '', show: opt.show, hide: opt.hide }, + inputValues, + arrayIndex + ) + }) + return filteredOptions.length === withDisplay.options.length ? withDisplay : { ...withDisplay, options: filteredOptions } + } + + return withDisplay + }) } /** diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index a384c01b329..e28834a652e 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -291,6 +291,31 @@ class Start_Agentflow implements INode { { label: 'Boolean', name: 'boolean' + }, + { + label: 'Object', + name: 'object', + hide: { webhookContentType: 'application/x-www-form-urlencoded' } + }, + { + label: 'Array[String]', + name: 'array[string]', + hide: { webhookContentType: 'application/x-www-form-urlencoded' } + }, + { + label: 'Array[Number]', + name: 'array[number]', + hide: { webhookContentType: 'application/x-www-form-urlencoded' } + }, + { + label: 'Array[Boolean]', + name: 'array[boolean]', + hide: { webhookContentType: 'application/x-www-form-urlencoded' } + }, + { + label: 'Array[Object]', + name: 'array[object]', + hide: { webhookContentType: 'application/x-www-form-urlencoded' } } ], default: 'string' diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts index eb6af15c09e..a7442318b1a 100644 --- a/packages/components/src/Interface.ts +++ b/packages/components/src/Interface.ts @@ -64,6 +64,8 @@ export interface INodeOptionsValue { description?: string imageSrc?: string client?: Array + show?: INodeDisplay + hide?: INodeDisplay } export interface INodeOutputsValue { diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts index df941f83f0e..bc1dfb27af2 100644 --- a/packages/server/src/services/webhook/index.test.ts +++ b/packages/server/src/services/webhook/index.test.ts @@ -260,6 +260,146 @@ describe('validateWebhookChatflow', () => { }) }) + // --- object type --- + + it('resolves when object param is a plain object', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: { key: 'val' } })).resolves.toMatchObject({}) + }) + + it('throws 400 when object param is a string', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: 'hello' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('meta') + }) + }) + + it('throws 400 when object param is an array', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: [1, 2] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('meta') + }) + }) + + // --- array[string] type --- + + it('resolves when array[string] param is an array of strings', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: ['a', 'b'] })).resolves.toMatchObject({}) + }) + + it('throws 400 when array[string] param contains non-strings', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: [1, 2] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('tags') + }) + }) + + it('throws 400 when array[string] param is not an array', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: 'hello' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('tags') + }) + }) + + // --- array[number] type --- + + it('resolves when array[number] param is an array of numbers', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: [1, 2, 3] })).resolves.toMatchObject({}) + }) + + it('throws 400 when array[number] param contains non-numbers', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: ['a', 'b'] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('scores') + }) + }) + + // --- array[boolean] type --- + + it('resolves when array[boolean] param is an array of booleans', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: [true, false] })).resolves.toMatchObject({}) + }) + + it('throws 400 when array[boolean] param contains boolean strings (no coercion for array elements)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: ['true', 'false'] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('flags') + }) + }) + + // --- array[object] type --- + + it('resolves when array[object] param is an array of plain objects', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [{ a: 1 }, { b: 2 }] })).resolves.toMatchObject( + {} + ) + }) + + it('throws 400 when array[object] param contains non-objects', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: ['a', 'b'] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('items') + }) + }) + + it('throws 400 when array[object] param contains nested arrays', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [[1, 2]] })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('items') + }) + }) + // --- Query param validation --- it('throws 400 when a required query param is missing', async () => { diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts index 6cd593ba59f..b7254bd5981 100644 --- a/packages/server/src/services/webhook/index.ts +++ b/packages/server/src/services/webhook/index.ts @@ -96,6 +96,14 @@ const validateWebhookChatflow = async ( const val = body[p.name] if (p.type === 'number') return val === '' || isNaN(Number(val)) if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false' + if (p.type === 'object') return typeof val !== 'object' || val === null || Array.isArray(val) + if (p.type === 'array[string]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'string') + if (p.type === 'array[number]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'number') + if (p.type === 'array[boolean]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'boolean') + if (p.type === 'array[object]') + return ( + !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'object' || el === null || Array.isArray(el)) + ) return typeof val !== p.type }) .map((p) => p.name) diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index ac834c77f19..76a968f397d 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -1292,6 +1292,17 @@ export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex) if (inputParam.hide) { _showHideOperation(nodeData, inputParam, 'hide', arrayIndex) } + + // Filter individual options within dropdowns based on their own show/hide conditions + if (inputParam.type === 'options' && inputParam.options) { + inputParam.options = inputParam.options.filter((opt) => { + if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true + const synthetic = { show: opt.show, hide: opt.hide, display: true } + if (opt.show) _showHideOperation(nodeData, synthetic, 'show', arrayIndex) + if (opt.hide) _showHideOperation(nodeData, synthetic, 'hide', arrayIndex) + return synthetic.display !== false + }) + } } return params From b6d11d712a01941f0909578aa77ebd8705706089 Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Fri, 24 Apr 2026 15:58:55 -0700 Subject: [PATCH 07/13] fix: prevent SSRF in webhook callback URL Remove the x-callback-url header override that allowed any external caller to control where the server sends POST requests. Callback URL now only comes from the Start node config (authenticated users). Add checkDenyList validation to block callback URLs targeting private networks, cloud metadata endpoints, and loopback addresses. --- .../src/controllers/webhook/index.test.ts | 53 +++++++++++-------- .../server/src/controllers/webhook/index.ts | 7 +-- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index 017a689379d..3f06ed02dbb 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -4,6 +4,7 @@ import { Request, Response, NextFunction } from 'express' const mockValidateWebhookChatflow = jest.fn() const mockBuildChatflow = jest.fn() const mockDispatchCallback = jest.fn() +const mockCheckDenyList = jest.fn() jest.mock('../../services/webhook', () => ({ __esModule: true, @@ -23,6 +24,9 @@ jest.mock('../../utils/rateLimit', () => ({ jest.mock('../../utils/callbackDispatcher', () => ({ dispatchCallback: mockDispatchCallback })) +jest.mock('flowise-components', () => ({ + checkDenyList: (url: string) => mockCheckDenyList(url) +})) jest.mock('uuid', () => ({ v4: () => 'generated-uuid' })) import webhookController from './index' @@ -218,12 +222,13 @@ describe('createWebhook', () => { // --- Async callback (FLOWISE-367) --- - it('returns 202 immediately when X-Callback-Url header is present', async () => { + it('returns 202 immediately when callbackUrl is configured on Start node', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() const next = mockNext() @@ -235,13 +240,13 @@ describe('createWebhook', () => { }) it('returns 202 with chatId from body when already provided', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) const req = mockReq({ - body: { chatId: 'existing-id', foo: 'bar' }, - headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any + body: { chatId: 'existing-id', foo: 'bar' } }) const res = mockRes() const next = mockNext() @@ -252,11 +257,12 @@ describe('createWebhook', () => { }) it('generates a chatId when not in body and callback URL is present', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() const next = mockNext() @@ -266,12 +272,13 @@ describe('createWebhook', () => { }) it('dispatches SUCCESS callback when flow completes without action', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) const apiResponse = { text: 'hello', executionId: 'exec-1' } mockBuildChatflow.mockResolvedValue(apiResponse) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() await webhookController.createWebhook(req, res, mockNext()) @@ -284,13 +291,14 @@ describe('createWebhook', () => { }) it('dispatches STOPPED callback when flow has action (HITL pause)', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) const action = { id: 'act-1', mapping: { approve: 'Proceed', reject: 'Reject' }, elements: [] } const apiResponse = { text: 'waiting', executionId: 'exec-2', action } mockBuildChatflow.mockResolvedValue(apiResponse) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() await webhookController.createWebhook(req, res, mockNext()) @@ -307,11 +315,12 @@ describe('createWebhook', () => { }) it('dispatches ERROR callback when flow throws', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockRejectedValue(new Error('flow exploded')) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() await webhookController.createWebhook(req, res, mockNext()) @@ -324,17 +333,17 @@ describe('createWebhook', () => { }) it('uses callbackSecret from Start node config when signing', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackSecret: 'node-secret' }) + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com', callbackSecret: 'node-secret' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any }) + const req = mockReq() const res = mockRes() await webhookController.createWebhook(req, res, mockNext()) - expect(mockDispatchCallback).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'node-secret') + expect(mockDispatchCallback).toHaveBeenCalledWith('https://cb.example.com', expect.any(Object), 'node-secret') }) it('uses callbackUrl from Start node config when no header is present', async () => { @@ -352,22 +361,22 @@ describe('createWebhook', () => { expect(mockDispatchCallback).toHaveBeenCalledWith('https://node-configured.example.com/cb', expect.any(Object), undefined) }) - it('header callbackUrl takes priority over Start node config', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node.example.com/cb' }) - mockBuildChatflow.mockResolvedValue({ text: 'done' }) - mockDispatchCallback.mockResolvedValue(undefined) - jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) - - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://header.example.com/cb' } as any }) + it('calls next with BAD_REQUEST when callbackUrl is denied by SSRF policy', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'http://169.254.169.254/latest/meta-data/' }) + mockCheckDenyList.mockRejectedValue(new Error('Access to this host is denied by policy.')) + const req = mockReq() const res = mockRes() + const next = mockNext() - await webhookController.createWebhook(req, res, mockNext()) + await webhookController.createWebhook(req, res, next) - expect(mockDispatchCallback).toHaveBeenCalledWith('https://header.example.com/cb', expect.any(Object), undefined) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST })) + expect(mockBuildChatflow).not.toHaveBeenCalled() }) - it('calls next with BAD_REQUEST when callbackUrl is not a valid http/https URL', async () => { - const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'ftp://bad.example.com' } as any }) + it('calls next with BAD_REQUEST when node callbackUrl is not a valid http/https URL', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'ftp://bad.example.com' }) + const req = mockReq() const res = mockRes() const next = mockNext() diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index 168ae699c70..59cda6c3434 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -5,6 +5,7 @@ import { RateLimiterManager } from '../../utils/rateLimit' import predictionsServices from '../../services/predictions' import webhookService from '../../services/webhook' import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { checkDenyList } from 'flowise-components' import { dispatchCallback } from '../../utils/callbackDispatcher' import { getErrorMessage } from '../../errors/utils' @@ -41,8 +42,7 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => isResume ? { skipFieldValidation: true } : undefined ) - // Header takes priority over Start node config - const callbackUrl: string | undefined = (req.headers['x-callback-url'] as string | undefined) || nodeCallbackUrl + const callbackUrl: string | undefined = nodeCallbackUrl // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist req.body = { @@ -62,8 +62,9 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => try { const parsed = new URL(callbackUrl) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() + await checkDenyList(callbackUrl) } catch { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid http or https URL`) + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid and safe http or https URL`) } // Pre-assign chatId so the 202 response and the background execution share the same ID From 1d3f843180f4ff31be2a799a9c299f5ca7367bf9 Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Mon, 27 Apr 2026 18:38:55 -0700 Subject: [PATCH 08/13] fix: exclude HTTP method from webhook URL copy Co-Authored-By: Claude Sonnet 4.6 --- packages/ui/src/views/canvas/NodeInputHandler.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/views/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx index 6fd2cdb5776..7a5e89efe58 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.jsx +++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx @@ -835,9 +835,8 @@ const NodeInputHandler = ({ }, [data.id, position, updateNodeInternals]) const webhookMethod = data.inputs?.webhookMethod ?? 'POST' - const webhookUrl = chatflowId - ? `${webhookMethod} ${baseURL}/api/v1/webhook/${chatflowId}` - : 'Save the flow first to generate the webhook URL' + const webhookUrlBase = chatflowId ? `${baseURL}/api/v1/webhook/${chatflowId}` : null + const webhookUrl = webhookUrlBase ? `${webhookMethod} ${webhookUrlBase}` : 'Save the flow first to generate the webhook URL' return (
@@ -1154,7 +1153,7 @@ const NodeInputHandler = ({ { - navigator.clipboard.writeText(webhookUrl).then( + navigator.clipboard.writeText(webhookUrlBase).then( () => enqueueSnackbar({ message: 'URL copied!', From 7d618620025b97eb8efecf07d9ea62075b89b840 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 3 May 2026 00:03:22 +0100 Subject: [PATCH 09/13] feat: enhance webhook functionality in Start node - Added new input options for webhook handling, including input modes (Custom Text, No Input, Full Webhook Payload) and response modes (Synchronous, Asynchronous, Streaming). - Implemented request signature verification and callback URL handling for asynchronous responses. - Updated validation logic for webhook requests, including content type and required headers. - Enhanced tests to cover new webhook features and validation scenarios --- .../components/nodes/agentflow/Start/Start.ts | 162 ++++++++++++-- packages/server/src/Interface.ts | 3 +- .../src/controllers/webhook/index.test.ts | 204 ++++++++++++++++-- .../server/src/controllers/webhook/index.ts | 70 ++++-- .../src/services/export-import/index.ts | 2 + .../server/src/services/predictions/index.ts | 5 +- .../server/src/services/webhook/index.test.ts | 196 +++++++++++++---- packages/server/src/services/webhook/index.ts | 145 ++++++++----- packages/server/src/utils/buildAgentflow.ts | 48 ++++- .../dialog/ViewMessagesDialog.jsx | 10 +- packages/ui/src/utils/genericHelper.js | 8 + .../agentexecutions/NodeExecutionDetails.jsx | 4 +- .../src/views/agentflowsv2/AgentFlowNode.jsx | 6 +- .../ui/src/views/canvas/NodeInputHandler.jsx | 44 +++- 14 files changed, 738 insertions(+), 169 deletions(-) diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 699c40285bf..6f3fc709162 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -210,15 +210,72 @@ class Start_Agentflow implements INode { startInputType: 'webhookTrigger' } }, + { + label: 'Input Mode', + name: 'webhookInputMode', + type: 'options', + description: 'What this Start node passes as input to the rest of the flow when a webhook fires.', + options: [ + { + label: 'Custom Text', + name: 'text', + description: + 'Pass a fixed string. Reference webhook fields with $webhook.body.* / $webhook.headers.* / $webhook.query.*' + }, + { + label: 'No Input', + name: 'none', + description: 'Pass nothing. Use $webhook.* references inside downstream node configs to access the payload.' + }, + { + label: 'Full Webhook Payload', + name: 'payload', + description: + 'Pass the full JSON-serialized webhook payload (body, headers, query). Useful for debugging; bloats LLM context.' + } + ], + default: 'text', + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Custom Text', + name: 'webhookDefaultInput', + type: 'string', + rows: 3, + placeholder: 'Answer user question: {{ $webhook.body.question }}', + description: + 'Text passed to downstream nodes as the user input. Use {{ $webhook.body.* }}, {{ $webhook.headers.* }}, or {{ $webhook.query.* }} to interpolate fields from the incoming request.', + optional: true, + acceptVariable: true, + show: { + startInputType: 'webhookTrigger', + webhookInputMode: 'text' + } + }, + { + label: 'Verify request signature', + name: 'webhookEnableAuth', + type: 'boolean', + description: + 'Reject incoming requests that do not include a valid signature. Turn this on if your sender (GitHub, Stripe, Slack, GitLab, etc.) signs each request with a shared secret, then generate a secret below and copy it to the sender. Leave off for testing or trusted networks.', + default: false, + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, { label: 'Webhook Secret', name: 'webhookSecret', type: 'string', description: - 'Optional secret used to verify incoming requests. When set, configure Signature Header and Signature Type below to match your sender.', + 'Click Generate Secret to create a random shared secret, then copy it into your sender so it can sign each request. Use Signature Header and Signature Type below to match how your sender delivers the signature.', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookEnableAuth: true } }, { @@ -230,7 +287,8 @@ class Start_Agentflow implements INode { placeholder: 'x-webhook-signature', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookEnableAuth: true } }, { @@ -245,6 +303,37 @@ class Start_Agentflow implements INode { ], default: 'hmac-sha256', optional: true, + show: { + startInputType: 'webhookTrigger', + webhookEnableAuth: true + } + }, + { + label: 'Response Mode', + name: 'webhookResponseMode', + type: 'options', + description: 'How Flowise replies to the incoming webhook request.', + options: [ + { + label: 'Synchronous', + name: 'sync', + description: + 'Wait for the flow to finish and return the full result as JSON. Simple but blocks the caller; can time out for senders with short HTTP windows.' + }, + { + label: 'Asynchronous (callback)', + name: 'async', + description: + 'Return 202 Accepted immediately and run the flow in the background. Set a Callback URL below to have the result POSTed there when the flow finishes; leave it blank for fire-and-forget. Best for senders with short HTTP timeouts.' + }, + { + label: 'Streaming (SSE)', + name: 'stream', + description: + 'Return a Server-Sent Events stream so the caller sees tokens and agent steps as they happen. Best for custom callers (browsers using fetch+ReadableStream, internal services). NOT compatible with senders that expect a single quick response.' + } + ], + default: 'sync', show: { startInputType: 'webhookTrigger' } @@ -254,11 +343,12 @@ class Start_Agentflow implements INode { name: 'callbackUrl', type: 'string', description: - 'If set, Flowise returns 202 immediately and POSTs the result to this URL when the flow finishes. Useful for platforms with strict HTTP timeout windows (GitHub, Slack, Zapier).', + 'Optional. Flowise will POST the flow result to this URL when the flow finishes. Leave blank for fire-and-forget — the flow still runs in the background, but no callback is delivered.', placeholder: 'https://example.com/flowise-callback', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookResponseMode: 'async' } }, { @@ -266,7 +356,20 @@ class Start_Agentflow implements INode { name: 'callbackSecret', type: 'string', description: - 'If set, outgoing callback POSTs are signed with HMAC-SHA256. The signature is sent as X-Flowise-Signature: sha256= so your callback endpoint can verify the request came from Flowise.', + 'Optional. If set, outgoing callback POSTs are signed with HMAC-SHA256 and delivered as X-Flowise-Signature: sha256= so your callback endpoint can verify the request came from Flowise.', + optional: true, + show: { + startInputType: 'webhookTrigger', + webhookResponseMode: 'async' + } + }, + { + label: 'Validate request shape', + name: 'webhookEnableValidation', + type: 'boolean', + description: + 'Reject requests that are missing required headers, body fields, or query parameters declared below. Turn this on to enforce a request contract and catch bad requests early. Leave off to accept any payload and let the flow handle validation itself.', + default: false, optional: true, show: { startInputType: 'webhookTrigger' @@ -279,7 +382,8 @@ class Start_Agentflow implements INode { type: 'array', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookEnableValidation: true }, array: [ { @@ -302,7 +406,8 @@ class Start_Agentflow implements INode { type: 'array', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookEnableValidation: true }, array: [ { @@ -370,7 +475,8 @@ class Start_Agentflow implements INode { type: 'array', optional: true, show: { - startInputType: 'webhookTrigger' + startInputType: 'webhookTrigger', + webhookEnableValidation: true }, array: [ { @@ -707,17 +813,37 @@ class Start_Agentflow implements INode { } if (startInputType === 'webhookTrigger') { - inputData.webhook = input - let webhookOutput: string | Record = input - try { - webhookOutput = typeof input === 'string' ? JSON.parse(input) : input - } catch (_) { - /* keep as-is */ + const webhookInputMode = (nodeData.inputs?.webhookInputMode as string) || 'text' + + // Always preserve the webhook payload in inputData/outputData so downstream nodes can + // reference $webhook.* and human-input resume can restore the original trigger data. + // The runtime fallback is the authoritative source when set (text/none modes don't pass + // the payload through `input`); otherwise parse it back from the JSON string `input`. + let webhookPayload: any = + options.agentflowRuntime?.webhook && Object.keys(options.agentflowRuntime.webhook).length + ? options.agentflowRuntime.webhook + : input + if (typeof webhookPayload === 'string') { + try { + webhookPayload = JSON.parse(webhookPayload) + } catch (_) { + /* leave as string if not valid JSON */ + } } - if (options.agentflowRuntime?.webhook && Object.keys(options.agentflowRuntime.webhook).length) { - webhookOutput = options.agentflowRuntime.webhook + inputData.webhook = webhookPayload + outputData.webhook = webhookPayload + + if (webhookInputMode === 'none') { + // Single-space sentinel — same convention as scheduleInputMode='none'. + inputData.question = ' ' + outputData.question = ' ' + } else if (webhookInputMode === 'text') { + // executeAgentFlow pre-resolves $webhook.* refs and passes the result as `input`. + const resolved = (typeof input === 'string' && input) || ' ' + inputData.question = resolved + outputData.question = resolved } - outputData.webhook = webhookOutput + // mode='payload' — webhook is exposed via outputData.webhook; no `question` is set. } if (startInputType === 'scheduleInput') { diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 27414117ce5..b14a43b05dd 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -32,7 +32,8 @@ export enum ChatType { EXTERNAL = 'EXTERNAL', EVALUATION = 'EVALUATION', MCP = 'MCP', - SCHEDULED = 'SCHEDULED' + SCHEDULED = 'SCHEDULED', + WEBHOOK = 'WEBHOOK' } export enum ChatMessageRatingType { diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index 3f06ed02dbb..cdc9c2dcac0 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -1,10 +1,18 @@ import { StatusCodes } from 'http-status-codes' import { Request, Response, NextFunction } from 'express' +import { ChatType } from '../../Interface' const mockValidateWebhookChatflow = jest.fn() const mockBuildChatflow = jest.fn() const mockDispatchCallback = jest.fn() const mockCheckDenyList = jest.fn() +const mockCheckIfChatflowIsValidForStreaming = jest.fn() +const mockSseStreamer = { + addExternalClient: jest.fn(), + streamMetadataEvent: jest.fn(), + streamErrorEvent: jest.fn(), + removeClient: jest.fn() +} jest.mock('../../services/webhook', () => ({ __esModule: true, @@ -14,6 +22,10 @@ jest.mock('../../services/predictions', () => ({ __esModule: true, default: { buildChatflow: mockBuildChatflow } })) +jest.mock('../../services/chatflows', () => ({ + __esModule: true, + default: { checkIfChatflowIsValidForStreaming: mockCheckIfChatflowIsValidForStreaming } +})) jest.mock('../../utils/rateLimit', () => ({ RateLimiterManager: { getInstance: () => ({ @@ -24,10 +36,17 @@ jest.mock('../../utils/rateLimit', () => ({ jest.mock('../../utils/callbackDispatcher', () => ({ dispatchCallback: mockDispatchCallback })) +jest.mock('../../utils/getRunningExpressApp', () => ({ + getRunningExpressApp: () => ({ sseStreamer: mockSseStreamer }) +})) jest.mock('flowise-components', () => ({ checkDenyList: (url: string) => mockCheckDenyList(url) })) jest.mock('uuid', () => ({ v4: () => 'generated-uuid' })) +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } +})) import webhookController from './index' @@ -54,8 +73,8 @@ const mockNext = (): NextFunction => jest.fn() describe('createWebhook', () => { beforeEach(() => { jest.clearAllMocks() - // Default: no callback config on Start node - mockValidateWebhookChatflow.mockResolvedValue({}) + // Default: synchronous response mode + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'sync' as const }) }) it('calls next with PRECONDITION_FAILED when id is missing', async () => { @@ -100,7 +119,8 @@ describe('createWebhook', () => { query: expect.any(Object) } } - }) + }), + ChatType.WEBHOOK ) }) @@ -126,7 +146,8 @@ describe('createWebhook', () => { query: { page: '2' } } } - }) + }), + ChatType.WEBHOOK ) }) @@ -216,14 +237,15 @@ describe('createWebhook', () => { chatId: 'abc123', webhook: expect.any(Object) }) - }) + }), + ChatType.WEBHOOK ) }) // --- Async callback (FLOWISE-367) --- it('returns 202 immediately when callbackUrl is configured on Start node', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -240,7 +262,7 @@ describe('createWebhook', () => { }) it('returns 202 with chatId from body when already provided', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -257,7 +279,7 @@ describe('createWebhook', () => { }) it('generates a chatId when not in body and callback URL is present', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -272,7 +294,7 @@ describe('createWebhook', () => { }) it('dispatches SUCCESS callback when flow completes without action', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) const apiResponse = { text: 'hello', executionId: 'exec-1' } mockBuildChatflow.mockResolvedValue(apiResponse) mockDispatchCallback.mockResolvedValue(undefined) @@ -291,7 +313,7 @@ describe('createWebhook', () => { }) it('dispatches STOPPED callback when flow has action (HITL pause)', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) const action = { id: 'act-1', mapping: { approve: 'Proceed', reject: 'Reject' }, elements: [] } const apiResponse = { text: 'waiting', executionId: 'exec-2', action } mockBuildChatflow.mockResolvedValue(apiResponse) @@ -315,7 +337,7 @@ describe('createWebhook', () => { }) it('dispatches ERROR callback when flow throws', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' }) mockBuildChatflow.mockRejectedValue(new Error('flow exploded')) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -333,7 +355,11 @@ describe('createWebhook', () => { }) it('uses callbackSecret from Start node config when signing', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://cb.example.com', callbackSecret: 'node-secret' }) + mockValidateWebhookChatflow.mockResolvedValue({ + responseMode: 'async' as const, + callbackUrl: 'https://cb.example.com', + callbackSecret: 'node-secret' + }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -347,7 +373,10 @@ describe('createWebhook', () => { }) it('uses callbackUrl from Start node config when no header is present', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node-configured.example.com/cb' }) + mockValidateWebhookChatflow.mockResolvedValue({ + responseMode: 'async' as const, + callbackUrl: 'https://node-configured.example.com/cb' + }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) mockDispatchCallback.mockResolvedValue(undefined) jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) @@ -362,7 +391,10 @@ describe('createWebhook', () => { }) it('calls next with BAD_REQUEST when callbackUrl is denied by SSRF policy', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'http://169.254.169.254/latest/meta-data/' }) + mockValidateWebhookChatflow.mockResolvedValue({ + responseMode: 'async' as const, + callbackUrl: 'http://169.254.169.254/latest/meta-data/' + }) mockCheckDenyList.mockRejectedValue(new Error('Access to this host is denied by policy.')) const req = mockReq() const res = mockRes() @@ -375,7 +407,7 @@ describe('createWebhook', () => { }) it('calls next with BAD_REQUEST when node callbackUrl is not a valid http/https URL', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'ftp://bad.example.com' }) + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'ftp://bad.example.com' }) const req = mockReq() const res = mockRes() const next = mockNext() @@ -386,7 +418,7 @@ describe('createWebhook', () => { expect(mockBuildChatflow).not.toHaveBeenCalled() }) - it('falls back to synchronous response when no callbackUrl is configured', async () => { + it('returns synchronous JSON response when responseMode is sync', async () => { const apiResult = { text: 'sync result' } mockBuildChatflow.mockResolvedValue(apiResult) @@ -400,4 +432,144 @@ describe('createWebhook', () => { expect(res.json).toHaveBeenCalledWith(apiResult) expect(mockDispatchCallback).not.toHaveBeenCalled() }) + + // --- Fire-and-forget mode (async on, no callback URL) --- + + it('returns 202 immediately in fire-and-forget mode (async on, no callback URL)', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.status).toHaveBeenCalledWith(202) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'PROCESSING' })) + }) + + it('does not dispatch a callback in fire-and-forget mode even on success', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + // setImmediate hasn't fired yet — flush microtasks + await new Promise((r) => setImmediate(r)) + await new Promise((r) => setImmediate(r)) + + expect(mockDispatchCallback).not.toHaveBeenCalled() + }) + + it('still runs the flow in fire-and-forget mode (does not skip buildChatflow)', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + await new Promise((r) => setImmediate(r)) + await new Promise((r) => setImmediate(r)) + + expect(mockBuildChatflow).toHaveBeenCalled() + }) + + it('does not validate URL or hit deny list when async is on without a callback URL', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const }) + mockBuildChatflow.mockResolvedValue({ text: 'done' }) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockCheckDenyList).not.toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + }) + + // --- Streaming response mode (SSE) --- + + it('opens an SSE stream when responseMode is stream and chatflow is streamable', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const }) + mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true }) + mockBuildChatflow.mockResolvedValue({ chatId: 'generated-uuid', text: 'streamed' }) + + const req = mockReq() + const res = mockRes() + // flushHeaders/setHeader aren't on the default mockRes — stub them. + ;(res as any).setHeader = jest.fn() + ;(res as any).flushHeaders = jest.fn() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect((res as any).setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream') + expect((res as any).flushHeaders).toHaveBeenCalled() + expect(mockSseStreamer.addExternalClient).toHaveBeenCalledWith('generated-uuid', res) + expect(mockBuildChatflow).toHaveBeenCalled() + expect(mockSseStreamer.streamMetadataEvent).toHaveBeenCalled() + expect(mockSseStreamer.removeClient).toHaveBeenCalledWith('generated-uuid') + }) + + it('sets streaming=true on req.body before invoking buildChatflow in stream mode', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const }) + mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true }) + mockBuildChatflow.mockResolvedValue({}) + + const req = mockReq() + const res = mockRes() + ;(res as any).setHeader = jest.fn() + ;(res as any).flushHeaders = jest.fn() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockBuildChatflow).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ streaming: true }) }), + ChatType.WEBHOOK + ) + }) + + it('emits an error event and closes the stream when buildChatflow rejects', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const }) + mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true }) + mockBuildChatflow.mockRejectedValue(new Error('flow blew up')) + + const req = mockReq() + const res = mockRes() + ;(res as any).setHeader = jest.fn() + ;(res as any).flushHeaders = jest.fn() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockSseStreamer.streamErrorEvent).toHaveBeenCalledWith('generated-uuid', expect.stringContaining('flow blew up')) + expect(mockSseStreamer.removeClient).toHaveBeenCalledWith('generated-uuid') + // next() must NOT be called — headers were already flushed; calling it would attempt a second response. + expect(next).not.toHaveBeenCalled() + }) + + it('falls back to synchronous JSON when responseMode is stream but chatflow is not streamable', async () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const }) + mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: false }) + mockBuildChatflow.mockResolvedValue({ text: 'sync fallback' }) + + const req = mockReq() + const res = mockRes() + ;(res as any).setHeader = jest.fn() + ;(res as any).flushHeaders = jest.fn() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockSseStreamer.addExternalClient).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith({ text: 'sync fallback' }) + }) }) diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index 59cda6c3434..8a2f6d1079f 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -3,11 +3,15 @@ import { StatusCodes } from 'http-status-codes' import { v4 as uuidv4 } from 'uuid' import { RateLimiterManager } from '../../utils/rateLimit' import predictionsServices from '../../services/predictions' +import chatflowsService from '../../services/chatflows' import webhookService from '../../services/webhook' +import { ChatType } from '../../Interface' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { checkDenyList } from 'flowise-components' import { dispatchCallback } from '../../utils/callbackDispatcher' import { getErrorMessage } from '../../errors/utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import logger from '../../utils/logger' const createWebhook = async (req: Request, res: Response, next: NextFunction) => { try { @@ -31,7 +35,7 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => const isResume = body?.humanInput != null - const { callbackUrl: nodeCallbackUrl, callbackSecret } = await webhookService.validateWebhookChatflow( + const { responseMode, callbackUrl, callbackSecret } = await webhookService.validateWebhookChatflow( req.params.id, workspaceId, body, @@ -42,8 +46,6 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => isResume ? { skipFieldValidation: true } : undefined ) - const callbackUrl: string | undefined = nodeCallbackUrl - // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist req.body = { webhook: { @@ -58,13 +60,49 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => if (bodyChatId != null) req.body.chatId = bodyChatId if (sessionId != null) req.body.sessionId = sessionId - if (callbackUrl) { - try { - const parsed = new URL(callbackUrl) - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() - await checkDenyList(callbackUrl) - } catch { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid and safe http or https URL`) + if (responseMode === 'stream') { + // Streaming mode: open an SSE channel and let downstream nodes push events through sseStreamer + // Falls back to synchronous JSON if the chatflow has no streaming-capable end nodes + const streamable = await chatflowsService.checkIfChatflowIsValidForStreaming(req.params.id) + if (streamable?.isStreaming) { + const sseStreamer = getRunningExpressApp().sseStreamer + const chatId: string = (bodyChatId as string | undefined) ?? uuidv4() + req.body.chatId = chatId + req.body.streaming = true + + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + sseStreamer.addExternalClient(chatId, res) + + try { + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) + sseStreamer.streamMetadataEvent(chatId, apiResponse) + } catch (err: any) { + sseStreamer.streamErrorEvent(chatId, getErrorMessage(err)) + } finally { + sseStreamer.removeClient(chatId) + } + return + } + } + + if (responseMode === 'async') { + // Validate the callback URL only when one was provided. Without a URL, the flow runs + // fire-and-forget — the 202 still goes out, but no callback is delivered when it finishes. + if (callbackUrl) { + try { + const parsed = new URL(callbackUrl) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() + await checkDenyList(callbackUrl) + } catch { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + `Invalid callbackUrl: must be a valid and safe http or https URL` + ) + } } // Pre-assign chatId so the 202 response and the background execution share the same ID @@ -75,7 +113,9 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => setImmediate(async () => { try { - const apiResponse = await predictionsServices.buildChatflow(req) + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) + + if (!callbackUrl) return // fire-and-forget — no delivery // apiResponse.action is the parsed humanInputAction — only present when flow is STOPPED (FLOWISE-387) if (apiResponse.action) { @@ -92,13 +132,17 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => await dispatchCallback(callbackUrl, { status: 'SUCCESS', chatId, data: apiResponse }, callbackSecret) } } catch (err: any) { - await dispatchCallback(callbackUrl, { status: 'ERROR', chatId, error: getErrorMessage(err) }, callbackSecret) + if (callbackUrl) { + await dispatchCallback(callbackUrl, { status: 'ERROR', chatId, error: getErrorMessage(err) }, callbackSecret) + } else { + logger.error(`[webhookController] fire-and-forget execution failed for chatId=${chatId}: ${getErrorMessage(err)}`) + } } }) return } - const apiResponse = await predictionsServices.buildChatflow(req) + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) return res.json(apiResponse) } catch (error) { next(error) diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 5c4e4cc43e1..0a0a60f1685 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -953,6 +953,8 @@ const getChatType = (chatType?: ChatType): string => { return 'MCP' case ChatType.SCHEDULED: return 'Scheduled' + case ChatType.WEBHOOK: + return 'Webhook' } } diff --git a/packages/server/src/services/predictions/index.ts b/packages/server/src/services/predictions/index.ts index 5d1d71ec098..0dc7f225ea2 100644 --- a/packages/server/src/services/predictions/index.ts +++ b/packages/server/src/services/predictions/index.ts @@ -1,12 +1,13 @@ import { Request } from 'express' import { StatusCodes } from 'http-status-codes' import { utilBuildChatflow } from '../../utils/buildChatflow' +import { ChatType } from '../../Interface' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' -const buildChatflow = async (req: Request) => { +const buildChatflow = async (req: Request, chatType?: ChatType) => { try { - const dbResponse = await utilBuildChatflow(req) + const dbResponse = await utilBuildChatflow(req, false, chatType) return dbResponse } catch (error) { throw new InternalFlowiseError( diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts index bc1dfb27af2..3e7b9e80245 100644 --- a/packages/server/src/services/webhook/index.test.ts +++ b/packages/server/src/services/webhook/index.test.ts @@ -19,6 +19,11 @@ const makeChatflow = ( webhookContentType?: string webhookHeaderParams?: unknown webhookQueryParams?: unknown + webhookEnableAuth?: boolean + webhookEnableCallback?: boolean + webhookEnableValidation?: boolean + callbackUrl?: string + callbackSecret?: string }, entityFields?: { webhookSecretConfigured?: boolean } ) => ({ @@ -105,7 +110,7 @@ describe('validateWebhookChatflow', () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' })) await expect( - webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' }) + webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { 'content-type': 'text/plain' }) ).rejects.toMatchObject({ statusCode: StatusCodes.UNSUPPORTED_MEDIA_TYPE }) }) @@ -113,7 +118,9 @@ describe('validateWebhookChatflow', () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' })) await expect( - webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'application/json; charset=utf-8' }) + webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { + 'content-type': 'application/json; charset=utf-8' + }) ).resolves.toMatchObject({}) }) @@ -121,15 +128,22 @@ describe('validateWebhookChatflow', () => { mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) await expect( - webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' }) + webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { 'content-type': 'text/plain' }) ).resolves.toMatchObject({}) }) + it('skips Content-Type check when request has no body (empty POST ping)', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' })) + + // Empty body, no Content-Type header — common for Postman pings before a body is filled in. + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).resolves.toMatchObject({}) + }) + // --- Header validation --- it('throws 400 when a required header is missing', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).rejects.toMatchObject({ @@ -140,7 +154,7 @@ describe('validateWebhookChatflow', () => { it('resolves when all required headers are present', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) ) await expect( @@ -151,7 +165,9 @@ describe('validateWebhookChatflow', () => { // --- Body param validation --- it('throws 400 when a required param is missing from body', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({ statusCode: StatusCodes.BAD_REQUEST @@ -159,7 +175,9 @@ describe('validateWebhookChatflow', () => { }) it('includes the missing field name in the 400 error message', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({ statusCode: StatusCodes.BAD_REQUEST, @@ -168,13 +186,15 @@ describe('validateWebhookChatflow', () => { }) it('resolves when all required params are present in body', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toMatchObject({}) }) it('resolves when webhookBodyParams is empty string (DB default)', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: '' })) + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: '' })) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toMatchObject({}) }) @@ -189,7 +209,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when a declared body param has wrong type', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 'not-a-number' })).rejects.toMatchObject({ @@ -200,7 +223,10 @@ describe('validateWebhookChatflow', () => { it('resolves when declared body param has correct type', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toMatchObject({}) @@ -208,7 +234,10 @@ describe('validateWebhookChatflow', () => { it('resolves when number param is sent as a numeric string (form-encoded)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toMatchObject({}) @@ -216,7 +245,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when number param is an empty string (form-encoded)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '' })).rejects.toMatchObject({ @@ -227,7 +259,10 @@ describe('validateWebhookChatflow', () => { it('resolves when boolean param is a native boolean (JSON)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toMatchObject({}) @@ -235,7 +270,10 @@ describe('validateWebhookChatflow', () => { it('resolves when boolean param is the string "true" (form-encoded)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toMatchObject({}) @@ -243,7 +281,10 @@ describe('validateWebhookChatflow', () => { it('resolves when boolean param is the string "false" (form-encoded)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'false' })).resolves.toMatchObject({}) @@ -251,7 +292,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when boolean param is an invalid string like "yes" (form-encoded)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'yes' })).rejects.toMatchObject({ @@ -264,7 +308,10 @@ describe('validateWebhookChatflow', () => { it('resolves when object param is a plain object', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: { key: 'val' } })).resolves.toMatchObject({}) @@ -272,7 +319,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when object param is a string', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: 'hello' })).rejects.toMatchObject({ @@ -283,7 +333,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when object param is an array', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: [1, 2] })).rejects.toMatchObject({ @@ -296,7 +349,10 @@ describe('validateWebhookChatflow', () => { it('resolves when array[string] param is an array of strings', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: ['a', 'b'] })).resolves.toMatchObject({}) @@ -304,7 +360,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[string] param contains non-strings', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: [1, 2] })).rejects.toMatchObject({ @@ -315,7 +374,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[string] param is not an array', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: 'hello' })).rejects.toMatchObject({ @@ -328,7 +390,10 @@ describe('validateWebhookChatflow', () => { it('resolves when array[number] param is an array of numbers', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: [1, 2, 3] })).resolves.toMatchObject({}) @@ -336,7 +401,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[number] param contains non-numbers', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: ['a', 'b'] })).rejects.toMatchObject({ @@ -349,7 +417,10 @@ describe('validateWebhookChatflow', () => { it('resolves when array[boolean] param is an array of booleans', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: [true, false] })).resolves.toMatchObject({}) @@ -357,7 +428,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[boolean] param contains boolean strings (no coercion for array elements)', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: ['true', 'false'] })).rejects.toMatchObject({ @@ -370,7 +444,10 @@ describe('validateWebhookChatflow', () => { it('resolves when array[object] param is an array of plain objects', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [{ a: 1 }, { b: 2 }] })).resolves.toMatchObject( @@ -380,7 +457,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[object] param contains non-objects', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: ['a', 'b'] })).rejects.toMatchObject({ @@ -391,7 +471,10 @@ describe('validateWebhookChatflow', () => { it('throws 400 when array[object] param contains nested arrays', async () => { mockGetChatflowById.mockResolvedValue( - makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] }) + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] + }) ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [[1, 2]] })).rejects.toMatchObject({ @@ -403,7 +486,9 @@ describe('validateWebhookChatflow', () => { // --- Query param validation --- it('throws 400 when a required query param is missing', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookQueryParams: [{ name: 'page', required: true }] }) + ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {})).rejects.toMatchObject({ statusCode: StatusCodes.BAD_REQUEST, @@ -412,7 +497,9 @@ describe('validateWebhookChatflow', () => { }) it('resolves when all required query params are present', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookQueryParams: [{ name: 'page', required: true }] }) + ) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toMatchObject({}) }) @@ -434,7 +521,9 @@ describe('validateWebhookChatflow', () => { }) it('resolves when secret is set and signature is valid', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } @@ -444,16 +533,35 @@ describe('validateWebhookChatflow', () => { }) it('throws 401 when secret is set but X-Webhook-Signature header is missing', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).rejects.toMatchObject({ - statusCode: 401 + statusCode: 401, + message: expect.stringContaining('Missing signature header') }) }) + it('throws 500 with config hint when auth is enabled but no secret has been generated', async () => { + // Toggle on, but getWebhookSecret returns null (no secret generated yet) + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookEnableAuth: true })) + mockGetWebhookSecret.mockResolvedValue(null) + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)).rejects.toMatchObject( + { + statusCode: 500, + message: expect.stringContaining('Generate Secret') + } + ) + }) + it('throws 401 when secret is set but signature is wrong', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) const headers = { 'x-webhook-signature': 'deadbeef' } @@ -465,7 +573,9 @@ describe('validateWebhookChatflow', () => { }) it('throws 401 when payload is tampered (signature computed against original body)', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) const tamperedBody = Buffer.from('{"event":"delete"}') const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } @@ -476,7 +586,9 @@ describe('validateWebhookChatflow', () => { }) it('throws 401 when secret is set but rawBody is undefined', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } @@ -488,7 +600,9 @@ describe('validateWebhookChatflow', () => { // --- skipFieldValidation option (resume calls) --- it('skips field validation when skipFieldValidation is true', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) // Missing required body param 'action' — would normally throw 400, but not on resume await expect( @@ -497,7 +611,9 @@ describe('validateWebhookChatflow', () => { }) it('still runs signature check when skipFieldValidation is true', async () => { - mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) mockGetWebhookSecret.mockResolvedValue(SECRET) // No signature header — should still 401 even with skipFieldValidation diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts index b7254bd5981..e76486e7ba7 100644 --- a/packages/server/src/services/webhook/index.ts +++ b/packages/server/src/services/webhook/index.ts @@ -14,7 +14,7 @@ const validateWebhookChatflow = async ( query?: Record, rawBody?: Buffer, options?: { skipFieldValidation?: boolean } -): Promise<{ callbackUrl?: string; callbackSecret?: string }> => { +): Promise<{ responseMode: 'sync' | 'async' | 'stream'; callbackUrl?: string; callbackSecret?: string }> => { try { const chatflow = await chatflowsService.getChatflowById(chatflowId, workspaceId) if (!chatflow) { @@ -29,31 +29,49 @@ const validateWebhookChatflow = async ( throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} is not configured as a webhook trigger`) } - const callbackUrl = (startNode?.data?.inputs?.callbackUrl as string | undefined) || undefined - const callbackSecret = (startNode?.data?.inputs?.callbackSecret as string | undefined) || undefined + const enableAuth = startNode?.data?.inputs?.webhookEnableAuth === true + const enableValidation = startNode?.data?.inputs?.webhookEnableValidation === true + // 'sync' (default) returns JSON when the flow finishes, 'async' returns 202 + optional + // callback POST, 'stream' returns an SSE stream of token/step events. + const rawResponseMode = startNode?.data?.inputs?.webhookResponseMode as string | undefined + const responseMode: 'sync' | 'async' | 'stream' = + rawResponseMode === 'async' || rawResponseMode === 'stream' ? rawResponseMode : 'sync' + + // callbackUrl is only meaningful in async mode — when omitted there, the flow runs + // fire-and-forget (202 returned, no callback delivered). + const callbackUrl = responseMode === 'async' ? (startNode?.data?.inputs?.callbackUrl as string | undefined) || undefined : undefined + const callbackSecret = + responseMode === 'async' ? (startNode?.data?.inputs?.callbackSecret as string | undefined) || undefined : undefined // Signature verification (runs before any other validation to fail-fast on bad auth) - if (chatflow.webhookSecretConfigured) { + if (enableAuth) { + const secret = await chatflowsService.getWebhookSecret(chatflowId, chatflow.workspaceId) + if (!secret) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + 'Webhook signature verification is enabled but no secret has been generated. Open the Start node and click Generate Secret.' + ) + } + const sigHeader = ((startNode?.data?.inputs?.webhookSignatureHeader as string) || 'x-webhook-signature').toLowerCase() const sigType = (startNode?.data?.inputs?.webhookSignatureType as string) || 'hmac-sha256' const sigValue = (headers?.[sigHeader] ?? '') as string if (!sigValue) { - throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid or missing webhook signature') + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Missing signature header: ${sigHeader}`) } - const secret = await chatflowsService.getWebhookSecret(chatflowId, chatflow.workspaceId) const valid = sigType === 'plain-token' - ? !!secret && verifyPlainToken(secret, sigValue) - : !!secret && !!rawBody && verifyWebhookSignature(secret, rawBody, sigValue) + ? verifyPlainToken(secret, sigValue) + : !!rawBody && verifyWebhookSignature(secret, rawBody, sigValue) if (!valid) { - throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid or missing webhook signature') + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid webhook signature') } } - if (options?.skipFieldValidation) return { callbackUrl, callbackSecret } + if (options?.skipFieldValidation) return { responseMode, callbackUrl, callbackSecret } // Method validation const webhookMethod = startNode?.data?.inputs?.webhookMethod @@ -61,66 +79,79 @@ const validateWebhookChatflow = async ( throw new InternalFlowiseError(StatusCodes.METHOD_NOT_ALLOWED, `Method ${method} not allowed. Expected ${webhookMethod}`) } - // Content-Type validation (startsWith handles "application/json; charset=utf-8" variants) + // Content-Type validation + const hasBody = (rawBody && rawBody.length > 0) || (body != null && Object.keys(body).length > 0) const webhookContentType = startNode?.data?.inputs?.webhookContentType const incomingContentType = (headers?.['content-type'] ?? '').toLowerCase() - if (webhookContentType && !incomingContentType.startsWith(webhookContentType)) { + if (webhookContentType && hasBody && !incomingContentType.startsWith(webhookContentType)) { throw new InternalFlowiseError( StatusCodes.UNSUPPORTED_MEDIA_TYPE, `Content-Type ${headers?.['content-type']} not allowed. Expected ${webhookContentType}` ) } - // Required header validation - const rawHeaderParams = startNode?.data?.inputs?.webhookHeaderParams - const webhookHeaderParams: Array<{ name: string; required: boolean }> = Array.isArray(rawHeaderParams) ? rawHeaderParams : [] - const missingHeaders = webhookHeaderParams.filter((p) => p.required && headers?.[p.name.toLowerCase()] == null).map((p) => p.name) - if (missingHeaders.length > 0) { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required headers: ${missingHeaders.join(', ')}`) - } + // Header / body / query shape validation runs only when the user has explicitly opted in. + if (enableValidation) { + // Required header validation + const rawHeaderParams = startNode?.data?.inputs?.webhookHeaderParams + const webhookHeaderParams: Array<{ name: string; required: boolean }> = Array.isArray(rawHeaderParams) ? rawHeaderParams : [] + const missingHeaders = webhookHeaderParams + .filter((p) => p.required && headers?.[p.name.toLowerCase()] == null) + .map((p) => p.name) + if (missingHeaders.length > 0) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required headers: ${missingHeaders.join(', ')}`) + } - // Required body param validation - const rawBodyParams = startNode?.data?.inputs?.webhookBodyParams - const webhookBodyParams: Array<{ name: string; type: string; required: boolean }> = Array.isArray(rawBodyParams) - ? rawBodyParams - : [] - const missingParams = webhookBodyParams.filter((p) => p.required && body?.[p.name] == null).map((p) => p.name) - if (missingParams.length > 0) { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required webhook body parameters: ${missingParams.join(', ')}`) - } + // Required body param validation + const rawBodyParams = startNode?.data?.inputs?.webhookBodyParams + const webhookBodyParams: Array<{ name: string; type: string; required: boolean }> = Array.isArray(rawBodyParams) + ? rawBodyParams + : [] + const missingParams = webhookBodyParams.filter((p) => p.required && body?.[p.name] == null).map((p) => p.name) + if (missingParams.length > 0) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + `Missing required webhook body parameters: ${missingParams.join(', ')}` + ) + } - // Body type validation (only for params that have an explicit type declared) - const typeMismatch = webhookBodyParams - .filter((p) => { - if (p.type == null || body?.[p.name] == null) return false - const val = body[p.name] - if (p.type === 'number') return val === '' || isNaN(Number(val)) - if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false' - if (p.type === 'object') return typeof val !== 'object' || val === null || Array.isArray(val) - if (p.type === 'array[string]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'string') - if (p.type === 'array[number]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'number') - if (p.type === 'array[boolean]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'boolean') - if (p.type === 'array[object]') - return ( - !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'object' || el === null || Array.isArray(el)) - ) - return typeof val !== p.type - }) - .map((p) => p.name) - - if (typeMismatch.length > 0) { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid type for parameter(s): ${typeMismatch.join(', ')}`) - } + // Body type validation (only for params that have an explicit type declared) + const typeMismatch = webhookBodyParams + .filter((p) => { + if (p.type == null || body?.[p.name] == null) return false + const val = body[p.name] + if (p.type === 'number') return val === '' || isNaN(Number(val)) + if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false' + if (p.type === 'object') return typeof val !== 'object' || val === null || Array.isArray(val) + if (p.type === 'array[string]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'string') + if (p.type === 'array[number]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'number') + if (p.type === 'array[boolean]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'boolean') + if (p.type === 'array[object]') + return ( + !Array.isArray(val) || + (val as unknown[]).some((el) => typeof el !== 'object' || el === null || Array.isArray(el)) + ) + return typeof val !== p.type + }) + .map((p) => p.name) + + if (typeMismatch.length > 0) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid type for parameter(s): ${typeMismatch.join(', ')}`) + } - // Required query param validation - const rawQueryParams = startNode?.data?.inputs?.webhookQueryParams - const webhookQueryParams: Array<{ name: string; required: boolean }> = Array.isArray(rawQueryParams) ? rawQueryParams : [] - const missingQueryParams = webhookQueryParams.filter((p) => p.required && query?.[p.name] == null).map((p) => p.name) - if (missingQueryParams.length > 0) { - throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required query parameters: ${missingQueryParams.join(', ')}`) + // Required query param validation + const rawQueryParams = startNode?.data?.inputs?.webhookQueryParams + const webhookQueryParams: Array<{ name: string; required: boolean }> = Array.isArray(rawQueryParams) ? rawQueryParams : [] + const missingQueryParams = webhookQueryParams.filter((p) => p.required && query?.[p.name] == null).map((p) => p.name) + if (missingQueryParams.length > 0) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + `Missing required query parameters: ${missingQueryParams.join(', ')}` + ) + } } - return { callbackUrl, callbackSecret } + return { responseMode, callbackUrl, callbackSecret } } catch (error) { if (error instanceof InternalFlowiseError) throw error throw new InternalFlowiseError( diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index b6f56b156ab..248d3ed82a2 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -98,6 +98,27 @@ interface IAgentFlowRuntime { webhook?: Record } +/** + * Resolves {{ $webhook.body.* }}, {{ $webhook.headers.* }}, {{ $webhook.query.* }} references in a + * template string against an incoming webhook payload. Used to pre-resolve webhookDefaultInput + * before any node runs, so the Start node's run() and downstream finalInput see the same value. + * Unknown references are left as-is. + */ +const resolveWebhookRefs = (template: string, webhook: Record | undefined | null): string => { + if (!template) return '' + if (!webhook) return template + return template.replace(/{{(.*?)}}/g, (match, ref) => { + const path = ref.trim() + if (!path.startsWith('$webhook.')) return match + // Block prototype-walking paths defensively — lodash.get follows __proto__/constructor/prototype. + const subPath = path.replace('$webhook.', '') + if (/(^|\.)(__proto__|constructor|prototype)(\.|$)/.test(subPath)) return match + const val = get(webhook, subPath) + if (val == null) return match + return Array.isArray(val) || (typeof val === 'object' && val !== null) ? JSON.stringify(val) : String(val) + }) +} + interface IExecuteNodeParams { nodeId: string reactFlowNode: IReactFlowNode @@ -1571,10 +1592,8 @@ export const executeAgentFlow = async ({ const edges = parsedFlowData.edges const { graph, nodeDependencies } = constructGraphs(nodes, edges) const { graph: reversedGraph } = constructGraphs(nodes, edges, { isReversed: true }) - const startInputType = nodes.find((node) => node.data.name === 'startAgentflow')?.data.inputs?.startInputType as - | 'chatInput' - | 'formInput' - | 'webhookTrigger' + const startNode = nodes.find((node) => node.data.name === 'startAgentflow') + const startInputType = startNode?.data.inputs?.startInputType as 'chatInput' | 'formInput' | 'webhookTrigger' if (!startInputType && !isRecursive) { throw new Error('Start input type not found') } @@ -1719,6 +1738,20 @@ export const executeAgentFlow = async ({ } } + if (startInputType === 'webhookTrigger' && !humanInput) { + const webhookInputMode = (startNode?.data?.inputs?.webhookInputMode as string) || 'text' + if (webhookInputMode === 'text') { + const template = (startNode?.data?.inputs?.webhookDefaultInput as string) || '' + incomingInput.question = resolveWebhookRefs(template, incomingInput.webhook) || ' ' + } else if (webhookInputMode === 'none') { + incomingInput.question = ' ' + } + } + + if (incomingInput.webhook && Object.keys(incomingInput.webhook).length) { + agentflowRuntime.webhook = incomingInput.webhook + } + // If it is human input, find the last checkpoint and resume // Skip human input resumption for recursive iteration calls - they should start fresh if (humanInput && !(isRecursive && iterationContext)) { @@ -2301,7 +2334,12 @@ export const executeAgentFlow = async ({ finalUserInput = question || humanInput?.feedback || ' ' } } else if (startInputType === 'webhookTrigger') { - finalUserInput = incomingInput.webhook ? JSON.stringify(incomingInput.webhook) : ' ' + const webhookInputMode = (startNode?.data?.inputs?.webhookInputMode as string) || 'text' + if (webhookInputMode === 'payload') { + finalUserInput = incomingInput.webhook ? JSON.stringify(incomingInput.webhook) : ' ' + } else { + finalUserInput = humanInput?.feedback || incomingInput.question || ' ' + } } const userMessage: Omit = { diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index b6840f866e8..416206f0312 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -195,7 +195,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { const [sourceDialogProps, setSourceDialogProps] = useState({}) const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false) const [hardDeleteDialogProps, setHardDeleteDialogProps] = useState({}) - const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED']) + const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED', 'WEBHOOK']) const [feedbackTypeFilter, setFeedbackTypeFilter] = useState([]) const [startDate, setStartDate] = useState(new Date(new Date().setMonth(new Date().getMonth() - 1))) const [endDate, setEndDate] = useState(new Date()) @@ -351,6 +351,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return 'MCP' } else if (chatType === 'SCHEDULED') { return 'Scheduled' + } else if (chatType === 'WEBHOOK') { + return 'Webhook' } return 'API/Embed' } @@ -760,7 +762,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return () => { setChatLogs([]) setChatMessages([]) - setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED']) + setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED', 'WEBHOOK']) setFeedbackTypeFilter([]) setSelectedMessageIndex(0) setSelectedChatId('') @@ -918,6 +920,10 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { label: 'Scheduled', name: 'SCHEDULED' }, + { + label: 'Webhook', + name: 'WEBHOOK' + }, { label: 'Evaluations', name: 'EVALUATION' diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index 19ec4993792..c077d3bceae 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -347,6 +347,14 @@ export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNo initNewComponentNodeData.outputAnchors[0].options = newOptions } + // Recompute show/hide visibility against the merged inputs so conditional fields + if (initNewComponentNodeData.inputParams) { + initNewComponentNodeData.inputParams = showHideInputParams(initNewComponentNodeData) + } + if (initNewComponentNodeData.inputAnchors) { + initNewComponentNodeData.inputAnchors = showHideInputAnchors(initNewComponentNodeData) + } + return initNewComponentNodeData } diff --git a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx index 2ac1e38e67d..a12439d11dd 100644 --- a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx +++ b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx @@ -917,8 +917,8 @@ export const NodeExecutionDetails = ({ data, label, status, metadata, isPublic, )) ) : data?.name === 'toolAgentflow' && data?.input ? ( - ) : data?.input?.form || data?.input?.http || data?.input?.conditions ? ( - + ) : data?.input?.form || data?.input?.http || data?.input?.webhook || data?.input?.conditions ? ( + ) : data?.input?.code ? ( { const iconMap = { chatInput: { icon: }, formInput: { icon: }, - scheduleInput: { icon: } + scheduleInput: { icon: }, + webhookTrigger: { icon: } } const info = iconMap[inputType] if (!info) return null diff --git a/packages/ui/src/views/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx index 18c87f6ee31..91ee92492bb 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.jsx +++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx @@ -20,7 +20,8 @@ import { Dialog, DialogTitle, DialogContent, - DialogActions + DialogActions, + Alert } from '@mui/material' import IconAutoFixHigh from '@mui/icons-material/AutoFixHigh' import InputAdornment from '@mui/material/InputAdornment' @@ -1174,7 +1175,9 @@ const NodeInputHandler = ({ ) }} > - + {/* Match the Start node's accent green (#7EE787) so the copy + action reads as a "primary" action on this node. */} + @@ -1187,15 +1190,34 @@ const NodeInputHandler = ({ {!canvasChatflow?.webhookSecretConfigured && !webhookSecretPlaintext ? ( // Not configured - - - No secret configured - - {chatflowId && ( - - )} + + + Generate a secret below — without one, every incoming webhook request will be rejected. + + + + No secret configured + + {chatflowId && ( + + )} + ) : ( // Configured — show masked or plaintext field with actions From 68a64b332ecdaf229f27f0767d861583a4176c2c Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 4 May 2026 12:30:19 +0100 Subject: [PATCH 10/13] adds a generic SSE observer primitive and a webhook listener registry that lets the canvas watch incoming webhook executions live --- .../src/controllers/webhook-listener/index.ts | 108 +++ .../server/src/controllers/webhook/index.ts | 35 +- packages/server/src/index.ts | 4 + .../server/src/queue/RedisEventPublisher.ts | 39 +- .../server/src/queue/RedisEventSubscriber.ts | 40 +- packages/server/src/routes/index.ts | 2 + .../src/routes/webhook-listener/index.ts | 11 + .../src/services/webhook-listener/index.ts | 2 + .../src/services/webhook-listener/registry.ts | 229 +++++ packages/server/src/utils/SSEStreamer.ts | 94 +- packages/server/src/utils/redis.ts | 36 + packages/ui/src/api/webhooklistener.js | 6 + packages/ui/src/views/agentflowsv2/Canvas.jsx | 9 + .../webhooklistener/WebhookListenerDrawer.jsx | 836 ++++++++++++++++++ .../webhooklistener/WebhookListenerFAB.jsx | 88 ++ 15 files changed, 1450 insertions(+), 89 deletions(-) create mode 100644 packages/server/src/controllers/webhook-listener/index.ts create mode 100644 packages/server/src/routes/webhook-listener/index.ts create mode 100644 packages/server/src/services/webhook-listener/index.ts create mode 100644 packages/server/src/services/webhook-listener/registry.ts create mode 100644 packages/server/src/utils/redis.ts create mode 100644 packages/ui/src/api/webhooklistener.js create mode 100644 packages/ui/src/views/webhooklistener/WebhookListenerDrawer.jsx create mode 100644 packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx diff --git a/packages/server/src/controllers/webhook-listener/index.ts b/packages/server/src/controllers/webhook-listener/index.ts new file mode 100644 index 00000000000..1b307c31a7d --- /dev/null +++ b/packages/server/src/controllers/webhook-listener/index.ts @@ -0,0 +1,108 @@ +import { Request, Response, NextFunction } from 'express' +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { getWebhookListenerRegistry } from '../../services/webhook-listener' +import { IReactFlowObject } from '../../Interface' +import chatflowsService from '../../services/chatflows' +import logger from '../../utils/logger' + +const HEARTBEAT_MS = 30_000 + +const assertChatflowIsWebhookTriggered = async (chatflowid: string, workspaceId?: string) => { + const chatflow = await chatflowsService.getChatflowById(chatflowid, workspaceId) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowid} not found`) + } + const parsedFlowData: IReactFlowObject = JSON.parse(chatflow.flowData) + const startNode = parsedFlowData.nodes.find((node) => node.data.name === 'startAgentflow') + if (startNode?.data?.inputs?.startInputType !== 'webhookTrigger') { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Chatflow ${chatflowid} is not configured as a webhook trigger`) + } +} + +const registerListener = async (req: Request, res: Response, next: NextFunction) => { + try { + const chatflowid = req.params.id + if (!chatflowid) throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id is required') + + await assertChatflowIsWebhookTriggered(chatflowid, req.user?.activeWorkspaceId) + + const registry = getWebhookListenerRegistry() + const listenerId = await registry.register(chatflowid) + return res.json({ listenerId }) + } catch (error) { + next(error) + } +} + +const streamListener = async (req: Request, res: Response, next: NextFunction) => { + const chatflowid = req.params.id + const listenerId = req.params.listenerId + + try { + if (!chatflowid || !listenerId) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id and listener id are required') + } + + await assertChatflowIsWebhookTriggered(chatflowid, req.user?.activeWorkspaceId) + + const sseStreamer = getRunningExpressApp().sseStreamer + const registry = getWebhookListenerRegistry() + + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + sseStreamer.addClient(listenerId, res) + + // Initial "ready" beacon so the UI can flip from "connecting…" to "listening". + res.write( + 'message:\ndata:' + + JSON.stringify({ event: 'listenerReady', data: { listenerId, replicaId: registry.getReplicaId() } }) + + '\n\n' + ) + + // Heartbeat both keeps the SSE connection alive through proxies AND refreshes the + // registry TTL so the listener stays discoverable to incoming webhooks. + const heartbeat = setInterval(() => { + try { + res.write(':heartbeat\n\n') + registry.heartbeat(chatflowid, listenerId).catch(() => {}) + } catch { + /* connection already torn down */ + } + }, HEARTBEAT_MS) + + req.on('close', async () => { + clearInterval(heartbeat) + sseStreamer.removeClient(listenerId) + try { + await registry.unregister(chatflowid, listenerId) + } catch (err) { + logger.warn(`[webhookListener] Failed to unregister ${listenerId}: ${err}`) + } + }) + } catch (error) { + next(error) + } +} + +const unregisterListener = async (req: Request, res: Response, next: NextFunction) => { + try { + const chatflowid = req.params.id + const listenerId = req.params.listenerId + if (!chatflowid || !listenerId) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id and listener id are required') + } + const registry = getWebhookListenerRegistry() + await registry.unregister(chatflowid, listenerId) + return res.json({ ok: true }) + } catch (error) { + next(error) + } +} + +export default { registerListener, streamListener, unregisterListener } diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index 8a2f6d1079f..3291636502b 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -5,6 +5,7 @@ import { RateLimiterManager } from '../../utils/rateLimit' import predictionsServices from '../../services/predictions' import chatflowsService from '../../services/chatflows' import webhookService from '../../services/webhook' +import { getWebhookListenerRegistry } from '../../services/webhook-listener' import { ChatType } from '../../Interface' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { checkDenyList } from 'flowise-components' @@ -60,14 +61,23 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => if (bodyChatId != null) req.body.chatId = bodyChatId if (sessionId != null) req.body.sessionId = sessionId + const executionChatId: string = (bodyChatId as string | undefined) ?? uuidv4() + req.body.chatId = executionChatId + + // Mirror this execution's events to any UI panels currently listening to this flow. + try { + await getWebhookListenerRegistry().bindExecution(req.params.id, executionChatId) + } catch (err) { + logger.warn(`[webhookController] Failed to bind webhook listeners: ${getErrorMessage(err)}`) + } + if (responseMode === 'stream') { // Streaming mode: open an SSE channel and let downstream nodes push events through sseStreamer // Falls back to synchronous JSON if the chatflow has no streaming-capable end nodes const streamable = await chatflowsService.checkIfChatflowIsValidForStreaming(req.params.id) if (streamable?.isStreaming) { const sseStreamer = getRunningExpressApp().sseStreamer - const chatId: string = (bodyChatId as string | undefined) ?? uuidv4() - req.body.chatId = chatId + const chatId = executionChatId req.body.streaming = true res.setHeader('Content-Type', 'text/event-stream') @@ -105,9 +115,8 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => } } - // Pre-assign chatId so the 202 response and the background execution share the same ID - const chatId: string = (bodyChatId as string | undefined) ?? uuidv4() - req.body.chatId = chatId + // 202 response and the background execution share the pre-assigned executionChatId + const chatId = executionChatId res.status(202).json({ chatId, status: 'PROCESSING' }) @@ -115,7 +124,10 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => try { const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) - if (!callbackUrl) return // fire-and-forget — no delivery + if (!callbackUrl) { + getRunningExpressApp().sseStreamer.removeClient(chatId) + return // fire-and-forget — no delivery + } // apiResponse.action is the parsed humanInputAction — only present when flow is STOPPED (FLOWISE-387) if (apiResponse.action) { @@ -137,13 +149,20 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => } else { logger.error(`[webhookController] fire-and-forget execution failed for chatId=${chatId}: ${getErrorMessage(err)}`) } + } finally { + // Notify webhook listeners that this execution is done; their SSE connections stay open. + getRunningExpressApp().sseStreamer.removeClient(chatId) } }) return } - const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) - return res.json(apiResponse) + try { + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) + return res.json(apiResponse) + } finally { + getRunningExpressApp().sseStreamer.removeClient(executionChatId) + } } catch (error) { next(error) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2c6812bc03f..d260b827188 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -25,6 +25,7 @@ import { NodesPool } from './NodesPool' import { QueueManager } from './queue/QueueManager' import { ScheduleBeat } from './schedule/ScheduleBeat' import { RedisEventSubscriber } from './queue/RedisEventSubscriber' +import { initWebhookListenerRegistry } from './services/webhook-listener' import flowiseApiV1Router from './routes' import { UsageCacheManager } from './UsageCacheManager' import { getEncryptionKey, getNodeModulesPackagePath } from './utils' @@ -155,6 +156,9 @@ export class App { logger.info('🔗 [server]: Redis event subscriber connected successfully') } + await initWebhookListenerRegistry(this.sseStreamer, this.redisSubscriber) + logger.info('📡 [server]: Webhook listener registry initialized successfully') + // Init ScheduleBeat (works in both queue and non-queue mode) await ScheduleBeat.getInstance().init() logger.info('⏰ [server]: ScheduleBeat initialized successfully') diff --git a/packages/server/src/queue/RedisEventPublisher.ts b/packages/server/src/queue/RedisEventPublisher.ts index 2c839072ae3..88729e2ed65 100644 --- a/packages/server/src/queue/RedisEventPublisher.ts +++ b/packages/server/src/queue/RedisEventPublisher.ts @@ -1,49 +1,14 @@ import { IServerSideEventStreamer } from 'flowise-components' import { createClient } from 'redis' import logger from '../utils/logger' +import { createRedisClient } from '../utils/redis' export class RedisEventPublisher implements IServerSideEventStreamer { private redisPublisher: ReturnType private connectPromise: Promise | null = null constructor() { - if (process.env.REDIS_URL) { - this.redisPublisher = createClient({ - url: process.env.REDIS_URL, - socket: { - keepAlive: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }, - pingInterval: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }) - } else { - this.redisPublisher = createClient({ - username: process.env.REDIS_USERNAME || undefined, - password: process.env.REDIS_PASSWORD || undefined, - socket: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379'), - tls: process.env.REDIS_TLS === 'true', - cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined, - key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined, - ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined, - keepAlive: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }, - pingInterval: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }) - } - + this.redisPublisher = createRedisClient() this.setupEventListeners() } diff --git a/packages/server/src/queue/RedisEventSubscriber.ts b/packages/server/src/queue/RedisEventSubscriber.ts index f892c1dc340..d734794bb0e 100644 --- a/packages/server/src/queue/RedisEventSubscriber.ts +++ b/packages/server/src/queue/RedisEventSubscriber.ts @@ -1,6 +1,7 @@ import { createClient } from 'redis' import { SSEStreamer } from '../utils/SSEStreamer' import logger from '../utils/logger' +import { createRedisClient } from '../utils/redis' export class RedisEventSubscriber { private redisSubscriber: ReturnType @@ -9,42 +10,7 @@ export class RedisEventSubscriber { private cleanupInterval: NodeJS.Timeout | null = null constructor(sseStreamer: SSEStreamer) { - if (process.env.REDIS_URL) { - this.redisSubscriber = createClient({ - url: process.env.REDIS_URL, - socket: { - keepAlive: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }, - pingInterval: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }) - } else { - this.redisSubscriber = createClient({ - username: process.env.REDIS_USERNAME || undefined, - password: process.env.REDIS_PASSWORD || undefined, - socket: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379'), - tls: process.env.REDIS_TLS === 'true', - cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined, - key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined, - ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined, - keepAlive: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }, - pingInterval: - process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10)) - ? parseInt(process.env.REDIS_KEEP_ALIVE, 10) - : undefined - }) - } + this.redisSubscriber = createRedisClient() this.sseStreamer = sseStreamer this.setupEventListeners() @@ -124,7 +90,7 @@ export class RedisEventSubscriber { startPeriodicCleanup(intervalMs: number = 60_000) { this.cleanupInterval = setInterval(() => { - const staleChannels = Array.from(this.subscribedChannels).filter((channel) => !this.sseStreamer.hasClient(channel)) + const staleChannels = Array.from(this.subscribedChannels).filter((channel) => !this.sseStreamer.hasClientOrObserver(channel)) if (staleChannels.length > 0) { for (const channel of staleChannels) { this.unsubscribe(channel) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 372c7a5407a..86f3b0d11ac 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -50,6 +50,7 @@ import variablesRouter from './variables' import vectorRouter from './vectors' import verifyRouter from './verify' import webhookRouter from './webhook' +import webhookListenerRouter from './webhook-listener' import versionRouter from './versions' import pricingRouter from './pricing' import nvidiaNimRouter from './nvidia-nim' @@ -120,6 +121,7 @@ router.use('/variables', variablesRouter) router.use('/vector', vectorRouter) router.use('/verify', verifyRouter) router.use('/webhook', webhookRouter) +router.use('/webhook-listener', webhookListenerRouter) router.use('/version', versionRouter) router.use('/upsert-history', upsertHistoryRouter) router.use('/settings', settingsRouter) diff --git a/packages/server/src/routes/webhook-listener/index.ts b/packages/server/src/routes/webhook-listener/index.ts new file mode 100644 index 00000000000..07380047a29 --- /dev/null +++ b/packages/server/src/routes/webhook-listener/index.ts @@ -0,0 +1,11 @@ +import express from 'express' +import webhookListenerController from '../../controllers/webhook-listener' + +const router = express.Router() + +// Listener lifecycle: register a listenerId, open an SSE stream for it, then unregister on close. +router.post('/:id/register', webhookListenerController.registerListener) +router.get('/:id/stream/:listenerId', webhookListenerController.streamListener) +router.delete('/:id/listener/:listenerId', webhookListenerController.unregisterListener) + +export default router diff --git a/packages/server/src/services/webhook-listener/index.ts b/packages/server/src/services/webhook-listener/index.ts new file mode 100644 index 00000000000..f881ba26a2a --- /dev/null +++ b/packages/server/src/services/webhook-listener/index.ts @@ -0,0 +1,2 @@ +export { getWebhookListenerRegistry, initWebhookListenerRegistry } from './registry' +export type { IWebhookListenerRegistry, WebhookListenerEntry } from './registry' diff --git a/packages/server/src/services/webhook-listener/registry.ts b/packages/server/src/services/webhook-listener/registry.ts new file mode 100644 index 00000000000..169dc5f2cc6 --- /dev/null +++ b/packages/server/src/services/webhook-listener/registry.ts @@ -0,0 +1,229 @@ +import { createClient } from 'redis' +import { v4 as uuidv4 } from 'uuid' +import { MODE } from '../../Interface' +import logger from '../../utils/logger' +import { createRedisClient } from '../../utils/redis' +import type { SSEStreamer } from '../../utils/SSEStreamer' +import type { RedisEventSubscriber } from '../../queue/RedisEventSubscriber' + +export type WebhookListenerEntry = { listenerId: string; replicaId: string } + +export interface IWebhookListenerRegistry { + /** Register a new listener for a flow. Returns the generated listenerId. */ + register(chatflowid: string): Promise + + /** Refresh TTL on the listener so it stays alive past the inactivity window. */ + heartbeat(chatflowid: string, listenerId: string): Promise + + /** Drop a listener immediately (called on SSE disconnect). */ + unregister(chatflowid: string, listenerId: string): Promise + + /** Look up everyone listening to this flow right now. */ + getActiveListeners(chatflowid: string): Promise + + /** + * Bind an in-flight execution chatId to every listener of a flow. The webhook handler + * calls this right before invoking the flow so events emitted under `executionChatId` are + * observed by every listener — locally on this replica AND across replicas via pub/sub. + */ + bindExecution(chatflowid: string, executionChatId: string): Promise + + /** Get the id used to identify this replica in cross-replica messages. */ + getReplicaId(): string + + /** Optional shutdown hook (queue mode tears down Redis subscribers). */ + dispose?(): Promise +} + +/** + * Single-replica, in-memory registry. Used in MAIN mode where there is no cross-replica concern + * and Redis is not necessarily configured. + */ +export class InMemoryWebhookListenerRegistry implements IWebhookListenerRegistry { + private readonly replicaId = `main-${uuidv4()}` + private readonly listeners: Map> = new Map() + private readonly ttlMs: number + private readonly sseStreamer: SSEStreamer + + constructor(sseStreamer: SSEStreamer, ttlMs = 120_000) { + this.sseStreamer = sseStreamer + this.ttlMs = ttlMs + } + + getReplicaId(): string { + return this.replicaId + } + + async register(chatflowid: string): Promise { + const listenerId = `wh-listener-${uuidv4()}` + this.scheduleEviction(chatflowid, listenerId) + return listenerId + } + + async heartbeat(chatflowid: string, listenerId: string): Promise { + this.scheduleEviction(chatflowid, listenerId) + } + + async unregister(chatflowid: string, listenerId: string): Promise { + const inner = this.listeners.get(chatflowid) + if (!inner) return + const handle = inner.get(listenerId) + if (handle) clearTimeout(handle) + inner.delete(listenerId) + if (inner.size === 0) this.listeners.delete(chatflowid) + } + + async getActiveListeners(chatflowid: string): Promise { + const inner = this.listeners.get(chatflowid) + if (!inner || inner.size === 0) return [] + return Array.from(inner.keys()).map((listenerId) => ({ listenerId, replicaId: this.replicaId })) + } + + async bindExecution(chatflowid: string, executionChatId: string): Promise { + const listeners = await this.getActiveListeners(chatflowid) + for (const { listenerId } of listeners) { + this.sseStreamer.addObserver(executionChatId, listenerId) + } + } + + private scheduleEviction(chatflowid: string, listenerId: string) { + let inner = this.listeners.get(chatflowid) + if (!inner) { + inner = new Map() + this.listeners.set(chatflowid, inner) + } + const existing = inner.get(listenerId) + if (existing) clearTimeout(existing) + const handle = setTimeout(() => { + this.unregister(chatflowid, listenerId).catch(() => {}) + }, this.ttlMs) + inner.set(listenerId, handle) + } +} + +/** + * Redis-backed registry. Listeners are kept in a per-flow hash with a refreshing TTL. Each + * replica boots subscribed to its own control channel; when a webhook fires on replica A and + * finds a listener on replica B, A publishes to B's channel telling it to observe events for + * the in-flight executionChatId on its local SSE client. + */ +export class RedisWebhookListenerRegistry implements IWebhookListenerRegistry { + private readonly replicaId = `replica-${uuidv4()}` + private readonly publisher: ReturnType + private readonly subscriber: ReturnType + private readonly sseStreamer: SSEStreamer + private readonly redisEventSubscriber: RedisEventSubscriber + private readonly ttlSeconds: number + private readonly listenerKey = (chatflowid: string) => `wh-listener:${chatflowid}` + private readonly bindChannel = (replicaId: string) => `wh-listener-bind:${replicaId}` + + constructor(sseStreamer: SSEStreamer, redisEventSubscriber: RedisEventSubscriber, ttlSeconds = 120) { + this.sseStreamer = sseStreamer + this.redisEventSubscriber = redisEventSubscriber + this.ttlSeconds = ttlSeconds + this.publisher = createRedisClient() + this.subscriber = createRedisClient() + } + + getReplicaId(): string { + return this.replicaId + } + + async connect(): Promise { + await Promise.all([this.publisher.connect(), this.subscriber.connect()]) + + await this.subscriber.subscribe(this.bindChannel(this.replicaId), async (message) => { + try { + const parsed = JSON.parse(message) as { executionChatId?: string; listenerId?: string } + if (!parsed.executionChatId || !parsed.listenerId) return + // Only attach if the listener actually lives on this replica (sanity check — + // dispatcher already routed by replicaId, but the local SSE client is the + // ground truth). + if (!this.sseStreamer.hasClient(parsed.listenerId)) return + + this.sseStreamer.addObserver(parsed.executionChatId, parsed.listenerId) + // Subscribe to the execution channel so the worker's published events land + // on this replica and get fanned out to the local listener client. + await this.redisEventSubscriber.subscribe(parsed.executionChatId) + } catch (err) { + logger.error('[WebhookListenerRegistry] Failed to handle bind notification', { error: err }) + } + }) + + logger.info(`[WebhookListenerRegistry] Connected to Redis (replicaId=${this.replicaId})`) + } + + async register(chatflowid: string): Promise { + const listenerId = `wh-listener-${uuidv4()}` + await this.publisher.hSet(this.listenerKey(chatflowid), listenerId, this.replicaId) + await this.publisher.expire(this.listenerKey(chatflowid), this.ttlSeconds) + return listenerId + } + + async heartbeat(chatflowid: string, listenerId: string): Promise { + // Re-set the field (idempotent) and bump the key's TTL so individual listeners staying + // connected keep the whole hash alive. + await this.publisher.hSet(this.listenerKey(chatflowid), listenerId, this.replicaId) + await this.publisher.expire(this.listenerKey(chatflowid), this.ttlSeconds) + } + + async unregister(chatflowid: string, listenerId: string): Promise { + await this.publisher.hDel(this.listenerKey(chatflowid), listenerId) + } + + async getActiveListeners(chatflowid: string): Promise { + const raw = await this.publisher.hGetAll(this.listenerKey(chatflowid)) + return Object.entries(raw).map(([listenerId, replicaId]) => ({ listenerId, replicaId: String(replicaId) })) + } + + async bindExecution(chatflowid: string, executionChatId: string): Promise { + const listeners = await this.getActiveListeners(chatflowid) + if (listeners.length === 0) return + + for (const { listenerId, replicaId } of listeners) { + if (replicaId === this.replicaId) { + // Listener lives on this replica — attach the observer immediately, no pub/sub hop. + this.sseStreamer.addObserver(executionChatId, listenerId) + await this.redisEventSubscriber.subscribe(executionChatId) + } else { + await this.publisher.publish(this.bindChannel(replicaId), JSON.stringify({ executionChatId, listenerId })) + } + } + } + + async dispose(): Promise { + try { + await this.subscriber.unsubscribe() + } catch { + /* ignore */ + } + await Promise.allSettled([this.publisher.quit(), this.subscriber.quit()]) + } +} + +let registry: IWebhookListenerRegistry | null = null + +/** + * Build the right registry implementation for the current MODE. Called once during App init. + * Queue mode: Redis-backed, requires a connected RedisEventSubscriber. Otherwise: in-memory. + */ +export const initWebhookListenerRegistry = async ( + sseStreamer: SSEStreamer, + redisEventSubscriber?: RedisEventSubscriber +): Promise => { + if (process.env.MODE === MODE.QUEUE && redisEventSubscriber) { + const r = new RedisWebhookListenerRegistry(sseStreamer, redisEventSubscriber) + await r.connect() + registry = r + } else { + registry = new InMemoryWebhookListenerRegistry(sseStreamer) + } + return registry +} + +export const getWebhookListenerRegistry = (): IWebhookListenerRegistry => { + if (!registry) { + throw new Error('WebhookListenerRegistry has not been initialized') + } + return registry +} diff --git a/packages/server/src/utils/SSEStreamer.ts b/packages/server/src/utils/SSEStreamer.ts index ef174fc59ab..feee99af695 100644 --- a/packages/server/src/utils/SSEStreamer.ts +++ b/packages/server/src/utils/SSEStreamer.ts @@ -12,12 +12,44 @@ type Client = { export class SSEStreamer implements IServerSideEventStreamer { private readonly clients: Map = new Map() + // Observers receive a passive copy of every event written for `sourceChatId` — one source, + // many destinations. Use cases: webhook listener panels watching an in-flight execution, + // multi-tab chat sync, admin shadowing, test/eval harnesses. Per-replica only — cross-replica + // fan-out happens upstream by having the observer's replica subscribe to the source chatId itself. + private readonly observers: Map> = new Map() private heartbeatInterval: NodeJS.Timeout | null = null hasClient(chatId: string): boolean { return this.clients.has(chatId) } + /** + * True when there's either a real client or at least one active observer for this chatId. + */ + hasClientOrObserver(chatId: string): boolean { + return this.clients.has(chatId) || (this.observers.get(chatId)?.size ?? 0) > 0 + } + + addObserver(sourceChatId: string, observerId: string) { + let set = this.observers.get(sourceChatId) + if (!set) { + set = new Set() + this.observers.set(sourceChatId, set) + } + set.add(observerId) + } + + removeObserver(sourceChatId: string, observerId: string) { + const set = this.observers.get(sourceChatId) + if (!set) return + set.delete(observerId) + if (set.size === 0) this.observers.delete(sourceChatId) + } + + clearObservers(sourceChatId: string) { + this.observers.delete(sourceChatId) + } + addExternalClient(chatId: string, res: Response) { this.clients.set(chatId, { clientType: 'EXTERNAL', response: res, started: false }) } @@ -29,17 +61,39 @@ export class SSEStreamer implements IServerSideEventStreamer { /** * Safely write data to a client's response. If the write fails (e.g., client already disconnected), * the client is automatically removed to prevent further writes to a dead connection. + * Also fans out to any registered observers of `chatId`. */ private safeWrite(chatId: string, data: string): boolean { const client = this.clients.get(chatId) - if (!client) return false - try { - client.response.write(data) - return true - } catch { - this.clients.delete(chatId) - return false + let ok = false + if (client) { + try { + client.response.write(data) + ok = true + } catch { + this.clients.delete(chatId) + } } + + const observerSet = this.observers.get(chatId) + if (observerSet && observerSet.size > 0) { + for (const observerId of Array.from(observerSet)) { + const observer = this.clients.get(observerId) + if (!observer) { + observerSet.delete(observerId) + continue + } + try { + observer.response.write(data) + } catch { + this.clients.delete(observerId) + observerSet.delete(observerId) + } + } + if (observerSet.size === 0) this.observers.delete(chatId) + } + + return ok } removeClient(chatId: string) { @@ -58,6 +112,32 @@ export class SSEStreamer implements IServerSideEventStreamer { this.clients.delete(chatId) } } + + // Notify any observers that this execution finished, but keep their long-lived + // connections open for whatever they're observing next. UI transitions in_progress → done → idle. + const observerSet = this.observers.get(chatId) + if (observerSet && observerSet.size > 0) { + for (const observerId of Array.from(observerSet)) { + const observer = this.clients.get(observerId) + if (!observer) continue + try { + const payload = { event: 'executionEnd', data: { chatId } } + observer.response.write('message:\ndata:' + JSON.stringify(payload) + '\n\n') + } catch { + this.clients.delete(observerId) + } + } + this.observers.delete(chatId) + } + + // If the removed `chatId` was itself an observer, scrub it from every observer Set that + // still references it. Otherwise stale references would sit in memory until the next + // write to each observed chatId organically failed and lazily cleaned them up. + for (const [sourceId, observerIds] of this.observers) { + if (observerIds.delete(chatId) && observerIds.size === 0) { + this.observers.delete(sourceId) + } + } } streamCustomEvent(chatId: string, eventType: string, data: any) { diff --git a/packages/server/src/utils/redis.ts b/packages/server/src/utils/redis.ts new file mode 100644 index 00000000000..e1b516a44dd --- /dev/null +++ b/packages/server/src/utils/redis.ts @@ -0,0 +1,36 @@ +import { createClient } from 'redis' + +export const buildRedisClientOptions = (): Parameters[0] => { + const keepAliveRaw = process.env.REDIS_KEEP_ALIVE + const keepAliveMs = keepAliveRaw && !isNaN(parseInt(keepAliveRaw, 10)) ? parseInt(keepAliveRaw, 10) : undefined + + if (process.env.REDIS_URL) { + return { + url: process.env.REDIS_URL, + socket: { keepAlive: keepAliveMs }, + pingInterval: keepAliveMs + } + } + + return { + username: process.env.REDIS_USERNAME || undefined, + password: process.env.REDIS_PASSWORD || undefined, + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + tls: process.env.REDIS_TLS === 'true', + cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined, + key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined, + ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined, + keepAlive: keepAliveMs + }, + pingInterval: keepAliveMs + } +} + +/** + * Convenience wrapper that returns a fresh, **un-connected** node-redis client built + * with the standard env-driven options. Callers still own the connection lifecycle + * (`.connect()`, `.quit()`, error listeners). + */ +export const createRedisClient = (): ReturnType => createClient(buildRedisClientOptions()) diff --git a/packages/ui/src/api/webhooklistener.js b/packages/ui/src/api/webhooklistener.js new file mode 100644 index 00000000000..4ae92d045dd --- /dev/null +++ b/packages/ui/src/api/webhooklistener.js @@ -0,0 +1,6 @@ +import client from './client' + +const register = (chatflowid) => client.post(`/webhook-listener/${chatflowid}/register`) +const unregister = (chatflowid, listenerId) => client.delete(`/webhook-listener/${chatflowid}/listener/${listenerId}`) + +export default { register, unregister } diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 0a3cb7370f6..962562c28c2 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -31,6 +31,7 @@ import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog' import ChatPopUp from '@/views/chatmessage/ChatPopUp' import ScheduleHistoryFAB from '@/views/schedule/ScheduleHistoryFAB' +import WebhookListenerFAB from '@/views/webhooklistener/WebhookListenerFAB' import ValidationPopUp from '@/views/chatmessage/ValidationPopUp' import { flowContext } from '@/store/context/ReactFlowContext' @@ -103,6 +104,12 @@ const AgentflowCanvas = () => { return startNode?.data?.inputs?.startInputType === 'scheduleInput' }, [nodes]) + const isWebhookFlow = useMemo(() => { + if (!nodes || nodes.length === 0) return false + const startNode = nodes.find((n) => n.data?.name === 'startAgentflow') + return startNode?.data?.inputs?.startInputType === 'webhookTrigger' + }, [nodes]) + const [selectedNode, setSelectedNode] = useState(null) const [isSyncNodesButtonEnabled, setIsSyncNodesButtonEnabled] = useState(false) const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false) @@ -805,6 +812,8 @@ const AgentflowCanvas = () => { )} {isScheduleFlow ? ( + ) : isWebhookFlow ? ( + ) : ( )} diff --git a/packages/ui/src/views/webhooklistener/WebhookListenerDrawer.jsx b/packages/ui/src/views/webhooklistener/WebhookListenerDrawer.jsx new file mode 100644 index 00000000000..4bbf4bcc881 --- /dev/null +++ b/packages/ui/src/views/webhooklistener/WebhookListenerDrawer.jsx @@ -0,0 +1,836 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { keyframes } from '@mui/system' + +// MUI +import { Box, Button, Chip, Collapse, Divider, Drawer, IconButton, Stack, Tooltip, Typography } from '@mui/material' +import { alpha, useTheme } from '@mui/material/styles' +import { + IconAntennaBars5, + IconArrowsMaximize, + IconArrowsMinimize, + IconChevronRight, + IconCircleCheck, + IconCopy, + IconLoader2, + IconRefresh, + IconX +} from '@tabler/icons-react' +import DragHandleIcon from '@mui/icons-material/DragHandle' + +// project +import { baseURL } from '@/store/constant' +import { flowContext } from '@/store/context/ReactFlowContext' +import webhookListenerApi from '@/api/webhooklistener' +import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown' +import AgentExecutedDataCard from '@/views/chatmessage/AgentExecutedDataCard' + +// ─── Geometry ───────────────────────────────────────────────────────────────── +const MIN_W = 380 +const DEFAULT_W = 460 +const MAX_W = typeof window !== 'undefined' ? Math.min(900, window.innerWidth - 80) : 900 + +// Mono stack tuned for code blocks; inherits weight/size from the parent. +const MONO_STACK = `'SFMono-Regular', ui-monospace, Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace` + +// ─── Animations ─────────────────────────────────────────────────────────────── +const sonar = keyframes` + 0% { transform: scale(0.4); opacity: 0.9; } + 100% { transform: scale(2.4); opacity: 0; } +` +const subtleBlink = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +` +const spin = keyframes` + to { transform: rotate(360deg); } +` + +// ─── Status palette ─────────────────────────────────────────────────────────── +const STATUS_META = { + connecting: { label: 'CONNECTING', tone: 'info' }, + idle: { label: 'IDLE', tone: 'default' }, + listening: { label: 'LISTENING', tone: 'success' }, + running: { label: 'RUNNING', tone: 'warning' }, + done: { label: 'COMPLETED', tone: 'success' }, + stopped: { label: 'STOPPED', tone: 'info' }, + error: { label: 'ERROR', tone: 'error' } +} + +const TONE_COLOR = (theme, tone) => { + if (tone === 'success') return theme.palette.success.main + if (tone === 'warning') return theme.palette.warning.main + if (tone === 'error') return theme.palette.error.main + if (tone === 'info') return theme.palette.info.main + return theme.palette.grey[500] +} + +// ─── Small atoms ────────────────────────────────────────────────────────────── +const Caption = ({ children }) => ( + + {children} + +) +Caption.propTypes = { children: PropTypes.node } + +const StatusPill = ({ status }) => { + const theme = useTheme() + const meta = STATUS_META[status] ?? STATUS_META.idle + const color = TONE_COLOR(theme, meta.tone) + const animated = status === 'listening' || status === 'running' + return ( + + + {meta.label} + + ) +} +StatusPill.propTypes = { status: PropTypes.string.isRequired } + +const SonarIdle = () => { + const theme = useTheme() + const accent = theme.palette.success.main + return ( + + {[0, 0.5, 1].map((delay, i) => ( + + ))} + + + + + ) +} + +// ─── Endpoint block ─────────────────────────────────────────────────────────── + +const EndpointBlock = ({ method, url, isDark, onCopy }) => { + const theme = useTheme() + const [showCurl, setShowCurl] = useState(false) + const [copied, setCopied] = useState(false) + const [copiedCurl, setCopiedCurl] = useState(false) + const iconColor = isDark ? 'common.white' : 'text.primary' + + const curl = useMemo( + () => `curl -X ${method} '${url}' \\\n -H 'Content-Type: application/json' \\\n -d '{ "question": "Hello from cURL" }'`, + [method, url] + ) + + const copy = (text, setter) => { + if (!text) return + navigator.clipboard.writeText(text).then(() => { + setter(true) + setTimeout(() => setter(false), 1500) + }) + if (onCopy) onCopy() + } + + return ( + + + + + {url} + + + copy(url, setCopied)} sx={{ p: 0.5, color: iconColor }}> + {copied ? : } + + + + + + + + + {curl} + + copy(curl, setCopiedCurl)} + sx={{ position: 'absolute', top: 6, right: 6, p: 0.5, color: iconColor }} + > + {copiedCurl ? : } + + + + + + + ) +} +EndpointBlock.propTypes = { + method: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + isDark: PropTypes.bool, + onCopy: PropTypes.func +} + +// ─── Drawer ─────────────────────────────────────────────────────────────────── + +const WebhookListenerDrawer = ({ open, chatflowid, onClose, onStatusChange }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + const isDark = customization?.isDarkMode + const { onAgentflowNodeStatusUpdate, clearAgentflowNodeStatus } = useContext(flowContext) + + // ── State machine: connecting → listening → running → done|stopped|error → listening (auto-reset) + const [status, setStatus] = useState('idle') + // Raw engine status from agentFlowEvent — passed to AgentExecutedDataCard for proper icon/state + // (FINISHED / INPROGRESS / STOPPED / TERMINATED / ERROR / TIMEOUT) + const [flowStatus, setFlowStatus] = useState(null) + const [executionChatId, setExecutionChatId] = useState(null) + const [finalMessage, setFinalMessage] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [executedData, setExecutedData] = useState(null) + const [startedAt, setStartedAt] = useState(null) + const [finishedAt, setFinishedAt] = useState(null) + const [width, setWidth] = useState(DEFAULT_W) + const [maximized, setMaximized] = useState(false) + + // Refs for lifecycle objects (the EventSource and abort controller) + const abortRef = useRef(null) + const listenerIdRef = useRef(null) + + // Keep parent FAB pulse in sync with internal status + useEffect(() => { + if (onStatusChange) onStatusChange(status) + }, [status, onStatusChange]) + + // ── Webhook URL (mirrors NodeInputHandler.jsx:843 derivation) + const method = useMemo(() => { + // Try to look up the webhookMethod from the loaded canvas; fall back to POST + // This is best-effort — the user can always override by sending whatever the Start node accepts. + return 'POST' + }, []) + const webhookUrl = chatflowid ? `${baseURL}/api/v1/webhook/${chatflowid}` : '' + + // ── Reset visible run state but keep the connection open and listening + const resetRun = useCallback(() => { + setFinalMessage('') + setErrorMessage(null) + setExecutedData(null) + setFlowStatus(null) + setExecutionChatId(null) + setStartedAt(null) + setFinishedAt(null) + clearAgentflowNodeStatus?.() + }, [clearAgentflowNodeStatus]) + + // ── Forward per-node status updates to the canvas only — the drawer renders the trace + // through AgentExecutedDataCard using the engine's executedData stream. + const applyNodeStatus = useCallback( + (data) => { + if (!data || !data.nodeId) return + try { + onAgentflowNodeStatusUpdate?.(data) + } catch { + /* canvas might not be mounted yet — ignore */ + } + }, + [onAgentflowNodeStatusUpdate] + ) + + // ── Open the SSE connection (call after register()) + const openStream = useCallback( + async (id) => { + const ctrl = new AbortController() + abortRef.current = ctrl + + try { + await fetchEventSource(`${baseURL}/api/v1/webhook-listener/${chatflowid}/stream/${id}`, { + openWhenHidden: true, + signal: ctrl.signal, + headers: { 'x-request-from': 'internal' }, + async onopen() { + // Server sends `listenerReady` as the first event — flip to listening there + }, + async onmessage(ev) { + if (!ev.data) return + let payload + try { + payload = JSON.parse(ev.data) + } catch { + return + } + switch (payload.event) { + case 'listenerReady': + setStatus('listening') + break + case 'metadata': + if (payload.data?.chatId) setExecutionChatId(payload.data.chatId) + break + case 'agentFlowEvent': { + const v = payload.data + setFlowStatus(v) + if (v === 'INPROGRESS') { + resetRun() + setFlowStatus('INPROGRESS') + setStartedAt(Date.now()) + setStatus('running') + } else if (v === 'FINISHED') { + setFinishedAt(Date.now()) + setStatus('done') + } else if (v === 'STOPPED' || v === 'TERMINATED') { + // Human-input pause — the flow is paused, awaiting input. Not a failure. + setFinishedAt(Date.now()) + setStatus('stopped') + } else if (v === 'ERROR' || v === 'TIMEOUT') { + setFinishedAt(Date.now()) + setStatus('error') + } + break + } + case 'nextAgentFlow': + applyNodeStatus(payload.data) + break + case 'agentFlowExecutedData': + setExecutedData(payload.data) + // Pull the assistant text out of the last node's output, if present + if (Array.isArray(payload.data) && payload.data.length) { + const last = payload.data[payload.data.length - 1] + const text = last?.data?.output?.content ?? last?.data?.output?.text + if (typeof text === 'string') setFinalMessage(text) + } + break + case 'token': + if (typeof payload.data === 'string') setFinalMessage((m) => m + payload.data) + break + case 'error': + setErrorMessage(typeof payload.data === 'string' ? payload.data : 'Execution error') + setStatus('error') + break + case 'executionEnd': + // Mirror finalize — promote to terminal state if we missed agentFlowEvent + setStatus((s) => (s === 'running' ? 'done' : s)) + if (!finishedAt) setFinishedAt(Date.now()) + break + case 'end': + // Source SSE closed — we ignore on the listener side; the listener stays open + break + default: + break + } + }, + async onerror(err) { + // Surface as 'error' but let fetch-event-source retry connect transparently + // unless the user closed the drawer (signal aborted). + if (ctrl.signal.aborted) throw err + } + }) + } catch (err) { + if (!ctrl.signal.aborted) { + setErrorMessage(err?.message || 'Listener disconnected') + setStatus('error') + } + } + }, + [chatflowid, applyNodeStatus, resetRun, finishedAt] + ) + + // ── Lifecycle: register listener on open, tear down on close + useEffect(() => { + if (!open || !chatflowid) return + + let cancelled = false + setStatus('connecting') + ;(async () => { + try { + const resp = await webhookListenerApi.register(chatflowid) + const id = resp?.data?.listenerId + if (cancelled || !id) return + listenerIdRef.current = id + openStream(id) + } catch (err) { + if (cancelled) return + setErrorMessage(err?.response?.data?.message || err?.message || 'Failed to register listener') + setStatus('error') + } + })() + + return () => { + cancelled = true + abortRef.current?.abort() + const id = listenerIdRef.current + if (id) { + webhookListenerApi.unregister(chatflowid, id).catch(() => {}) + listenerIdRef.current = null + } + setStatus('idle') + resetRun() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, chatflowid]) + + // ── Drag-to-resize (left edge) + const onMouseMove = useCallback((e) => { + const newWidth = document.body.offsetWidth - e.clientX + if (newWidth >= MIN_W && newWidth <= MAX_W) setWidth(newWidth) + }, []) + const onMouseUp = useCallback(() => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + }, [onMouseMove]) + const onMouseDown = useCallback(() => { + document.body.style.userSelect = 'none' + document.body.style.cursor = 'ew-resize' + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [onMouseMove, onMouseUp]) + + const elapsed = useMemo(() => { + if (!startedAt) return null + const end = finishedAt ?? Date.now() + const ms = end - startedAt + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 2)}s` + }, [startedAt, finishedAt]) + + const drawerWidth = maximized ? Math.min(720, MAX_W) : width + + return ( + + {/* Resize handle */} + + + {/* ─── Header ───────────────────────────────────────────────── */} + + + + + + + + Webhook Listener + + Live observatory + + + setMaximized((v) => !v)} + sx={{ color: isDark ? 'common.white' : 'text.primary' }} + > + {maximized ? : } + + + + + + + + + + {elapsed && {elapsed}} + {executionChatId && ( + + + {executionChatId.slice(0, 8)}… + + + )} + + + + {/* ─── Body ─────────────────────────────────────────────────── */} + + {/* Endpoint */} + + Endpoint + + + + + + {/* Process flow — same expandable tree the chat panel uses, with per-node JSON drilldown */} + + + Process flow + {status === 'running' && ( + streaming… + )} + + + {executedData && Array.isArray(executedData) && executedData.length > 0 ? ( + + ) : status === 'listening' ? ( + + + + Waiting for an incoming webhook request… + + + Send a {method} to the endpoint above to trigger the flow. + + + ) : status === 'connecting' ? ( + + + Opening event stream… + + ) : status === 'running' ? ( + + + Flow started — first node executing… + + ) : status === 'error' ? ( + + {errorMessage || 'Listener error'} + + ) : ( + + )} + + + {/* Final response */} + {(finalMessage || ((status === 'done' || status === 'stopped') && executedData)) && ( + <> + + + Response + + {finalMessage ? ( + {finalMessage} + ) : ( + + Flow completed without a text response. + + )} + + + + )} + + {/* Error band when status is error but we already have some context */} + {status === 'error' && executedData && errorMessage && ( + <> + + + Error + + {errorMessage} + + + + )} + + + {/* ─── Footer ───────────────────────────────────────────────── */} + + + + + ) +} + +WebhookListenerDrawer.propTypes = { + open: PropTypes.bool.isRequired, + chatflowid: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onStatusChange: PropTypes.func +} + +export default WebhookListenerDrawer diff --git a/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx b/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx new file mode 100644 index 00000000000..abec70abafa --- /dev/null +++ b/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { Badge, Tooltip } from '@mui/material' +import { keyframes } from '@mui/system' +import { IconWebhook } from '@tabler/icons-react' + +import { StyledFab } from '@/ui-component/button/StyledFab' +import WebhookListenerDrawer from './WebhookListenerDrawer' + +// Two pulses: a slow ambient one for "listening" (waiting), a fast one for "running" (in-flight) +const pulseSlow = keyframes` + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.35; } +` +const pulseFast = keyframes` + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.7); opacity: 0.2; } +` + +const STATUS_COLOR = { + idle: 'grey.500', + connecting: 'info.main', + listening: 'success.main', + running: 'warning.main', + done: 'success.dark', + stopped: 'info.main', + error: 'error.main' +} + +const WebhookListenerFAB = ({ chatflowid, onOpenChange }) => { + const [open, setOpen] = useState(false) + const [status, setStatus] = useState('idle') + + const handleToggle = () => { + const next = !open + setOpen(next) + if (onOpenChange) onOpenChange(next) + } + + const dotShouldPulse = status === 'listening' || status === 'running' + + return ( + <> + + { + const token = STATUS_COLOR[status] ?? 'grey.500' + const [k, v] = token.split('.') + return theme.palette[k]?.[v] ?? theme.palette.grey[500] + }, + boxShadow: '0 0 0 2px var(--mui-palette-background-default)', + animation: dotShouldPulse ? `${status === 'running' ? pulseFast : pulseSlow} 1.4s ease-in-out infinite` : 'none' + } + }} + > + + + + + + + { + setOpen(false) + if (onOpenChange) onOpenChange(false) + }} + onStatusChange={setStatus} + /> + + ) +} + +WebhookListenerFAB.propTypes = { + chatflowid: PropTypes.string.isRequired, + onOpenChange: PropTypes.func +} + +export default WebhookListenerFAB From c1eb19e68dac0ec50f740add0814cab5a54c57fc Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 4 May 2026 12:42:12 +0100 Subject: [PATCH 11/13] fix webhook tests by mocking webhook listener registry and updating expectations for chatId in createWebhook function --- .../src/controllers/webhook/index.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index cdc9c2dcac0..7afd8454920 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -39,6 +39,10 @@ jest.mock('../../utils/callbackDispatcher', () => ({ jest.mock('../../utils/getRunningExpressApp', () => ({ getRunningExpressApp: () => ({ sseStreamer: mockSseStreamer }) })) +const mockBindExecution = jest.fn() +jest.mock('../../services/webhook-listener', () => ({ + getWebhookListenerRegistry: () => ({ bindExecution: mockBindExecution }) +})) jest.mock('flowise-components', () => ({ checkDenyList: (url: string) => mockCheckDenyList(url) })) @@ -112,13 +116,16 @@ describe('createWebhook', () => { expect(mockBuildChatflow).toHaveBeenCalledWith( expect.objectContaining({ - body: { + body: expect.objectContaining({ webhook: { body: originalBody, headers: expect.any(Object), query: expect.any(Object) - } - } + }, + // Controller pre-assigns a chatId so all response modes share an executionChatId + // and webhook-listener observers can be bound before the flow emits any events. + chatId: expect.any(String) + }) }), ChatType.WEBHOOK ) @@ -139,13 +146,13 @@ describe('createWebhook', () => { expect(mockBuildChatflow).toHaveBeenCalledWith( expect.objectContaining({ - body: { + body: expect.objectContaining({ webhook: { body: { action: 'push' }, headers: expect.objectContaining({ 'x-github-event': 'push' }), query: { page: '2' } } - } + }) }), ChatType.WEBHOOK ) From 2360a4ebdcb77ebb42b31b97d3c429951deb1223 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 5 May 2026 00:00:51 +0100 Subject: [PATCH 12/13] fix: harden webhook trigger surface against SSRF, secret leakage, and listener data exposure --- .../components/src/headerValidation.test.ts | 40 +++++++++++++++- packages/components/src/headerValidation.ts | 26 ++++++++++ .../src/controllers/webhook/index.test.ts | 47 ++++++++++--------- .../server/src/controllers/webhook/index.ts | 10 ++-- .../src/routes/webhook-listener/index.ts | 10 ++-- .../server/src/services/chatflows/index.ts | 20 ++++++-- .../src/utils/callbackDispatcher.test.ts | 37 ++++++++------- .../server/src/utils/callbackDispatcher.ts | 4 +- 8 files changed, 137 insertions(+), 57 deletions(-) diff --git a/packages/components/src/headerValidation.test.ts b/packages/components/src/headerValidation.test.ts index 137574b7f92..56d9d2b2e3c 100644 --- a/packages/components/src/headerValidation.test.ts +++ b/packages/components/src/headerValidation.test.ts @@ -1,4 +1,4 @@ -import { validateCustomHeaders } from './headerValidation' +import { redactSensitiveHeaders, validateCustomHeaders } from './headerValidation' describe('validateCustomHeaders', () => { it('accepts a typical auth header set', () => { @@ -64,3 +64,41 @@ describe('validateCustomHeaders', () => { expect(() => validateCustomHeaders({ 'X-Foo': 123 as any })).toThrow(/must be a string/) }) }) + +describe('redactSensitiveHeaders', () => { + it('returns empty object when headers are undefined or null', () => { + expect(redactSensitiveHeaders(undefined)).toEqual({}) + expect(redactSensitiveHeaders(null)).toEqual({}) + }) + + it('redacts authorization header regardless of casing', () => { + const result = redactSensitiveHeaders({ Authorization: 'Bearer abc', AUTHORIZATION: 'token' }) + expect(result.Authorization).toBe('[REDACTED]') + expect(result.AUTHORIZATION).toBe('[REDACTED]') + }) + + it('redacts the full set of credential-bearing headers', () => { + const result = redactSensitiveHeaders({ + authorization: 'Bearer x', + 'proxy-authorization': 'Basic y', + cookie: 'session=z', + 'x-api-key': 'apikey', + 'x-auth-token': 'token', + 'x-amz-security-token': 'aws-token' + }) + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('passes non-sensitive headers through unchanged', () => { + const result = redactSensitiveHeaders({ + 'content-type': 'application/json', + 'user-agent': 'GitHub-Hookshot/abc', + 'x-github-event': 'push', + authorization: 'Bearer leak' + }) + expect(result['content-type']).toBe('application/json') + expect(result['user-agent']).toBe('GitHub-Hookshot/abc') + expect(result['x-github-event']).toBe('push') + expect(result.authorization).toBe('[REDACTED]') + }) +}) diff --git a/packages/components/src/headerValidation.ts b/packages/components/src/headerValidation.ts index 7e1ec2b4e09..fd995b21b73 100644 --- a/packages/components/src/headerValidation.ts +++ b/packages/components/src/headerValidation.ts @@ -14,6 +14,18 @@ const DENIED_HEADER_NAMES = new Set([ const DENIED_HEADER_PREFIXES = ['proxy-', 'x-forwarded-', 'sec-'] +const SENSITIVE_HEADER_NAMES = new Set([ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-amz-security-token' +]) + +const REDACTED_PLACEHOLDER = '[REDACTED]' + const MAX_HEADERS = 25 const MAX_KEY_LENGTH = 128 const MAX_VALUE_LENGTH = 2048 @@ -64,3 +76,17 @@ export function validateCustomHeaders(headers: Record): void { } } } + +/** + * Returns a copy of `headers` with credential-bearing entries (Authorization, Cookie, X-Api-Key, …) + * replaced by a placeholder string. Used at trust boundaries before a header bag is exposed to flow + * templates, observers, or logs. Comparison is case-insensitive; non-sensitive headers pass through. + */ +export function redactSensitiveHeaders(headers: Record | undefined | null): Record { + if (!headers) return {} + const out: Record = {} + for (const [key, value] of Object.entries(headers)) { + out[key] = SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED_PLACEHOLDER : value + } + return out +} diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index 7afd8454920..0874623642b 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -5,7 +5,6 @@ import { ChatType } from '../../Interface' const mockValidateWebhookChatflow = jest.fn() const mockBuildChatflow = jest.fn() const mockDispatchCallback = jest.fn() -const mockCheckDenyList = jest.fn() const mockCheckIfChatflowIsValidForStreaming = jest.fn() const mockSseStreamer = { addExternalClient: jest.fn(), @@ -43,9 +42,6 @@ const mockBindExecution = jest.fn() jest.mock('../../services/webhook-listener', () => ({ getWebhookListenerRegistry: () => ({ bindExecution: mockBindExecution }) })) -jest.mock('flowise-components', () => ({ - checkDenyList: (url: string) => mockCheckDenyList(url) -})) jest.mock('uuid', () => ({ v4: () => 'generated-uuid' })) jest.mock('../../utils/logger', () => ({ __esModule: true, @@ -158,6 +154,30 @@ describe('createWebhook', () => { ) }) + it('redacts credential-bearing headers before they reach the flow', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const req = mockReq({ + body: { action: 'push' }, + headers: { + 'x-github-event': 'push', + authorization: 'Bearer leaked-token', + cookie: 'session=secret', + 'x-api-key': 'apikey' + } as any + }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + const passedHeaders = (mockBuildChatflow.mock.calls[0][0] as any).body.webhook.headers + expect(passedHeaders.authorization).toBe('[REDACTED]') + expect(passedHeaders.cookie).toBe('[REDACTED]') + expect(passedHeaders['x-api-key']).toBe('[REDACTED]') + expect(passedHeaders['x-github-event']).toBe('push') + }) + it('returns buildChatflow result as JSON response', async () => { const apiResult = { output: 'ok' } mockBuildChatflow.mockResolvedValue(apiResult) @@ -397,22 +417,6 @@ describe('createWebhook', () => { expect(mockDispatchCallback).toHaveBeenCalledWith('https://node-configured.example.com/cb', expect.any(Object), undefined) }) - it('calls next with BAD_REQUEST when callbackUrl is denied by SSRF policy', async () => { - mockValidateWebhookChatflow.mockResolvedValue({ - responseMode: 'async' as const, - callbackUrl: 'http://169.254.169.254/latest/meta-data/' - }) - mockCheckDenyList.mockRejectedValue(new Error('Access to this host is denied by policy.')) - const req = mockReq() - const res = mockRes() - const next = mockNext() - - await webhookController.createWebhook(req, res, next) - - expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST })) - expect(mockBuildChatflow).not.toHaveBeenCalled() - }) - it('calls next with BAD_REQUEST when node callbackUrl is not a valid http/https URL', async () => { mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'ftp://bad.example.com' }) const req = mockReq() @@ -487,7 +491,7 @@ describe('createWebhook', () => { expect(mockBuildChatflow).toHaveBeenCalled() }) - it('does not validate URL or hit deny list when async is on without a callback URL', async () => { + it('does not error on URL validation when async is on without a callback URL', async () => { mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const }) mockBuildChatflow.mockResolvedValue({ text: 'done' }) @@ -497,7 +501,6 @@ describe('createWebhook', () => { await webhookController.createWebhook(req, res, next) - expect(mockCheckDenyList).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled() }) diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index 3291636502b..9829cb3843d 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -6,9 +6,9 @@ import predictionsServices from '../../services/predictions' import chatflowsService from '../../services/chatflows' import webhookService from '../../services/webhook' import { getWebhookListenerRegistry } from '../../services/webhook-listener' +import { redactSensitiveHeaders } from 'flowise-components' import { ChatType } from '../../Interface' import { InternalFlowiseError } from '../../errors/internalFlowiseError' -import { checkDenyList } from 'flowise-components' import { dispatchCallback } from '../../utils/callbackDispatcher' import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' @@ -51,7 +51,7 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => req.body = { webhook: { body, - headers: req.headers, + headers: redactSensitiveHeaders(req.headers as Record), query: req.query } } @@ -106,12 +106,8 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => try { const parsed = new URL(callbackUrl) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error() - await checkDenyList(callbackUrl) } catch { - throw new InternalFlowiseError( - StatusCodes.BAD_REQUEST, - `Invalid callbackUrl: must be a valid and safe http or https URL` - ) + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid http or https URL`) } } diff --git a/packages/server/src/routes/webhook-listener/index.ts b/packages/server/src/routes/webhook-listener/index.ts index 07380047a29..49986a907e2 100644 --- a/packages/server/src/routes/webhook-listener/index.ts +++ b/packages/server/src/routes/webhook-listener/index.ts @@ -1,11 +1,13 @@ import express from 'express' import webhookListenerController from '../../controllers/webhook-listener' +import { checkAnyPermission } from '../../enterprise/rbac/PermissionCheck' const router = express.Router() -// Listener lifecycle: register a listenerId, open an SSE stream for it, then unregister on close. -router.post('/:id/register', webhookListenerController.registerListener) -router.get('/:id/stream/:listenerId', webhookListenerController.streamListener) -router.delete('/:id/listener/:listenerId', webhookListenerController.unregisterListener) +const requireFlowEdit = checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update') + +router.post('/:id/register', requireFlowEdit, webhookListenerController.registerListener) +router.get('/:id/stream/:listenerId', requireFlowEdit, webhookListenerController.streamListener) +router.delete('/:id/listener/:listenerId', requireFlowEdit, webhookListenerController.unregisterListener) export default router diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 8ef7bd37292..b786fe9bd8a 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -15,7 +15,15 @@ import { getWorkspaceSearchOptions } from '../../enterprise/utils/ControllerServ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' import documentStoreService from '../../services/documentstore' -import { constructGraphs, getAppVersion, getEndingNodes, getTelemetryFlowObj, isFlowValidForStream } from '../../utils' +import { + constructGraphs, + decryptCredentialData, + encryptCredentialData, + getAppVersion, + getEndingNodes, + getTelemetryFlowObj, + isFlowValidForStream +} from '../../utils' import { sanitizeAllowedUploadMimeTypesFromConfig } from '../../utils/fileValidation' import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository' import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData' @@ -623,10 +631,11 @@ const setWebhookSecret = async (chatflowId: string, workspaceId: string): Promis const repo = appServer.AppDataSource.getRepository(ChatFlow) const chatflow = await repo.findOne({ where: { id: chatflowId, workspaceId } }) if (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) - chatflow.webhookSecret = randomBytes(32).toString('hex') + const plaintext = randomBytes(32).toString('hex') + chatflow.webhookSecret = await encryptCredentialData({ secret: plaintext }) chatflow.webhookSecretConfigured = true await repo.save(chatflow) - return { webhookSecret: chatflow.webhookSecret } + return { webhookSecret: plaintext } } catch (error) { if (error instanceof InternalFlowiseError) throw error throw new InternalFlowiseError( @@ -663,7 +672,10 @@ const getWebhookSecret = async (chatflowId: string, workspaceId: string): Promis .where('chatflow.id = :id', { id: chatflowId }) .andWhere('chatflow.workspaceId = :workspaceId', { workspaceId }) .getOne() - return dbResponse?.webhookSecret ?? null + const stored = dbResponse?.webhookSecret + if (!stored) return null + const decrypted = await decryptCredentialData(stored) + return (decrypted?.secret as string | undefined) ?? null } catch (error) { if (error instanceof InternalFlowiseError) throw error throw new InternalFlowiseError( diff --git a/packages/server/src/utils/callbackDispatcher.test.ts b/packages/server/src/utils/callbackDispatcher.test.ts index bff70ab46fd..8a5525fb27c 100644 --- a/packages/server/src/utils/callbackDispatcher.test.ts +++ b/packages/server/src/utils/callbackDispatcher.test.ts @@ -1,9 +1,9 @@ import { createHmac } from 'crypto' -const mockAxiosPost = jest.fn() +const mockSecureAxiosRequest = jest.fn() const mockLoggerError = jest.fn() -jest.mock('axios', () => ({ post: mockAxiosPost })) +jest.mock('flowise-components', () => ({ secureAxiosRequest: mockSecureAxiosRequest })) jest.mock('./logger', () => ({ error: mockLoggerError })) import { dispatchCallback } from './callbackDispatcher' @@ -25,29 +25,32 @@ describe('dispatchCallback', () => { jest.useRealTimers() }) - it('POSTs JSON payload to the callback URL', async () => { - mockAxiosPost.mockResolvedValue({ status: 200 }) + it('POSTs JSON payload to the callback URL via secureAxiosRequest', async () => { + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) await dispatchCallback(URL, PAYLOAD) - expect(mockAxiosPost).toHaveBeenCalledTimes(1) - expect(mockAxiosPost).toHaveBeenCalledWith(URL, JSON.stringify(PAYLOAD), { + expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(1) + expect(mockSecureAxiosRequest).toHaveBeenCalledWith({ + method: 'POST', + url: URL, + data: JSON.stringify(PAYLOAD), headers: { 'Content-Type': 'application/json' }, timeout: 10000 }) }) it('includes X-Flowise-Signature header when secret is provided', async () => { - mockAxiosPost.mockResolvedValue({ status: 200 }) + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) const secret = 'my-secret' const body = JSON.stringify(PAYLOAD) await dispatchCallback(URL, PAYLOAD, secret) - expect(mockAxiosPost).toHaveBeenCalledWith( - URL, - body, + expect(mockSecureAxiosRequest).toHaveBeenCalledWith( expect.objectContaining({ + url: URL, + data: body, headers: expect.objectContaining({ 'X-Flowise-Signature': expectedSignature(body, secret) }) @@ -56,34 +59,34 @@ describe('dispatchCallback', () => { }) it('does not include X-Flowise-Signature when no secret is provided', async () => { - mockAxiosPost.mockResolvedValue({ status: 200 }) + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) await dispatchCallback(URL, PAYLOAD) - const call = mockAxiosPost.mock.calls[0] - expect(call[2].headers).not.toHaveProperty('X-Flowise-Signature') + const call = mockSecureAxiosRequest.mock.calls[0] + expect(call[0].headers).not.toHaveProperty('X-Flowise-Signature') }) it('retries on failure and succeeds on second attempt', async () => { - mockAxiosPost.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ status: 200 }) + mockSecureAxiosRequest.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ status: 200 }) const promise = dispatchCallback(URL, PAYLOAD) await jest.advanceTimersByTimeAsync(3000) await promise - expect(mockAxiosPost).toHaveBeenCalledTimes(2) + expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(2) expect(mockLoggerError).not.toHaveBeenCalled() }) it('logs an error after all 3 attempts fail and does not throw', async () => { - mockAxiosPost.mockRejectedValue(new Error('unreachable')) + mockSecureAxiosRequest.mockRejectedValue(new Error('unreachable')) const promise = dispatchCallback(URL, PAYLOAD) await jest.advanceTimersByTimeAsync(3000) await jest.advanceTimersByTimeAsync(6000) await promise - expect(mockAxiosPost).toHaveBeenCalledTimes(3) + expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(3) expect(mockLoggerError).toHaveBeenCalledWith(expect.stringContaining('Failed to deliver callback')) }) }) diff --git a/packages/server/src/utils/callbackDispatcher.ts b/packages/server/src/utils/callbackDispatcher.ts index d32c560cc8d..71400b2b4c4 100644 --- a/packages/server/src/utils/callbackDispatcher.ts +++ b/packages/server/src/utils/callbackDispatcher.ts @@ -1,5 +1,5 @@ -import axios from 'axios' import { createHmac } from 'crypto' +import { secureAxiosRequest } from 'flowise-components' import logger from './logger' // Delays in ms before each attempt: attempt 1 is immediate, attempt 2 waits 3s, attempt 3 waits 6s @@ -19,7 +19,7 @@ export async function dispatchCallback(url: string, payload: Record setTimeout(r, RETRY_DELAYS[attempt])) } try { - await axios.post(url, body, { headers, timeout: 10000 }) + await secureAxiosRequest({ method: 'POST', url, data: body, headers, timeout: 10000 }) return } catch (err: any) { if (attempt === RETRY_DELAYS.length - 1) { From 12b2396e7e13cd750de2f69bd582a8237668a192 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 5 May 2026 18:35:53 +0100 Subject: [PATCH 13/13] fix: improve webhook listener reliability with initial heartbeat and logging --- .../src/controllers/webhook-listener/index.ts | 6 ++++++ .../src/services/webhook-listener/registry.ts | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/server/src/controllers/webhook-listener/index.ts b/packages/server/src/controllers/webhook-listener/index.ts index 1b307c31a7d..ca57815eb71 100644 --- a/packages/server/src/controllers/webhook-listener/index.ts +++ b/packages/server/src/controllers/webhook-listener/index.ts @@ -58,6 +58,12 @@ const streamListener = async (req: Request, res: Response, next: NextFunction) = sseStreamer.addClient(listenerId, res) + try { + await registry.heartbeat(chatflowid, listenerId) + } catch (err) { + logger.warn(`[webhookListener] Initial heartbeat failed for ${listenerId}: ${err}`) + } + // Initial "ready" beacon so the UI can flip from "connecting…" to "listening". res.write( 'message:\ndata:' + diff --git a/packages/server/src/services/webhook-listener/registry.ts b/packages/server/src/services/webhook-listener/registry.ts index 169dc5f2cc6..29d4b7da668 100644 --- a/packages/server/src/services/webhook-listener/registry.ts +++ b/packages/server/src/services/webhook-listener/registry.ts @@ -139,7 +139,13 @@ export class RedisWebhookListenerRegistry implements IWebhookListenerRegistry { // Only attach if the listener actually lives on this replica (sanity check — // dispatcher already routed by replicaId, but the local SSE client is the // ground truth). - if (!this.sseStreamer.hasClient(parsed.listenerId)) return + if (!this.sseStreamer.hasClient(parsed.listenerId)) { + logger.warn( + `[WebhookListenerRegistry] Bind dropped: listener ${parsed.listenerId} not on this replica (${this.replicaId}). ` + + `Likely caused by ALB routing without sticky sessions, or by a webhook firing between register and stream.` + ) + return + } this.sseStreamer.addObserver(parsed.executionChatId, parsed.listenerId) // Subscribe to the execution channel so the worker's published events land @@ -153,11 +159,8 @@ export class RedisWebhookListenerRegistry implements IWebhookListenerRegistry { logger.info(`[WebhookListenerRegistry] Connected to Redis (replicaId=${this.replicaId})`) } - async register(chatflowid: string): Promise { - const listenerId = `wh-listener-${uuidv4()}` - await this.publisher.hSet(this.listenerKey(chatflowid), listenerId, this.replicaId) - await this.publisher.expire(this.listenerKey(chatflowid), this.ttlSeconds) - return listenerId + async register(_chatflowid: string): Promise { + return `wh-listener-${uuidv4()}` } async heartbeat(chatflowid: string, listenerId: string): Promise {