mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Inital work on declarative webhook triggers
This commit is contained in:
parent
0008cd58e3
commit
c2f0da70bb
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
import type { WebhookRepository } from '@n8n/db';
|
||||
import { createDeclarativeWebhook, createDeclarativeWebhookMethods } from 'n8n-core';
|
||||
import type {
|
||||
IDataObject,
|
||||
IDeclarativeWebhookTrigger,
|
||||
INode,
|
||||
INodeType,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
import { shouldAssignExecuteMethod } from '@/utils';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
|
||||
/**
|
||||
* Drives a declarative webhook trigger through the full WebhookService flow —
|
||||
* createWebhookIfNotExists → runWebhook → deleteWebhook — with the vendor
|
||||
* "hook registry" mocked by nock, the way E2eTestDeclarativeWebhookTrigger is
|
||||
* meant to be used.
|
||||
*/
|
||||
describe('declarative webhook trigger through WebhookService', () => {
|
||||
const registryUrl = 'http://registry.test';
|
||||
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
checkExists: {
|
||||
routing: {
|
||||
request: { method: 'GET', url: '={{ $parameter.url }}/hooks' },
|
||||
output: { postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }] },
|
||||
},
|
||||
matchOn: [{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' }],
|
||||
store: { webhookId: '={{ $item.id }}' },
|
||||
},
|
||||
create: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '={{ $parameter.url }}/hooks',
|
||||
body: { target_url: '={{ $webhookUrl }}', events: '={{ $parameter.events }}' },
|
||||
},
|
||||
},
|
||||
store: { webhookId: '={{ $response.id }}' },
|
||||
},
|
||||
delete: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'DELETE',
|
||||
url: '={{ $parameter.url + "/hooks/" + $staticData.webhookId }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: {
|
||||
filter: {
|
||||
allowed: '={{ $parameter.events }}',
|
||||
actual: '={{ $request.body.event }}',
|
||||
wildcard: '*',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const nodeType: INodeType = {
|
||||
description: {
|
||||
displayName: 'Declarative Webhook Trigger',
|
||||
name: 'declarativeWebhookTrigger',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: '',
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
properties: [
|
||||
{ displayName: 'URL', name: 'url', type: 'string', default: '' },
|
||||
{ displayName: 'Events', name: 'events', type: 'multiOptions', options: [], default: [] },
|
||||
],
|
||||
webhooks: [
|
||||
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
|
||||
],
|
||||
trigger,
|
||||
},
|
||||
};
|
||||
// Attach what DirectoryLoader.applySpecialNodeParameters attaches.
|
||||
nodeType.webhookMethods = { default: createDeclarativeWebhookMethods(nodeType, trigger) };
|
||||
nodeType.webhook = createDeclarativeWebhook(trigger);
|
||||
|
||||
const node: INode = {
|
||||
id: 'n1',
|
||||
name: 'Trigger',
|
||||
type: 'declarativeWebhookTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { url: registryUrl, events: ['created'] },
|
||||
};
|
||||
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||
const webhookService = new WebhookService(
|
||||
mock(),
|
||||
mock<WebhookRepository>(),
|
||||
mock<CacheService>(),
|
||||
nodeTypes,
|
||||
);
|
||||
|
||||
const additionalData = {
|
||||
executionId: 'e1',
|
||||
webhookBaseUrl: 'http://localhost:5678/webhook',
|
||||
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||
} as IWorkflowExecuteAdditionalData;
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'wf1',
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
active: true,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const webhookData = {
|
||||
node: node.name,
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook',
|
||||
workflowId: workflow.id,
|
||||
webhookDescription: { name: 'default', httpMethod: 'POST', path: 'webhook' },
|
||||
workflowExecuteAdditionalData: additionalData,
|
||||
} as IWebhookData;
|
||||
|
||||
const staticData = () => workflow.getStaticData('node', node);
|
||||
|
||||
const runIncomingWebhook = async (body: IDataObject) => {
|
||||
const requestData = {
|
||||
...additionalData,
|
||||
httpRequest: { body, headers: {}, query: {}, params: {} },
|
||||
} as IWorkflowExecuteAdditionalData;
|
||||
return await webhookService.runWebhook(
|
||||
workflow,
|
||||
webhookData,
|
||||
node,
|
||||
requestData,
|
||||
'webhook',
|
||||
null,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
let registeredTargetUrl: string;
|
||||
|
||||
test('activation registers the webhook and stores its id', async () => {
|
||||
let createBody: IDataObject | undefined;
|
||||
const scope = nock(registryUrl)
|
||||
.get('/hooks')
|
||||
.reply(200, { hooks: [] })
|
||||
.post('/hooks', (body) => {
|
||||
createBody = body as IDataObject;
|
||||
return true;
|
||||
})
|
||||
.reply(200, { id: 'h1' });
|
||||
|
||||
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
|
||||
|
||||
expect(scope.isDone()).toBe(true);
|
||||
expect(staticData().webhookId).toBe('h1');
|
||||
expect(createBody).toEqual({
|
||||
target_url: expect.stringContaining('/webhook'),
|
||||
events: ['created'],
|
||||
});
|
||||
registeredTargetUrl = createBody!.target_url as string;
|
||||
});
|
||||
|
||||
test('re-activation finds the registration and recovers a forgotten id', async () => {
|
||||
const scope = nock(registryUrl)
|
||||
.get('/hooks')
|
||||
.reply(200, { hooks: [{ id: 'h1', target_url: registeredTargetUrl }] });
|
||||
|
||||
delete staticData().webhookId;
|
||||
|
||||
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
|
||||
|
||||
expect(scope.isDone()).toBe(true);
|
||||
expect(staticData().webhookId).toBe('h1');
|
||||
});
|
||||
|
||||
test('a matching delivery starts the workflow with the body', async () => {
|
||||
const response = await runIncomingWebhook({ event: 'created', n: 1 });
|
||||
|
||||
expect(response).toEqual({ workflowData: [[{ json: { event: 'created', n: 1 } }]] });
|
||||
});
|
||||
|
||||
test('a non-matching delivery responds 200 without a run', async () => {
|
||||
const response = await runIncomingWebhook({ event: 'deleted' });
|
||||
|
||||
expect(response).toEqual({});
|
||||
});
|
||||
|
||||
test('a declarative webhook trigger with requestDefaults never gets a synthesized execute', () => {
|
||||
// Regression: with an assigned execute, runNode routes deliveries through
|
||||
// RoutingNode (a bare GET to requestDefaults.baseURL) instead of the
|
||||
// webhook pass-through — the trigger's output becomes the API root listing.
|
||||
const withRequestDefaults = {
|
||||
...nodeType,
|
||||
description: { ...nodeType.description, requestDefaults: { baseURL: registryUrl } },
|
||||
};
|
||||
|
||||
expect(shouldAssignExecuteMethod(withRequestDefaults)).toBe(false);
|
||||
});
|
||||
|
||||
test('deactivation deregisters and clears static data', async () => {
|
||||
const scope = nock(registryUrl).delete('/hooks/h1').reply(200, {});
|
||||
|
||||
await webhookService.deleteWebhook(workflow, webhookData, 'trigger', 'update');
|
||||
|
||||
expect(scope.isDone()).toBe(true);
|
||||
expect(staticData().webhookId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,857 @@
|
|||
import type {
|
||||
DeclarativeWebhookVerification,
|
||||
IDataObject,
|
||||
IDeclarativeWebhookTrigger,
|
||||
IHttpRequestOptions,
|
||||
INode,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { createDeclarativeWebhook, createDeclarativeWebhookMethods } from '../declarative-webhook';
|
||||
import { HookContext, WebhookContext } from '../node-execution-context';
|
||||
|
||||
const httpRequest = vi.fn<(options: IHttpRequestOptions) => Promise<unknown>>();
|
||||
|
||||
vi.mock('../node-execution-context/utils/request-helper-functions', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getRequestHelperFunctions: () => ({ httpRequest }),
|
||||
}));
|
||||
|
||||
const makeNodeType = (trigger: IDeclarativeWebhookTrigger): INodeType => ({
|
||||
description: {
|
||||
displayName: 'Test Webhook',
|
||||
name: 'testWebhook',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: '',
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
// The Workflow constructor strips node parameters that no property declares,
|
||||
// so the fixture declares the ones the tests set.
|
||||
properties: [
|
||||
{ displayName: 'Event', name: 'event', type: 'string', default: '' },
|
||||
{ displayName: 'Events', name: 'events', type: 'multiOptions', options: [], default: [] },
|
||||
{ displayName: 'Secret', name: 'secret', type: 'string', default: '' },
|
||||
],
|
||||
webhooks: [
|
||||
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
|
||||
],
|
||||
requestDefaults: { baseURL: 'https://api.example.com' },
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
|
||||
const registryTrigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
checkExists: {
|
||||
routing: {
|
||||
request: { method: 'GET', url: '/hooks' },
|
||||
output: { postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }] },
|
||||
},
|
||||
matchOn: [{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' }],
|
||||
store: { webhookId: '={{ $item.id }}' },
|
||||
},
|
||||
create: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/hooks',
|
||||
body: { target_url: '={{ $webhookUrl }}' },
|
||||
},
|
||||
},
|
||||
store: { webhookId: '={{ $response.id }}' },
|
||||
},
|
||||
delete: {
|
||||
routing: {
|
||||
request: { method: 'DELETE', url: '={{ "/hooks/" + $staticData.webhookId }}' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function setup(
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
{
|
||||
parameters = {},
|
||||
mode = 'trigger' as WorkflowExecuteMode,
|
||||
}: { parameters?: INodeParameters; mode?: WorkflowExecuteMode } = {},
|
||||
) {
|
||||
const nodeType = makeNodeType(trigger);
|
||||
const node: INode = {
|
||||
id: 'n1',
|
||||
name: 'Hook',
|
||||
type: 'testWebhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters,
|
||||
};
|
||||
const nodeTypes = mock<INodeTypes>({ getByNameAndVersion: () => nodeType });
|
||||
const workflow = new Workflow({
|
||||
id: 'w1',
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
active: true,
|
||||
nodeTypes,
|
||||
});
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'e1',
|
||||
webhookBaseUrl: 'http://localhost:5678/webhook',
|
||||
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||
});
|
||||
const context = new HookContext(workflow, node, additionalData, mode, 'init');
|
||||
const methods = createDeclarativeWebhookMethods(nodeType, trigger);
|
||||
const staticData = () => workflow.getStaticData('node', node);
|
||||
return {
|
||||
webhookUrl: context.getNodeWebhookUrl('default')!,
|
||||
checkExists: async () => await methods.checkExists.call(context),
|
||||
create: async () => await methods.create.call(context),
|
||||
delete: async () => await methods.delete.call(context),
|
||||
staticData,
|
||||
node,
|
||||
workflow,
|
||||
additionalData,
|
||||
nodeType,
|
||||
};
|
||||
}
|
||||
|
||||
const respond = (body: IDataObject) =>
|
||||
httpRequest.mockResolvedValueOnce({ body, headers: {}, statusCode: 200 });
|
||||
|
||||
const respondNotFound = () =>
|
||||
httpRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 404 },
|
||||
message: 'not found',
|
||||
});
|
||||
|
||||
beforeEach(() => httpRequest.mockReset());
|
||||
|
||||
describe('createDeclarativeWebhookMethods', () => {
|
||||
describe('checkExists with matchOn', () => {
|
||||
test('no matching item ⇒ false', async () => {
|
||||
const { checkExists, staticData } = setup(registryTrigger);
|
||||
respond({ hooks: [{ id: 7, target_url: 'https://elsewhere.example.com' }] });
|
||||
|
||||
expect(await checkExists()).toBe(false);
|
||||
expect(staticData().webhookId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('matching item ⇒ true and recovers store values', async () => {
|
||||
const { checkExists, staticData, webhookUrl } = setup(registryTrigger);
|
||||
respond({
|
||||
hooks: [
|
||||
{ id: 5, target_url: 'other' },
|
||||
{ id: 7, target_url: webhookUrl },
|
||||
],
|
||||
});
|
||||
|
||||
expect(await checkExists()).toBe(true);
|
||||
expect(staticData().webhookId).toBe(7);
|
||||
});
|
||||
|
||||
test('every clause must hold', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
lifecycle: {
|
||||
...registryTrigger.lifecycle,
|
||||
checkExists: {
|
||||
routing: {
|
||||
request: { method: 'GET', url: '/hooks' },
|
||||
output: {
|
||||
postReceive: [{ type: 'rootProperty', properties: { property: 'hooks' } }],
|
||||
},
|
||||
},
|
||||
matchOn: [
|
||||
{ itemProperty: 'target_url', value: '={{ $webhookUrl }}' },
|
||||
{ itemProperty: 'event', value: '={{ $parameter.event }}' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { checkExists, webhookUrl } = setup(trigger, { parameters: { event: 'created' } });
|
||||
|
||||
respond({ hooks: [{ target_url: webhookUrl, event: 'deleted' }] });
|
||||
expect(await checkExists()).toBe(false);
|
||||
|
||||
respond({ hooks: [{ target_url: webhookUrl, event: 'created' }] });
|
||||
expect(await checkExists()).toBe(true);
|
||||
});
|
||||
|
||||
test('a clause on empty static data never matches', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
lifecycle: {
|
||||
...registryTrigger.lifecycle,
|
||||
checkExists: {
|
||||
routing: { request: { method: 'GET', url: '/hooks' } },
|
||||
matchOn: [{ itemProperty: 'id', value: '={{ $staticData.webhookId }}' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { checkExists } = setup(trigger);
|
||||
respond([{ id: 7 }] as unknown as IDataObject);
|
||||
|
||||
expect(await checkExists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkExists by id', () => {
|
||||
const byIdTrigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
lifecycle: {
|
||||
...registryTrigger.lifecycle,
|
||||
checkExists: {
|
||||
routing: {
|
||||
request: { method: 'GET', url: '={{ "/hooks/" + $staticData.webhookId }}' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('missing id ⇒ false without a request', async () => {
|
||||
const { checkExists } = setup(byIdTrigger);
|
||||
|
||||
expect(await checkExists()).toBe(false);
|
||||
expect(httpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('2xx ⇒ true', async () => {
|
||||
const { checkExists, staticData } = setup(byIdTrigger);
|
||||
staticData().webhookId = 7;
|
||||
respond({ id: 7 });
|
||||
|
||||
expect(await checkExists()).toBe(true);
|
||||
expect(httpRequest.mock.lastCall?.[0].url).toBe('/hooks/7');
|
||||
});
|
||||
|
||||
test('404 ⇒ managed keys cleared, false', async () => {
|
||||
const { checkExists, staticData } = setup(byIdTrigger);
|
||||
staticData().webhookId = 7;
|
||||
respondNotFound();
|
||||
|
||||
expect(await checkExists()).toBe(false);
|
||||
expect(staticData().webhookId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('other errors rethrow', async () => {
|
||||
const { checkExists, staticData } = setup(byIdTrigger);
|
||||
staticData().webhookId = 7;
|
||||
httpRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
message: 'boom',
|
||||
});
|
||||
|
||||
await expect(checkExists()).rejects.toThrow();
|
||||
expect(staticData().webhookId).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
test('stores values from the response and sends the webhook url', async () => {
|
||||
const { create, staticData, webhookUrl } = setup(registryTrigger);
|
||||
respond({ id: 42 });
|
||||
|
||||
expect(await create()).toBe(true);
|
||||
expect(staticData().webhookId).toBe(42);
|
||||
expect(httpRequest.mock.lastCall?.[0].body).toEqual({ target_url: webhookUrl });
|
||||
});
|
||||
|
||||
test('rejectLocalhostUrl refuses a localhost webhook url without a request', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
create: {
|
||||
routing: { request: { method: 'POST', url: '/hooks' } },
|
||||
rejectLocalhostUrl: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
// The fixture's webhookBaseUrl is http://localhost:5678/webhook.
|
||||
const { create } = setup(trigger);
|
||||
|
||||
await expect(create()).rejects.toThrow('localhost');
|
||||
expect(httpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('validateResponse rejects an unexpected creation response', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
create: {
|
||||
routing: { request: { method: 'POST', url: '/hooks' } },
|
||||
validateResponse: '={{ $response.active === true }}',
|
||||
store: { webhookId: '={{ $response.id }}' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failing = setup(trigger);
|
||||
respond({ id: 7, active: false });
|
||||
await expect(failing.create()).rejects.toThrow('expected data');
|
||||
expect(failing.staticData().webhookId).toBeUndefined();
|
||||
|
||||
const passing = setup(trigger);
|
||||
respond({ id: 7, active: true });
|
||||
expect(await passing.create()).toBe(true);
|
||||
expect(passing.staticData().webhookId).toBe(7);
|
||||
});
|
||||
|
||||
test('generate mints $secrets usable in the request and store', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
create: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/hooks',
|
||||
body: { secret: '={{ $generated.webhookSecret }}' },
|
||||
},
|
||||
},
|
||||
generate: { webhookSecret: { type: 'hex', length: 16 } },
|
||||
store: {
|
||||
webhookId: '={{ $response.id }}',
|
||||
webhookSecret: '={{ $generated.webhookSecret }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { create, staticData } = setup(trigger);
|
||||
respond({ id: 7 });
|
||||
|
||||
expect(await create()).toBe(true);
|
||||
const sentSecret = (httpRequest.mock.lastCall?.[0].body as IDataObject).secret;
|
||||
expect(sentSecret).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(staticData().webhookSecret).toBe(sentSecret);
|
||||
expect(staticData().webhookId).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
test('missing id ⇒ true without a request', async () => {
|
||||
const { delete: del } = setup(registryTrigger);
|
||||
|
||||
expect(await del()).toBe(true);
|
||||
expect(httpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('success ⇒ managed keys cleared', async () => {
|
||||
const { delete: del, staticData } = setup(registryTrigger);
|
||||
staticData().webhookId = 42;
|
||||
respond({});
|
||||
|
||||
expect(await del()).toBe(true);
|
||||
expect(staticData().webhookId).toBeUndefined();
|
||||
expect(httpRequest.mock.lastCall?.[0].url).toBe('/hooks/42');
|
||||
});
|
||||
|
||||
test('request error ⇒ false, static data kept', async () => {
|
||||
const { delete: del, staticData } = setup(registryTrigger);
|
||||
staticData().webhookId = 42;
|
||||
httpRequest.mockRejectedValueOnce(new Error('nope'));
|
||||
|
||||
expect(await del()).toBe(false);
|
||||
expect(staticData().webhookId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot defaults and escape hatches', () => {
|
||||
test('omitted checkExists always re-registers; omitted delete is a no-op', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: { create: registryTrigger.lifecycle.create },
|
||||
};
|
||||
const { checkExists, delete: del } = setup(trigger);
|
||||
|
||||
expect(await checkExists()).toBe(false);
|
||||
expect(await del()).toBe(true);
|
||||
expect(httpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a function-valued slot is used as-is', async () => {
|
||||
const custom = vi.fn(async () => true);
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
type: 'webhook',
|
||||
lifecycle: { ...registryTrigger.lifecycle, checkExists: custom },
|
||||
};
|
||||
const { checkExists } = setup(trigger);
|
||||
|
||||
expect(await checkExists()).toBe(true);
|
||||
expect(custom).toHaveBeenCalled();
|
||||
expect(httpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDeclarativeWebhook', () => {
|
||||
function webhookSetup(
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
body: IDataObject,
|
||||
parameters: INodeParameters = {},
|
||||
request: {
|
||||
headers?: IDataObject;
|
||||
rawBody?: string;
|
||||
query?: IDataObject;
|
||||
webhookName?: string;
|
||||
} = {},
|
||||
) {
|
||||
const nodeType = makeNodeType(trigger);
|
||||
const node: INode = {
|
||||
id: 'n1',
|
||||
name: 'Hook',
|
||||
type: 'testWebhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters,
|
||||
};
|
||||
const nodeTypes = mock<INodeTypes>({ getByNameAndVersion: () => nodeType });
|
||||
const workflow = new Workflow({
|
||||
id: 'w1',
|
||||
nodes: [node],
|
||||
connections: {},
|
||||
active: true,
|
||||
nodeTypes,
|
||||
});
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
executionId: 'e1',
|
||||
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||
});
|
||||
additionalData.httpRequest = {
|
||||
body,
|
||||
headers: request.headers ?? {},
|
||||
query: request.query ?? {},
|
||||
params: {},
|
||||
rawBody: request.rawBody,
|
||||
} as unknown as IWorkflowExecuteAdditionalData['httpRequest'];
|
||||
const response = {
|
||||
statusCode: undefined as number | undefined,
|
||||
body: undefined as unknown,
|
||||
headers: {} as Record<string, string>,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
send(payload?: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
additionalData.httpResponse =
|
||||
response as unknown as IWorkflowExecuteAdditionalData['httpResponse'];
|
||||
const webhookData = {
|
||||
webhookDescription: {
|
||||
name: request.webhookName ?? 'default',
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook',
|
||||
},
|
||||
} as IWebhookData;
|
||||
const context = new WebhookContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
'webhook',
|
||||
webhookData,
|
||||
[],
|
||||
null,
|
||||
);
|
||||
const webhook = createDeclarativeWebhook(trigger);
|
||||
const staticData = () => workflow.getStaticData('node', node);
|
||||
return { run: async () => await webhook.call(context), response, staticData };
|
||||
}
|
||||
|
||||
test('without a filter, emits the body', async () => {
|
||||
const { run } = webhookSetup(registryTrigger, { event: 'created', n: 1 });
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created', n: 1 } }]] });
|
||||
});
|
||||
|
||||
const filtered: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
filter: {
|
||||
allowed: '={{ $parameter.events }}',
|
||||
actual: '={{ $request.body.event }}',
|
||||
wildcard: '*',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('filter match ⇒ emit, miss ⇒ 200 without a run', async () => {
|
||||
expect(
|
||||
await webhookSetup(filtered, { event: 'created' }, { events: ['created'] }).run(),
|
||||
).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
|
||||
|
||||
expect(
|
||||
await webhookSetup(filtered, { event: 'deleted' }, { events: ['created'] }).run(),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('wildcard accepts everything', async () => {
|
||||
expect(await webhookSetup(filtered, { event: 'anything' }, { events: ['*'] }).run()).toEqual({
|
||||
workflowData: [[{ json: { event: 'anything' } }]],
|
||||
});
|
||||
});
|
||||
|
||||
describe('handshake and setup endpoint', () => {
|
||||
test('a setup endpoint with no handshake answers OK', async () => {
|
||||
const { run } = webhookSetup(registryTrigger, {}, {}, { webhookName: 'setup' });
|
||||
|
||||
expect(await run()).toEqual({ webhookResponse: 'OK' });
|
||||
});
|
||||
|
||||
test('a setup endpoint echoes a challenge and 401s a failed `when`', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
handshake: {
|
||||
when: '={{ $request.query["verify_token"] === $parameter.secret }}',
|
||||
respond: { body: '={{ $request.query["challenge"] }}' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ok = webhookSetup(
|
||||
trigger,
|
||||
{},
|
||||
{ secret: 'tok' },
|
||||
{
|
||||
webhookName: 'setup',
|
||||
query: { verify_token: 'tok', challenge: 'c-123' },
|
||||
},
|
||||
);
|
||||
expect(await ok.run()).toEqual({ webhookResponse: 'c-123' });
|
||||
|
||||
const bad = webhookSetup(
|
||||
trigger,
|
||||
{},
|
||||
{ secret: 'tok' },
|
||||
{
|
||||
webhookName: 'setup',
|
||||
query: { verify_token: 'wrong', challenge: 'c-123' },
|
||||
},
|
||||
);
|
||||
expect(await bad.run()).toEqual({ noWebhookResponse: true });
|
||||
expect(bad.response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('on the default endpoint a matching `when` answers the handshake, otherwise the delivery proceeds', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
handshake: {
|
||||
when: '={{ $request.body.type === "url_verification" }}',
|
||||
respond: { body: '={{ $request.body.challenge }}' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const shake = webhookSetup(trigger, { type: 'url_verification', challenge: 'c-9' });
|
||||
expect(await shake.run()).toEqual({ webhookResponse: 'c-9' });
|
||||
|
||||
const event = webhookSetup(trigger, { type: 'event', n: 1 });
|
||||
expect(await event.run()).toEqual({
|
||||
workflowData: [[{ json: { type: 'event', n: 1 } }]],
|
||||
});
|
||||
});
|
||||
|
||||
test('a handshake can store request values and answer with headers', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
handshake: {
|
||||
when: '={{ $request.headers["x-hook-secret"] !== undefined }}',
|
||||
store: { webhookSecret: '={{ $request.headers["x-hook-secret"] }}' },
|
||||
respond: { headers: { 'X-Hook-Secret': '={{ $request.headers["x-hook-secret"] }}' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { run, response, staticData } = webhookSetup(
|
||||
trigger,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
headers: { 'x-hook-secret': 's3cret' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({ noWebhookResponse: true });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['X-Hook-Secret']).toBe('s3cret');
|
||||
expect(staticData().webhookSecret).toBe('s3cret');
|
||||
});
|
||||
|
||||
test('the handshake runs before verification, so it needs no signature', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
handshake: {
|
||||
when: '={{ $request.body.type === "url_verification" }}',
|
||||
respond: { body: '={{ $request.body.challenge }}' },
|
||||
},
|
||||
verification: {
|
||||
algorithm: 'hmac-sha256',
|
||||
signatureHeader: 'x-sig',
|
||||
secret: '={{ $parameter.secret }}',
|
||||
},
|
||||
},
|
||||
};
|
||||
const { run } = webhookSetup(
|
||||
trigger,
|
||||
{ type: 'url_verification', challenge: 'c-1' },
|
||||
{
|
||||
secret: 'tok',
|
||||
},
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({ webhookResponse: 'c-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping', () => {
|
||||
const pinged: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
ping: { when: '={{ $request.headers["x-event"] === "ping" }}', response: 'PONG' },
|
||||
filter: {
|
||||
allowed: '={{ $parameter.events }}',
|
||||
actual: '={{ $request.headers["x-event"] }}',
|
||||
wildcard: '*',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('a ping answers without a run, even when the filter would drop it', async () => {
|
||||
const { run } = webhookSetup(
|
||||
pinged,
|
||||
{ zen: 'hi' },
|
||||
{ events: ['created'] },
|
||||
{
|
||||
headers: { 'x-event': 'ping' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({ webhookResponse: 'PONG' });
|
||||
});
|
||||
|
||||
test('non-ping deliveries proceed through the filter', async () => {
|
||||
const { run } = webhookSetup(
|
||||
pinged,
|
||||
{ n: 1 },
|
||||
{ events: ['created'] },
|
||||
{
|
||||
headers: { 'x-event': 'created' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: { n: 1 } }]] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('output', () => {
|
||||
test('data expression selects what is emitted; arrays fan out', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: { output: { data: '={{ $request.body.records }}' } },
|
||||
};
|
||||
const { run } = webhookSetup(trigger, { records: [{ a: 1 }, { a: 2 }] });
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: { a: 1 } }, { json: { a: 2 } }]] });
|
||||
});
|
||||
|
||||
test('includeMeta wraps as { body, headers, query }', async () => {
|
||||
const trigger: IDeclarativeWebhookTrigger = {
|
||||
...registryTrigger,
|
||||
handler: { output: { includeMeta: true } },
|
||||
};
|
||||
const { run } = webhookSetup(
|
||||
trigger,
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{ headers: { 'x-a': 'b' }, query: { q: '1' } },
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
workflowData: [
|
||||
[{ json: { body: { event: 'created' }, headers: { 'x-a': 'b' }, query: { q: '1' } } }],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification', () => {
|
||||
const sign = (payload: string, secret: string, algorithm = 'sha256', encoding = 'hex') =>
|
||||
createHmac(algorithm, secret)
|
||||
.update(payload)
|
||||
.digest(encoding as 'hex' | 'base64');
|
||||
|
||||
const hmacTrigger = (
|
||||
overrides: Partial<DeclarativeWebhookVerification> = {},
|
||||
): IDeclarativeWebhookTrigger => ({
|
||||
...registryTrigger,
|
||||
handler: {
|
||||
verification: {
|
||||
algorithm: 'hmac-sha256',
|
||||
signatureHeader: 'x-hub-signature-256',
|
||||
secret: '={{ $staticData.webhookSecret }}',
|
||||
prefix: 'sha256=',
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test('valid HMAC over the raw body ⇒ emit', async () => {
|
||||
const rawBody = '{"event":"created"}';
|
||||
const signature = `sha256=${sign(rawBody, 's3cret')}`;
|
||||
const { run, staticData } = webhookSetup(
|
||||
hmacTrigger(),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{ rawBody, headers: { 'x-hub-signature-256': signature } },
|
||||
);
|
||||
staticData().webhookSecret = 's3cret';
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
|
||||
});
|
||||
|
||||
test('bad signature ⇒ 401 without a run', async () => {
|
||||
const { run, response, staticData } = webhookSetup(
|
||||
hmacTrigger(),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{ rawBody: '{"event":"created"}', headers: { 'x-hub-signature-256': 'sha256=deadbeef' } },
|
||||
);
|
||||
staticData().webhookSecret = 's3cret';
|
||||
|
||||
expect(await run()).toEqual({ noWebhookResponse: true });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('missing signature header ⇒ 401', async () => {
|
||||
const { run, response, staticData } = webhookSetup(
|
||||
hmacTrigger(),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{ rawBody: '{"event":"created"}' },
|
||||
);
|
||||
staticData().webhookSecret = 's3cret';
|
||||
|
||||
expect(await run()).toEqual({ noWebhookResponse: true });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('no secret ⇒ 401 by default, emit with skipIfNoSecret', async () => {
|
||||
const request = { rawBody: '{"event":"created"}' };
|
||||
const rejecting = webhookSetup(hmacTrigger(), { event: 'created' }, {}, request);
|
||||
expect(await rejecting.run()).toEqual({ noWebhookResponse: true });
|
||||
|
||||
const skipping = webhookSetup(
|
||||
hmacTrigger({ skipIfNoSecret: true }),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
request,
|
||||
);
|
||||
expect(await skipping.run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
|
||||
});
|
||||
|
||||
test('signedPayload jsonStringifiedBody signs the parsed body', async () => {
|
||||
const body = { event: 'created', n: 2 };
|
||||
const signature = `sha256=${sign(JSON.stringify(body), 's3cret')}`;
|
||||
const { run, staticData } = webhookSetup(
|
||||
hmacTrigger({ signedPayload: 'jsonStringifiedBody' }),
|
||||
body,
|
||||
{},
|
||||
{ headers: { 'x-hub-signature-256': signature } },
|
||||
);
|
||||
staticData().webhookSecret = 's3cret';
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: body }]] });
|
||||
});
|
||||
|
||||
test('credentialType loads the credential for the secret expression', async () => {
|
||||
const getCredentials = vi
|
||||
.spyOn(WebhookContext.prototype, 'getCredentials')
|
||||
.mockResolvedValue({ signatureSecret: 's3cret' });
|
||||
try {
|
||||
const rawBody = '{"event":"created"}';
|
||||
const signature = `sha256=${sign(rawBody, 's3cret')}`;
|
||||
const { run } = webhookSetup(
|
||||
hmacTrigger({
|
||||
secret: '={{ $credentials.signatureSecret }}',
|
||||
credentialType: 'exampleApi',
|
||||
}),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{ rawBody, headers: { 'x-hub-signature-256': signature } },
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
|
||||
expect(getCredentials).toHaveBeenCalledWith('exampleApi');
|
||||
} finally {
|
||||
getCredentials.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('token algorithm compares the secret itself', async () => {
|
||||
const trigger = hmacTrigger({
|
||||
algorithm: 'token',
|
||||
signatureHeader: 'x-secret-token',
|
||||
secret: '={{ $parameter.secret }}',
|
||||
prefix: undefined,
|
||||
});
|
||||
const ok = webhookSetup(
|
||||
trigger,
|
||||
{ event: 'created' },
|
||||
{ secret: 'tok' },
|
||||
{ headers: { 'x-secret-token': 'tok' } },
|
||||
);
|
||||
expect(await ok.run()).toEqual({ workflowData: [[{ json: { event: 'created' } }]] });
|
||||
|
||||
const bad = webhookSetup(
|
||||
trigger,
|
||||
{ event: 'created' },
|
||||
{ secret: 'tok' },
|
||||
{ headers: { 'x-secret-token': 'nope' } },
|
||||
);
|
||||
expect(await bad.run()).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
test('stale timestamp ⇒ 401 even with a valid signature', async () => {
|
||||
const rawBody = '{"event":"created"}';
|
||||
const signature = `sha256=${sign(rawBody, 's3cret')}`;
|
||||
const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600);
|
||||
const { run, response, staticData } = webhookSetup(
|
||||
hmacTrigger({ timestampHeader: 'x-timestamp' }),
|
||||
{ event: 'created' },
|
||||
{},
|
||||
{
|
||||
rawBody,
|
||||
headers: { 'x-hub-signature-256': signature, 'x-timestamp': staleTimestamp },
|
||||
},
|
||||
);
|
||||
staticData().webhookSecret = 's3cret';
|
||||
|
||||
expect(await run()).toEqual({ noWebhookResponse: true });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
441
packages/core/src/execution-engine/declarative-webhook.ts
Normal file
441
packages/core/src/execution-engine/declarative-webhook.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import get from 'lodash/get';
|
||||
import type {
|
||||
DeclarativeSecretGenerator,
|
||||
DeclarativeWebhookCheckExists,
|
||||
DeclarativeWebhookCreate,
|
||||
DeclarativeWebhookDelete,
|
||||
DeclarativeWebhookHandshake,
|
||||
DeclarativeWebhookVerification,
|
||||
IDataObject,
|
||||
IDeclarativeWebhookTrigger,
|
||||
IHookFunctions,
|
||||
INodeExecutionData,
|
||||
INodePropertyRouting,
|
||||
INodeType,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
IWorkflowDataProxyAdditionalKeys,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
createRunExecutionData,
|
||||
NodeApiError,
|
||||
NodeOperationError,
|
||||
UnexpectedError,
|
||||
} from 'n8n-workflow';
|
||||
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { ExecuteContext, HookContext, WebhookContext } from './node-execution-context';
|
||||
import { RoutingNode } from './routing-node';
|
||||
|
||||
type LifecycleSlots = NonNullable<NonNullable<INodeType['webhookMethods']>['default']>;
|
||||
|
||||
/**
|
||||
* Runs one lifecycle `routing` block through `RoutingNode`, the same way
|
||||
* `createDeclarativePoll` runs its poll request: a fabricated `ExecuteContext`
|
||||
* with one synthetic input item, and the routing carried by a nameless hidden
|
||||
* property (`RoutingNode` reads routing off properties and skips the parameter
|
||||
* lookup when the name is empty).
|
||||
*/
|
||||
async function runLifecycleRouting(
|
||||
context: HookContext,
|
||||
nodeType: INodeType,
|
||||
routing: INodePropertyRouting,
|
||||
extraKeys: IWorkflowDataProxyAdditionalKeys,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { workflow, node, additionalData, mode } = context;
|
||||
const executeData = { node, data: {}, source: null };
|
||||
const executeContext = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
createRunExecutionData(),
|
||||
0,
|
||||
[],
|
||||
{ main: [[{ json: {} }]] },
|
||||
executeData,
|
||||
[],
|
||||
);
|
||||
const hookNodeType: INodeType = {
|
||||
...nodeType,
|
||||
description: {
|
||||
...nodeType.description,
|
||||
properties: [
|
||||
...nodeType.description.properties,
|
||||
{ displayName: '', name: '', type: 'hidden', default: '', routing },
|
||||
],
|
||||
},
|
||||
};
|
||||
const routingNode = new RoutingNode(executeContext, hookNodeType, undefined, extraKeys);
|
||||
return (await routingNode.runNode())?.[0] ?? [];
|
||||
}
|
||||
|
||||
/** The static-data keys a trigger manages: whatever its create step stores. */
|
||||
function managedKeys(trigger: IDeclarativeWebhookTrigger): string[] {
|
||||
const create = trigger.lifecycle.create;
|
||||
if (typeof create === 'function' || !create.store) return [];
|
||||
return Object.keys(create.store);
|
||||
}
|
||||
|
||||
function clearManagedKeys(trigger: IDeclarativeWebhookTrigger, staticData: IDataObject): void {
|
||||
for (const key of managedKeys(trigger)) delete staticData[key];
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
context: HookContext | WebhookContext,
|
||||
value: string,
|
||||
extraKeys: IWorkflowDataProxyAdditionalKeys,
|
||||
): unknown {
|
||||
return context.workflow.expression.getSimpleParameterValue(
|
||||
context.node,
|
||||
value,
|
||||
context.mode,
|
||||
extraKeys,
|
||||
);
|
||||
}
|
||||
|
||||
function applyStore(
|
||||
context: HookContext | WebhookContext,
|
||||
store: Record<string, string> | undefined,
|
||||
extraKeys: IWorkflowDataProxyAdditionalKeys,
|
||||
staticData: IDataObject,
|
||||
): void {
|
||||
if (!store) return;
|
||||
for (const [key, value] of Object.entries(store)) {
|
||||
staticData[key] = resolveValue(context, value, extraKeys) as IDataObject[string];
|
||||
}
|
||||
}
|
||||
|
||||
function assertHookContext(context: IHookFunctions): asserts context is HookContext {
|
||||
if (!(context instanceof HookContext)) {
|
||||
throw new UnexpectedError('Declarative webhook lifecycle needs a HookContext');
|
||||
}
|
||||
}
|
||||
|
||||
/** `$webhookUrl` + `$staticData`, shared by every lifecycle request. */
|
||||
function hookKeys(context: HookContext, staticData: IDataObject): IWorkflowDataProxyAdditionalKeys {
|
||||
return {
|
||||
$webhookUrl: context.getNodeWebhookUrl('default'),
|
||||
$staticData: staticData,
|
||||
} as IWorkflowDataProxyAdditionalKeys;
|
||||
}
|
||||
|
||||
function createCheckExists(
|
||||
nodeType: INodeType,
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
config: DeclarativeWebhookCheckExists,
|
||||
): LifecycleSlots['checkExists'] {
|
||||
return async function (this: IHookFunctions) {
|
||||
assertHookContext(this);
|
||||
const staticData = this.getWorkflowStaticData('node');
|
||||
const extraKeys = hookKeys(this, staticData);
|
||||
|
||||
if (!config.matchOn) {
|
||||
const idKey = config.idKey ?? 'webhookId';
|
||||
if (staticData[idKey] === undefined) return false;
|
||||
try {
|
||||
await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const notFound = config.notFoundHttpCodes ?? [404];
|
||||
if (
|
||||
error instanceof NodeApiError &&
|
||||
error.httpCode !== null &&
|
||||
notFound.includes(Number(error.httpCode))
|
||||
) {
|
||||
clearManagedKeys(trigger, staticData);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const items = await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
|
||||
for (const item of items) {
|
||||
const itemKeys = { ...extraKeys, $item: item.json };
|
||||
const matches = config.matchOn.every(
|
||||
({ itemProperty, value }) =>
|
||||
get(item.json, itemProperty) === resolveValue(this, value, itemKeys),
|
||||
);
|
||||
if (matches) {
|
||||
applyStore(this, config.store, itemKeys, staticData);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
function generateSecret({ type, length, prefix = '' }: DeclarativeSecretGenerator): string {
|
||||
switch (type) {
|
||||
case 'hex':
|
||||
return prefix + randomBytes(length ?? 32).toString('hex');
|
||||
case 'base64':
|
||||
return prefix + randomBytes(length ?? 32).toString('base64');
|
||||
case 'uuid':
|
||||
return prefix + randomUUID();
|
||||
case 'alphanumericLower': {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const chars = randomBytes(length ?? 10);
|
||||
return prefix + [...chars].map((byte) => alphabet[byte % alphabet.length]).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCreate(
|
||||
nodeType: INodeType,
|
||||
config: DeclarativeWebhookCreate,
|
||||
): LifecycleSlots['create'] {
|
||||
return async function (this: IHookFunctions) {
|
||||
assertHookContext(this);
|
||||
const staticData = this.getWorkflowStaticData('node');
|
||||
const $generated = Object.fromEntries(
|
||||
Object.entries(config.generate ?? {}).map(([name, generator]) => [
|
||||
name,
|
||||
generateSecret(generator),
|
||||
]),
|
||||
);
|
||||
const extraKeys = { ...hookKeys(this, staticData), $generated };
|
||||
|
||||
if (config.rejectLocalhostUrl && this.getNodeWebhookUrl('default')?.includes('//localhost')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The Webhook can not work on "localhost". Please setup n8n on a custom domain.',
|
||||
);
|
||||
}
|
||||
|
||||
const items = await runLifecycleRouting(this, nodeType, config.routing, extraKeys);
|
||||
const response = items[0]?.json ?? {};
|
||||
|
||||
if (config.validateResponse) {
|
||||
const valid = resolveValue(this, config.validateResponse, {
|
||||
...extraKeys,
|
||||
$response: response,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new NodeApiError(this.getNode(), response as JsonObject, {
|
||||
message: 'Webhook creation response did not contain the expected data.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyStore(this, config.store, { ...extraKeys, $response: response }, staticData);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function createDelete(
|
||||
nodeType: INodeType,
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
config: DeclarativeWebhookDelete,
|
||||
): LifecycleSlots['delete'] {
|
||||
return async function (this: IHookFunctions) {
|
||||
assertHookContext(this);
|
||||
const staticData = this.getWorkflowStaticData('node');
|
||||
const idKey = config.idKey ?? 'webhookId';
|
||||
if (staticData[idKey] === undefined) return true;
|
||||
try {
|
||||
await runLifecycleRouting(this, nodeType, config.routing, hookKeys(this, staticData));
|
||||
} catch {
|
||||
// Deregistration failures must not block deactivation; static data is
|
||||
// kept so a later delete can retry (matches the shipped trigger nodes).
|
||||
return false;
|
||||
}
|
||||
clearManagedKeys(trigger, staticData);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `webhookMethods.default` slots for a node whose description
|
||||
* carries `trigger: { type: 'webhook' }`. A function-valued slot is used
|
||||
* as-is; an omitted checkExists always re-registers (⇒ false) and an omitted
|
||||
* delete has nothing to deregister (⇒ true).
|
||||
*/
|
||||
export function createDeclarativeWebhookMethods(
|
||||
nodeType: INodeType,
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
): LifecycleSlots {
|
||||
const { checkExists, create, delete: del } = trigger.lifecycle;
|
||||
return {
|
||||
checkExists:
|
||||
checkExists === undefined
|
||||
? async () => false
|
||||
: typeof checkExists === 'function'
|
||||
? checkExists
|
||||
: createCheckExists(nodeType, trigger, checkExists),
|
||||
create: typeof create === 'function' ? create : createCreate(nodeType, create),
|
||||
delete:
|
||||
del === undefined
|
||||
? async () => true
|
||||
: typeof del === 'function'
|
||||
? del
|
||||
: createDelete(nodeType, trigger, del),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ported from `packages/nodes-base/utils/webhook-signature-verification.ts` (core cannot import nodes-base). */
|
||||
function isTimestampValid(timestamp: string | undefined, maxAgeSeconds: number): boolean {
|
||||
if (timestamp === undefined) return false;
|
||||
const parsed = parseInt(timestamp, 10);
|
||||
if (isNaN(parsed)) return false;
|
||||
// Convert to seconds if the timestamp is in milliseconds
|
||||
const timestampSec = parsed > 1e10 ? Math.floor(parsed / 1000) : parsed;
|
||||
const currentTimeSec = Math.floor(Date.now() / 1000);
|
||||
return Math.abs(currentTimeSec - timestampSec) <= maxAgeSeconds;
|
||||
}
|
||||
|
||||
function constantTimeEquals(expected: string, actual: string): boolean {
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const actualBuffer = Buffer.from(actual);
|
||||
return (
|
||||
expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a `handler.verification` descriptor to the incoming delivery.
|
||||
* Same semantics as the shared programmatic util: constant-time comparison,
|
||||
* optional replay window, `skipIfNoSecret` for unsigned legacy hooks. Returns
|
||||
* true when the delivery may proceed; never throws.
|
||||
*/
|
||||
async function verifyDeliverySignature(
|
||||
context: WebhookContext,
|
||||
config: DeclarativeWebhookVerification,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const headers = context.getHeaderData();
|
||||
|
||||
if (config.timestampHeader) {
|
||||
const timestamp = headers[config.timestampHeader.toLowerCase()] as string | undefined;
|
||||
if (!isTimestampValid(timestamp, config.maxTimestampAgeSeconds ?? 300)) return false;
|
||||
}
|
||||
|
||||
const secret = resolveValue(context, config.secret, {
|
||||
$staticData: context.getWorkflowStaticData('node'),
|
||||
...(config.credentialType && {
|
||||
$credentials: await context.getCredentials(config.credentialType),
|
||||
}),
|
||||
} as IWorkflowDataProxyAdditionalKeys);
|
||||
if (typeof secret !== 'string' || secret === '') {
|
||||
if (config.skipIfNoSecret) return true;
|
||||
// A hook can sit without a stored secret until it is re-registered, and
|
||||
// rejecting is the safe answer for that window. Say so, or a stopped
|
||||
// trigger looks identical to a bad signature.
|
||||
context.logger.warn(
|
||||
`Webhook trigger "${context.getNode().name}" rejected a delivery because no signature secret is available. Activate the workflow again to re-register the webhook.`,
|
||||
{ workflowId: context.getWorkflow().id },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const actual = headers[config.signatureHeader.toLowerCase()];
|
||||
if (typeof actual !== 'string' || actual === '') return false;
|
||||
|
||||
if (config.algorithm === 'token') {
|
||||
return constantTimeEquals(secret, actual);
|
||||
}
|
||||
|
||||
const req: { rawBody?: Buffer | string } = context.getRequestObject();
|
||||
const payload =
|
||||
(config.signedPayload ?? 'rawBody') === 'rawBody'
|
||||
? (req.rawBody ?? JSON.stringify(context.getBodyData()))
|
||||
: JSON.stringify(context.getBodyData());
|
||||
const hmac = createHmac(config.algorithm === 'hmac-sha1' ? 'sha1' : 'sha256', secret);
|
||||
hmac.update(payload);
|
||||
const expected = (config.prefix ?? '') + hmac.digest(config.encoding ?? 'hex');
|
||||
return constantTimeEquals(expected, actual);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers a handshake: static-data writes, then the configured response. */
|
||||
function respondToHandshake(
|
||||
context: WebhookContext,
|
||||
handshake: DeclarativeWebhookHandshake,
|
||||
requestKeys: IWorkflowDataProxyAdditionalKeys,
|
||||
): IWebhookResponseData {
|
||||
applyStore(context, handshake.store, requestKeys, context.getWorkflowStaticData('node'));
|
||||
|
||||
const respond = handshake.respond ?? {};
|
||||
const body = respond.body ? resolveValue(context, respond.body, requestKeys) : 'OK';
|
||||
if (!respond.statusCode && !respond.contentType && !respond.headers) {
|
||||
return { webhookResponse: body };
|
||||
}
|
||||
|
||||
const res = context.getResponseObject();
|
||||
for (const [name, value] of Object.entries(respond.headers ?? {})) {
|
||||
res.setHeader(name, String(resolveValue(context, value, requestKeys)));
|
||||
}
|
||||
if (respond.contentType) res.setHeader('content-type', respond.contentType);
|
||||
res
|
||||
.status(respond.statusCode ?? 200)
|
||||
.send(body)
|
||||
.end();
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `webhook()` for a declarative webhook trigger. On a `setup` endpoint
|
||||
* every request is a handshake. On the `default` endpoint the pipeline is:
|
||||
* handshake (`when` matches ⇒ respond, no run) → verification (failure ⇒ 401,
|
||||
* no run) → ping (⇒ custom response, no run) → filter (miss ⇒ 200, no run) →
|
||||
* output shaping → emit.
|
||||
*/
|
||||
export function createDeclarativeWebhook(
|
||||
trigger: IDeclarativeWebhookTrigger,
|
||||
): NonNullable<INodeType['webhook']> {
|
||||
const { handshake, verification, ping, filter, output } = trigger.handler ?? {};
|
||||
return async function (this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
if (!(this instanceof WebhookContext)) {
|
||||
throw new UnexpectedError('Declarative webhook handler needs a WebhookContext');
|
||||
}
|
||||
|
||||
const requestKeys = {
|
||||
$request: {
|
||||
body: this.getBodyData(),
|
||||
headers: this.getHeaderData(),
|
||||
query: this.getQueryData(),
|
||||
},
|
||||
} as IWorkflowDataProxyAdditionalKeys;
|
||||
|
||||
if (this.getWebhookName() === 'setup') {
|
||||
if (handshake?.when && !resolveValue(this, handshake.when, requestKeys)) {
|
||||
this.getResponseObject().status(401).send('Unauthorized').end();
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
return respondToHandshake(this, handshake ?? {}, requestKeys);
|
||||
}
|
||||
|
||||
if (handshake?.when && resolveValue(this, handshake.when, requestKeys)) {
|
||||
return respondToHandshake(this, handshake, requestKeys);
|
||||
}
|
||||
|
||||
if (verification && !(await verifyDeliverySignature(this, verification))) {
|
||||
this.getResponseObject().status(401).send('Unauthorized').end();
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
if (ping && resolveValue(this, ping.when, requestKeys)) {
|
||||
return { webhookResponse: ping.response ?? 'OK' };
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
const allowedValue = resolveValue(this, filter.allowed, requestKeys);
|
||||
const allowed = Array.isArray(allowedValue) ? allowedValue : [allowedValue];
|
||||
const actual = resolveValue(this, filter.actual, requestKeys);
|
||||
const wildcardHit = filter.wildcard !== undefined && allowed.includes(filter.wildcard);
|
||||
if (!wildcardHit && !allowed.includes(actual)) return {};
|
||||
}
|
||||
|
||||
const data = output?.data ? resolveValue(this, output.data, requestKeys) : this.getBodyData();
|
||||
const emitted = output?.includeMeta
|
||||
? { body: data, headers: this.getHeaderData(), query: this.getQueryData() }
|
||||
: data;
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray(emitted as IDataObject | IDataObject[])],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1043,4 +1043,61 @@ describe('DirectoryLoader', () => {
|
|||
expect(nodeType.description.properties).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declarative webhook trigger synthesis', () => {
|
||||
// Plain objects (not deep mocks): synthesis must see undefined members.
|
||||
const makeWebhookTriggerNode = (extra: Partial<INodeType> = {}): INodeType =>
|
||||
({
|
||||
description: {
|
||||
displayName: 'node1',
|
||||
name: 'node1',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: '',
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
webhooks: [
|
||||
{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' },
|
||||
],
|
||||
trigger: {
|
||||
type: 'webhook',
|
||||
lifecycle: {
|
||||
create: { routing: { request: { method: 'POST', url: '/hooks' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
}) as unknown as INodeType;
|
||||
|
||||
test('synthesizes webhookMethods and webhook', async () => {
|
||||
mockNode1 = makeWebhookTriggerNode();
|
||||
const loader = new CustomDirectoryLoader(directory);
|
||||
await loader.loadAll();
|
||||
|
||||
const nodeType = loader.getNode('node1').type as INodeType;
|
||||
expect(nodeType.webhookMethods?.default?.checkExists).toBeInstanceOf(Function);
|
||||
expect(nodeType.webhookMethods?.default?.create).toBeInstanceOf(Function);
|
||||
expect(nodeType.webhookMethods?.default?.delete).toBeInstanceOf(Function);
|
||||
expect(nodeType.webhook).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test('class-defined webhook and lifecycle slots win, missing slots are filled', async () => {
|
||||
const ownWebhook = vi.fn();
|
||||
const ownCreate = vi.fn();
|
||||
mockNode1 = makeWebhookTriggerNode({
|
||||
webhook: ownWebhook,
|
||||
webhookMethods: { default: { create: ownCreate } },
|
||||
} as unknown as Partial<INodeType>);
|
||||
const loader = new CustomDirectoryLoader(directory);
|
||||
await loader.loadAll();
|
||||
|
||||
const nodeType = loader.getNode('node1').type as INodeType;
|
||||
expect(nodeType.webhook).toBe(ownWebhook);
|
||||
expect(nodeType.webhookMethods?.default?.create).toBe(ownCreate);
|
||||
expect(nodeType.webhookMethods?.default?.checkExists).toBeInstanceOf(Function);
|
||||
expect(nodeType.webhookMethods?.default?.delete).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
18
packages/testing/playwright/scripts/hook-registry.ts
Normal file
18
packages/testing/playwright/scripts/hook-registry.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Standalone hook registry for manually demoing declarative webhook triggers.
|
||||
*
|
||||
* pnpm --filter=n8n-playwright hook-registry # port 9090
|
||||
* PORT=9999 pnpm --filter=n8n-playwright hook-registry
|
||||
*
|
||||
* Point an E2E Test Declarative Webhook Trigger's URL parameter at it
|
||||
* (http://localhost:9090), activate the workflow, then deliver events:
|
||||
*
|
||||
* curl -s localhost:9090/fire -H 'content-type: application/json' \
|
||||
* -d '{"event":"created","name":"demo"}'
|
||||
*/
|
||||
import { HookRegistryServer } from '../services/hook-registry-server';
|
||||
|
||||
const port = Number(process.env.PORT ?? 9090);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
void new HookRegistryServer((line) => console.log(`[hook-registry] ${line}`)).start(port);
|
||||
141
packages/testing/playwright/services/hook-registry-server.ts
Normal file
141
packages/testing/playwright/services/hook-registry-server.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { createHmac } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export interface RegisteredHook {
|
||||
id: string;
|
||||
target_url: string;
|
||||
events?: string[];
|
||||
/** When registered with a secret, deliveries are HMAC-signed with it. */
|
||||
secret?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RegistryCall {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful mock "hook registry" for webhook-trigger lifecycle testing — the
|
||||
* vendor API that E2eTestDeclarativeWebhookTrigger registers against:
|
||||
*
|
||||
* GET /hooks → { hooks: RegisteredHook[] }
|
||||
* POST /hooks → registers the body, returns { id }
|
||||
* DELETE /hooks/:id → deregisters
|
||||
* POST /fire → delivers the body to every registered target_url,
|
||||
* returns { delivered }
|
||||
*
|
||||
* Usable programmatically from a spec (start/stop, `hooks`, `calls`, `fire()`)
|
||||
* and standalone for manual demos via `pnpm --filter=n8n-playwright hook-registry`.
|
||||
*/
|
||||
export class HookRegistryServer {
|
||||
readonly hooks = new Map<string, RegisteredHook>();
|
||||
|
||||
/** Every lifecycle call received, for assertions. */
|
||||
readonly calls: RegistryCall[] = [];
|
||||
|
||||
private server?: Server;
|
||||
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly log: (line: string) => void = () => {}) {}
|
||||
|
||||
get port(): number {
|
||||
const address = this.server?.address() as AddressInfo | null | undefined;
|
||||
if (!address) throw new Error('Hook registry is not listening');
|
||||
return address.port;
|
||||
}
|
||||
|
||||
/** Base URL as reachable from this host. */
|
||||
get url(): string {
|
||||
return `http://localhost:${this.port}`;
|
||||
}
|
||||
|
||||
async start(port = 0): Promise<this> {
|
||||
this.server = createServer((req, res) => {
|
||||
void this.handle(req, res);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.once('error', reject);
|
||||
// No host: bind dual-stack (`::`), so both `localhost` resolutions
|
||||
// (127.0.0.1 and ::1) reach the registry.
|
||||
this.server!.listen(port, resolve);
|
||||
});
|
||||
this.log(`hook registry listening on ${this.url}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
this.server!.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
this.server = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a payload to every registered hook, like the vendor firing an
|
||||
* event. A hook registered with a `secret` gets its delivery signed:
|
||||
* `x-registry-signature: sha256=<HMAC-SHA256 hex over the raw body>`.
|
||||
*/
|
||||
async fire(payload: Record<string, unknown>): Promise<{ delivered: number; statuses: number[] }> {
|
||||
const statuses: number[] = [];
|
||||
for (const hook of this.hooks.values()) {
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (typeof hook.secret === 'string' && hook.secret !== '') {
|
||||
headers['x-registry-signature'] =
|
||||
`sha256=${createHmac('sha256', hook.secret).update(body).digest('hex')}`;
|
||||
}
|
||||
const response = await fetch(hook.target_url, { method: 'POST', headers, body });
|
||||
this.log(
|
||||
`fired ${body}${headers['x-registry-signature'] ? ' (signed)' : ''} → ${hook.target_url} (${response.status})`,
|
||||
);
|
||||
statuses.push(response.status);
|
||||
}
|
||||
return { delivered: statuses.length, statuses };
|
||||
}
|
||||
|
||||
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const method = req.method ?? 'GET';
|
||||
const path = (req.url ?? '/').split('?')[0];
|
||||
const body = await this.readBody(req);
|
||||
this.calls.push({ method, path, body });
|
||||
this.log(`${method} ${path}${body ? ` ${JSON.stringify(body)}` : ''}`);
|
||||
|
||||
const respond = (statusCode: number, payload: unknown) => {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
if (method === 'GET' && path === '/hooks') {
|
||||
respond(200, { hooks: [...this.hooks.values()] });
|
||||
} else if (method === 'POST' && path === '/hooks') {
|
||||
const id = `h${this.nextId++}`;
|
||||
this.hooks.set(id, { ...(body as Record<string, unknown>), id } as RegisteredHook);
|
||||
respond(200, { id });
|
||||
} else if (method === 'DELETE' && path.startsWith('/hooks/')) {
|
||||
const id = path.slice('/hooks/'.length);
|
||||
if (this.hooks.delete(id)) respond(200, { deleted: id });
|
||||
else respond(404, { error: `no hook ${id}` });
|
||||
} else if (method === 'POST' && path === '/fire') {
|
||||
respond(200, await this.fire(body as Record<string, unknown>));
|
||||
} else {
|
||||
respond(404, { error: `no route ${method} ${path}` });
|
||||
}
|
||||
}
|
||||
|
||||
private async readBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
if (chunks.length === 0) return undefined;
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -2824,6 +2824,185 @@ export interface IDeclarativePollingTrigger {
|
|||
manual?: { maxResults?: number };
|
||||
}
|
||||
|
||||
/** A hand-written lifecycle method — full-control escape hatch per slot. */
|
||||
export type DeclarativeHookMethod = (this: IHookFunctions) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Answers "is our webhook still registered with the vendor?" on activation.
|
||||
* With `matchOn`: true iff some returned item satisfies ALL clauses (`$item` =
|
||||
* the candidate); `store` then recovers values from the matched item.
|
||||
* Without `matchOn`: any 2xx ⇒ true; a status in `notFoundHttpCodes` (default
|
||||
* [404]) ⇒ clear the keys written by `create.store`, return false; skipped
|
||||
* (⇒ false) when `$staticData[idKey]` is absent.
|
||||
*/
|
||||
export interface DeclarativeWebhookCheckExists {
|
||||
routing: INodePropertyRouting;
|
||||
matchOn?: Array<{ itemProperty: string; value: string }>;
|
||||
/** Static-data writes from the matched item; exprs over `$item`. */
|
||||
store?: Record<string, string>;
|
||||
notFoundHttpCodes?: number[];
|
||||
/** Static-data key required before requesting. Default 'webhookId'. Only used without `matchOn`. */
|
||||
idKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the webhook. `store` writes static data from the response
|
||||
* (`$response` = the response body); its keys are the "managed set" cleared by
|
||||
* delete and by checkExists-not-found. `generate` mints random values before
|
||||
* the request, available as `$generated.<name>` in the routing and in `store`.
|
||||
*/
|
||||
export interface DeclarativeWebhookCreate {
|
||||
routing: INodePropertyRouting;
|
||||
store?: Record<string, string>;
|
||||
generate?: Record<string, DeclarativeSecretGenerator>;
|
||||
/** Refuse to register a `//localhost` webhook URL with a clear error. Default false. */
|
||||
rejectLocalhostUrl?: boolean;
|
||||
/** Boolean expression over `$response`; falsy ⇒ registration fails. */
|
||||
validateResponse?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregisters. Skipped (⇒ true) when `$staticData[idKey]` is absent. Request
|
||||
* errors ⇒ return false, keep static data. On success the keys written by
|
||||
* `create.store` are cleared.
|
||||
*/
|
||||
export interface DeclarativeWebhookDelete {
|
||||
routing: INodePropertyRouting;
|
||||
/** Default 'webhookId'. */
|
||||
idKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A vendor endpoint-ownership handshake. On a `setup` endpoint every request
|
||||
* is treated as a handshake (`when`, if set, must additionally hold or the
|
||||
* request is answered 401); on the `default` endpoint the handshake applies
|
||||
* only when `when` (expr over `$request`) is truthy. Runs before verification,
|
||||
* never starts a workflow.
|
||||
*/
|
||||
export interface DeclarativeWebhookHandshake {
|
||||
when?: string;
|
||||
/** Static-data writes from the handshake request; exprs over `$request`. */
|
||||
store?: Record<string, string>;
|
||||
respond?: {
|
||||
/** Expr over `$request`, e.g. '={{ $request.query["hub.challenge"] }}'. Default 'OK'. */
|
||||
body?: string;
|
||||
statusCode?: number;
|
||||
contentType?: string;
|
||||
/** Response headers; values are exprs over `$request`. */
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime handling of incoming deliveries; omitted stages default to "emit the
|
||||
* body". Pipeline order: handshake → verification → ping → filter → output.
|
||||
*/
|
||||
export interface DeclarativeWebhookHandler {
|
||||
handshake?: DeclarativeWebhookHandshake;
|
||||
/**
|
||||
* Signature verification, composed from explicit primitives (no vendor
|
||||
* presets). A failed check answers 401 without starting the workflow.
|
||||
*/
|
||||
verification?: DeclarativeWebhookVerification;
|
||||
/**
|
||||
* Vendor liveness-check short-circuit: when `when` (expr over `$request`)
|
||||
* is truthy, answer with `response` (default 'OK') and start no workflow —
|
||||
* unconditionally, before any event filtering.
|
||||
*/
|
||||
ping?: { when: string; response?: string };
|
||||
/**
|
||||
* Drop deliveries whose event doesn't match: `actual` (expr over `$request`)
|
||||
* must be included in `allowed` (expr, usually '={{ $parameter.events }}';
|
||||
* a plain string counts as a one-element list). A miss responds 200 without
|
||||
* starting the workflow. `wildcard` names a value inside `allowed` that
|
||||
* accepts everything (e.g. '*').
|
||||
*/
|
||||
filter?: { allowed: string; actual: string; wildcard?: string };
|
||||
/** Shape of the emitted item. Omitted: the request body. */
|
||||
output?: {
|
||||
/**
|
||||
* Expression selecting the emitted data, over `$request`. Default: the
|
||||
* request body. Arrays fan out into one item each.
|
||||
*/
|
||||
data?: string;
|
||||
/** Wrap the emitted data as `{ body, headers, query }` — the classic trigger item shape. */
|
||||
includeMeta?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDeclarativeWebhookTrigger {
|
||||
type: 'webhook';
|
||||
/**
|
||||
* Webhook registration lifecycle, synthesized into `webhookMethods.default`.
|
||||
* Requests are `routing` blocks (same shape as an operation's routing) run by
|
||||
* `RoutingNode`; expressions see `$webhookUrl` and `$staticData` next to
|
||||
* `$parameter` and `$credentials`. Slots the node class defines itself are
|
||||
* left untouched, and any slot may be a plain function instead of config.
|
||||
*/
|
||||
lifecycle: {
|
||||
/** Omit to always re-register on activation. */
|
||||
checkExists?: DeclarativeHookMethod | DeclarativeWebhookCheckExists;
|
||||
create: DeclarativeHookMethod | DeclarativeWebhookCreate;
|
||||
/** Omit when the vendor has nothing to deregister. */
|
||||
delete?: DeclarativeHookMethod | DeclarativeWebhookDelete;
|
||||
};
|
||||
handler?: DeclarativeWebhookHandler;
|
||||
}
|
||||
|
||||
/** A random value minted before a lifecycle request, exposed as `$generated.<name>`. */
|
||||
export interface DeclarativeSecretGenerator {
|
||||
type: 'hex' | 'base64' | 'uuid' | 'alphanumericLower';
|
||||
/** Random bytes (hex/base64) or characters (alphanumericLower). Default: 32, or 10 for alphanumericLower. */
|
||||
length?: number;
|
||||
/** Literal prefix, e.g. 'n8n-'. */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative webhook signature verification. Composes the same primitives the
|
||||
* shared `webhook-signature-verification` util gives programmatic triggers:
|
||||
* constant-time comparison, an optional replay window, and a
|
||||
* skip-when-unsigned escape for hooks registered before secrets existed.
|
||||
*/
|
||||
export interface DeclarativeWebhookVerification {
|
||||
/**
|
||||
* 'hmac-sha256' / 'hmac-sha1': HMAC over `signedPayload` with `secret`.
|
||||
* 'token': constant-time comparison of `secret` itself against the header
|
||||
* (the shared-token pattern, e.g. Telegram's secret_token).
|
||||
*/
|
||||
algorithm: 'hmac-sha256' | 'hmac-sha1' | 'token';
|
||||
/** Header carrying the signature (or token), e.g. 'x-hub-signature-256'. */
|
||||
signatureHeader: string;
|
||||
/**
|
||||
* Expression yielding the secret: '={{ $staticData.webhookSecret }}',
|
||||
* '={{ $parameter.secret }}', or — with `credentialType` set —
|
||||
* '={{ $credentials.signatureSecret }}'.
|
||||
*/
|
||||
secret: string;
|
||||
/**
|
||||
* Credential type to load and expose as `$credentials` for the `secret`
|
||||
* expression (e.g. 'slackApi'), for vendors whose signing secret lives in
|
||||
* the credential rather than being minted at registration.
|
||||
*/
|
||||
credentialType?: string;
|
||||
/** Digest encoding for HMAC. Default 'hex'. */
|
||||
encoding?: 'hex' | 'base64';
|
||||
/** Literal prefix the vendor puts before the digest, e.g. 'sha256='. */
|
||||
prefix?: string;
|
||||
/** What was signed. Default 'rawBody'; some vendors sign the JSON-stringified parsed body. */
|
||||
signedPayload?: 'rawBody' | 'jsonStringifiedBody';
|
||||
/** Header carrying a unix timestamp; enables the replay window when set. */
|
||||
timestampHeader?: string;
|
||||
/** Replay window in seconds. Default 300. */
|
||||
maxTimestampAgeSeconds?: number;
|
||||
/**
|
||||
* Accept unsigned deliveries when the secret expression resolves empty —
|
||||
* for hooks registered before the node minted secrets. Default false
|
||||
* (no secret ⇒ reject).
|
||||
*/
|
||||
skipIfNoSecret?: boolean;
|
||||
}
|
||||
|
||||
export type PostReceiveAction =
|
||||
| ((
|
||||
this: IExecuteSingleFunctions,
|
||||
|
|
@ -3064,10 +3243,12 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
|
|||
maxNodes?: number; // How many nodes of that type can be created in a workflow
|
||||
polling?: true | undefined;
|
||||
/**
|
||||
* Declarative trigger. The loader synthesizes `poll()` from it, so the node
|
||||
* class needs no trigger code. Ignored when the class defines `poll()`.
|
||||
* Declarative trigger. The loader synthesizes the trigger methods from it —
|
||||
* `poll()` for `type: 'polling'`, `webhookMethods`/`webhook()` for
|
||||
* `type: 'webhook'` — so the node class needs no trigger code. Members the
|
||||
* class defines itself always win, per slot.
|
||||
*/
|
||||
trigger?: IDeclarativePollingTrigger;
|
||||
trigger?: IDeclarativePollingTrigger | IDeclarativeWebhookTrigger;
|
||||
supportsCORS?: true | undefined;
|
||||
requestDefaults?: DeclarativeRestApiSettings.HttpRequestOptions;
|
||||
requestOperations?: IN8nRequestOperations;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user