Inital work on declarative webhook triggers

This commit is contained in:
Jonathan 2026-09-07 16:34:44 +01:00
parent 0008cd58e3
commit c2f0da70bb
No known key found for this signature in database
20 changed files with 2763 additions and 5 deletions

View File

@ -144,6 +144,20 @@ describe('shouldAssignExecuteMethod', () => {
expect(shouldAssignExecuteMethod(nodeType)).toBe(true);
});
it('should return false for a declarative webhook trigger with requestDefaults', () => {
// requestDefaults serves the trigger's lifecycle requests; assigning execute
// would route deliveries into RoutingNode instead of the webhook pass-through.
const nodeType = {
description: {
requestDefaults: {},
trigger: { type: 'webhook', lifecycle: {} },
},
webhook: vi.fn(), // synthesized by the loader
} as unknown as INodeType;
expect(shouldAssignExecuteMethod(nodeType)).toBe(false);
});
it('should return false when node has methods and is not declarative', () => {
const nodeType = {
methods: {},

View File

@ -100,7 +100,11 @@ export const isPositiveInteger = (maybeInt: string) => /^[1-9]\d*$/.test(maybeIn
* Check if a execute method should be assigned to the node
*/
export const shouldAssignExecuteMethod = (nodeType: INodeType) => {
const isDeclarativeNode = nodeType?.description?.requestDefaults !== undefined;
// A declarative webhook trigger carries requestDefaults for its lifecycle
// requests without being a declarative (routing) action node.
const isDeclarativeNode =
nodeType?.description?.requestDefaults !== undefined &&
nodeType.description.trigger?.type !== 'webhook';
return (
!nodeType.execute &&

View File

@ -0,0 +1,224 @@
import type { WebhookRepository } from '@n8n/db';
import { createDeclarativeWebhook, createDeclarativeWebhookMethods } from 'n8n-core';
import type {
IDataObject,
IDeclarativeWebhookTrigger,
INode,
INodeType,
IWebhookData,
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
import nock from 'nock';
import { mock } from 'vitest-mock-extended';
import type { NodeTypes } from '@/node-types';
import type { CacheService } from '@/services/cache/cache.service';
import { shouldAssignExecuteMethod } from '@/utils';
import { WebhookService } from '@/webhooks/webhook.service';
/**
* Drives a declarative webhook trigger through the full WebhookService flow —
* createWebhookIfNotExists → runWebhook → deleteWebhook — with the vendor
* "hook registry" mocked by nock, the way E2eTestDeclarativeWebhookTrigger is
* meant to be used.
*/
describe('declarative webhook trigger through WebhookService', () => {
const registryUrl = 'http://registry.test';
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: {
checkExists: {
routing: {
request: { method: 'GET', url: '={{ $parameter.url }}/hooks' },
output: { postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }] },
},
matchOn: [{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' }],
store: { webhookId: '={{ $item.id }}' },
},
create: {
routing: {
request: {
method: 'POST',
url: '={{ $parameter.url }}/hooks',
body: { target_url: '={{ $webhookUrl }}', events: '={{ $parameter.events }}' },
},
},
store: { webhookId: '={{ $response.id }}' },
},
delete: {
routing: {
request: {
method: 'DELETE',
url: '={{ $parameter.url + "/hooks/" + $staticData.webhookId }}',
},
},
},
},
handler: {
filter: {
allowed: '={{ $parameter.events }}',
actual: '={{ $request.body.event }}',
wildcard: '*',
},
},
};
const nodeType: INodeType = {
description: {
displayName: 'Declarative Webhook Trigger',
name: 'declarativeWebhookTrigger',
group: ['trigger'],
version: 1,
description: '',
defaults: {},
inputs: [],
outputs: [NodeConnectionTypes.Main],
properties: [
{ displayName: 'URL', name: 'url', type: 'string', default: '' },
{ displayName: 'Events', name: 'events', type: 'multiOptions', options: [], default: [] },
],
webhooks: [
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
],
trigger,
},
};
// Attach what DirectoryLoader.applySpecialNodeParameters attaches.
nodeType.webhookMethods = { default: createDeclarativeWebhookMethods(nodeType, trigger) };
nodeType.webhook = createDeclarativeWebhook(trigger);
const node: INode = {
id: 'n1',
name: 'Trigger',
type: 'declarativeWebhookTrigger',
typeVersion: 1,
position: [0, 0],
parameters: { url: registryUrl, events: ['created'] },
};
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
const webhookService = new WebhookService(
mock(),
mock<WebhookRepository>(),
mock<CacheService>(),
nodeTypes,
);
const additionalData = {
executionId: 'e1',
webhookBaseUrl: 'http://localhost:5678/webhook',
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
} as IWorkflowExecuteAdditionalData;
const workflow = new Workflow({
id: 'wf1',
nodes: [node],
connections: {},
active: true,
nodeTypes,
});
const webhookData = {
node: node.name,
httpMethod: 'POST',
path: 'webhook',
workflowId: workflow.id,
webhookDescription: { name: 'default', httpMethod: 'POST', path: 'webhook' },
workflowExecuteAdditionalData: additionalData,
} as IWebhookData;
const staticData = () => workflow.getStaticData('node', node);
const runIncomingWebhook = async (body: IDataObject) => {
const requestData = {
...additionalData,
httpRequest: { body, headers: {}, query: {}, params: {} },
} as IWorkflowExecuteAdditionalData;
return await webhookService.runWebhook(
workflow,
webhookData,
node,
requestData,
'webhook',
null,
);
};
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
afterEach(() => nock.cleanAll());
let registeredTargetUrl: string;
test('activation registers the webhook and stores its id', async () => {
let createBody: IDataObject | undefined;
const scope = nock(registryUrl)
.get('/hooks')
.reply(200, { hooks: [] })
.post('/hooks', (body) => {
createBody = body as IDataObject;
return true;
})
.reply(200, { id: 'h1' });
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
expect(scope.isDone()).toBe(true);
expect(staticData().webhookId).toBe('h1');
expect(createBody).toEqual({
target_url: expect.stringContaining('/webhook'),
events: ['created'],
});
registeredTargetUrl = createBody!.target_url as string;
});
test('re-activation finds the registration and recovers a forgotten id', async () => {
const scope = nock(registryUrl)
.get('/hooks')
.reply(200, { hooks: [{ id: 'h1', target_url: registeredTargetUrl }] });
delete staticData().webhookId;
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
expect(scope.isDone()).toBe(true);
expect(staticData().webhookId).toBe('h1');
});
test('a matching delivery starts the workflow with the body', async () => {
const response = await runIncomingWebhook({ event: 'created', n: 1 });
expect(response).toEqual({ workflowData: [[{ json: { event: 'created', n: 1 } }]] });
});
test('a non-matching delivery responds 200 without a run', async () => {
const response = await runIncomingWebhook({ event: 'deleted' });
expect(response).toEqual({});
});
test('a declarative webhook trigger with requestDefaults never gets a synthesized execute', () => {
// Regression: with an assigned execute, runNode routes deliveries through
// RoutingNode (a bare GET to requestDefaults.baseURL) instead of the
// webhook pass-through — the trigger's output becomes the API root listing.
const withRequestDefaults = {
...nodeType,
description: { ...nodeType.description, requestDefaults: { baseURL: registryUrl } },
};
expect(shouldAssignExecuteMethod(withRequestDefaults)).toBe(false);
});
test('deactivation deregisters and clears static data', async () => {
const scope = nock(registryUrl).delete('/hooks/h1').reply(200, {});
await webhookService.deleteWebhook(workflow, webhookData, 'trigger', 'update');
expect(scope.isDone()).toBe(true);
expect(staticData().webhookId).toBeUndefined();
});
});

View File

@ -0,0 +1,857 @@
import type {
DeclarativeWebhookVerification,
IDataObject,
IDeclarativeWebhookTrigger,
IHttpRequestOptions,
INode,
INodeParameters,
INodeType,
INodeTypes,
IWebhookData,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
import { createHmac } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { createDeclarativeWebhook, createDeclarativeWebhookMethods } from '../declarative-webhook';
import { HookContext, WebhookContext } from '../node-execution-context';
const httpRequest = vi.fn<(options: IHttpRequestOptions) => Promise<unknown>>();
vi.mock('../node-execution-context/utils/request-helper-functions', async (importOriginal) => ({
...(await importOriginal<object>()),
getRequestHelperFunctions: () => ({ httpRequest }),
}));
const makeNodeType = (trigger: IDeclarativeWebhookTrigger): INodeType => ({
description: {
displayName: 'Test Webhook',
name: 'testWebhook',
group: ['trigger'],
version: 1,
description: '',
defaults: {},
inputs: [],
outputs: [NodeConnectionTypes.Main],
// The Workflow constructor strips node parameters that no property declares,
// so the fixture declares the ones the tests set.
properties: [
{ displayName: 'Event', name: 'event', type: 'string', default: '' },
{ displayName: 'Events', name: 'events', type: 'multiOptions', options: [], default: [] },
{ displayName: 'Secret', name: 'secret', type: 'string', default: '' },
],
webhooks: [
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
],
requestDefaults: { baseURL: 'https://api.example.com' },
trigger,
},
});
const registryTrigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: {
checkExists: {
routing: {
request: { method: 'GET', url: '/hooks' },
output: { postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }] },
},
matchOn: [{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' }],
store: { webhookId: '={{ $item.id }}' },
},
create: {
routing: {
request: {
method: 'POST',
url: '/hooks',
body: { target_url: '={{ $webhookUrl }}' },
},
},
store: { webhookId: '={{ $response.id }}' },
},
delete: {
routing: {
request: { method: 'DELETE', url: '={{ "/hooks/" + $staticData.webhookId }}' },
},
},
},
};
function setup(
trigger: IDeclarativeWebhookTrigger,
{
parameters = {},
mode = 'trigger' as WorkflowExecuteMode,
}: { parameters?: INodeParameters; mode?: WorkflowExecuteMode } = {},
) {
const nodeType = makeNodeType(trigger);
const node: INode = {
id: 'n1',
name: 'Hook',
type: 'testWebhook',
typeVersion: 1,
position: [0, 0],
parameters,
};
const nodeTypes = mock<INodeTypes>({ getByNameAndVersion: () => nodeType });
const workflow = new Workflow({
id: 'w1',
nodes: [node],
connections: {},
active: true,
nodeTypes,
});
const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: 'e1',
webhookBaseUrl: 'http://localhost:5678/webhook',
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const context = new HookContext(workflow, node, additionalData, mode, 'init');
const methods = createDeclarativeWebhookMethods(nodeType, trigger);
const staticData = () => workflow.getStaticData('node', node);
return {
webhookUrl: context.getNodeWebhookUrl('default')!,
checkExists: async () => await methods.checkExists.call(context),
create: async () => await methods.create.call(context),
delete: async () => await methods.delete.call(context),
staticData,
node,
workflow,
additionalData,
nodeType,
};
}
const respond = (body: IDataObject) =>
httpRequest.mockResolvedValueOnce({ body, headers: {}, statusCode: 200 });
const respondNotFound = () =>
httpRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 404 },
message: 'not found',
});
beforeEach(() => httpRequest.mockReset());
describe('createDeclarativeWebhookMethods', () => {
describe('checkExists with matchOn', () => {
test('no matching item ⇒ false', async () => {
const { checkExists, staticData } = setup(registryTrigger);
respond({ hooks: [{ id: 7, target_url: 'https://elsewhere.example.com' }] });
expect(await checkExists()).toBe(false);
expect(staticData().webhookId).toBeUndefined();
});
test('matching item ⇒ true and recovers store values', async () => {
const { checkExists, staticData, webhookUrl } = setup(registryTrigger);
respond({
hooks: [
{ id: 5, target_url: 'other' },
{ id: 7, target_url: webhookUrl },
],
});
expect(await checkExists()).toBe(true);
expect(staticData().webhookId).toBe(7);
});
test('every clause must hold', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
lifecycle: {
...registryTrigger.lifecycle,
checkExists: {
routing: {
request: { method: 'GET', url: '/hooks' },
output: {
postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }],
},
},
matchOn: [
{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' },
{ itemProperty: 'event', value: '={{ $parameter.event }}' },
],
},
},
};
const { checkExists, webhookUrl } = setup(trigger, { parameters: { event: 'created' } });
respond({ hooks: [{ target_url: webhookUrl, event: 'deleted' }] });
expect(await checkExists()).toBe(false);
respond({ hooks: [{ target_url: webhookUrl, event: 'created' }] });
expect(await checkExists()).toBe(true);
});
test('a clause on empty static data never matches', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
lifecycle: {
...registryTrigger.lifecycle,
checkExists: {
routing: { request: { method: 'GET', url: '/hooks' } },
matchOn: [{ itemProperty: 'id', value: '={{ $staticData.webhookId }}' }],
},
},
};
const { checkExists } = setup(trigger);
respond([{ id: 7 }] as unknown as IDataObject);
expect(await checkExists()).toBe(false);
});
});
describe('checkExists by id', () => {
const byIdTrigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
lifecycle: {
...registryTrigger.lifecycle,
checkExists: {
routing: {
request: { method: 'GET', url: '={{ "/hooks/" + $staticData.webhookId }}' },
},
},
},
};
test('missing id ⇒ false without a request', async () => {
const { checkExists } = setup(byIdTrigger);
expect(await checkExists()).toBe(false);
expect(httpRequest).not.toHaveBeenCalled();
});
test('2xx ⇒ true', async () => {
const { checkExists, staticData } = setup(byIdTrigger);
staticData().webhookId = 7;
respond({ id: 7 });
expect(await checkExists()).toBe(true);
expect(httpRequest.mock.lastCall?.[0].url).toBe('/hooks/7');
});
test('404 ⇒ managed keys cleared, false', async () => {
const { checkExists, staticData } = setup(byIdTrigger);
staticData().webhookId = 7;
respondNotFound();
expect(await checkExists()).toBe(false);
expect(staticData().webhookId).toBeUndefined();
});
test('other errors rethrow', async () => {
const { checkExists, staticData } = setup(byIdTrigger);
staticData().webhookId = 7;
httpRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: 'boom',
});
await expect(checkExists()).rejects.toThrow();
expect(staticData().webhookId).toBe(7);
});
});
describe('create', () => {
test('stores values from the response and sends the webhook url', async () => {
const { create, staticData, webhookUrl } = setup(registryTrigger);
respond({ id: 42 });
expect(await create()).toBe(true);
expect(staticData().webhookId).toBe(42);
expect(httpRequest.mock.lastCall?.[0].body).toEqual({ target_url: webhookUrl });
});
test('rejectLocalhostUrl refuses a localhost webhook url without a request', async () => {
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: {
create: {
routing: { request: { method: 'POST', url: '/hooks' } },
rejectLocalhostUrl: true,
},
},
};
// The fixture's webhookBaseUrl is http://localhost:5678/webhook.
const { create } = setup(trigger);
await expect(create()).rejects.toThrow('localhost');
expect(httpRequest).not.toHaveBeenCalled();
});
test('validateResponse rejects an unexpected creation response', async () => {
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: {
create: {
routing: { request: { method: 'POST', url: '/hooks' } },
validateResponse: '={{ $response.active === true }}',
store: { webhookId: '={{ $response.id }}' },
},
},
};
const failing = setup(trigger);
respond({ id: 7, active: false });
await expect(failing.create()).rejects.toThrow('expected data');
expect(failing.staticData().webhookId).toBeUndefined();
const passing = setup(trigger);
respond({ id: 7, active: true });
expect(await passing.create()).toBe(true);
expect(passing.staticData().webhookId).toBe(7);
});
test('generate mints $secrets usable in the request and store', async () => {
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: {
create: {
routing: {
request: {
method: 'POST',
url: '/hooks',
body: { secret: '={{ $generated.webhookSecret }}' },
},
},
generate: { webhookSecret: { type: 'hex', length: 16 } },
store: {
webhookId: '={{ $response.id }}',
webhookSecret: '={{ $generated.webhookSecret }}',
},
},
},
};
const { create, staticData } = setup(trigger);
respond({ id: 7 });
expect(await create()).toBe(true);
const sentSecret = (httpRequest.mock.lastCall?.[0].body as IDataObject).secret;
expect(sentSecret).toMatch(/^[0-9a-f]{32}$/);
expect(staticData().webhookSecret).toBe(sentSecret);
expect(staticData().webhookId).toBe(7);
});
});
describe('delete', () => {
test('missing id ⇒ true without a request', async () => {
const { delete: del } = setup(registryTrigger);
expect(await del()).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});
test('success ⇒ managed keys cleared', async () => {
const { delete: del, staticData } = setup(registryTrigger);
staticData().webhookId = 42;
respond({});
expect(await del()).toBe(true);
expect(staticData().webhookId).toBeUndefined();
expect(httpRequest.mock.lastCall?.[0].url).toBe('/hooks/42');
});
test('request error ⇒ false, static data kept', async () => {
const { delete: del, staticData } = setup(registryTrigger);
staticData().webhookId = 42;
httpRequest.mockRejectedValueOnce(new Error('nope'));
expect(await del()).toBe(false);
expect(staticData().webhookId).toBe(42);
});
});
describe('slot defaults and escape hatches', () => {
test('omitted checkExists always re-registers; omitted delete is a no-op', async () => {
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: { create: registryTrigger.lifecycle.create },
};
const { checkExists, delete: del } = setup(trigger);
expect(await checkExists()).toBe(false);
expect(await del()).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});
test('a function-valued slot is used as-is', async () => {
const custom = vi.fn(async () => true);
const trigger: IDeclarativeWebhookTrigger = {
type: 'webhook',
lifecycle: { ...registryTrigger.lifecycle, checkExists: custom },
};
const { checkExists } = setup(trigger);
expect(await checkExists()).toBe(true);
expect(custom).toHaveBeenCalled();
expect(httpRequest).not.toHaveBeenCalled();
});
});
});
describe('createDeclarativeWebhook', () => {
function webhookSetup(
trigger: IDeclarativeWebhookTrigger,
body: IDataObject,
parameters: INodeParameters = {},
request: {
headers?: IDataObject;
rawBody?: string;
query?: IDataObject;
webhookName?: string;
} = {},
) {
const nodeType = makeNodeType(trigger);
const node: INode = {
id: 'n1',
name: 'Hook',
type: 'testWebhook',
typeVersion: 1,
position: [0, 0],
parameters,
};
const nodeTypes = mock<INodeTypes>({ getByNameAndVersion: () => nodeType });
const workflow = new Workflow({
id: 'w1',
nodes: [node],
connections: {},
active: true,
nodeTypes,
});
const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: 'e1',
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
additionalData.httpRequest = {
body,
headers: request.headers ?? {},
query: request.query ?? {},
params: {},
rawBody: request.rawBody,
} as unknown as IWorkflowExecuteAdditionalData['httpRequest'];
const response = {
statusCode: undefined as number | undefined,
body: undefined as unknown,
headers: {} as Record<string, string>,
status(code: number) {
this.statusCode = code;
return this;
},
setHeader(name: string, value: string) {
this.headers[name] = value;
return this;
},
send(payload?: unknown) {
this.body = payload;
return this;
},
end() {
return this;
},
};
additionalData.httpResponse =
response as unknown as IWorkflowExecuteAdditionalData['httpResponse'];
const webhookData = {
webhookDescription: {
name: request.webhookName ?? 'default',
httpMethod: 'POST',
path: 'webhook',
},
} as IWebhookData;
const context = new WebhookContext(
workflow,
node,
additionalData,
'webhook',
webhookData,
[],
null,
);
const webhook = createDeclarativeWebhook(trigger);
const staticData = () => workflow.getStaticData('node', node);
return { run: async () => await webhook.call(context), response, staticData };
}
test('without a filter, emits the body', async () => {
const { run } = webhookSetup(registryTrigger, { event: 'created', n: 1 });
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created', n: 1 } }]] });
});
const filtered: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
filter: {
allowed: '={{ $parameter.events }}',
actual: '={{ $request.body.event }}',
wildcard: '*',
},
},
};
test('filter match ⇒ emit, miss ⇒ 200 without a run', async () => {
expect(
await webhookSetup(filtered, { event: 'created' }, { events: ['created'] }).run(),
).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
expect(
await webhookSetup(filtered, { event: 'deleted' }, { events: ['created'] }).run(),
).toEqual({});
});
test('wildcard accepts everything', async () => {
expect(await webhookSetup(filtered, { event: 'anything' }, { events: ['*'] }).run()).toEqual({
workflowData: [[{ json: { event: 'anything' } }]],
});
});
describe('handshake and setup endpoint', () => {
test('a setup endpoint with no handshake answers OK', async () => {
const { run } = webhookSetup(registryTrigger, {}, {}, { webhookName: 'setup' });
expect(await run()).toEqual({ webhookResponse: 'OK' });
});
test('a setup endpoint echoes a challenge and 401s a failed `when`', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
handshake: {
when: '={{ $request.query["verify_token"] === $parameter.secret }}',
respond: { body: '={{ $request.query["challenge"] }}' },
},
},
};
const ok = webhookSetup(
trigger,
{},
{ secret: 'tok' },
{
webhookName: 'setup',
query: { verify_token: 'tok', challenge: 'c-123' },
},
);
expect(await ok.run()).toEqual({ webhookResponse: 'c-123' });
const bad = webhookSetup(
trigger,
{},
{ secret: 'tok' },
{
webhookName: 'setup',
query: { verify_token: 'wrong', challenge: 'c-123' },
},
);
expect(await bad.run()).toEqual({ noWebhookResponse: true });
expect(bad.response.statusCode).toBe(401);
});
test('on the default endpoint a matching `when` answers the handshake, otherwise the delivery proceeds', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
handshake: {
when: '={{ $request.body.type === "url_verification" }}',
respond: { body: '={{ $request.body.challenge }}' },
},
},
};
const shake = webhookSetup(trigger, { type: 'url_verification', challenge: 'c-9' });
expect(await shake.run()).toEqual({ webhookResponse: 'c-9' });
const event = webhookSetup(trigger, { type: 'event', n: 1 });
expect(await event.run()).toEqual({
workflowData: [[{ json: { type: 'event', n: 1 } }]],
});
});
test('a handshake can store request values and answer with headers', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
handshake: {
when: '={{ $request.headers["x-hook-secret"] !== undefined }}',
store: { webhookSecret: '={{ $request.headers["x-hook-secret"] }}' },
respond: { headers: { 'X-Hook-Secret': '={{ $request.headers["x-hook-secret"] }}' } },
},
},
};
const { run, response, staticData } = webhookSetup(
trigger,
{},
{},
{
headers: { 'x-hook-secret': 's3cret' },
},
);
expect(await run()).toEqual({ noWebhookResponse: true });
expect(response.statusCode).toBe(200);
expect(response.headers['X-Hook-Secret']).toBe('s3cret');
expect(staticData().webhookSecret).toBe('s3cret');
});
test('the handshake runs before verification, so it needs no signature', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
handshake: {
when: '={{ $request.body.type === "url_verification" }}',
respond: { body: '={{ $request.body.challenge }}' },
},
verification: {
algorithm: 'hmac-sha256',
signatureHeader: 'x-sig',
secret: '={{ $parameter.secret }}',
},
},
};
const { run } = webhookSetup(
trigger,
{ type: 'url_verification', challenge: 'c-1' },
{
secret: 'tok',
},
);
expect(await run()).toEqual({ webhookResponse: 'c-1' });
});
});
describe('ping', () => {
const pinged: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: {
ping: { when: '={{ $request.headers["x-event"] === "ping" }}', response: 'PONG' },
filter: {
allowed: '={{ $parameter.events }}',
actual: '={{ $request.headers["x-event"] }}',
wildcard: '*',
},
},
};
test('a ping answers without a run, even when the filter would drop it', async () => {
const { run } = webhookSetup(
pinged,
{ zen: 'hi' },
{ events: ['created'] },
{
headers: { 'x-event': 'ping' },
},
);
expect(await run()).toEqual({ webhookResponse: 'PONG' });
});
test('non-ping deliveries proceed through the filter', async () => {
const { run } = webhookSetup(
pinged,
{ n: 1 },
{ events: ['created'] },
{
headers: { 'x-event': 'created' },
},
);
expect(await run()).toEqual({ workflowData: [[{ json: { n: 1 } }]] });
});
});
describe('output', () => {
test('data expression selects what is emitted; arrays fan out', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: { output: { data: '={{ $request.body.records }}' } },
};
const { run } = webhookSetup(trigger, { records: [{ a: 1 }, { a: 2 }] });
expect(await run()).toEqual({ workflowData: [[{ json: { a: 1 } }, { json: { a: 2 } }]] });
});
test('includeMeta wraps as { body, headers, query }', async () => {
const trigger: IDeclarativeWebhookTrigger = {
...registryTrigger,
handler: { output: { includeMeta: true } },
};
const { run } = webhookSetup(
trigger,
{ event: 'created' },
{},
{ headers: { 'x-a': 'b' }, query: { q: '1' } },
);
expect(await run()).toEqual({
workflowData: [
[{ json: { body: { event: 'created' }, headers: { 'x-a': 'b' }, query: { q: '1' } } }],
],
});
});
});
describe('verification', () => {
const sign = (payload: string, secret: string, algorithm = 'sha256', encoding = 'hex') =>
createHmac(algorithm, secret)
.update(payload)
.digest(encoding as 'hex' | 'base64');
const hmacTrigger = (
overrides: Partial<DeclarativeWebhookVerification> = {},
): IDeclarativeWebhookTrigger => ({
...registryTrigger,
handler: {
verification: {
algorithm: 'hmac-sha256',
signatureHeader: 'x-hub-signature-256',
secret: '={{ $staticData.webhookSecret }}',
prefix: 'sha256=',
...overrides,
},
},
});
test('valid HMAC over the raw body ⇒ emit', async () => {
const rawBody = '{"event":"created"}';
const signature = `sha256=${sign(rawBody, 's3cret')}`;
const { run, staticData } = webhookSetup(
hmacTrigger(),
{ event: 'created' },
{},
{ rawBody, headers: { 'x-hub-signature-256': signature } },
);
staticData().webhookSecret = 's3cret';
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
});
test('bad signature ⇒ 401 without a run', async () => {
const { run, response, staticData } = webhookSetup(
hmacTrigger(),
{ event: 'created' },
{},
{ rawBody: '{"event":"created"}', headers: { 'x-hub-signature-256': 'sha256=deadbeef' } },
);
staticData().webhookSecret = 's3cret';
expect(await run()).toEqual({ noWebhookResponse: true });
expect(response.statusCode).toBe(401);
});
test('missing signature header ⇒ 401', async () => {
const { run, response, staticData } = webhookSetup(
hmacTrigger(),
{ event: 'created' },
{},
{ rawBody: '{"event":"created"}' },
);
staticData().webhookSecret = 's3cret';
expect(await run()).toEqual({ noWebhookResponse: true });
expect(response.statusCode).toBe(401);
});
test('no secret ⇒ 401 by default, emit with skipIfNoSecret', async () => {
const request = { rawBody: '{"event":"created"}' };
const rejecting = webhookSetup(hmacTrigger(), { event: 'created' }, {}, request);
expect(await rejecting.run()).toEqual({ noWebhookResponse: true });
const skipping = webhookSetup(
hmacTrigger({ skipIfNoSecret: true }),
{ event: 'created' },
{},
request,
);
expect(await skipping.run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
});
test('signedPayload jsonStringifiedBody signs the parsed body', async () => {
const body = { event: 'created', n: 2 };
const signature = `sha256=${sign(JSON.stringify(body), 's3cret')}`;
const { run, staticData } = webhookSetup(
hmacTrigger({ signedPayload: 'jsonStringifiedBody' }),
body,
{},
{ headers: { 'x-hub-signature-256': signature } },
);
staticData().webhookSecret = 's3cret';
expect(await run()).toEqual({ workflowData: [[{ json: body }]] });
});
test('credentialType loads the credential for the secret expression', async () => {
const getCredentials = vi
.spyOn(WebhookContext.prototype, 'getCredentials')
.mockResolvedValue({ signatureSecret: 's3cret' });
try {
const rawBody = '{"event":"created"}';
const signature = `sha256=${sign(rawBody, 's3cret')}`;
const { run } = webhookSetup(
hmacTrigger({
secret: '={{ $credentials.signatureSecret }}',
credentialType: 'exampleApi',
}),
{ event: 'created' },
{},
{ rawBody, headers: { 'x-hub-signature-256': signature } },
);
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
expect(getCredentials).toHaveBeenCalledWith('exampleApi');
} finally {
getCredentials.mockRestore();
}
});
test('token algorithm compares the secret itself', async () => {
const trigger = hmacTrigger({
algorithm: 'token',
signatureHeader: 'x-secret-token',
secret: '={{ $parameter.secret }}',
prefix: undefined,
});
const ok = webhookSetup(
trigger,
{ event: 'created' },
{ secret: 'tok' },
{ headers: { 'x-secret-token': 'tok' } },
);
expect(await ok.run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
const bad = webhookSetup(
trigger,
{ event: 'created' },
{ secret: 'tok' },
{ headers: { 'x-secret-token': 'nope' } },
);
expect(await bad.run()).toEqual({ noWebhookResponse: true });
});
test('stale timestamp ⇒ 401 even with a valid signature', async () => {
const rawBody = '{"event":"created"}';
const signature = `sha256=${sign(rawBody, 's3cret')}`;
const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600);
const { run, response, staticData } = webhookSetup(
hmacTrigger({ timestampHeader: 'x-timestamp' }),
{ event: 'created' },
{},
{
rawBody,
headers: { 'x-hub-signature-256': signature, 'x-timestamp': staleTimestamp },
},
);
staticData().webhookSecret = 's3cret';
expect(await run()).toEqual({ noWebhookResponse: true });
expect(response.statusCode).toBe(401);
});
});
});

View File

@ -1214,6 +1214,33 @@ describe('WorkflowExecute.runNode - Real Implementation', () => {
expect(mockRoutingNodeInstance.runNode).toHaveBeenCalled();
expect(result).toEqual({ data: mockData });
});
it('should pass through input data for a declarative webhook trigger with requestDefaults', async () => {
mockNodeType.webhook = vi.fn();
mockNodeType.execute = undefined;
mockNodeType.supplyData = undefined;
mockNodeType.poll = undefined;
mockNodeType.trigger = undefined;
// requestDefaults serves the trigger's lifecycle requests here, so the
// node must not be treated as a declarative (routing) action node.
mockNodeType.description.requestDefaults = {};
mockNodeType.description.trigger = {
type: 'webhook',
lifecycle: { create: { routing: { request: { method: 'POST', url: '/hooks' } } } },
};
const result = await workflowExecute.runNode(
mockWorkflow,
mockExecutionData,
mockRunExecutionData,
0,
mockAdditionalData,
'manual',
);
expect(mockRoutingNode).not.toHaveBeenCalled();
expect(result).toEqual({ data: mockExecutionData.data.main });
});
});
describe('fallback routing node handling', () => {

View File

@ -0,0 +1,441 @@
import get from 'lodash/get';
import type {
DeclarativeSecretGenerator,
DeclarativeWebhookCheckExists,
DeclarativeWebhookCreate,
DeclarativeWebhookDelete,
DeclarativeWebhookHandshake,
DeclarativeWebhookVerification,
IDataObject,
IDeclarativeWebhookTrigger,
IHookFunctions,
INodeExecutionData,
INodePropertyRouting,
INodeType,
IWebhookFunctions,
IWebhookResponseData,
IWorkflowDataProxyAdditionalKeys,
JsonObject,
} from 'n8n-workflow';
import {
createRunExecutionData,
NodeApiError,
NodeOperationError,
UnexpectedError,
} from 'n8n-workflow';
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { ExecuteContext, HookContext, WebhookContext } from './node-execution-context';
import { RoutingNode } from './routing-node';
type LifecycleSlots = NonNullable<NonNullable<INodeType['webhookMethods']>['default']>;
/**
* Runs one lifecycle `routing` block through `RoutingNode`, the same way
* `createDeclarativePoll` runs its poll request: a fabricated `ExecuteContext`
* with one synthetic input item, and the routing carried by a nameless hidden
* property (`RoutingNode` reads routing off properties and skips the parameter
* lookup when the name is empty).
*/
async function runLifecycleRouting(
context: HookContext,
nodeType: INodeType,
routing: INodePropertyRouting,
extraKeys: IWorkflowDataProxyAdditionalKeys,
): Promise<INodeExecutionData[]> {
const { workflow, node, additionalData, mode } = context;
const executeData = { node, data: {}, source: null };
const executeContext = new ExecuteContext(
workflow,
node,
additionalData,
mode,
createRunExecutionData(),
0,
[],
{ main: [[{ json: {} }]] },
executeData,
[],
);
const hookNodeType: INodeType = {
...nodeType,
description: {
...nodeType.description,
properties: [
...nodeType.description.properties,
{ displayName: '', name: '', type: 'hidden', default: '', routing },
],
},
};
const routingNode = new RoutingNode(executeContext, hookNodeType, undefined, extraKeys);
return (await routingNode.runNode())?.[0] ?? [];
}
/** The static-data keys a trigger manages: whatever its create step stores. */
function managedKeys(trigger: IDeclarativeWebhookTrigger): string[] {
const create = trigger.lifecycle.create;
if (typeof create === 'function' || !create.store) return [];
return Object.keys(create.store);
}
function clearManagedKeys(trigger: IDeclarativeWebhookTrigger, staticData: IDataObject): void {
for (const key of managedKeys(trigger)) delete staticData[key];
}
function resolveValue(
context: HookContext | WebhookContext,
value: string,
extraKeys: IWorkflowDataProxyAdditionalKeys,
): unknown {
return context.workflow.expression.getSimpleParameterValue(
context.node,
value,
context.mode,
extraKeys,
);
}
function applyStore(
context: HookContext | WebhookContext,
store: Record<string, string> | undefined,
extraKeys: IWorkflowDataProxyAdditionalKeys,
staticData: IDataObject,
): void {
if (!store) return;
for (const [key, value] of Object.entries(store)) {
staticData[key] = resolveValue(context, value, extraKeys) as IDataObject[string];
}
}
function assertHookContext(context: IHookFunctions): asserts context is HookContext {
if (!(context instanceof HookContext)) {
throw new UnexpectedError('Declarative webhook lifecycle needs a HookContext');
}
}
/** `$webhookUrl` + `$staticData`, shared by every lifecycle request. */
function hookKeys(context: HookContext, staticData: IDataObject): IWorkflowDataProxyAdditionalKeys {
return {
$webhookUrl: context.getNodeWebhookUrl('default'),
$staticData: staticData,
} as IWorkflowDataProxyAdditionalKeys;
}
function createCheckExists(
nodeType: INodeType,
trigger: IDeclarativeWebhookTrigger,
config: DeclarativeWebhookCheckExists,
): LifecycleSlots['checkExists'] {
return async function (this: IHookFunctions) {
assertHookContext(this);
const staticData = this.getWorkflowStaticData('node');
const extraKeys = hookKeys(this, staticData);
if (!config.matchOn) {
const idKey = config.idKey ?? 'webhookId';
if (staticData[idKey] === undefined) return false;
try {
await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
return true;
} catch (error) {
const notFound = config.notFoundHttpCodes ?? [404];
if (
error instanceof NodeApiError &&
error.httpCode !== null &&
notFound.includes(Number(error.httpCode))
) {
clearManagedKeys(trigger, staticData);
return false;
}
throw error;
}
}
const items = await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
for (const item of items) {
const itemKeys = { ...extraKeys, $item: item.json };
const matches = config.matchOn.every(
({ itemProperty, value }) =>
get(item.json, itemProperty) === resolveValue(this, value, itemKeys),
);
if (matches) {
applyStore(this, config.store, itemKeys, staticData);
return true;
}
}
return false;
};
}
function generateSecret({ type, length, prefix = '' }: DeclarativeSecretGenerator): string {
switch (type) {
case 'hex':
return prefix + randomBytes(length ?? 32).toString('hex');
case 'base64':
return prefix + randomBytes(length ?? 32).toString('base64');
case 'uuid':
return prefix + randomUUID();
case 'alphanumericLower': {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const chars = randomBytes(length ?? 10);
return prefix + [...chars].map((byte) => alphabet[byte % alphabet.length]).join('');
}
}
}
function createCreate(
nodeType: INodeType,
config: DeclarativeWebhookCreate,
): LifecycleSlots['create'] {
return async function (this: IHookFunctions) {
assertHookContext(this);
const staticData = this.getWorkflowStaticData('node');
const $generated = Object.fromEntries(
Object.entries(config.generate ?? {}).map(([name, generator]) => [
name,
generateSecret(generator),
]),
);
const extraKeys = { ...hookKeys(this, staticData), $generated };
if (config.rejectLocalhostUrl && this.getNodeWebhookUrl('default')?.includes('//localhost')) {
throw new NodeOperationError(
this.getNode(),
'The Webhook can not work on "localhost". Please setup n8n on a custom domain.',
);
}
const items = await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
const response = items[0]?.json ?? {};
if (config.validateResponse) {
const valid = resolveValue(this, config.validateResponse, {
...extraKeys,
$response: response,
});
if (!valid) {
throw new NodeApiError(this.getNode(), response as JsonObject, {
message: 'Webhook creation response did not contain the expected data.',
});
}
}
applyStore(this, config.store, { ...extraKeys, $response: response }, staticData);
return true;
};
}
function createDelete(
nodeType: INodeType,
trigger: IDeclarativeWebhookTrigger,
config: DeclarativeWebhookDelete,
): LifecycleSlots['delete'] {
return async function (this: IHookFunctions) {
assertHookContext(this);
const staticData = this.getWorkflowStaticData('node');
const idKey = config.idKey ?? 'webhookId';
if (staticData[idKey] === undefined) return true;
try {
await runLifecycleRouting(this, nodeType, config.routing, hookKeys(this, staticData));
} catch {
// Deregistration failures must not block deactivation; static data is
// kept so a later delete can retry (matches the shipped trigger nodes).
return false;
}
clearManagedKeys(trigger, staticData);
return true;
};
}
/**
* Builds the `webhookMethods.default` slots for a node whose description
* carries `trigger: { type: 'webhook' }`. A function-valued slot is used
* as-is; an omitted checkExists always re-registers (⇒ false) and an omitted
* delete has nothing to deregister (⇒ true).
*/
export function createDeclarativeWebhookMethods(
nodeType: INodeType,
trigger: IDeclarativeWebhookTrigger,
): LifecycleSlots {
const { checkExists, create, delete: del } = trigger.lifecycle;
return {
checkExists:
checkExists === undefined
? async () => false
: typeof checkExists === 'function'
? checkExists
: createCheckExists(nodeType, trigger, checkExists),
create: typeof create === 'function' ? create : createCreate(nodeType, create),
delete:
del === undefined
? async () => true
: typeof del === 'function'
? del
: createDelete(nodeType, trigger, del),
};
}
/** Ported from `packages/nodes-base/utils/webhook-signature-verification.ts` (core cannot import nodes-base). */
function isTimestampValid(timestamp: string | undefined, maxAgeSeconds: number): boolean {
if (timestamp === undefined) return false;
const parsed = parseInt(timestamp, 10);
if (isNaN(parsed)) return false;
// Convert to seconds if the timestamp is in milliseconds
const timestampSec = parsed > 1e10 ? Math.floor(parsed / 1000) : parsed;
const currentTimeSec = Math.floor(Date.now() / 1000);
return Math.abs(currentTimeSec - timestampSec) <= maxAgeSeconds;
}
function constantTimeEquals(expected: string, actual: string): boolean {
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(actual);
return (
expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer)
);
}
/**
* Applies a `handler.verification` descriptor to the incoming delivery.
* Same semantics as the shared programmatic util: constant-time comparison,
* optional replay window, `skipIfNoSecret` for unsigned legacy hooks. Returns
* true when the delivery may proceed; never throws.
*/
async function verifyDeliverySignature(
context: WebhookContext,
config: DeclarativeWebhookVerification,
): Promise<boolean> {
try {
const headers = context.getHeaderData();
if (config.timestampHeader) {
const timestamp = headers[config.timestampHeader.toLowerCase()] as string | undefined;
if (!isTimestampValid(timestamp, config.maxTimestampAgeSeconds ?? 300)) return false;
}
const secret = resolveValue(context, config.secret, {
$staticData: context.getWorkflowStaticData('node'),
...(config.credentialType && {
$credentials: await context.getCredentials(config.credentialType),
}),
} as IWorkflowDataProxyAdditionalKeys);
if (typeof secret !== 'string' || secret === '') {
if (config.skipIfNoSecret) return true;
// A hook can sit without a stored secret until it is re-registered, and
// rejecting is the safe answer for that window. Say so, or a stopped
// trigger looks identical to a bad signature.
context.logger.warn(
`Webhook trigger "${context.getNode().name}" rejected a delivery because no signature secret is available. Activate the workflow again to re-register the webhook.`,
{ workflowId: context.getWorkflow().id },
);
return false;
}
const actual = headers[config.signatureHeader.toLowerCase()];
if (typeof actual !== 'string' || actual === '') return false;
if (config.algorithm === 'token') {
return constantTimeEquals(secret, actual);
}
const req: { rawBody?: Buffer | string } = context.getRequestObject();
const payload =
(config.signedPayload ?? 'rawBody') === 'rawBody'
? (req.rawBody ?? JSON.stringify(context.getBodyData()))
: JSON.stringify(context.getBodyData());
const hmac = createHmac(config.algorithm === 'hmac-sha1' ? 'sha1' : 'sha256', secret);
hmac.update(payload);
const expected = (config.prefix ?? '') + hmac.digest(config.encoding ?? 'hex');
return constantTimeEquals(expected, actual);
} catch {
return false;
}
}
/** Answers a handshake: static-data writes, then the configured response. */
function respondToHandshake(
context: WebhookContext,
handshake: DeclarativeWebhookHandshake,
requestKeys: IWorkflowDataProxyAdditionalKeys,
): IWebhookResponseData {
applyStore(context, handshake.store, requestKeys, context.getWorkflowStaticData('node'));
const respond = handshake.respond ?? {};
const body = respond.body ? resolveValue(context, respond.body, requestKeys) : 'OK';
if (!respond.statusCode && !respond.contentType && !respond.headers) {
return { webhookResponse: body };
}
const res = context.getResponseObject();
for (const [name, value] of Object.entries(respond.headers ?? {})) {
res.setHeader(name, String(resolveValue(context, value, requestKeys)));
}
if (respond.contentType) res.setHeader('content-type', respond.contentType);
res
.status(respond.statusCode ?? 200)
.send(body)
.end();
return { noWebhookResponse: true };
}
/**
* Default `webhook()` for a declarative webhook trigger. On a `setup` endpoint
* every request is a handshake. On the `default` endpoint the pipeline is:
* handshake (`when` matches ⇒ respond, no run) → verification (failure ⇒ 401,
* no run) → ping (⇒ custom response, no run) → filter (miss ⇒ 200, no run) →
* output shaping → emit.
*/
export function createDeclarativeWebhook(
trigger: IDeclarativeWebhookTrigger,
): NonNullable<INodeType['webhook']> {
const { handshake, verification, ping, filter, output } = trigger.handler ?? {};
return async function (this: IWebhookFunctions): Promise<IWebhookResponseData> {
if (!(this instanceof WebhookContext)) {
throw new UnexpectedError('Declarative webhook handler needs a WebhookContext');
}
const requestKeys = {
$request: {
body: this.getBodyData(),
headers: this.getHeaderData(),
query: this.getQueryData(),
},
} as IWorkflowDataProxyAdditionalKeys;
if (this.getWebhookName() === 'setup') {
if (handshake?.when && !resolveValue(this, handshake.when, requestKeys)) {
this.getResponseObject().status(401).send('Unauthorized').end();
return { noWebhookResponse: true };
}
return respondToHandshake(this, handshake ?? {}, requestKeys);
}
if (handshake?.when && resolveValue(this, handshake.when, requestKeys)) {
return respondToHandshake(this, handshake, requestKeys);
}
if (verification && !(await verifyDeliverySignature(this, verification))) {
this.getResponseObject().status(401).send('Unauthorized').end();
return { noWebhookResponse: true };
}
if (ping && resolveValue(this, ping.when, requestKeys)) {
return { webhookResponse: ping.response ?? 'OK' };
}
if (filter) {
const allowedValue = resolveValue(this, filter.allowed, requestKeys);
const allowed = Array.isArray(allowedValue) ? allowedValue : [allowedValue];
const actual = resolveValue(this, filter.actual, requestKeys);
const wildcardHit = filter.wildcard !== undefined && allowed.includes(filter.wildcard);
if (!wildcardHit && !allowed.includes(actual)) return {};
}
const data = output?.data ? resolveValue(this, output.data, requestKeys) : this.getBodyData();
const emitted = output?.includeMeta
? { body: data, headers: this.getHeaderData(), query: this.getQueryData() }
: data;
return {
workflowData: [this.helpers.returnJsonArray(emitted as IDataObject | IDataObject[])],
};
};
}

View File

@ -105,6 +105,10 @@ export { commitStagedCursor, runPollInStagingScope } from './poll-cursor-hooks';
export { PollJobManager } from './poll-job-manager';
export { PollTriggerExecutor } from './poll-trigger-executor';
export { isEngineRequest } from './requests-response';
export {
createDeclarativeWebhook,
createDeclarativeWebhookMethods,
} from './declarative-webhook';
export * from './routing-node';
export * from './scheduled-task-manager';
export { TriggersAndPollers } from './triggers-and-pollers';

View File

@ -1462,7 +1462,11 @@ export class WorkflowExecute {
);
}
const isDeclarativeNode = nodeType.description.requestDefaults !== undefined;
// A declarative webhook trigger may carry requestDefaults for its lifecycle
// requests without being a declarative (routing) action node.
const isDeclarativeNode =
nodeType.description.requestDefaults !== undefined &&
nodeType.description.trigger?.type !== 'webhook';
if (nodeType.webhook && !isDeclarativeNode) {
// Check if the node have requestDefaults(Declarative Node),
// else for webhook nodes always simply pass the data through

View File

@ -1043,4 +1043,61 @@ describe('DirectoryLoader', () => {
expect(nodeType.description.properties).toEqual([]);
});
});
describe('declarative webhook trigger synthesis', () => {
// Plain objects (not deep mocks): synthesis must see undefined members.
const makeWebhookTriggerNode = (extra: Partial<INodeType> = {}): INodeType =>
({
description: {
displayName: 'node1',
name: 'node1',
group: ['trigger'],
version: 1,
description: '',
defaults: {},
inputs: [],
outputs: ['main'],
properties: [],
webhooks: [
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
],
trigger: {
type: 'webhook',
lifecycle: {
create: { routing: { request: { method: 'POST', url: '/hooks' } } },
},
},
},
...extra,
}) as unknown as INodeType;
test('synthesizes webhookMethods and webhook', async () => {
mockNode1 = makeWebhookTriggerNode();
const loader = new CustomDirectoryLoader(directory);
await loader.loadAll();
const nodeType = loader.getNode('node1').type as INodeType;
expect(nodeType.webhookMethods?.default?.checkExists).toBeInstanceOf(Function);
expect(nodeType.webhookMethods?.default?.create).toBeInstanceOf(Function);
expect(nodeType.webhookMethods?.default?.delete).toBeInstanceOf(Function);
expect(nodeType.webhook).toBeInstanceOf(Function);
});
test('class-defined webhook and lifecycle slots win, missing slots are filled', async () => {
const ownWebhook = vi.fn();
const ownCreate = vi.fn();
mockNode1 = makeWebhookTriggerNode({
webhook: ownWebhook,
webhookMethods: { default: { create: ownCreate } },
} as unknown as Partial<INodeType>);
const loader = new CustomDirectoryLoader(directory);
await loader.loadAll();
const nodeType = loader.getNode('node1').type as INodeType;
expect(nodeType.webhook).toBe(ownWebhook);
expect(nodeType.webhookMethods?.default?.create).toBe(ownCreate);
expect(nodeType.webhookMethods?.default?.checkExists).toBeInstanceOf(Function);
expect(nodeType.webhookMethods?.default?.delete).toBeInstanceOf(Function);
});
});
});

View File

@ -24,6 +24,10 @@ import * as path from 'path';
import { UnrecognizedCredentialTypeError } from '@/errors/unrecognized-credential-type.error';
import { UnrecognizedNodeTypeError } from '@/errors/unrecognized-node-type.error';
import { createDeclarativePoll } from '@/execution-engine/declarative-poll';
import {
createDeclarativeWebhook,
createDeclarativeWebhookMethods,
} from '@/execution-engine/declarative-webhook';
import {
commonCORSParameters,
@ -411,6 +415,23 @@ export abstract class DirectoryLoader implements NodeLoader {
nodeType.poll = createDeclarativePoll(nodeType, trigger);
}
if (trigger?.type === 'webhook') {
// Synthesized members fill only the slots the class leaves undefined.
const methods = createDeclarativeWebhookMethods(nodeType, trigger);
const existing = nodeType.webhookMethods?.default;
nodeType.webhookMethods = {
...nodeType.webhookMethods,
default: {
checkExists: existing?.checkExists ?? methods.checkExists,
create: existing?.create ?? methods.create,
delete: existing?.delete ?? methods.delete,
},
};
if (!nodeType.webhook) {
nodeType.webhook = createDeclarativeWebhook(trigger);
}
}
const { properties, polling, supportsCORS } = nodeType.description;
if (polling) {
properties.unshift(...commonPollingParameters);

View File

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.e2eTestDeclarativeWebhookTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "This is a test node for e2e testing of declarative webhook triggers.",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.editimage/"
}
]
},
"subcategories": {
"Core Nodes": ["Helpers"]
},
"e2e": true
}

View File

@ -0,0 +1,115 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
/**
* Declarative webhook trigger for e2e testing: no `webhookMethods`, no
* `webhook()`; the loader builds both from `description.trigger`. Registers
* itself against a mock "hook registry" API and emits every delivery whose
* `event` matches the selected events.
*/
export class E2eTestDeclarativeWebhookTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'E2E Test Declarative Webhook Trigger',
name: 'e2eTestDeclarativeWebhookTrigger',
icon: 'fa:play',
group: ['trigger'],
version: 1,
description: 'Dummy declarative webhook trigger for e2e testing',
subtitle: '={{$parameter["url"]}}',
defaults: {
name: 'E2E Test Declarative Webhook Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
required: true,
description:
'Base URL of the hook registry: GET /hooks lists registrations, POST /hooks registers this webhook, DELETE /hooks/{webhookId} deregisters it',
},
{
displayName: 'Events',
name: 'events',
type: 'multiOptions',
default: ['created'],
options: [
{ name: 'All', value: '*' },
{ name: 'Created', value: 'created' },
{ name: 'Deleted', value: 'deleted' },
{ name: 'Updated', value: 'updated' },
],
},
],
trigger: {
type: 'webhook',
lifecycle: {
checkExists: {
routing: {
request: { method: 'GET', url: '={{ $parameter.url }}/hooks' },
output: { postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }] },
},
matchOn: [{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' }],
store: { webhookId: '={{ $item.id }}' },
},
// TEMPORARY (handshake-store verification): create does NOT mint or send a
// secret, so the handshake below is the only way webhookSecret can enter
// static data. Revert to the generate/$generated version afterwards.
create: {
routing: {
request: {
method: 'POST',
url: '={{ $parameter.url }}/hooks',
body: {
target_url: '={{ $webhookUrl }}',
events: '={{ $parameter.events }}',
},
},
},
store: { webhookId: '={{ $response.id }}' },
},
delete: {
routing: {
request: {
method: 'DELETE',
url: '={{ $parameter.url + "/hooks/" + $staticData.webhookId }}',
},
},
},
},
handler: {
// TEMPORARY (handshake-store verification): the secret arrives in a
// handshake request and is stored during webhook handling — the exact
// write path under test.
handshake: {
when: '={{ $request.headers["x-hook-secret"] !== undefined }}',
store: { webhookSecret: '={{ $request.headers["x-hook-secret"] }}' },
respond: { headers: { 'X-Hook-Secret': '={{ $request.headers["x-hook-secret"] }}' } },
},
// The registry signs each delivery with the secret it received at
// registration; an unsigned or tampered delivery is answered 401.
verification: {
algorithm: 'hmac-sha256',
signatureHeader: 'x-registry-signature',
secret: '={{ $staticData.webhookSecret }}',
prefix: 'sha256=',
},
filter: {
allowed: '={{ $parameter.events }}',
actual: '={{ $request.body.event }}',
wildcard: '*',
},
},
},
};
}

View File

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.githubDeclarativeTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/github/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.githubtrigger/"
}
]
}
}

View File

@ -0,0 +1,512 @@
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { getRepositories, getUsers } from './SearchFunctions';
/**
* Declarative twin of `GithubTrigger`: the webhook lifecycle and event handling
* come entirely from `description.trigger` — no `webhookMethods`, no
* `webhook()`. Uses the same GitHub credentials as the original node, mints a
* webhook secret on registration, and verifies each delivery's HMAC signature.
*
* Known differences from `GithubTrigger` (features of later phases of the
* declarative-webhook-triggers spec): no 422 conflict-adoption and no friendly
* per-status error messages, and the owner/repository resource locators have no
* paste-a-URL mode.
*/
export class GithubDeclarativeTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub Declarative Trigger',
name: 'githubDeclarativeTrigger',
icon: { light: 'file:github.svg', dark: 'file:github.dark.svg' },
group: ['trigger'],
version: 1,
subtitle:
'={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}',
description: 'Starts the workflow when GitHub events occur (declarative trigger)',
defaults: {
name: 'GitHub Declarative Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'githubApi',
required: true,
displayOptions: {
show: {
authentication: ['accessToken'],
},
},
},
{
name: 'githubOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
{
name: 'githubAppApi',
required: true,
displayOptions: {
show: {
authentication: ['githubAppApi'],
},
},
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
requestDefaults: {
baseURL: '={{ $credentials.server || "https://api.github.com" }}',
json: true,
},
trigger: {
type: 'webhook',
lifecycle: {
checkExists: {
routing: {
request: {
method: 'GET',
url: '={{ "/repos/" + $parameter.owner + "/" + $parameter.repository + "/hooks/" + $staticData.webhookId }}',
},
},
notFoundHttpCodes: [404],
},
create: {
routing: {
request: {
method: 'POST',
url: '={{ "/repos/" + $parameter.owner + "/" + $parameter.repository + "/hooks" }}',
body: {
name: 'web',
config: {
url: '={{ $webhookUrl }}',
content_type: 'json',
secret: '={{ $generated.webhookSecret }}',
insecure_ssl: '={{ $parameter.options?.insecureSSL ? "1" : "0" }}',
},
events: '={{ $parameter.events }}',
active: true,
},
},
},
generate: { webhookSecret: { type: 'hex', length: 32 } },
rejectLocalhostUrl: true,
validateResponse: '={{ $response.id !== undefined && $response.active === true }}',
store: {
webhookId: '={{ $response.id }}',
webhookEvents: '={{ $response.events }}',
webhookSecret: '={{ $generated.webhookSecret }}',
},
},
delete: {
routing: {
request: {
method: 'DELETE',
url: '={{ "/repos/" + $parameter.owner + "/" + $parameter.repository + "/hooks/" + $staticData.webhookId }}',
},
},
},
},
handler: {
verification: {
algorithm: 'hmac-sha256',
signatureHeader: 'x-hub-signature-256',
secret: '={{ $staticData.webhookSecret }}',
encoding: 'hex',
prefix: 'sha256=',
signedPayload: 'rawBody',
},
// GitHub's post-registration liveness check: answer, never run.
ping: { when: '={{ $request.headers["x-github-event"] === "ping" }}', response: 'OK' },
filter: {
allowed: '={{ $parameter.events }}',
actual: '={{ $request.headers["x-github-event"] }}',
wildcard: '*',
},
// Same item shape as GithubTrigger
output: { includeMeta: true },
},
},
properties: [
{
displayName:
'Only members with owner privileges for an organization or admin privileges for a repository can set up the webhooks this node requires.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
{
name: 'GitHub App',
value: 'githubAppApi',
},
],
default: 'accessToken',
},
{
displayName: 'Repository Owner',
name: 'owner',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'Repository Owner',
name: 'list',
type: 'list',
placeholder: 'Select an owner...',
typeOptions: {
searchListMethod: 'getUsers',
searchable: true,
searchFilterRequired: true,
},
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n-io',
validation: [
{
type: 'regex',
properties: {
regex: '[-_a-zA-Z0-9]+',
errorMessage: 'Not a valid Github Owner Name',
},
},
],
url: '=https://github.com/{{$value}}',
},
],
},
{
displayName: 'Repository Name',
name: 'repository',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'Repository Name',
name: 'list',
type: 'list',
placeholder: 'Select a Repository...',
typeOptions: {
searchListMethod: 'getRepositories',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n',
validation: [
{
type: 'regex',
properties: {
regex: '[-_.0-9a-zA-Z]+',
errorMessage: 'Not a valid Github Repository Name',
},
},
],
url: '=https://github.com/{{$parameter["owner"]}}/{{$value}}',
},
],
},
{
displayName: 'Events',
name: 'events',
type: 'multiOptions',
options: [
{
name: '*',
value: '*',
description: 'Any time any event is triggered (Wildcard Event)',
},
{
name: 'Check Run',
value: 'check_run',
description:
'Triggered when a check run is created, rerequested, completed, or has a requested_action',
},
{
name: 'Check Suite',
value: 'check_suite',
description: 'Triggered when a check suite is completed, requested, or rerequested',
},
{
name: 'Commit Comment',
value: 'commit_comment',
description: 'Triggered when a commit comment is created',
},
{
name: 'Create',
value: 'create',
description: 'Represents a created repository, branch, or tag',
},
{
name: 'Delete',
value: 'delete',
description: 'Represents a deleted branch or tag',
},
{
name: 'Deploy Key',
value: 'deploy_key',
description: 'Triggered when a deploy key is added or removed from a repository',
},
{
name: 'Deployment',
value: 'deployment',
description: 'Represents a deployment',
},
{
name: 'Deployment Status',
value: 'deployment_status',
description: 'Represents a deployment status',
},
{
name: 'Fork',
value: 'fork',
description: 'Triggered when a user forks a repository',
},
{
name: 'Github App Authorization',
value: 'github_app_authorization',
description: 'Triggered when someone revokes their authorization of a GitHub App',
},
{
name: 'Gollum',
value: 'gollum',
description: 'Triggered when a Wiki page is created or updated',
},
{
name: 'Installation',
value: 'installation',
description:
'Triggered when someone installs (created), uninstalls (deleted), or accepts new permissions (new_permissions_accepted) for a GitHub App. When a GitHub App owner requests new permissions, the person who installed the GitHub App must accept the new permissions request.',
},
{
name: 'Installation Repositories',
value: 'installation_repositories',
description: 'Triggered when a repository is added or removed from an installation',
},
{
name: 'Issue Comment',
value: 'issue_comment',
description: 'Triggered when an issue comment is created, edited, or deleted',
},
{
name: 'Issues',
value: 'issues',
description:
'Triggered when an issue is opened, edited, deleted, transferred, pinned, unpinned, closed, reopened, assigned, unassigned, labeled, unlabeled, locked, unlocked, milestoned, or demilestoned',
},
{
name: 'Label',
value: 'label',
description: "Triggered when a repository's label is created, edited, or deleted",
},
{
name: 'Marketplace Purchase',
value: 'marketplace_purchase',
description:
'Triggered when someone purchases a GitHub Marketplace plan, cancels their plan, upgrades their plan (effective immediately), downgrades a plan that remains pending until the end of the billing cycle, or cancels a pending plan change',
},
{
name: 'Member',
value: 'member',
description:
'Triggered when a user accepts an invitation or is removed as a collaborator to a repository, or has their permissions changed',
},
{
name: 'Membership',
value: 'membership',
description:
'Triggered when a user is added or removed from a team. Organization hooks only.',
},
{
name: 'Meta',
value: 'meta',
description: 'Triggered when the webhook that this event is configured on is deleted',
},
{
name: 'Milestone',
value: 'milestone',
description:
'Triggered when a milestone is created, closed, opened, edited, or deleted',
},
{
name: 'Org Block',
value: 'org_block',
description:
'Triggered when an organization blocks or unblocks a user. Organization hooks only.',
},
{
name: 'Organization',
value: 'organization',
description:
'Triggered when an organization is deleted and renamed, and when a user is added, removed, or invited to an organization. Organization hooks only.',
},
{
name: 'Page Build',
value: 'page_build',
description:
'Triggered on push to a GitHub Pages enabled branch (gh-pages for project pages, master for user and organization pages)',
},
{
name: 'Project',
value: 'project',
description:
'Triggered when a project is created, updated, closed, reopened, or deleted',
},
{
name: 'Project Card',
value: 'project_card',
description:
'Triggered when a project card is created, edited, moved, converted to an issue, or deleted',
},
{
name: 'Project Column',
value: 'project_column',
description: 'Triggered when a project column is created, updated, moved, or deleted',
},
{
name: 'Public',
value: 'public',
description: 'Triggered when a private repository is open sourced',
},
{
name: 'Pull Request',
value: 'pull_request',
description:
'Triggered when a pull request is assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, ready_for_review, locked, unlocked, a pull request review is requested, or a review request is removed',
},
{
name: 'Pull Request Review',
value: 'pull_request_review',
description:
'Triggered when a pull request review is submitted into a non-pending state, the body is edited, or the review is dismissed',
},
{
name: 'Pull Request Review Comment',
value: 'pull_request_review_comment',
description:
"Triggered when a comment on a pull request's unified diff is created, edited, or deleted (in the Files Changed tab)",
},
{
name: 'Push',
value: 'push',
description:
'Triggered on a push to a repository branch. Branch pushes and repository tag pushes also trigger webhook push events. This is the default event.',
},
{
name: 'Release',
value: 'release',
description:
'Triggered when a release is published, unpublished, created, edited, deleted, or prereleased',
},
{
name: 'Repository',
value: 'repository',
description:
'Triggered when a repository is created, archived, unarchived, renamed, edited, transferred, made public, or made private. Organization hooks are also triggered when a repository is deleted.',
},
{
name: 'Repository Import',
value: 'repository_import',
description:
'Triggered when a successful, cancelled, or failed repository import finishes for a GitHub organization or a personal repository',
},
{
name: 'Repository Vulnerability Alert',
value: 'repository_vulnerability_alert',
description: 'Triggered when a security alert is created, dismissed, or resolved',
},
{
name: 'Security Advisory',
value: 'security_advisory',
description:
'Triggered when a new security advisory is published, updated, or withdrawn',
},
{
name: 'Star',
value: 'star',
description: 'Triggered when a star is added or removed from a repository',
},
{
name: 'Status',
value: 'status',
description: 'Triggered when the status of a Git commit changes',
},
{
name: 'Team',
value: 'team',
description:
"Triggered when an organization's team is created, deleted, edited, added_to_repository, or removed_from_repository. Organization hooks only.",
},
{
name: 'Team Add',
value: 'team_add',
description: 'Triggered when a repository is added to a team',
},
{
name: 'Watch',
value: 'watch',
description: 'Triggered when someone stars a repository',
},
],
required: true,
default: [],
description: 'The events to listen to',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Insecure SSL',
name: 'insecureSSL',
type: 'boolean',
default: false,
description:
'Whether the SSL certificate of the n8n host be verified by GitHub when delivering payloads',
},
],
},
],
};
methods = {
listSearch: {
getUsers,
getRepositories,
},
};
}

View File

@ -540,6 +540,7 @@
"dist/nodes/E2eTest/E2eTest.node.js",
"dist/nodes/E2eTest/E2eTestPollingTrigger.node.js",
"dist/nodes/E2eTest/E2eTestDeclarativePollingTrigger.node.js",
"dist/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.js",
"dist/nodes/Egoi/Egoi.node.js",
"dist/nodes/Elastic/Elasticsearch/Elasticsearch.node.js",
"dist/nodes/Elastic/ElasticSecurity/ElasticSecurity.node.js",
@ -582,6 +583,7 @@
"dist/nodes/Ghost/Ghost.node.js",
"dist/nodes/Git/Git.node.js",
"dist/nodes/Github/Github.node.js",
"dist/nodes/Github/GithubDeclarativeTrigger.node.js",
"dist/nodes/Github/GithubTrigger.node.js",
"dist/nodes/Gitlab/Gitlab.node.js",
"dist/nodes/Gitlab/GitlabTrigger.node.js",

View File

@ -38,6 +38,7 @@
"coverage:emit-spec-backend-lcovs": "tsx scripts/emit-spec-backend-lcovs.ts",
"coverage:clean": "rm -rf coverage coverage-by-spec coverage-by-spec-backend .v8-coverage .nyc_output",
"coverage:analyse": "node scripts/coverage-analysis.mjs",
"hook-registry": "tsx scripts/hook-registry.ts",
"install-browsers": "PLAYWRIGHT_BROWSERS_PATH=./.playwright-browsers playwright install chromium --with-deps",
"browsers:uninstall": "playwright uninstall --all",
"prepare-test-image": "node scripts/build-test-image.mjs",

View File

@ -0,0 +1,18 @@
/**
* Standalone hook registry for manually demoing declarative webhook triggers.
*
* pnpm --filter=n8n-playwright hook-registry # port 9090
* PORT=9999 pnpm --filter=n8n-playwright hook-registry
*
* Point an E2E Test Declarative Webhook Trigger's URL parameter at it
* (http://localhost:9090), activate the workflow, then deliver events:
*
* curl -s localhost:9090/fire -H 'content-type: application/json' \
* -d '{"event":"created","name":"demo"}'
*/
import { HookRegistryServer } from '../services/hook-registry-server';
const port = Number(process.env.PORT ?? 9090);
// eslint-disable-next-line no-console
void new HookRegistryServer((line) => console.log(`[hook-registry] ${line}`)).start(port);

View File

@ -0,0 +1,141 @@
import { createHmac } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
export interface RegisteredHook {
id: string;
target_url: string;
events?: string[];
/** When registered with a secret, deliveries are HMAC-signed with it. */
secret?: string;
[key: string]: unknown;
}
interface RegistryCall {
method: string;
path: string;
body?: unknown;
}
/**
* A stateful mock "hook registry" for webhook-trigger lifecycle testing — the
* vendor API that E2eTestDeclarativeWebhookTrigger registers against:
*
* GET /hooks → { hooks: RegisteredHook[] }
* POST /hooks → registers the body, returns { id }
* DELETE /hooks/:id → deregisters
* POST /fire → delivers the body to every registered target_url,
* returns { delivered }
*
* Usable programmatically from a spec (start/stop, `hooks`, `calls`, `fire()`)
* and standalone for manual demos via `pnpm --filter=n8n-playwright hook-registry`.
*/
export class HookRegistryServer {
readonly hooks = new Map<string, RegisteredHook>();
/** Every lifecycle call received, for assertions. */
readonly calls: RegistryCall[] = [];
private server?: Server;
private nextId = 1;
constructor(private readonly log: (line: string) => void = () => {}) {}
get port(): number {
const address = this.server?.address() as AddressInfo | null | undefined;
if (!address) throw new Error('Hook registry is not listening');
return address.port;
}
/** Base URL as reachable from this host. */
get url(): string {
return `http://localhost:${this.port}`;
}
async start(port = 0): Promise<this> {
this.server = createServer((req, res) => {
void this.handle(req, res);
});
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject);
// No host: bind dual-stack (`::`), so both `localhost` resolutions
// (127.0.0.1 and ::1) reach the registry.
this.server!.listen(port, resolve);
});
this.log(`hook registry listening on ${this.url}`);
return this;
}
async stop(): Promise<void> {
if (!this.server) return;
await new Promise<void>((resolve, reject) =>
this.server!.close((error) => (error ? reject(error) : resolve())),
);
this.server = undefined;
}
/**
* Delivers a payload to every registered hook, like the vendor firing an
* event. A hook registered with a `secret` gets its delivery signed:
* `x-registry-signature: sha256=<HMAC-SHA256 hex over the raw body>`.
*/
async fire(payload: Record<string, unknown>): Promise<{ delivered: number; statuses: number[] }> {
const statuses: number[] = [];
for (const hook of this.hooks.values()) {
const body = JSON.stringify(payload);
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (typeof hook.secret === 'string' && hook.secret !== '') {
headers['x-registry-signature'] =
`sha256=${createHmac('sha256', hook.secret).update(body).digest('hex')}`;
}
const response = await fetch(hook.target_url, { method: 'POST', headers, body });
this.log(
`fired ${body}${headers['x-registry-signature'] ? ' (signed)' : ''} → ${hook.target_url} (${response.status})`,
);
statuses.push(response.status);
}
return { delivered: statuses.length, statuses };
}
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const method = req.method ?? 'GET';
const path = (req.url ?? '/').split('?')[0];
const body = await this.readBody(req);
this.calls.push({ method, path, body });
this.log(`${method} ${path}${body ? ` ${JSON.stringify(body)}` : ''}`);
const respond = (statusCode: number, payload: unknown) => {
res.writeHead(statusCode, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
};
if (method === 'GET' && path === '/hooks') {
respond(200, { hooks: [...this.hooks.values()] });
} else if (method === 'POST' && path === '/hooks') {
const id = `h${this.nextId++}`;
this.hooks.set(id, { ...(body as Record<string, unknown>), id } as RegisteredHook);
respond(200, { id });
} else if (method === 'DELETE' && path.startsWith('/hooks/')) {
const id = path.slice('/hooks/'.length);
if (this.hooks.delete(id)) respond(200, { deleted: id });
else respond(404, { error: `no hook ${id}` });
} else if (method === 'POST' && path === '/fire') {
respond(200, await this.fire(body as Record<string, unknown>));
} else {
respond(404, { error: `no route ${method} ${path}` });
}
}
private async readBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
if (chunks.length === 0) return undefined;
const raw = Buffer.concat(chunks).toString('utf8');
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
}

View File

@ -0,0 +1,99 @@
import { workflow, trigger, node } from '@n8n/workflow-sdk';
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import { HookRegistryServer } from '../../../services/hook-registry-server';
/**
* E2E for declarative webhook triggers (`description.trigger: { type: 'webhook' }`):
* activation must register the webhook with the vendor (the hook registry),
* matching deliveries must run the workflow, filtered deliveries must not, and
* deactivation must deregister.
*
* The registry runs on the test host. The n8n under test reaches it via
* HOOK_REGISTRY_PUBLIC_HOST (default `localhost`; set `host.docker.internal`
* when n8n runs inside a container without host networking).
*/
const REGISTRY_PUBLIC_HOST = process.env.HOOK_REGISTRY_PUBLIC_HOST ?? 'localhost';
const makeDeclarativeWebhookWorkflow = (registryUrl: string) => {
const webhookTrigger = trigger({
type: 'n8n-nodes-base.e2eTestDeclarativeWebhookTrigger',
version: 1,
config: {
name: 'Declarative Webhook Trigger',
parameters: { url: registryUrl, events: ['created'] },
},
});
const noOp = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'NoOp' },
});
return workflow(nanoid(), `Declarative Webhook Trigger Test ${nanoid()}`).add(
webhookTrigger.to(noOp),
);
};
test.describe(
'Declarative Webhook Trigger',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
let registry: HookRegistryServer;
test.beforeAll(async () => {
registry = await new HookRegistryServer().start();
});
test.afterAll(async () => {
await registry.stop();
});
test('registers on activation, runs only matching deliveries, deregisters on deactivation', async ({
api,
}) => {
const registryUrl = `http://${REGISTRY_PUBLIC_HOST}:${registry.port}`;
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
makeDeclarativeWebhookWorkflow(registryUrl).toJSON() as never,
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
// Activation registered exactly one hook, carrying the selected events.
expect(registry.hooks.size).toBe(1);
const [hook] = [...registry.hooks.values()];
expect(hook.target_url).toContain('/webhook');
expect(hook.events).toEqual(['created']);
// A filtered delivery is answered 200 but must not start a run; the
// matching delivery afterwards is the synchronization point proving it.
const filtered = await registry.fire({ event: 'deleted', note: 'must not run' });
expect(filtered.statuses).toEqual([200]);
// A delivery with a bad signature is rejected outright.
const tampered = await fetch(hook.target_url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-registry-signature': 'sha256=deadbeef',
},
body: JSON.stringify({ event: 'created', note: 'forged, must not run' }),
});
expect(tampered.status).toBe(401);
await registry.fire({ event: 'created', note: 'must run' });
const execution = await api.workflows.waitForExecution(workflowId, 30_000, 'webhook');
expect(execution.status).toBe('success');
const executions = await api.workflows.getExecutions(workflowId, 50);
expect(executions.filter((e) => e.mode === 'webhook')).toHaveLength(1);
await api.workflows.deactivate(workflowId);
expect(registry.hooks.size).toBe(0);
});
},
);

View File

@ -2824,6 +2824,185 @@ export interface IDeclarativePollingTrigger {
manual?: { maxResults?: number };
}
/** A hand-written lifecycle method — full-control escape hatch per slot. */
export type DeclarativeHookMethod = (this: IHookFunctions) => Promise<boolean>;
/**
* Answers "is our webhook still registered with the vendor?" on activation.
* With `matchOn`: true iff some returned item satisfies ALL clauses (`$item` =
* the candidate); `store` then recovers values from the matched item.
* Without `matchOn`: any 2xx ⇒ true; a status in `notFoundHttpCodes` (default
* [404]) ⇒ clear the keys written by `create.store`, return false; skipped
* (⇒ false) when `$staticData[idKey]` is absent.
*/
export interface DeclarativeWebhookCheckExists {
routing: INodePropertyRouting;
matchOn?: Array<{ itemProperty: string; value: string }>;
/** Static-data writes from the matched item; exprs over `$item`. */
store?: Record<string, string>;
notFoundHttpCodes?: number[];
/** Static-data key required before requesting. Default 'webhookId'. Only used without `matchOn`. */
idKey?: string;
}
/**
* Registers the webhook. `store` writes static data from the response
* (`$response` = the response body); its keys are the "managed set" cleared by
* delete and by checkExists-not-found. `generate` mints random values before
* the request, available as `$generated.<name>` in the routing and in `store`.
*/
export interface DeclarativeWebhookCreate {
routing: INodePropertyRouting;
store?: Record<string, string>;
generate?: Record<string, DeclarativeSecretGenerator>;
/** Refuse to register a `//localhost` webhook URL with a clear error. Default false. */
rejectLocalhostUrl?: boolean;
/** Boolean expression over `$response`; falsy ⇒ registration fails. */
validateResponse?: string;
}
/**
* Deregisters. Skipped (⇒ true) when `$staticData[idKey]` is absent. Request
* errors ⇒ return false, keep static data. On success the keys written by
* `create.store` are cleared.
*/
export interface DeclarativeWebhookDelete {
routing: INodePropertyRouting;
/** Default 'webhookId'. */
idKey?: string;
}
/**
* A vendor endpoint-ownership handshake. On a `setup` endpoint every request
* is treated as a handshake (`when`, if set, must additionally hold or the
* request is answered 401); on the `default` endpoint the handshake applies
* only when `when` (expr over `$request`) is truthy. Runs before verification,
* never starts a workflow.
*/
export interface DeclarativeWebhookHandshake {
when?: string;
/** Static-data writes from the handshake request; exprs over `$request`. */
store?: Record<string, string>;
respond?: {
/** Expr over `$request`, e.g. '={{ $request.query["hub.challenge"] }}'. Default 'OK'. */
body?: string;
statusCode?: number;
contentType?: string;
/** Response headers; values are exprs over `$request`. */
headers?: Record<string, string>;
};
}
/**
* Runtime handling of incoming deliveries; omitted stages default to "emit the
* body". Pipeline order: handshake → verification → ping → filter → output.
*/
export interface DeclarativeWebhookHandler {
handshake?: DeclarativeWebhookHandshake;
/**
* Signature verification, composed from explicit primitives (no vendor
* presets). A failed check answers 401 without starting the workflow.
*/
verification?: DeclarativeWebhookVerification;
/**
* Vendor liveness-check short-circuit: when `when` (expr over `$request`)
* is truthy, answer with `response` (default 'OK') and start no workflow —
* unconditionally, before any event filtering.
*/
ping?: { when: string; response?: string };
/**
* Drop deliveries whose event doesn't match: `actual` (expr over `$request`)
* must be included in `allowed` (expr, usually '={{ $parameter.events }}';
* a plain string counts as a one-element list). A miss responds 200 without
* starting the workflow. `wildcard` names a value inside `allowed` that
* accepts everything (e.g. '*').
*/
filter?: { allowed: string; actual: string; wildcard?: string };
/** Shape of the emitted item. Omitted: the request body. */
output?: {
/**
* Expression selecting the emitted data, over `$request`. Default: the
* request body. Arrays fan out into one item each.
*/
data?: string;
/** Wrap the emitted data as `{ body, headers, query }` — the classic trigger item shape. */
includeMeta?: boolean;
};
}
export interface IDeclarativeWebhookTrigger {
type: 'webhook';
/**
* Webhook registration lifecycle, synthesized into `webhookMethods.default`.
* Requests are `routing` blocks (same shape as an operation's routing) run by
* `RoutingNode`; expressions see `$webhookUrl` and `$staticData` next to
* `$parameter` and `$credentials`. Slots the node class defines itself are
* left untouched, and any slot may be a plain function instead of config.
*/
lifecycle: {
/** Omit to always re-register on activation. */
checkExists?: DeclarativeHookMethod | DeclarativeWebhookCheckExists;
create: DeclarativeHookMethod | DeclarativeWebhookCreate;
/** Omit when the vendor has nothing to deregister. */
delete?: DeclarativeHookMethod | DeclarativeWebhookDelete;
};
handler?: DeclarativeWebhookHandler;
}
/** A random value minted before a lifecycle request, exposed as `$generated.<name>`. */
export interface DeclarativeSecretGenerator {
type: 'hex' | 'base64' | 'uuid' | 'alphanumericLower';
/** Random bytes (hex/base64) or characters (alphanumericLower). Default: 32, or 10 for alphanumericLower. */
length?: number;
/** Literal prefix, e.g. 'n8n-'. */
prefix?: string;
}
/**
* Declarative webhook signature verification. Composes the same primitives the
* shared `webhook-signature-verification` util gives programmatic triggers:
* constant-time comparison, an optional replay window, and a
* skip-when-unsigned escape for hooks registered before secrets existed.
*/
export interface DeclarativeWebhookVerification {
/**
* 'hmac-sha256' / 'hmac-sha1': HMAC over `signedPayload` with `secret`.
* 'token': constant-time comparison of `secret` itself against the header
* (the shared-token pattern, e.g. Telegram's secret_token).
*/
algorithm: 'hmac-sha256' | 'hmac-sha1' | 'token';
/** Header carrying the signature (or token), e.g. 'x-hub-signature-256'. */
signatureHeader: string;
/**
* Expression yielding the secret: '={{ $staticData.webhookSecret }}',
* '={{ $parameter.secret }}', or — with `credentialType` set —
* '={{ $credentials.signatureSecret }}'.
*/
secret: string;
/**
* Credential type to load and expose as `$credentials` for the `secret`
* expression (e.g. 'slackApi'), for vendors whose signing secret lives in
* the credential rather than being minted at registration.
*/
credentialType?: string;
/** Digest encoding for HMAC. Default 'hex'. */
encoding?: 'hex' | 'base64';
/** Literal prefix the vendor puts before the digest, e.g. 'sha256='. */
prefix?: string;
/** What was signed. Default 'rawBody'; some vendors sign the JSON-stringified parsed body. */
signedPayload?: 'rawBody' | 'jsonStringifiedBody';
/** Header carrying a unix timestamp; enables the replay window when set. */
timestampHeader?: string;
/** Replay window in seconds. Default 300. */
maxTimestampAgeSeconds?: number;
/**
* Accept unsigned deliveries when the secret expression resolves empty —
* for hooks registered before the node minted secrets. Default false
* (no secret ⇒ reject).
*/
skipIfNoSecret?: boolean;
}
export type PostReceiveAction =
| ((
this: IExecuteSingleFunctions,
@ -3064,10 +3243,12 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
maxNodes?: number; // How many nodes of that type can be created in a workflow
polling?: true | undefined;
/**
* Declarative trigger. The loader synthesizes `poll()` from it, so the node
* class needs no trigger code. Ignored when the class defines `poll()`.
* Declarative trigger. The loader synthesizes the trigger methods from it —
* `poll()` for `type: 'polling'`, `webhookMethods`/`webhook()` for
* `type: 'webhook'` — so the node class needs no trigger code. Members the
* class defines itself always win, per slot.
*/
trigger?: IDeclarativePollingTrigger;
trigger?: IDeclarativePollingTrigger | IDeclarativeWebhookTrigger;
supportsCORS?: true | undefined;
requestDefaults?: DeclarativeRestApiSettings.HttpRequestOptions;
requestOperations?: IN8nRequestOperations;