diff --git a/packages/agentflow/README.md b/packages/agentflow/README.md index f323f76411f..97f17c36b79 100644 --- a/packages/agentflow/README.md +++ b/packages/agentflow/README.md @@ -1,270 +1,270 @@ -# @flowiseai/agentflow - -[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow) -[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) - -> Embeddable React component for building and visualizing AI agent workflows - -## Status: Dev - -**Current version: `0.0.0-dev.13`** - -This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use. - -**What works today:** - -- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end) -- Connection validation, flow validation, and export -- Async option loading (models, credentials, tools) -- Variable picker and field visibility conditions -- AI flow generation from natural language -- Dark/light mode theming -- Read-only mode and custom rendering via render props - -## Overview - -`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes. - -## Features - -- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls -- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow -- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options) -- **Credential Management** — Create and edit credentials inline from the node editor -- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded) -- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder -- **AI Flow Generator** — Generate flows from natural language descriptions with model selection -- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback -- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables -- **Read-Only Mode** — Disable editing for view-only embedding -- **Custom Rendering** — Replace the default header and node palette with your own components via render props -- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`) -- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback -- **Keyboard Shortcuts** — Cmd/Ctrl+S to save - -## Installation - -```bash -pnpm add @flowiseai/agentflow -``` - -**Peer Dependencies:** - -```bash -pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow -``` - -## Basic Usage - -```tsx -import { Agentflow } from '@flowiseai/agentflow' - -import '@flowiseai/agentflow/flowise.css' - -export default function App() { - return ( -
- -
- ) -} -``` - -### With Initial Flow Data and Callbacks - -```tsx -import { useRef } from 'react' - -import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow' - -import '@flowiseai/agentflow/flowise.css' - -export default function App() { - const ref = useRef(null) - - const initialFlow: FlowData = { - nodes: [ - { - id: 'startAgentflow_0', - type: 'agentflowNode', - position: { x: 100, y: 100 }, - data: { - id: 'startAgentflow_0', - name: 'startAgentflow', - label: 'Start', - color: '#7EE787', - hideInput: true, - outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }] - } - } - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 } - } - - return ( -
- console.log('Flow changed:', flow)} - onSave={(flow) => console.log('Flow saved:', flow)} - /> -
- ) -} -``` - -### More Examples - -The [examples app](./examples/README.md) includes working demos for: - -- **Variable usage** — `{{variable}}` syntax, variable picker, available sources -- **Async options** — Loading models, tools, and credentials from the API -- **Status indicators** — Node execution states (running, finished, error, stopped) -- **Field visibility** — Show/hide conditions on node inputs -- **State management** — Dirty tracking, flow change callbacks -- **Dark mode** — Light/dark theme toggle -- **Custom UI** — Custom header and node palette via render props -- **Filtered components** — Restricting available node types with presets -- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls - -Run `cd examples && npm install && npm run dev` to try them locally. - -## Props - - -| Prop | Type | Default | Description | -| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- | -| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint | -| `token` | `string` | — | Authentication token for API calls | -| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. | -| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) | -| `flowId` | `string` | — | Flow identifier (reserved for future use) | -| `components` | `string[]` | — | Restrict which node types appear in the palette | -| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) | -| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save | -| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI | -| `isDarkMode` | `boolean` | `false` | Use dark mode theme | -| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) | -| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) | -| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette | -| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button | -| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. | -| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer | -| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer | - -### Imperative Methods (via `ref`) - -| Method | Return Type | Description | -| ------------------------ | ------------------------- | ------------------------------------- | -| `getFlow()` | `FlowData` | Get current flow data | -| `toJSON()` | `string` | Export flow as JSON string | -| `validate()` | `ValidationResult` | Validate the current flow | -| `fitView()` | `void` | Fit all nodes into view | -| `clear()` | `void` | Remove all nodes and edges | -| `addNode(nodeData)` | `void` | Add a node (`Partial`) | -| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance | - -### Security: `requestInterceptor` - -The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior. - -**Guidelines for consumers:** - -- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor. -- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers). -- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely. - -### Node Types - -The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown. - - -| Node Type | Description | -| -------------------------- | ------------------------------------ | -| `startAgentflow` | Entry point (required, always shown) | -| `agentAgentflow` | AI agent execution | -| `llmAgentflow` | LLM / language model call | -| `conditionAgentflow` | Conditional branching | -| `conditionAgentAgentflow` | Agent-level conditional branching | -| `directReplyAgentflow` | Direct response to user | -| `customFunctionAgentflow` | Custom JavaScript function | -| `toolAgentflow` | Tool integration | -| `retrieverAgentflow` | Data retrieval | -| `stickyNoteAgentflow` | Canvas annotation (not connectable) | -| `httpAgentflow` | HTTP request | -| `iterationAgentflow` | Iteration / map-reduce container | -| `executeFlowAgentflow` | Execute a sub-flow | - -### Design Note - -`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes. - -## Development - -```bash -# Install dependencies -pnpm install - -# Build the package -pnpm build - -# Run tests -pnpm test - -# Run examples -cd examples && pnpm install && pnpm dev -``` - -Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status. - -## Troubleshooting - -### API Connection Issues - -- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration. -- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop. -- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath. - -### Validation Errors - -- **"Flow is empty"** — Add at least one node to the canvas. -- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette. -- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes. -- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle. - -### Theme Issues - -- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme. -- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions. - -### Variables Not Resolving - -- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node. -- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly. - -### Async Options Not Loading - -- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running. -- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console. - -## Documentation - -- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns -- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration -- [Examples](./examples/README.md) - Usage examples and demos - -## Contributing - -This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines. - -## License - -Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details. - ---- - -Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem +# @flowiseai/agentflow + +[![Version](https://img.shields.io/npm/v/@flowiseai/agentflow)](https://www.npmjs.com/package/@flowiseai/agentflow) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) + +> Embeddable React component for building and visualizing AI agent workflows + +## Status: Dev + +**Current version: `0.0.0-dev.13`** + +This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use. + +**What works today:** + +- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end) +- Connection validation, flow validation, and export +- Async option loading (models, credentials, tools) +- Variable picker and field visibility conditions +- AI flow generation from natural language +- Dark/light mode theming +- Read-only mode and custom rendering via render props + +## Overview + +`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes. + +## Features + +- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls +- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow +- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options) +- **Credential Management** — Create and edit credentials inline from the node editor +- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded) +- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder +- **AI Flow Generator** — Generate flows from natural language descriptions with model selection +- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback +- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables +- **Read-Only Mode** — Disable editing for view-only embedding +- **Custom Rendering** — Replace the default header and node palette with your own components via render props +- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`) +- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback +- **Keyboard Shortcuts** — Cmd/Ctrl+S to save + +## Installation + +```bash +pnpm add @flowiseai/agentflow +``` + +**Peer Dependencies:** + +```bash +pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow +``` + +## Basic Usage + +```tsx +import { Agentflow } from '@flowiseai/agentflow' + +import '@flowiseai/agentflow/flowise.css' + +export default function App() { + return ( +
+ +
+ ) +} +``` + +### With Initial Flow Data and Callbacks + +```tsx +import { useRef } from 'react' + +import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow' + +import '@flowiseai/agentflow/flowise.css' + +export default function App() { + const ref = useRef(null) + + const initialFlow: FlowData = { + nodes: [ + { + id: 'startAgentflow_0', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'startAgentflow_0', + name: 'startAgentflow', + label: 'Start', + color: '#7EE787', + hideInput: true, + outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }] + } + } + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 } + } + + return ( +
+ console.log('Flow changed:', flow)} + onSave={(flow) => console.log('Flow saved:', flow)} + /> +
+ ) +} +``` + +### More Examples + +The [examples app](./examples/README.md) includes working demos for: + +- **Variable usage** — `{{variable}}` syntax, variable picker, available sources +- **Async options** — Loading models, tools, and credentials from the API +- **Status indicators** — Node execution states (running, finished, error, stopped) +- **Field visibility** — Show/hide conditions on node inputs +- **State management** — Dirty tracking, flow change callbacks +- **Dark mode** — Light/dark theme toggle +- **Custom UI** — Custom header and node palette via render props +- **Filtered components** — Restricting available node types with presets +- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls + +Run `cd examples && npm install && npm run dev` to try them locally. + +## Props + + +| Prop | Type | Default | Description | +| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- | +| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint | +| `token` | `string` | — | Authentication token for API calls | +| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. | +| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) | +| `flowId` | `string` | — | Flow identifier (reserved for future use) | +| `components` | `string[]` | — | Restrict which node types appear in the palette | +| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) | +| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save | +| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI | +| `isDarkMode` | `boolean` | `false` | Use dark mode theme | +| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) | +| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) | +| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette | +| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button | +| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. | +| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer | +| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer | + +### Imperative Methods (via `ref`) + +| Method | Return Type | Description | +| ------------------------ | ------------------------- | ------------------------------------- | +| `getFlow()` | `FlowData` | Get current flow data | +| `toJSON()` | `string` | Export flow as JSON string | +| `validate()` | `ValidationResult` | Validate the current flow | +| `fitView()` | `void` | Fit all nodes into view | +| `clear()` | `void` | Remove all nodes and edges | +| `addNode(nodeData)` | `void` | Add a node (`Partial`) | +| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance | + +### Security: `requestInterceptor` + +The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior. + +**Guidelines for consumers:** + +- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor. +- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers). +- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely. + +### Node Types + +The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown. + + +| Node Type | Description | +| -------------------------- | ------------------------------------ | +| `startAgentflow` | Entry point (required, always shown) | +| `agentAgentflow` | AI agent execution | +| `llmAgentflow` | LLM / language model call | +| `conditionAgentflow` | Conditional branching | +| `conditionAgentAgentflow` | Agent-level conditional branching | +| `directReplyAgentflow` | Direct response to user | +| `customFunctionAgentflow` | Custom JavaScript function | +| `toolAgentflow` | Tool integration | +| `retrieverAgentflow` | Data retrieval | +| `stickyNoteAgentflow` | Canvas annotation (not connectable) | +| `httpAgentflow` | HTTP request | +| `iterationAgentflow` | Iteration / map-reduce container | +| `executeFlowAgentflow` | Execute a sub-flow | + +### Design Note + +`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes. + +## Development + +```bash +# Install dependencies +pnpm install + +# Build the package +pnpm build + +# Run tests +pnpm test + +# Run examples +cd examples && pnpm install && pnpm dev +``` + +Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status. + +## Troubleshooting + +### API Connection Issues + +- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration. +- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop. +- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath. + +### Validation Errors + +- **"Flow is empty"** — Add at least one node to the canvas. +- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette. +- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes. +- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle. + +### Theme Issues + +- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme. +- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions. + +### Variables Not Resolving + +- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node. +- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly. + +### Async Options Not Loading + +- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running. +- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console. + +## Documentation + +- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns +- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration +- [Examples](./examples/README.md) - Usage examples and demos + +## Contributing + +This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines. + +## License + +Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details. + +--- + +Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem diff --git a/packages/agentflow/src/core/types/node.ts b/packages/agentflow/src/core/types/node.ts index b10e8d483b4..20ee866e4b2 100644 --- a/packages/agentflow/src/core/types/node.ts +++ b/packages/agentflow/src/core/types/node.ts @@ -87,7 +87,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 267de648cea..91080445852 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.test.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.test.ts @@ -200,6 +200,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 60250ebfdfb..9d3b76adafc 100644 --- a/packages/agentflow/src/core/utils/fieldVisibility.ts +++ b/packages/agentflow/src/core/utils/fieldVisibility.ts @@ -140,14 +140,28 @@ function inputValuesWithDeclaredDefaults(params: InputParam[], inputValues: Reco /** * Evaluate visibility for all params, returning new param objects with computed `display`. + * Also filters individual options within `type: 'options'` params based on their own show/hide conditions. * Does not mutate the originals. */ export function evaluateFieldVisibility(params: InputParam[], inputValues: Record, arrayIndex?: number): InputParam[] { const effectiveInputs = inputValuesWithDeclaredDefaults(params, inputValues) - return params.map((param) => ({ - ...param, - display: evaluateParamVisibility(param, effectiveInputs, arrayIndex) - })) + return params.map((param) => { + const withDisplay = { ...param, display: evaluateParamVisibility(param, effectiveInputs, 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 }, + effectiveInputs, + arrayIndex + ) + }) + return filteredOptions.length === withDisplay.options.length ? withDisplay : { ...withDisplay, options: filteredOptions } + } + + return withDisplay + }) } export function applyVisibleFieldDefaults( diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index e2ab91c75a3..6f3fc709162 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -48,7 +48,7 @@ class Start_Agentflow implements INode { constructor() { this.label = 'Start' this.name = 'startAgentflow' - this.version = 1.3 + this.version = 1.4 this.type = 'Start' this.category = 'Agent Flows' this.description = 'Starting point of the agentflow' @@ -72,6 +72,12 @@ class Start_Agentflow implements INode { description: 'Start the workflow with form inputs', client: ['agentflowv2'] }, + { + label: 'Webhook Trigger', + name: 'webhookTrigger', + description: 'Trigger the workflow via an external webhook', + client: ['agentflowv2'] + }, { label: 'Schedule Input', name: 'scheduleInput', @@ -162,6 +168,330 @@ class Start_Agentflow implements INode { } ] }, + { + label: 'HTTP Method', + name: 'webhookMethod', + type: 'options', + options: [ + { label: 'GET', name: 'GET' }, + { label: 'POST', name: 'POST' }, + { label: 'PUT', name: 'PUT' }, + { label: 'PATCH', name: 'PATCH' }, + { label: 'DELETE', name: 'DELETE' } + ], + default: 'POST', + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Content Type', + name: 'webhookContentType', + type: 'options', + description: + 'Expected Content-Type of incoming requests. For application/x-www-form-urlencoded, if the entire payload is a JSON string in a "payload" field (e.g. GitHub webhooks), it is automatically parsed — use $webhook.body.* as normal.', + options: [ + { label: 'application/json', name: 'application/json' }, + { label: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded' } + ], + default: 'application/json', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Webhook URL', + name: 'webhookURL', + type: 'string', + description: 'Send a request to this URL to trigger the workflow', + optional: true, + show: { + 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: + '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', + webhookEnableAuth: true + } + }, + { + label: 'Signature Header', + name: 'webhookSignatureHeader', + type: 'string', + description: + 'The request header that carries the signature. e.g. x-hub-signature-256 for GitHub, stripe-signature for Stripe, x-gitlab-token for GitLab.', + placeholder: 'x-webhook-signature', + optional: true, + show: { + startInputType: 'webhookTrigger', + webhookEnableAuth: true + } + }, + { + label: 'Signature Type', + name: 'webhookSignatureType', + type: 'options', + description: + 'How to verify the signature. HMAC-SHA256 for GitHub, Stripe, Slack (supports sha256= prefix automatically). Plain Token for GitLab-style plain secret comparison.', + options: [ + { label: 'HMAC-SHA256', name: 'hmac-sha256' }, + { label: 'Plain Token', name: 'plain-token' } + ], + 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' + } + }, + { + label: 'Callback URL', + name: 'callbackUrl', + type: 'string', + description: + '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', + webhookResponseMode: 'async' + } + }, + { + label: 'Callback Secret', + name: 'callbackSecret', + type: 'string', + description: + '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' + } + }, + { + label: 'Expected Query Parameters', + name: 'webhookQueryParams', + description: 'Declare expected query parameters. Leave empty to accept any.', + type: 'array', + optional: true, + show: { + startInputType: 'webhookTrigger', + webhookEnableValidation: true + }, + array: [ + { + label: 'Variable Name', + name: 'name', + type: 'string', + placeholder: 'e.g. page' + }, + { + label: 'Required', + name: 'required', + type: 'boolean' + } + ] + }, + { + label: 'Expected Body Parameters', + name: 'webhookBodyParams', + description: 'Define expected parameters in the webhook request body. Leave empty to accept any JSON body.', + type: 'array', + optional: true, + show: { + startInputType: 'webhookTrigger', + webhookEnableValidation: true + }, + array: [ + { + label: 'Variable Name', + name: 'name', + type: 'string', + placeholder: 'e.g. userId' + }, + { + label: 'Type', + name: 'type', + type: 'options', + options: [ + { + label: 'String', + name: 'string' + }, + { + label: 'Number', + name: 'number' + }, + { + 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' + }, + { + label: 'Required', + name: 'required', + type: 'boolean' + } + ] + }, + { + label: 'Expected Headers', + name: 'webhookHeaderParams', + description: 'Declare expected request headers. Leave empty to accept any.', + type: 'array', + optional: true, + show: { + startInputType: 'webhookTrigger', + webhookEnableValidation: true + }, + array: [ + { + label: 'Header Name', + name: 'name', + type: 'string', + placeholder: 'e.g. x-github-event' + }, + { + label: 'Required', + name: 'required', + type: 'boolean' + } + ] + }, { label: 'Schedule Type', name: 'scheduleType', @@ -482,6 +812,40 @@ class Start_Agentflow implements INode { outputData.form = form } + if (startInputType === 'webhookTrigger') { + 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 */ + } + } + 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 + } + // mode='payload' — webhook is exposed via outputData.webhook; no `question` is set. + } + if (startInputType === 'scheduleInput') { const scheduleInputMode = (nodeData.inputs?.scheduleInputMode as string) || 'text' if (scheduleInputMode === 'form') { 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/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/Interface.ts b/packages/server/src/Interface.ts index abb680f407f..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 { @@ -74,6 +75,8 @@ export interface IChatFlow { type?: ChatflowType mcpServerConfig?: string workspaceId: string + webhookSecret?: string | null + webhookSecretConfigured?: boolean } export interface IChatMessage { @@ -371,6 +374,7 @@ export interface IncomingAgentflowInput extends Omit question?: string form?: Record humanInput?: IHumanInput + webhook?: Record } export interface IActiveChatflows { diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index b88927b2224..dcc0c152d88 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -284,6 +284,44 @@ const checkIfChatflowHasChanged = async (req: Request, res: Response, next: Next } } +const setWebhookSecret = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + `Error: chatflowsController.setWebhookSecret - id not provided!` + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.setWebhookSecret - workspace not found!`) + } + const apiResponse = await chatflowsService.setWebhookSecret(req.params.id, workspaceId) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + +const clearWebhookSecret = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + `Error: chatflowsController.clearWebhookSecret - id not provided!` + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.clearWebhookSecret - workspace not found!`) + } + await chatflowsService.clearWebhookSecret(req.params.id, workspaceId) + return res.sendStatus(StatusCodes.NO_CONTENT) + } catch (error) { + next(error) + } +} + const getScheduleStatus = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.params?.id) { @@ -396,6 +434,8 @@ export default { getSinglePublicChatflow, getSinglePublicChatbotConfig, checkIfChatflowHasChanged, + setWebhookSecret, + clearWebhookSecret, getScheduleStatus, getScheduleTriggerLogs, deleteScheduleTriggerLogs, 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..ca57815eb71 --- /dev/null +++ b/packages/server/src/controllers/webhook-listener/index.ts @@ -0,0 +1,114 @@ +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) + + 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:' + + 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.test.ts b/packages/server/src/controllers/webhook/index.test.ts new file mode 100644 index 00000000000..0874623642b --- /dev/null +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -0,0 +1,585 @@ +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 mockCheckIfChatflowIsValidForStreaming = jest.fn() +const mockSseStreamer = { + addExternalClient: jest.fn(), + streamMetadataEvent: jest.fn(), + streamErrorEvent: jest.fn(), + removeClient: jest.fn() +} + +jest.mock('../../services/webhook', () => ({ + __esModule: true, + default: { validateWebhookChatflow: mockValidateWebhookChatflow } +})) +jest.mock('../../services/predictions', () => ({ + __esModule: true, + default: { buildChatflow: mockBuildChatflow } +})) +jest.mock('../../services/chatflows', () => ({ + __esModule: true, + default: { checkIfChatflowIsValidForStreaming: mockCheckIfChatflowIsValidForStreaming } +})) +jest.mock('../../utils/rateLimit', () => ({ + RateLimiterManager: { + getInstance: () => ({ + getRateLimiter: () => (_req: Request, _res: Response, next: NextFunction) => next() + }) + } +})) +jest.mock('../../utils/callbackDispatcher', () => ({ + dispatchCallback: mockDispatchCallback +})) +jest.mock('../../utils/getRunningExpressApp', () => ({ + getRunningExpressApp: () => ({ sseStreamer: mockSseStreamer }) +})) +const mockBindExecution = jest.fn() +jest.mock('../../services/webhook-listener', () => ({ + getWebhookListenerRegistry: () => ({ bindExecution: mockBindExecution }) +})) +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' + +const mockReq = (overrides: Partial = {}): Request => + ({ + params: { id: 'chatflow-123' }, + body: { foo: 'bar' }, + method: 'POST', + headers: { 'content-type': 'application/json' }, + query: { page: '1' }, + user: undefined, + ...overrides + } as unknown as Request) + +const mockRes = (): Response => { + const res = {} as Response + res.json = jest.fn().mockReturnValue(res) + res.status = jest.fn().mockReturnValue(res) + return res +} + +const mockNext = (): NextFunction => jest.fn() + +describe('createWebhook', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default: synchronous response mode + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'sync' as const }) + }) + + it('calls next with PRECONDITION_FAILED when id is missing', async () => { + const req = mockReq({ params: {} as any }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.PRECONDITION_FAILED })) + }) + + it('calls next with error when validateWebhookChatflow rejects', async () => { + const error = { statusCode: StatusCodes.NOT_FOUND, message: 'not found' } + mockValidateWebhookChatflow.mockRejectedValue(error) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(next).toHaveBeenCalledWith(error) + }) + + it('wraps req.body under webhook key before calling buildChatflow', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const originalBody = { foo: 'bar' } + const req = mockReq({ body: originalBody }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockBuildChatflow).toHaveBeenCalledWith( + expect.objectContaining({ + 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 + ) + }) + + it('builds namespaced webhook payload with body, headers, and query', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const req = mockReq({ + body: { action: 'push' }, + headers: { 'x-github-event': 'push' } as any, + query: { page: '2' } as any + }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockBuildChatflow).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + webhook: { + body: { action: 'push' }, + headers: expect.objectContaining({ 'x-github-event': 'push' }), + query: { page: '2' } + } + }) + }), + ChatType.WEBHOOK + ) + }) + + 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) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(res.json).toHaveBeenCalledWith(apiResult) + expect(next).not.toHaveBeenCalled() + }) + + it('calls next with error when buildChatflow rejects', async () => { + const error = new Error('execution failed') + mockBuildChatflow.mockRejectedValue(error) + + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(next).toHaveBeenCalledWith(error) + }) + + it('passes the original body to validateWebhookChatflow before mutation', async () => { + mockBuildChatflow.mockResolvedValue({}) + + const req = mockReq({ body: { foo: 'bar' } }) + const res = mockRes() + const next = mockNext() + + await webhookController.createWebhook(req, res, next) + + expect(mockValidateWebhookChatflow).toHaveBeenCalledWith( + 'chatflow-123', + undefined, + { foo: 'bar' }, + 'POST', + expect.any(Object), + expect.any(Object), + 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) + }) + }), + ChatType.WEBHOOK + ) + }) + + // --- Async callback (FLOWISE-367) --- + + it('returns 202 immediately when callbackUrl is configured on Start node', async () => { + 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()) + + const req = mockReq() + 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 () => { + 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()) + + const req = mockReq({ + body: { chatId: 'existing-id', foo: 'bar' } + }) + 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 () => { + 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()) + + const req = mockReq() + 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 () => { + mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, 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() + 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 () => { + 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) + mockDispatchCallback.mockResolvedValue(undefined) + jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn()) + + const req = mockReq() + 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 () => { + 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()) + + const req = mockReq() + 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({ + 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()) + + const req = mockReq() + const res = mockRes() + + await webhookController.createWebhook(req, res, mockNext()) + + 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 () => { + 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()) + + 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('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() + 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('returns synchronous JSON response when responseMode is sync', 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() + }) + + // --- 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 error on URL validation 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(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 new file mode 100644 index 00000000000..9829cb3843d --- /dev/null +++ b/packages/server/src/controllers/webhook/index.ts @@ -0,0 +1,178 @@ +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 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 { 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 { + if (typeof req.params === 'undefined' || !req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: webhookController.createWebhook - id not provided!`) + } + + const workspaceId = req.user?.activeWorkspaceId + + // For form-encoded requests, unwrap JSON encoded in a `payload` field (e.g. GitHub webhooks) + // so $webhook.body.* resolves against the actual payload fields. + const contentType = (req.headers['content-type'] ?? '').toLowerCase() + let body = req.body + if (contentType.startsWith('application/x-www-form-urlencoded') && typeof body?.payload === 'string') { + try { + body = JSON.parse(body.payload) + } catch { + // leave body as-is if payload isn't valid JSON + } + } + + const isResume = body?.humanInput != null + + const { responseMode, callbackUrl, callbackSecret } = await webhookService.validateWebhookChatflow( + req.params.id, + workspaceId, + body, + req.method, + req.headers, + req.query, + (req as any).rawBody, + isResume ? { skipFieldValidation: true } : undefined + ) + + // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist + req.body = { + webhook: { + body, + headers: redactSensitiveHeaders(req.headers as Record), + query: req.query + } + } + + 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 + + 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 = executionChatId + 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() + } catch { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid http or https URL`) + } + } + + // 202 response and the background execution share the pre-assigned executionChatId + const chatId = executionChatId + + res.status(202).json({ chatId, status: 'PROCESSING' }) + + setImmediate(async () => { + try { + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) + + 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) { + 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) { + 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)}`) + } + } finally { + // Notify webhook listeners that this execution is done; their SSE connections stay open. + getRunningExpressApp().sseStreamer.removeClient(chatId) + } + }) + return + } + + try { + const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK) + return res.json(apiResponse) + } finally { + getRunningExpressApp().sseStreamer.removeClient(executionChatId) + } + } catch (error) { + next(error) + } +} + +const getRateLimiterMiddleware = async (req: Request, res: Response, next: NextFunction) => { + try { + return RateLimiterManager.getInstance().getRateLimiter()(req, res, next) + } catch (error) { + next(error) + } +} + +export default { + createWebhook, + getRateLimiterMiddleware +} diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts index bc1a2347ec4..bd895df1dc1 100644 --- a/packages/server/src/database/entities/ChatFlow.ts +++ b/packages/server/src/database/entities/ChatFlow.ts @@ -64,6 +64,12 @@ export class ChatFlow implements IChatFlow { @Column({ nullable: true, type: 'text' }) mcpServerConfig?: string + @Column({ nullable: true, type: 'text', select: false }) + webhookSecret?: string | null + + @Column({ nullable: true, default: false }) + webhookSecretConfigured?: boolean + @Column({ nullable: false, type: 'text' }) workspaceId: string } diff --git a/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..df14de0d812 --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000003 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;') + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;') + await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;') + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;') + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index 3f66de53f19..82ed7dc488f 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -44,6 +44,7 @@ import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000- import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer' +import { AddWebhookSecretToChatFlow1776240000003 } from './1776240000003-AddWebhookSecretToChatFlow' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' @@ -117,6 +118,7 @@ export const mariadbMigrations = [ AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, AddCustomMcpServer1766000000000, + AddWebhookSecretToChatFlow1776240000003, AddMcpServerConfigToChatFlow1767000000000, AddScheduleEntities1772000000000 ] diff --git a/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..23753a3f53c --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;') + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;') + await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;') + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;') + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index 00505cd321d..58c08d4c7b5 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -44,6 +44,7 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000002 } from './1776240000002-AddWebhookSecretToChatFlow' import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' @@ -118,6 +119,7 @@ export const mysqlMigrations = [ FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000002, AddCustomMcpServer1766000000000, AddMcpServerConfigToChatFlow1767000000000, AddScheduleEntities1772000000000 diff --git a/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..787f51a9ec2 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecret" TEXT;`) + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`) + await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`) + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index ad23c95c9bb..750d7daf647 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -42,6 +42,7 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000001 } from './1776240000001-AddWebhookSecretToChatFlow' import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' @@ -114,6 +115,7 @@ export const postgresMigrations = [ AddChatFlowNameIndex1759424903973, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000001, AddCustomMcpServer1766000000000, AddMcpServerConfigToChatFlow1767000000000, AddScheduleEntities1772000000000 diff --git a/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..e8217f14c4f --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecret" TEXT;`) + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`) + await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`) + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index f4378260178..db277bacda0 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -40,6 +40,7 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000000 } from './1776240000000-AddWebhookSecretToChatFlow' import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities' @@ -110,6 +111,7 @@ export const sqliteMigrations = [ AddChatFlowNameIndex1759424923093, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000000, AddCustomMcpServer1766000000000, AddMcpServerConfigToChatFlow1767000000000, AddScheduleEntities1772000000000 diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ee1d6f6dc5b..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') @@ -168,8 +172,13 @@ export class App { async config() { // Limit is needed to allow sending/receiving base64 encoded string const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb' - this.app.use(express.json({ limit: flowise_file_size_limit })) - this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true })) + + // Preserve raw bytes before JSON parsing for webhook HMAC signature verification + const captureRawBody = (req: Request, _res: Response, buf: Buffer) => { + ;(req as any).rawBody = buf + } + this.app.use(express.json({ limit: flowise_file_size_limit, verify: captureRawBody })) + this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true, verify: captureRawBody })) // Enhanced trust proxy settings for load balancer let trustProxy: string | boolean | number | undefined = process.env.TRUST_PROXY 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/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 22eaac4bfba..8e963b041a7 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -33,6 +33,10 @@ router.put( // DELETE router.delete(['/', '/:id'], checkAnyPermission('chatflows:delete,agentflows:delete'), chatflowsController.deleteChatflow) +// WEBHOOK SECRET +router.post('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.setWebhookSecret) +router.delete('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.clearWebhookSecret) + // CHECK FOR CHANGE router.get( '/has-changed/:id/:lastUpdatedDateTime', diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index b7196f3532e..86f3b0d11ac 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -49,6 +49,8 @@ import upsertHistoryRouter from './upsert-history' 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' @@ -118,6 +120,8 @@ router.use('/tools', toolsRouter) 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..49986a907e2 --- /dev/null +++ b/packages/server/src/routes/webhook-listener/index.ts @@ -0,0 +1,13 @@ +import express from 'express' +import webhookListenerController from '../../controllers/webhook-listener' +import { checkAnyPermission } from '../../enterprise/rbac/PermissionCheck' + +const router = express.Router() + +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/routes/webhook/index.ts b/packages/server/src/routes/webhook/index.ts new file mode 100644 index 00000000000..029bdd51299 --- /dev/null +++ b/packages/server/src/routes/webhook/index.ts @@ -0,0 +1,9 @@ +import express from 'express' +import webhookController from '../../controllers/webhook' + +const router = express.Router() + +// Unauthenticated at route level — API key validation happens downstream in utilBuildChatflow. +router.all('/:id', webhookController.getRateLimiterMiddleware, webhookController.createWebhook) + +export default router diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 531d838e54e..b786fe9bd8a 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import { ICommonObject, removeFolderFromStorage } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { Brackets, In, QueryRunner } from 'typeorm' @@ -14,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' @@ -616,6 +625,66 @@ const checkIfChatflowHasChanged = async (chatflowId: string, lastUpdatedDateTime } } +const setWebhookSecret = async (chatflowId: string, workspaceId: string): Promise<{ webhookSecret: string }> => { + try { + const appServer = getRunningExpressApp() + 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`) + const plaintext = randomBytes(32).toString('hex') + chatflow.webhookSecret = await encryptCredentialData({ secret: plaintext }) + chatflow.webhookSecretConfigured = true + await repo.save(chatflow) + return { webhookSecret: plaintext } + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.setWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + +const clearWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + 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 = null + chatflow.webhookSecretConfigured = false + await repo.save(chatflow) + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.clearWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + +const getWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow) + .createQueryBuilder('chatflow') + .select('chatflow.webhookSecret') + .where('chatflow.id = :id', { id: chatflowId }) + .andWhere('chatflow.workspaceId = :workspaceId', { workspaceId }) + .getOne() + 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( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.getWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + export default { assertChatflowIdsInWorkspace, checkIfChatflowIsValidForStreaming, @@ -630,5 +699,8 @@ export default { updateChatflow, getSinglePublicChatbotConfig, checkIfChatflowHasChanged, - getAllChatflowsCountByOrganization + getAllChatflowsCountByOrganization, + setWebhookSecret, + clearWebhookSecret, + getWebhookSecret } 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-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..29d4b7da668 --- /dev/null +++ b/packages/server/src/services/webhook-listener/registry.ts @@ -0,0 +1,232 @@ +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)) { + 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 + // 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 { + return `wh-listener-${uuidv4()}` + } + + 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/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts new file mode 100644 index 00000000000..3e7b9e80245 --- /dev/null +++ b/packages/server/src/services/webhook/index.test.ts @@ -0,0 +1,624 @@ +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' + +const mockGetChatflowById = jest.fn() +const mockGetWebhookSecret = jest.fn() + +jest.mock('../chatflows', () => ({ + __esModule: true, + default: { getChatflowById: mockGetChatflowById, getWebhookSecret: mockGetWebhookSecret } +})) + +import webhookService from './index' + +const makeChatflow = ( + startInputType: string, + inputs?: { + webhookBodyParams?: unknown + webhookMethod?: string + webhookContentType?: string + webhookHeaderParams?: unknown + webhookQueryParams?: unknown + webhookEnableAuth?: boolean + webhookEnableCallback?: boolean + webhookEnableValidation?: boolean + callbackUrl?: string + callbackSecret?: string + }, + entityFields?: { webhookSecretConfigured?: boolean } +) => ({ + id: 'test-id', + ...entityFields, + flowData: JSON.stringify({ + nodes: [ + { + id: 'startAgentflow_0', + data: { + name: 'startAgentflow', + inputs: { + startInputType, + ...inputs + } + } + } + ], + edges: [] + }) +}) + +describe('validateWebhookChatflow', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('throws 404 when chatflow is not found', async () => { + mockGetChatflowById.mockResolvedValue(null) + + await expect(webhookService.validateWebhookChatflow('missing-id')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('throws 404 when chatflow is not configured as a webhook trigger', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('chatInput')) + + await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('resolves without error for a valid webhook chatflow', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) + + await expect(webhookService.validateWebhookChatflow('some-id')).resolves.toMatchObject({}) + }) + + it('throws 500 for unexpected errors from getChatflowById', async () => { + mockGetChatflowById.mockRejectedValue(new Error('db connection failed')) + + await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toMatchObject({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR + }) + }) + + it('preserves InternalFlowiseError without wrapping', async () => { + const original = new InternalFlowiseError(StatusCodes.NOT_FOUND, 'already an internal error') + mockGetChatflowById.mockRejectedValue(original) + + await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toBe(original) + }) + + // --- Method validation --- + + it('throws 405 when HTTP method does not match configured webhookMethod', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookMethod: 'POST' })) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'GET')).rejects.toMatchObject({ + statusCode: StatusCodes.METHOD_NOT_ALLOWED + }) + }) + + it('resolves for any method when webhookMethod is not configured', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'DELETE')).resolves.toMatchObject({}) + }) + + // --- Content-Type validation --- + + it('throws 415 when Content-Type does not match configured webhookContentType', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' })) + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { 'content-type': 'text/plain' }) + ).rejects.toMatchObject({ statusCode: StatusCodes.UNSUPPORTED_MEDIA_TYPE }) + }) + + it('resolves when Content-Type starts with configured value (handles charset suffix)', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' })) + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { + 'content-type': 'application/json; charset=utf-8' + }) + ).resolves.toMatchObject({}) + }) + + it('resolves when webhookContentType is not configured', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) + + await expect( + 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', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('x-api-key') + }) + }) + + it('resolves when all required headers are present', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] }) + ) + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'x-api-key': 'secret' }) + ).resolves.toMatchObject({}) + }) + + // --- Body param validation --- + + it('throws 400 when a required param is missing from body', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST + }) + }) + + it('includes the missing field name in the 400 error message', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('action') + }) + }) + + it('resolves when all required params are present in body', async () => { + 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', { webhookEnableValidation: true, webhookBodyParams: '' })) + + 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.toMatchObject({}) + }) + + // --- Body type validation --- + + it('throws 400 when a declared body param has wrong type', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 'not-a-number' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('count') + }) + }) + + it('resolves when declared body param has correct type', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) + ) + + 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 () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toMatchObject({}) + }) + + it('throws 400 when number param is an empty string (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'count', type: 'number', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('count') + }) + }) + + it('resolves when boolean param is a native boolean (JSON)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toMatchObject({}) + }) + + it('resolves when boolean param is the string "true" (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toMatchObject({}) + }) + + it('resolves when boolean param is the string "false" (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) + ) + + 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 () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] + }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'yes' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('active') + }) + }) + + // --- object type --- + + it('resolves when object param is a plain object', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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', { + webhookEnableValidation: true, + 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 () => { + 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, + message: expect.stringContaining('page') + }) + }) + + it('resolves when all required query params are present', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookQueryParams: [{ name: 'page', required: true }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toMatchObject({}) + }) + + // --- HMAC signature verification --- + + const SECRET = 'test-secret-abc123' + const RAW_BODY = Buffer.from('{"event":"push"}') + + function sign(secret: string, body: Buffer): string { + const { createHmac } = require('crypto') + return createHmac('sha256', secret).update(body).digest('hex') + } + + 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.toMatchObject({}) + }) + + it('resolves when secret is set and signature is valid', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY) + ).resolves.toMatchObject({}) + }) + + it('throws 401 when secret is set but X-Webhook-Signature header is missing', async () => { + 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, + 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', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': 'deadbeef' } + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)).rejects.toMatchObject( + { + statusCode: 401 + } + ) + }) + + it('throws 401 when payload is tampered (signature computed against original body)', async () => { + 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) } + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, tamperedBody) + ).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('throws 401 when secret is set but rawBody is undefined', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true }) + ) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect( + 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', { webhookEnableValidation: true, 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', { webhookEnableAuth: true }, { 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 new file mode 100644 index 00000000000..e76486e7ba7 --- /dev/null +++ b/packages/server/src/services/webhook/index.ts @@ -0,0 +1,166 @@ +import { StatusCodes } from 'http-status-codes' +import { IReactFlowObject } from '../../Interface' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { getErrorMessage } from '../../errors/utils' +import { verifyWebhookSignature, verifyPlainToken } from '../../utils/signatureVerification' +import chatflowsService from '../chatflows' + +const validateWebhookChatflow = async ( + chatflowId: string, + workspaceId?: string, + body?: Record, + method?: string, + headers?: Record, + query?: Record, + rawBody?: Buffer, + options?: { skipFieldValidation?: boolean } +): Promise<{ responseMode: 'sync' | 'async' | 'stream'; callbackUrl?: string; callbackSecret?: string }> => { + try { + 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') + const startInputType = startNode?.data?.inputs?.startInputType + + if (startInputType !== 'webhookTrigger') { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} is not configured as a webhook trigger`) + } + + 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 (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, `Missing signature header: ${sigHeader}`) + } + + const valid = + sigType === 'plain-token' + ? verifyPlainToken(secret, sigValue) + : !!rawBody && verifyWebhookSignature(secret, rawBody, sigValue) + + if (!valid) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid webhook signature') + } + } + + if (options?.skipFieldValidation) return { responseMode, callbackUrl, callbackSecret } + + // Method validation + const webhookMethod = startNode?.data?.inputs?.webhookMethod + if (webhookMethod && method?.toUpperCase() !== webhookMethod.toUpperCase()) { + throw new InternalFlowiseError(StatusCodes.METHOD_NOT_ALLOWED, `Method ${method} not allowed. Expected ${webhookMethod}`) + } + + // 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 && hasBody && !incomingContentType.startsWith(webhookContentType)) { + throw new InternalFlowiseError( + StatusCodes.UNSUPPORTED_MEDIA_TYPE, + `Content-Type ${headers?.['content-type']} not allowed. Expected ${webhookContentType}` + ) + } + + // 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(', ')}` + ) + } + + // 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(', ')}` + ) + } + } + + return { responseMode, callbackUrl, callbackSecret } + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: webhookService.validateWebhookChatflow - ${getErrorMessage(error)}` + ) + } +} + +export default { + validateWebhookChatflow +} 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/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index 3c4a451a17b..248d3ed82a2 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -95,6 +95,28 @@ interface IAgentFlowRuntime { state?: ICommonObject chatHistory?: IMessage[] form?: Record + 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 { @@ -225,7 +247,8 @@ export const resolveVariables = async ( componentNodes: IComponentNodes, agentFlowExecutedData?: IAgentflowExecutedData[], iterationContext?: ICommonObject, - loopCounts?: Map + loopCounts?: Map, + webhook?: Record ): Promise => { let flowNodeData = cloneDeep(reactFlowNodeData) const types = 'inputs' @@ -286,6 +309,17 @@ export const resolveVariables = async ( } } + if (variableFullPath.startsWith('$webhook.')) { + const variableValue = get(webhook, variableFullPath.replace('$webhook.', '')) + if (variableValue != null) { + const formattedValue = + Array.isArray(variableValue) || (typeof variableValue === 'object' && variableValue !== null) + ? JSON.stringify(variableValue) + : variableValue + resolvedValue = resolvedValue.replace(match, formattedValue) + } + } + if (variableFullPath === FILE_ATTACHMENT_PREFIX) { resolvedValue = resolvedValue.replace(match, uploadedFilesContent) } @@ -1124,6 +1158,12 @@ const executeNode = async ({ } else if (isObjectNotEmpty(agentflowRuntime.form)) { formValue = agentflowRuntime.form as Record } + let webhookValue: Record = {} + if (isObjectNotEmpty(incomingInput.webhook)) { + webhookValue = incomingInput.webhook as Record + } else if (isObjectNotEmpty(agentflowRuntime.webhook)) { + webhookValue = agentflowRuntime.webhook as Record + } const reactFlowNodeData: INodeData = await resolveVariables( flowNodeData, incomingInput.question ?? '', @@ -1136,7 +1176,8 @@ const executeNode = async ({ componentNodes, agentFlowExecutedData, iterationContext, - loopCounts + loopCounts, + webhookValue ) // Handle human input if present @@ -1176,6 +1217,8 @@ const executeNode = async ({ finalInput = Object.entries(incomingInput.form || {}) .map(([key, value]) => `${key}: ${value}`) .join('\n') + } else if (incomingInput.webhook) { + finalInput = JSON.stringify(incomingInput.webhook) } // Prepare run parameters @@ -1549,9 +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' + 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') } @@ -1595,7 +1637,8 @@ export const executeAgentFlow = async ({ let agentflowRuntime: IAgentFlowRuntime = { state: {}, chatHistory: [], - form: {} + form: {}, + webhook: {} } let previousExecution: Execution | undefined @@ -1678,6 +1721,37 @@ 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 (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)) { @@ -2259,6 +2333,13 @@ export const executeAgentFlow = async ({ } else { finalUserInput = question || humanInput?.feedback || ' ' } + } else if (startInputType === 'webhookTrigger') { + 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/server/src/utils/callbackDispatcher.test.ts b/packages/server/src/utils/callbackDispatcher.test.ts new file mode 100644 index 00000000000..8a5525fb27c --- /dev/null +++ b/packages/server/src/utils/callbackDispatcher.test.ts @@ -0,0 +1,92 @@ +import { createHmac } from 'crypto' + +const mockSecureAxiosRequest = jest.fn() +const mockLoggerError = jest.fn() + +jest.mock('flowise-components', () => ({ secureAxiosRequest: mockSecureAxiosRequest })) +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 via secureAxiosRequest', async () => { + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) + + await dispatchCallback(URL, 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 () => { + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) + const secret = 'my-secret' + const body = JSON.stringify(PAYLOAD) + + await dispatchCallback(URL, PAYLOAD, secret) + + expect(mockSecureAxiosRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: URL, + data: body, + headers: expect.objectContaining({ + 'X-Flowise-Signature': expectedSignature(body, secret) + }) + }) + ) + }) + + it('does not include X-Flowise-Signature when no secret is provided', async () => { + mockSecureAxiosRequest.mockResolvedValue({ status: 200 }) + + await dispatchCallback(URL, PAYLOAD) + + 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 () => { + mockSecureAxiosRequest.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ status: 200 }) + + const promise = dispatchCallback(URL, PAYLOAD) + await jest.advanceTimersByTimeAsync(3000) + await promise + + expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(2) + expect(mockLoggerError).not.toHaveBeenCalled() + }) + + it('logs an error after all 3 attempts fail and does not throw', async () => { + mockSecureAxiosRequest.mockRejectedValue(new Error('unreachable')) + + const promise = dispatchCallback(URL, PAYLOAD) + await jest.advanceTimersByTimeAsync(3000) + await jest.advanceTimersByTimeAsync(6000) + await promise + + 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 new file mode 100644 index 00000000000..71400b2b4c4 --- /dev/null +++ b/packages/server/src/utils/callbackDispatcher.ts @@ -0,0 +1,32 @@ +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 +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 secureAxiosRequest({ method: 'POST', url, data: 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}` + ) + } + } + } +} diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index 900a716b4a1..a20f55c4c43 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -10,6 +10,7 @@ export const WHITELIST_URLS = [ '/api/v1/public-chatbotConfig', '/api/v1/public-executions', '/api/v1/prediction/', + '/api/v1/webhook/', '/api/v1/chatmessage/abort', '/api/v1/node-icon/', '/api/v1/components-credentials-icon/', 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/server/src/utils/signatureVerification.test.ts b/packages/server/src/utils/signatureVerification.test.ts new file mode 100644 index 00000000000..093c9b5493d --- /dev/null +++ b/packages/server/src/utils/signatureVerification.test.ts @@ -0,0 +1,103 @@ +import { createHmac } from 'crypto' +import { verifyWebhookSignature, verifyPlainToken } from './signatureVerification' + +const SECRET = 'test-secret-abc123' +const BODY = Buffer.from('{"event":"push"}') + +function sign(secret: string, body: Buffer): string { + return createHmac('sha256', secret).update(new Uint8Array(body)).digest('hex') +} + +describe('verifyWebhookSignature', () => { + it('returns true for a valid signature', () => { + expect(verifyWebhookSignature(SECRET, BODY, sign(SECRET, BODY))).toBe(true) + }) + + it('returns false for a wrong secret', () => { + expect(verifyWebhookSignature('wrong-secret', BODY, sign(SECRET, BODY))).toBe(false) + }) + + it('returns false for a tampered body', () => { + const tamperedBody = Buffer.from('{"event":"delete"}') + expect(verifyWebhookSignature(SECRET, tamperedBody, sign(SECRET, BODY))).toBe(false) + }) + + it('returns false for an empty signature string', () => { + expect(verifyWebhookSignature(SECRET, BODY, '')).toBe(false) + }) + + it('returns false for a non-hex signature string', () => { + expect(verifyWebhookSignature(SECRET, BODY, 'not-hex!!')).toBe(false) + }) + + it('returns false for a signature that is too short', () => { + const truncated = sign(SECRET, BODY).slice(0, 10) + expect(verifyWebhookSignature(SECRET, BODY, truncated)).toBe(false) + }) + + it('returns false for a signature that is too long', () => { + const padded = sign(SECRET, BODY) + 'aabb' + expect(verifyWebhookSignature(SECRET, BODY, padded)).toBe(false) + }) + + it('returns true for an empty body when signed correctly', () => { + const emptyBody = Buffer.from('') + expect(verifyWebhookSignature(SECRET, emptyBody, sign(SECRET, emptyBody))).toBe(true) + }) + + describe('sha256= prefix', () => { + it('returns true for a valid sha256= signature', () => { + const sig = 'sha256=' + sign(SECRET, BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true) + }) + + it('returns false for sha256= with wrong secret', () => { + const sig = 'sha256=' + sign('wrong-secret', BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + + it('returns false for sha256= with tampered body', () => { + const sig = 'sha256=' + sign(SECRET, Buffer.from('{"event":"delete"}')) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + }) + + describe('sha1= prefix', () => { + function signSha1(secret: string, body: Buffer): string { + return createHmac('sha1', secret).update(new Uint8Array(body)).digest('hex') + } + + it('returns true for a valid sha1= signature', () => { + const sig = 'sha1=' + signSha1(SECRET, BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true) + }) + + it('returns false for sha1= with wrong secret', () => { + const sig = 'sha1=' + signSha1('wrong-secret', BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + + it('returns false for sha1= with tampered body', () => { + const sig = 'sha1=' + signSha1(SECRET, Buffer.from('{"event":"delete"}')) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + }) +}) + +describe('verifyPlainToken', () => { + it('returns true when provided token matches secret', () => { + expect(verifyPlainToken(SECRET, SECRET)).toBe(true) + }) + + it('returns false when provided token does not match secret', () => { + expect(verifyPlainToken(SECRET, 'wrong-token')).toBe(false) + }) + + it('returns false when lengths differ (shorter provided)', () => { + expect(verifyPlainToken(SECRET, SECRET.slice(0, -1))).toBe(false) + }) + + it('returns false when lengths differ (longer provided)', () => { + expect(verifyPlainToken(SECRET, SECRET + 'x')).toBe(false) + }) +}) diff --git a/packages/server/src/utils/signatureVerification.ts b/packages/server/src/utils/signatureVerification.ts new file mode 100644 index 00000000000..00f7118c8c5 --- /dev/null +++ b/packages/server/src/utils/signatureVerification.ts @@ -0,0 +1,50 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +/** + * Verifies that `providedHex` is the HMAC of `rawBody` keyed with `secret`. + * Uses constant-time comparison to prevent timing attacks. + * + * Automatically detects the algorithm from a leading "=" prefix: + * - "sha256=" → HMAC-SHA256 (GitHub X-Hub-Signature-256, Slack, Bitbucket) + * - "sha1=" → HMAC-SHA1 (GitHub X-Hub-Signature legacy) + * - no prefix → HMAC-SHA256 (default) + * + * @param secret The webhook secret stored on the chatflow + * @param rawBody The raw request body bytes + * @param providedHex The hex digest (optionally prefixed) from the signature request header + * @returns true if the signature is valid, false otherwise + */ +export function verifyWebhookSignature(secret: string, rawBody: Buffer, providedHex: string): boolean { + let algo = 'sha256' + let hex = providedHex + if (providedHex.includes('=')) { + const [prefix, ...rest] = providedHex.split('=') + hex = rest.join('=') + if (prefix === 'sha1') algo = 'sha1' + } + const expected = createHmac(algo, secret).update(new Uint8Array(rawBody)).digest() + let provided: Buffer + try { + provided = Buffer.from(hex, 'hex') + } catch { + return false + } + if (provided.length !== expected.length) return false + return timingSafeEqual(new Uint8Array(provided), new Uint8Array(expected)) +} + +/** + * Verifies a plain-token signature by doing a constant-time string comparison + * between the stored secret and the value provided in the request header. + * Used for GitLab-style webhooks that send the raw secret directly in a header. + * + * @param secret The webhook secret stored on the chatflow + * @param provided The raw value from the signature request header + * @returns true if the values match, false otherwise + */ +export function verifyPlainToken(secret: string, provided: string): boolean { + const secretBuf = Buffer.from(secret) + const providedBuf = Buffer.from(provided) + if (secretBuf.length !== providedBuf.length) return false + return timingSafeEqual(new Uint8Array(secretBuf), new Uint8Array(providedBuf)) +} diff --git a/packages/server/src/utils/stripProtectedFields.test.ts b/packages/server/src/utils/stripProtectedFields.test.ts index 8bcfdec93df..9e83cc72574 100644 --- a/packages/server/src/utils/stripProtectedFields.test.ts +++ b/packages/server/src/utils/stripProtectedFields.test.ts @@ -29,6 +29,18 @@ describe('stripProtectedFields', () => { expect(result).toHaveProperty('credential', 'cred-uuid') }) + it('removes webhookSecret from input', () => { + const result = stripProtectedFields({ webhookSecret: 'attacker-chosen-secret', name: 'test' }) + expect(result).not.toHaveProperty('webhookSecret') + expect(result).toHaveProperty('name', 'test') + }) + + it('removes webhookSecretConfigured from input', () => { + const result = stripProtectedFields({ webhookSecretConfigured: true, name: 'test' }) + expect(result).not.toHaveProperty('webhookSecretConfigured') + expect(result).toHaveProperty('name', 'test') + }) + it('removes all protected fields when all are present', () => { const body = { id: 'abc', @@ -36,6 +48,8 @@ describe('stripProtectedFields', () => { updatedDate: '2026-01-02T00:00:00.000Z', workspaceId: '11111111-2222-3333-4444-555555555555', organizationId: 'org-789', + webhookSecret: 'some-secret', + webhookSecretConfigured: true, details: '{"name":"my assistant"}', credential: 'cred-uuid', iconSrc: null diff --git a/packages/server/src/utils/stripProtectedFields.ts b/packages/server/src/utils/stripProtectedFields.ts index 8fd91a1e012..043f3f645a6 100644 --- a/packages/server/src/utils/stripProtectedFields.ts +++ b/packages/server/src/utils/stripProtectedFields.ts @@ -2,7 +2,16 @@ * Fields that are managed exclusively by the server and must never be * overwritten by user-supplied request bodies. */ -export const PROTECTED_FIELDS = ['id', 'createdDate', 'updatedDate', 'runDate', 'workspaceId', 'organizationId'] as const +export const PROTECTED_FIELDS = [ + 'id', + 'createdDate', + 'updatedDate', + 'runDate', + 'workspaceId', + 'organizationId', + 'webhookSecret', + 'webhookSecretConfigured' +] as const export type ProtectedField = (typeof PROTECTED_FIELDS)[number] diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index ea42c67225c..ba56a5ff0e0 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -22,6 +22,10 @@ const getHasChatflowChanged = (id, lastUpdatedDateTime) => client.get(`/chatflow const generateAgentflow = (body) => client.post(`/agentflowv2-generator/generate`, body) +const setWebhookSecret = (id) => client.post(`/chatflows/${id}/webhook-secret`) + +const clearWebhookSecret = (id) => client.delete(`/chatflows/${id}/webhook-secret`) + const getScheduleStatus = (id) => client.get(`/chatflows/${id}/schedule/status`) const toggleScheduleEnabled = (id, enabled) => client.patch(`/chatflows/${id}/schedule/enabled`, { enabled }) @@ -42,6 +46,8 @@ export default { getAllowChatflowUploads, getHasChatflowChanged, generateAgentflow, + setWebhookSecret, + clearWebhookSecret, getScheduleStatus, toggleScheduleEnabled, getScheduleTriggerLogs, 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/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/ui-component/input/suggestionOption.js b/packages/ui/src/ui-component/input/suggestionOption.js index fe8546a2f58..f806f37be1d 100644 --- a/packages/ui/src/ui-component/input/suggestionOption.js +++ b/packages/ui/src/ui-component/input/suggestionOption.js @@ -138,6 +138,7 @@ export const suggestionOptions = ( })) const startAgentflowNode = nodes.find((node) => node.data.name === 'startAgentflow') + const { webhookQueryParams, webhookBodyParams, webhookHeaderParams } = startAgentflowNode?.data?.inputs ?? {} const startInputType = startAgentflowNode?.data?.inputs?.startInputType const scheduleInputMode = startAgentflowNode?.data?.inputs?.scheduleInputMode const activeFormInputTypes = @@ -155,6 +156,36 @@ export const suggestionOptions = ( })) } + let webhookQueryItems = [] + if (webhookQueryParams) { + webhookQueryItems = webhookQueryParams.map((input) => ({ + id: `$webhook.query.${input.name}`, + mentionLabel: `$webhook.query.${input.name}`, + description: `Webhook Query: ${input.name}`, + category: 'Webhook Inputs' + })) + } + + let webhookItems = [] + if (webhookBodyParams) { + webhookItems = webhookBodyParams.map((input) => ({ + id: `$webhook.body.${input.name}`, + mentionLabel: `$webhook.body.${input.name}`, + description: `Webhook Body: ${input.name}`, + category: 'Webhook Inputs' + })) + } + + let webhookHeaderItems = [] + if (webhookHeaderParams) { + webhookHeaderItems = webhookHeaderParams.map((input) => ({ + id: `$webhook.headers.${input.name}`, + mentionLabel: `$webhook.headers.${input.name}`, + description: `Webhook Header: ${input.name}`, + category: 'Webhook Inputs' + })) + } + const nodeItems = (availableNodesForVariable || []).map((node) => { const selectedOutputAnchor = node.data.outputAnchors?.[0]?.options?.find((ancr) => ancr.name === node.data.outputs['output']) @@ -169,7 +200,16 @@ export const suggestionOptions = ( } }) - const allItems = [...defaultItems, ...formItems, ...nodeItems, ...stateItems, ...variableItems] + const allItems = [ + ...defaultItems, + ...formItems, + ...webhookQueryItems, + ...webhookItems, + ...webhookHeaderItems, + ...nodeItems, + ...stateItems, + ...variableItems + ] return allItems.filter( (item) => item.mentionLabel.toLowerCase().includes(query.toLowerCase()) || item.id.toLowerCase().includes(query.toLowerCase()) diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index d9f464828a4..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 } @@ -1309,6 +1317,17 @@ export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex) if (inputParam.hide) { _showHideOperation(effectiveNodeData, 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 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/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/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx index 24b486e29d3..91ee92492bb 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.jsx +++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx @@ -1,6 +1,7 @@ import PropTypes from 'prop-types' import { Handle, Position, useUpdateNodeInternals } from 'reactflow' import { useEffect, useRef, useState, useContext } from 'react' +import { useParams } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' import { cloneDeep } from 'lodash' import showdown from 'showdown' @@ -19,12 +20,24 @@ import { Dialog, DialogTitle, DialogContent, - DialogActions + DialogActions, + Alert } from '@mui/material' -import { useGridApiContext } from '@mui/x-data-grid' import IconAutoFixHigh from '@mui/icons-material/AutoFixHigh' +import InputAdornment from '@mui/material/InputAdornment' import { tooltipClasses } from '@mui/material/Tooltip' -import { IconWand, IconVariable, IconArrowsMaximize, IconEdit, IconAlertTriangle, IconBulb, IconRefresh, IconX } from '@tabler/icons-react' +import { useGridApiContext } from '@mui/x-data-grid' +import { + IconWand, + IconVariable, + IconArrowsMaximize, + IconEdit, + IconAlertTriangle, + IconBulb, + IconRefresh, + IconX, + IconCopy +} from '@tabler/icons-react' import { Tabs } from '@mui/base/Tabs' import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete' @@ -68,6 +81,7 @@ import PromptGeneratorDialog from '@/ui-component/dialog/PromptGeneratorDialog' // API import assistantsApi from '@/api/assistants' +import chatflowsApi from '@/api/chatflows' import documentstoreApi from '@/api/documentstore' // utils @@ -82,7 +96,7 @@ import useNotifier from '@/utils/useNotifier' // const import { baseURL, FLOWISE_CREDENTIAL_ID } from '@/store/constant' -import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions' +import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, SET_CHATFLOW } from '@/store/actions' const EDITABLE_OPTIONS = ['selectedTool', 'selectedAssistant'] @@ -125,6 +139,10 @@ const NodeInputHandler = ({ onHideNodeInfoDialog, onCustomDataChange }) => { + const { id: chatflowIdFromParams } = useParams() + const canvasChatflow = useSelector((state) => state.canvas.chatflow) + const chatflowId = chatflowIdFromParams || canvasChatflow?.id + const theme = useTheme() const customization = useSelector((state) => state.customization) const ref = useRef(null) @@ -156,6 +174,46 @@ const NodeInputHandler = ({ const [isNvidiaNIMDialogOpen, setIsNvidiaNIMDialogOpen] = useState(false) const [tabValue, setTabValue] = useState(0) + // Webhook secret — holds plaintext only for the current session (generate/regenerate response). + // Cleared on page reload; the configured state comes from canvasChatflow.webhookSecretConfigured. + const [webhookSecretPlaintext, setWebhookSecretPlaintext] = useState(null) + + const handleSetWebhookSecret = async () => { + if (!chatflowId) return + try { + const resp = await chatflowsApi.setWebhookSecret(chatflowId) + setWebhookSecretPlaintext(resp.data.webhookSecret) + dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: true } }) + enqueueSnackbar({ + message: 'Webhook secret generated.', + options: { key: new Date().getTime() + Math.random(), variant: 'success' } + }) + } catch (error) { + enqueueSnackbar({ + message: error?.response?.data?.message || 'Failed to generate webhook secret.', + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + + const handleClearWebhookSecret = async () => { + if (!chatflowId) return + try { + await chatflowsApi.clearWebhookSecret(chatflowId) + setWebhookSecretPlaintext(null) + dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: false } }) + enqueueSnackbar({ + message: 'Webhook secret removed.', + options: { key: new Date().getTime() + Math.random(), variant: 'success' } + }) + } catch (error) { + enqueueSnackbar({ + message: error?.response?.data?.message || 'Failed to remove webhook secret.', + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + const [modelSelectionDialogOpen, setModelSelectionDialogOpen] = useState(false) const [availableChatModels, setAvailableChatModels] = useState([]) const [availableChatModelsOptions, setAvailableChatModelsOptions] = useState([]) @@ -781,6 +839,10 @@ const NodeInputHandler = ({ updateNodeInternals(data.id) }, [data.id, position, updateNodeInternals]) + const webhookMethod = data.inputs?.webhookMethod ?? 'POST' + const webhookUrlBase = chatflowId ? `${baseURL}/api/v1/webhook/${chatflowId}` : null + const webhookUrl = webhookUrlBase ? `${webhookMethod} ${webhookUrlBase}` : 'Save the flow first to generate the webhook URL' + return (
{inputAnchor && ( @@ -1081,7 +1143,144 @@ const NodeInputHandler = ({ )} - {(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && + {inputParam.name === 'webhookURL' && ( + + + { + navigator.clipboard.writeText(webhookUrlBase).then( + () => + enqueueSnackbar({ + message: 'URL copied!', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success' + } + }), + () => + enqueueSnackbar({ + message: 'Failed to copy URL.', + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + ) + }} + > + {/* Match the Start node's accent green (#7EE787) so the copy + action reads as a "primary" action on this node. */} + + + + + ) : undefined + }} + /> + )} + + {inputParam.name === 'webhookSecret' && ( + + {!canvasChatflow?.webhookSecretConfigured && !webhookSecretPlaintext ? ( + // Not configured + + + 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 + + {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')) ? ( { + 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