Skip to content

Commit 0a50bf9

Browse files
authored
feat: add object/array body param types and per-option show/hide on d… (#6273)
* feat: add webhook secret & HMAC signature verification to webhook trigger Adds server-side webhook secret management (generate/clear/verify) and a UI control in the Start node for configuring the secret, signature header, and signature type (HMAC-SHA256 or plain token). Raw request body is now captured before JSON parsing so HMAC signatures can be verified against the original bytes. Migrations added for all four supported databases. * fix: accept string-coerced numbers and booleans in webhook body type validation application/x-www-form-urlencoded payloads deliver all values as strings, so the strict typeof check was incorrectly rejecting valid numeric ("42") and boolean ("true"/"false") values. Updated the filter to coerce and validate instead, with tests covering both JSON and form-encoded cases. * Added HITL support for webhooks * feat: add async callback URL to webhook trigger Optional Callback URL / Secret on the Start node (or x-callback-url header). Webhook now returns 202 immediately and POSTs SUCCESS, STOPPED (HITL), or ERROR to the callback URL, signed with HMAC-SHA256 when a secret is set. Retries 3x with 0s/3s/6s backoff. * feat: add object/array body param types and per-option show/hide on dropdowns - Add object, array[string/number/boolean/object] as webhook body param types, available when content type is application/json - Extend options fields with show/hide conditions so individual dropdown choices can be hidden based on other param values
1 parent bf2e368 commit 0a50bf9

8 files changed

Lines changed: 313 additions & 5 deletions

File tree

packages/agentflow/src/core/types/node.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,17 @@ export interface InputParam {
8484
type: string
8585
default?: unknown
8686
optional?: boolean
87-
options?: Array<{ label: string; name: string; description?: string; client?: Array<ClientType> } | string>
87+
options?: Array<
88+
| {
89+
label: string
90+
name: string
91+
description?: string
92+
client?: Array<ClientType>
93+
show?: Record<string, unknown>
94+
hide?: Record<string, unknown>
95+
}
96+
| string
97+
>
8898
placeholder?: string
8999
rows?: number
90100
description?: string

packages/agentflow/src/core/utils/fieldVisibility.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,104 @@ describe('evaluateFieldVisibility', () => {
194194
expect(params[0].display).toBeUndefined()
195195
expect(params[1].display).toBeUndefined()
196196
})
197+
198+
describe('option-level show/hide filtering', () => {
199+
it('removes options whose hide condition matches', () => {
200+
const param = makeParam({
201+
type: 'options',
202+
options: [
203+
{ label: 'String', name: 'string' },
204+
{ label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
205+
] as any
206+
})
207+
208+
const result = evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' })
209+
expect(result[0].options).toHaveLength(1)
210+
expect(result[0].options![0]).toMatchObject({ name: 'string' })
211+
})
212+
213+
it('keeps options whose hide condition does not match', () => {
214+
const param = makeParam({
215+
type: 'options',
216+
options: [
217+
{ label: 'String', name: 'string' },
218+
{ label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
219+
] as any
220+
})
221+
222+
const result = evaluateFieldVisibility([param], { contentType: 'application/json' })
223+
expect(result[0].options).toHaveLength(2)
224+
})
225+
226+
it('removes options whose show condition does not match', () => {
227+
const param = makeParam({
228+
type: 'options',
229+
options: [
230+
{ label: 'Basic', name: 'basic' },
231+
{ label: 'Advanced', name: 'advanced', show: { mode: 'expert' } }
232+
] as any
233+
})
234+
235+
const result = evaluateFieldVisibility([param], { mode: 'beginner' })
236+
expect(result[0].options).toHaveLength(1)
237+
expect(result[0].options![0]).toMatchObject({ name: 'basic' })
238+
})
239+
240+
it('keeps options whose show condition matches', () => {
241+
const param = makeParam({
242+
type: 'options',
243+
options: [
244+
{ label: 'Basic', name: 'basic' },
245+
{ label: 'Advanced', name: 'advanced', show: { mode: 'expert' } }
246+
] as any
247+
})
248+
249+
const result = evaluateFieldVisibility([param], { mode: 'expert' })
250+
expect(result[0].options).toHaveLength(2)
251+
})
252+
253+
it('passes through string options unchanged', () => {
254+
const param = makeParam({
255+
type: 'options',
256+
options: ['one', 'two', 'three'] as any
257+
})
258+
259+
const result = evaluateFieldVisibility([param], {})
260+
expect(result[0].options).toHaveLength(3)
261+
})
262+
263+
it('passes through options with no show/hide unchanged', () => {
264+
const param = makeParam({
265+
type: 'options',
266+
options: [
267+
{ label: 'A', name: 'a' },
268+
{ label: 'B', name: 'b' }
269+
] as any
270+
})
271+
272+
const result = evaluateFieldVisibility([param], {})
273+
expect(result[0].options).toHaveLength(2)
274+
})
275+
276+
it('does not mutate the original options array', () => {
277+
const options = [
278+
{ label: 'String', name: 'string' },
279+
{ label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
280+
] as any
281+
const param = makeParam({ type: 'options', options })
282+
283+
evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' })
284+
285+
// Original options array is untouched
286+
expect(options).toHaveLength(2)
287+
})
288+
289+
it('does not affect non-options params', () => {
290+
const param = makeParam({ type: 'string' })
291+
const result = evaluateFieldVisibility([param], {})
292+
expect(result[0].options).toBeUndefined()
293+
})
294+
})
197295
})
198296

199297
describe('evaluateFieldVisibility – nested array $index pattern (Start node formInputTypes)', () => {

packages/agentflow/src/core/utils/fieldVisibility.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,27 @@ export function evaluateParamVisibility(param: InputParam, inputValues: Record<s
129129

130130
/**
131131
* Evaluate visibility for all params, returning new param objects with computed `display`.
132+
* Also filters individual options within `type: 'options'` params based on their own show/hide conditions.
132133
* Does not mutate the originals.
133134
*/
134135
export function evaluateFieldVisibility(params: InputParam[], inputValues: Record<string, unknown>, arrayIndex?: number): InputParam[] {
135-
return params.map((param) => ({
136-
...param,
137-
display: evaluateParamVisibility(param, inputValues, arrayIndex)
138-
}))
136+
return params.map((param) => {
137+
const withDisplay = { ...param, display: evaluateParamVisibility(param, inputValues, arrayIndex) }
138+
139+
if (withDisplay.type === 'options' && withDisplay.options) {
140+
const filteredOptions = withDisplay.options.filter((opt) => {
141+
if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true
142+
return evaluateParamVisibility(
143+
{ id: '', name: '', label: '', type: '', show: opt.show, hide: opt.hide },
144+
inputValues,
145+
arrayIndex
146+
)
147+
})
148+
return filteredOptions.length === withDisplay.options.length ? withDisplay : { ...withDisplay, options: filteredOptions }
149+
}
150+
151+
return withDisplay
152+
})
139153
}
140154

141155
/**

packages/components/nodes/agentflow/Start/Start.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,31 @@ class Start_Agentflow implements INode {
291291
{
292292
label: 'Boolean',
293293
name: 'boolean'
294+
},
295+
{
296+
label: 'Object',
297+
name: 'object',
298+
hide: { webhookContentType: 'application/x-www-form-urlencoded' }
299+
},
300+
{
301+
label: 'Array[String]',
302+
name: 'array[string]',
303+
hide: { webhookContentType: 'application/x-www-form-urlencoded' }
304+
},
305+
{
306+
label: 'Array[Number]',
307+
name: 'array[number]',
308+
hide: { webhookContentType: 'application/x-www-form-urlencoded' }
309+
},
310+
{
311+
label: 'Array[Boolean]',
312+
name: 'array[boolean]',
313+
hide: { webhookContentType: 'application/x-www-form-urlencoded' }
314+
},
315+
{
316+
label: 'Array[Object]',
317+
name: 'array[object]',
318+
hide: { webhookContentType: 'application/x-www-form-urlencoded' }
294319
}
295320
],
296321
default: 'string'

packages/components/src/Interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export interface INodeOptionsValue {
6464
description?: string
6565
imageSrc?: string
6666
client?: Array<ClientType>
67+
show?: INodeDisplay
68+
hide?: INodeDisplay
6769
}
6870

6971
export interface INodeOutputsValue {

packages/server/src/services/webhook/index.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,146 @@ describe('validateWebhookChatflow', () => {
260260
})
261261
})
262262

263+
// --- object type ---
264+
265+
it('resolves when object param is a plain object', async () => {
266+
mockGetChatflowById.mockResolvedValue(
267+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] })
268+
)
269+
270+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: { key: 'val' } })).resolves.toMatchObject({})
271+
})
272+
273+
it('throws 400 when object param is a string', async () => {
274+
mockGetChatflowById.mockResolvedValue(
275+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] })
276+
)
277+
278+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: 'hello' })).rejects.toMatchObject({
279+
statusCode: StatusCodes.BAD_REQUEST,
280+
message: expect.stringContaining('meta')
281+
})
282+
})
283+
284+
it('throws 400 when object param is an array', async () => {
285+
mockGetChatflowById.mockResolvedValue(
286+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'meta', type: 'object', required: false }] })
287+
)
288+
289+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: [1, 2] })).rejects.toMatchObject({
290+
statusCode: StatusCodes.BAD_REQUEST,
291+
message: expect.stringContaining('meta')
292+
})
293+
})
294+
295+
// --- array[string] type ---
296+
297+
it('resolves when array[string] param is an array of strings', async () => {
298+
mockGetChatflowById.mockResolvedValue(
299+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] })
300+
)
301+
302+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: ['a', 'b'] })).resolves.toMatchObject({})
303+
})
304+
305+
it('throws 400 when array[string] param contains non-strings', async () => {
306+
mockGetChatflowById.mockResolvedValue(
307+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] })
308+
)
309+
310+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: [1, 2] })).rejects.toMatchObject({
311+
statusCode: StatusCodes.BAD_REQUEST,
312+
message: expect.stringContaining('tags')
313+
})
314+
})
315+
316+
it('throws 400 when array[string] param is not an array', async () => {
317+
mockGetChatflowById.mockResolvedValue(
318+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }] })
319+
)
320+
321+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: 'hello' })).rejects.toMatchObject({
322+
statusCode: StatusCodes.BAD_REQUEST,
323+
message: expect.stringContaining('tags')
324+
})
325+
})
326+
327+
// --- array[number] type ---
328+
329+
it('resolves when array[number] param is an array of numbers', async () => {
330+
mockGetChatflowById.mockResolvedValue(
331+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] })
332+
)
333+
334+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: [1, 2, 3] })).resolves.toMatchObject({})
335+
})
336+
337+
it('throws 400 when array[number] param contains non-numbers', async () => {
338+
mockGetChatflowById.mockResolvedValue(
339+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }] })
340+
)
341+
342+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: ['a', 'b'] })).rejects.toMatchObject({
343+
statusCode: StatusCodes.BAD_REQUEST,
344+
message: expect.stringContaining('scores')
345+
})
346+
})
347+
348+
// --- array[boolean] type ---
349+
350+
it('resolves when array[boolean] param is an array of booleans', async () => {
351+
mockGetChatflowById.mockResolvedValue(
352+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] })
353+
)
354+
355+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: [true, false] })).resolves.toMatchObject({})
356+
})
357+
358+
it('throws 400 when array[boolean] param contains boolean strings (no coercion for array elements)', async () => {
359+
mockGetChatflowById.mockResolvedValue(
360+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }] })
361+
)
362+
363+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: ['true', 'false'] })).rejects.toMatchObject({
364+
statusCode: StatusCodes.BAD_REQUEST,
365+
message: expect.stringContaining('flags')
366+
})
367+
})
368+
369+
// --- array[object] type ---
370+
371+
it('resolves when array[object] param is an array of plain objects', async () => {
372+
mockGetChatflowById.mockResolvedValue(
373+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] })
374+
)
375+
376+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [{ a: 1 }, { b: 2 }] })).resolves.toMatchObject(
377+
{}
378+
)
379+
})
380+
381+
it('throws 400 when array[object] param contains non-objects', async () => {
382+
mockGetChatflowById.mockResolvedValue(
383+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] })
384+
)
385+
386+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: ['a', 'b'] })).rejects.toMatchObject({
387+
statusCode: StatusCodes.BAD_REQUEST,
388+
message: expect.stringContaining('items')
389+
})
390+
})
391+
392+
it('throws 400 when array[object] param contains nested arrays', async () => {
393+
mockGetChatflowById.mockResolvedValue(
394+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }] })
395+
)
396+
397+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [[1, 2]] })).rejects.toMatchObject({
398+
statusCode: StatusCodes.BAD_REQUEST,
399+
message: expect.stringContaining('items')
400+
})
401+
})
402+
263403
// --- Query param validation ---
264404

265405
it('throws 400 when a required query param is missing', async () => {

packages/server/src/services/webhook/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ const validateWebhookChatflow = async (
9696
const val = body[p.name]
9797
if (p.type === 'number') return val === '' || isNaN(Number(val))
9898
if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false'
99+
if (p.type === 'object') return typeof val !== 'object' || val === null || Array.isArray(val)
100+
if (p.type === 'array[string]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'string')
101+
if (p.type === 'array[number]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'number')
102+
if (p.type === 'array[boolean]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'boolean')
103+
if (p.type === 'array[object]')
104+
return (
105+
!Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'object' || el === null || Array.isArray(el))
106+
)
99107
return typeof val !== p.type
100108
})
101109
.map((p) => p.name)

packages/ui/src/utils/genericHelper.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,17 @@ export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex)
12921292
if (inputParam.hide) {
12931293
_showHideOperation(nodeData, inputParam, 'hide', arrayIndex)
12941294
}
1295+
1296+
// Filter individual options within dropdowns based on their own show/hide conditions
1297+
if (inputParam.type === 'options' && inputParam.options) {
1298+
inputParam.options = inputParam.options.filter((opt) => {
1299+
if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true
1300+
const synthetic = { show: opt.show, hide: opt.hide, display: true }
1301+
if (opt.show) _showHideOperation(nodeData, synthetic, 'show', arrayIndex)
1302+
if (opt.hide) _showHideOperation(nodeData, synthetic, 'hide', arrayIndex)
1303+
return synthetic.display !== false
1304+
})
1305+
}
12951306
}
12961307

12971308
return params

0 commit comments

Comments
 (0)