From c2f0da70bb4843ea30a944fa56e8bc79fe33fcdc Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 7 Sep 2026 16:34:44 +0100 Subject: [PATCH] Inital work on declarative webhook triggers --- packages/cli/src/__tests__/utils.test.ts | 14 + packages/cli/src/utils.ts | 6 +- .../declarative-webhook-trigger.test.ts | 224 +++++ .../__tests__/declarative-webhook.test.ts | 857 ++++++++++++++++++ .../workflow-execute-run-node.test.ts | 27 + .../execution-engine/declarative-webhook.ts | 441 +++++++++ packages/core/src/execution-engine/index.ts | 4 + .../src/execution-engine/workflow-execute.ts | 6 +- .../__tests__/directory-loader.test.ts | 57 ++ .../core/src/nodes-loader/directory-loader.ts | 21 + ...E2eTestDeclarativeWebhookTrigger.node.json | 18 + .../E2eTestDeclarativeWebhookTrigger.node.ts | 115 +++ .../Github/GithubDeclarativeTrigger.node.json | 18 + .../Github/GithubDeclarativeTrigger.node.ts | 512 +++++++++++ packages/nodes-base/package.json | 2 + packages/testing/playwright/package.json | 1 + .../playwright/scripts/hook-registry.ts | 18 + .../services/hook-registry-server.ts | 141 +++ .../declarative-webhook-trigger.spec.ts | 99 ++ packages/workflow/src/interfaces.ts | 187 +++- 20 files changed, 2763 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/webhooks/__tests__/declarative-webhook-trigger.test.ts create mode 100644 packages/core/src/execution-engine/__tests__/declarative-webhook.test.ts create mode 100644 packages/core/src/execution-engine/declarative-webhook.ts create mode 100644 packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.json create mode 100644 packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.json create mode 100644 packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.ts create mode 100644 packages/testing/playwright/scripts/hook-registry.ts create mode 100644 packages/testing/playwright/services/hook-registry-server.ts create mode 100644 packages/testing/playwright/tests/e2e/webhooks/declarative-webhook-trigger.spec.ts diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts index 5d448b51653..8cf835dc2a0 100644 --- a/packages/cli/src/__tests__/utils.test.ts +++ b/packages/cli/src/__tests__/utils.test.ts @@ -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: {}, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index a8c50f3be15..f9bfd0393e0 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -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 && diff --git a/packages/cli/src/webhooks/__tests__/declarative-webhook-trigger.test.ts b/packages/cli/src/webhooks/__tests__/declarative-webhook-trigger.test.ts new file mode 100644 index 00000000000..47cbc02a16b --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/declarative-webhook-trigger.test.ts @@ -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.getByNameAndVersion.mockReturnValue(nodeType); + const webhookService = new WebhookService( + mock(), + mock(), + mock(), + 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(); + }); +}); diff --git a/packages/core/src/execution-engine/__tests__/declarative-webhook.test.ts b/packages/core/src/execution-engine/__tests__/declarative-webhook.test.ts new file mode 100644 index 00000000000..40698fc9c40 --- /dev/null +++ b/packages/core/src/execution-engine/__tests__/declarative-webhook.test.ts @@ -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>(); + +vi.mock('../node-execution-context/utils/request-helper-functions', async (importOriginal) => ({ + ...(await importOriginal()), + 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({ getByNameAndVersion: () => nodeType }); + const workflow = new Workflow({ + id: 'w1', + nodes: [node], + connections: {}, + active: true, + nodeTypes, + }); + const additionalData = mock({ + 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({ getByNameAndVersion: () => nodeType }); + const workflow = new Workflow({ + id: 'w1', + nodes: [node], + connections: {}, + active: true, + nodeTypes, + }); + const additionalData = mock({ + 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, + 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 = {}, + ): 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); + }); + }); +}); diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts index 0ffcf62a528..78a03baae1c 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts @@ -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', () => { diff --git a/packages/core/src/execution-engine/declarative-webhook.ts b/packages/core/src/execution-engine/declarative-webhook.ts new file mode 100644 index 00000000000..e59a5832eea --- /dev/null +++ b/packages/core/src/execution-engine/declarative-webhook.ts @@ -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['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 { + 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 | 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 { + 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 { + const { handshake, verification, ping, filter, output } = trigger.handler ?? {}; + return async function (this: IWebhookFunctions): Promise { + 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[])], + }; + }; +} diff --git a/packages/core/src/execution-engine/index.ts b/packages/core/src/execution-engine/index.ts index 9a41306ed21..6e2676d9f28 100644 --- a/packages/core/src/execution-engine/index.ts +++ b/packages/core/src/execution-engine/index.ts @@ -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'; diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index f22048340d4..72148a16bb5 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -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 diff --git a/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts index f725f5a69ff..0cdf9c507c7 100644 --- a/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts +++ b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts @@ -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 => + ({ + 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); + 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); + }); + }); }); diff --git a/packages/core/src/nodes-loader/directory-loader.ts b/packages/core/src/nodes-loader/directory-loader.ts index 0bfe38db59d..38d60b9e382 100644 --- a/packages/core/src/nodes-loader/directory-loader.ts +++ b/packages/core/src/nodes-loader/directory-loader.ts @@ -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); diff --git a/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.json b/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.json new file mode 100644 index 00000000000..de73ccdceaa --- /dev/null +++ b/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.json @@ -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 +} diff --git a/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.ts b/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.ts new file mode 100644 index 00000000000..9c249277100 --- /dev/null +++ b/packages/nodes-base/nodes/E2eTest/E2eTestDeclarativeWebhookTrigger.node.ts @@ -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: '*', + }, + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.json b/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.json new file mode 100644 index 00000000000..b56ce662ecc --- /dev/null +++ b/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.json @@ -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/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.ts b/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.ts new file mode 100644 index 00000000000..5a37b047b87 --- /dev/null +++ b/packages/nodes-base/nodes/Github/GithubDeclarativeTrigger.node.ts @@ -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, + }, + }; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 43bc8774fdf..0caec92b86f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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", diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index 99ec786335d..12b8ddfc878 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -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", diff --git a/packages/testing/playwright/scripts/hook-registry.ts b/packages/testing/playwright/scripts/hook-registry.ts new file mode 100644 index 00000000000..bf36871aec0 --- /dev/null +++ b/packages/testing/playwright/scripts/hook-registry.ts @@ -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); diff --git a/packages/testing/playwright/services/hook-registry-server.ts b/packages/testing/playwright/services/hook-registry-server.ts new file mode 100644 index 00000000000..bc28e642abc --- /dev/null +++ b/packages/testing/playwright/services/hook-registry-server.ts @@ -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(); + + /** 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.server = createServer((req, res) => { + void this.handle(req, res); + }); + await new Promise((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 { + if (!this.server) return; + await new Promise((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=`. + */ + async fire(payload: Record): Promise<{ delivered: number; statuses: number[] }> { + const statuses: number[] = []; + for (const hook of this.hooks.values()) { + const body = JSON.stringify(payload); + const headers: Record = { '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 { + 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), 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)); + } else { + respond(404, { error: `no route ${method} ${path}` }); + } + } + + private async readBody(req: IncomingMessage): Promise { + 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; + } + } +} diff --git a/packages/testing/playwright/tests/e2e/webhooks/declarative-webhook-trigger.spec.ts b/packages/testing/playwright/tests/e2e/webhooks/declarative-webhook-trigger.spec.ts new file mode 100644 index 00000000000..62b288ebd3a --- /dev/null +++ b/packages/testing/playwright/tests/e2e/webhooks/declarative-webhook-trigger.spec.ts @@ -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); + }); + }, +); diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 5a357cc14a5..0687d459e07 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -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; + +/** + * 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; + 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.` in the routing and in `store`. + */ +export interface DeclarativeWebhookCreate { + routing: INodePropertyRouting; + store?: Record; + generate?: Record; + /** 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; + 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; + }; +} + +/** + * 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.`. */ +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;