From 92dca5f739c535d8145fd54be8235e441e2c08ba Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 25 Nov 2025 17:01:00 +0100 Subject: [PATCH] feat(core): Inject hooks into applicable trigger node properties for the node UI (#22290) --- .../context-establishment-hook.ts | 52 +++- .../load-nodes-and-credentials.test.ts | 267 +++++++++++++++++- packages/cli/src/commands/base-command.ts | 2 + packages/cli/src/commands/start.ts | 2 - packages/cli/src/commands/worker.ts | 2 - .../cli/src/load-nodes-and-credentials.ts | 126 +++++++++ 6 files changed, 438 insertions(+), 13 deletions(-) diff --git a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts index 0c214076028..174534c9b42 100644 --- a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts +++ b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts @@ -4,6 +4,7 @@ import type { INodeExecutionData, PlaintextExecutionContext, IWorkflowBase, + INodeProperties, } from 'n8n-workflow'; /** @@ -137,7 +138,17 @@ export type ContextEstablishmentResult = { * @ContextEstablishmentHook() * export class BearerTokenHook implements IContextEstablishmentHook { * hookDescription = { - * name: 'credentials.bearerToken' + * name: 'credentials.bearerToken', + * displayName: 'Bearer Token', + * options: [ + * { + * displayName: 'Remove from Item', + * name: 'removeFromItem', + * type: 'boolean', + * default: true, + * description: 'Whether to remove the Authorization header from the trigger item' + * } + * ] * }; * * // ... hook implementation @@ -171,6 +182,45 @@ export type HookDescription = { * @example 'audit.requestMetadata' */ name: string; + + /** + * Human-readable display name for the hook. + * Used in the UI when presenting the hook selection to users. + * If not provided, the name will be used as the display name. + * + * @example 'Bearer Token Authentication' + * @example 'API Key from Header' + */ + displayName?: string; + + /** + * Hook-specific configuration options that will be exposed in the trigger node UI. + * These options are passed to the hook's execute() method via the options parameter. + * + * Each option should be a valid node property object with at minimum: + * displayName, name, type, and default fields. + * + * @example + * ```typescript + * options: [ + * { + * displayName: 'Remove from Item', + * name: 'removeFromItem', + * type: 'boolean', + * default: true, + * description: 'Whether to remove the Authorization header from trigger items' + * }, + * { + * displayName: 'Header Name', + * name: 'headerName', + * type: 'string', + * default: 'Authorization', + * description: 'The name of the header containing the bearer token' + * } + * ] + * ``` + */ + options?: INodeProperties[]; }; /** diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index 6048f8b914c..c9abc099ff3 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -1,12 +1,12 @@ +import { Service } from '@n8n/di'; +import watcher from '@parcel/watcher'; import fs from 'fs/promises'; import { mock } from 'jest-mock-extended'; import type { DirectoryLoader } from 'n8n-core'; import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; -import watcher from '@parcel/watcher'; import { LoadNodesAndCredentials } from '../load-nodes-and-credentials'; -import { Service } from '@n8n/di'; jest.mock('lodash/debounce', () => (fn: () => void) => fn); @@ -30,7 +30,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); instance.loaders.package1 = mock({ directory: '/icons/package1', }); @@ -58,7 +58,7 @@ describe('LoadNodesAndCredentials', () => { }); describe('convertNodeToAiTool', () => { - const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); let fullNodeWrapper: { description: INodeTypeDescription }; @@ -290,7 +290,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); instance.knownNodes['n8n-nodes-base.test'] = { className: 'Test', sourcePath: '/nodes-base/dist/nodes/Test/Test.node.js', @@ -330,7 +330,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); instance.types.nodes = [ { name: 'testNode', @@ -447,7 +447,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); }); it('should return true for credentials with authenticate property', () => { const credential = { @@ -572,6 +572,257 @@ describe('LoadNodesAndCredentials', () => { }); }); + describe('injectContextEstablishmentHooks', () => { + let instance: LoadNodesAndCredentials; + let mockLogger: { debug: jest.Mock }; + let mockExecutionContextHookRegistry: { getHookForTriggerType: jest.Mock }; + + beforeEach(() => { + // Enable the feature flag for tests + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true'; + + mockLogger = { + debug: jest.fn(), + }; + mockExecutionContextHookRegistry = { + getHookForTriggerType: jest.fn(), + }; + instance = new LoadNodesAndCredentials( + mockLogger as never, + mock(), + mock(), + mock(), + mock(), + mockExecutionContextHookRegistry as never, + ); + }); + + afterEach(() => { + // Clean up the environment variable after each test + delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS; + }); + + it('should not inject hooks when feature flag is disabled', () => { + // Disable the feature flag + delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS; + + const triggerNode: INodeTypeDescription = { + name: 'webhookTrigger', + displayName: 'Webhook Trigger', + group: ['trigger'], + description: 'A webhook trigger', + version: 1, + defaults: {}, + inputs: [], + outputs: ['main'], + properties: [], + }; + + instance.types.nodes = [triggerNode]; + + const mockHook = { + hookDescription: { + name: 'credentials.bearerToken', + displayName: 'Bearer Token', + options: [], + }, + }; + + mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([mockHook]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectContextEstablishmentHooks(); + + expect(triggerNode.properties).toHaveLength(0); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Context establishment hooks feature is disabled', + ); + + // Re-enable for other tests + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true'; + }); + + it('should not inject hooks if no hooks exist', () => { + const triggerNode: INodeTypeDescription = { + name: 'manualTrigger', + displayName: 'Manual Trigger', + group: ['trigger'], + description: 'A manual trigger', + version: 1, + defaults: {}, + inputs: [], + outputs: ['main'], + properties: [], + }; + + instance.types.nodes = [triggerNode]; + + mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectContextEstablishmentHooks(); + + expect(triggerNode.properties).toHaveLength(0); + }); + + it('should not inject hooks when isApplicableToTriggerNode returns false', () => { + const manualTriggerNode: INodeTypeDescription = { + name: 'manualTrigger', + displayName: 'Manual Trigger', + group: ['trigger'], + description: 'A manual trigger', + version: 1, + defaults: {}, + inputs: [], + outputs: ['main'], + properties: [], + }; + + instance.types.nodes = [manualTriggerNode]; + + const mockNonApplicableHook = { + hookDescription: { + name: 'credentials.bearerToken', + displayName: 'Bearer Token', + options: [ + { + displayName: 'Remove from Item', + name: 'removeFromItem', + type: 'boolean', + default: true, + }, + ], + }, + isApplicableToTriggerNode: jest.fn((nodeType: string) => { + // Only applicable to webhook trigger + return nodeType === 'webhookTrigger'; + }), + }; + + // Mock getHookForTriggerType to simulate real filtering behavior + mockExecutionContextHookRegistry.getHookForTriggerType.mockImplementation((nodeType) => { + const allHooks = [mockNonApplicableHook]; + // Filter hooks based on isApplicableToTriggerNode + return allHooks.filter((hook) => hook.isApplicableToTriggerNode(nodeType)); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectContextEstablishmentHooks(); + + // Verify isApplicableToTriggerNode was called, but no properties added + expect(mockNonApplicableHook.isApplicableToTriggerNode).toHaveBeenCalledWith('manualTrigger'); + expect(manualTriggerNode.properties).toHaveLength(0); + }); + + it('should inject hooks with multiple hook types and options', () => { + const triggerNode: INodeTypeDescription = { + name: 'webhookTrigger', + displayName: 'Webhook Trigger', + group: ['trigger'], + description: 'A webhook trigger', + version: 1, + defaults: {}, + inputs: [], + outputs: ['main'], + properties: [], + }; + + instance.types.nodes = [triggerNode]; + + // Hook 1: With 2 options (one with existing displayOptions) + const mockHookWithOptions = { + hookDescription: { + name: 'credentials.bearerToken', + displayName: 'Bearer Token', + options: [ + { + displayName: 'Remove from Item', + name: 'removeFromItem', + type: 'boolean', + default: true, + }, + { + displayName: 'Advanced Option', + name: 'advancedOption', + type: 'string', + default: '', + displayOptions: { + show: { + someOtherField: ['value'], + }, + }, + }, + ], + }, + }; + + // Hook 2: Without options + const mockHookWithoutOptions = { + hookDescription: { + name: 'credentials.apiKey', + displayName: 'API Key', + options: [], + }, + }; + + mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([ + mockHookWithOptions, + mockHookWithoutOptions, + ]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance as any).injectContextEstablishmentHooks(); + + // Verify three properties are injected + expect(triggerNode.properties).toHaveLength(3); + expect(triggerNode.properties[0].name).toBe('executionsHooksVersion'); + expect(triggerNode.properties[0].type).toBe('hidden'); + expect(triggerNode.properties[1].name).toBe('contextEstablishmentHooks'); + expect(triggerNode.properties[1].type).toBe('fixedCollection'); + expect(triggerNode.properties[2].name).toBe('contextHooksNotice'); + expect(triggerNode.properties[2].type).toBe('notice'); + + // Verify the hook collection structure + const hookProperty = triggerNode.properties[1]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hookValues = (hookProperty as any).options?.[0]?.values as INodeProperties[]; + + // Should have: hookName selector + isAllowedToFail + 2 options from first hook = 4 values + expect(hookValues).toHaveLength(4); + + // Verify hookName selector with both hook options + expect(hookValues[0].name).toBe('hookName'); + expect(hookValues[0].type).toBe('options'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hookNameOptions = (hookValues[0] as any).options; + expect(hookNameOptions).toHaveLength(2); + expect(hookNameOptions[0].value).toBe('credentials.bearerToken'); + expect(hookNameOptions[1].value).toBe('credentials.apiKey'); + + // Verify isAllowedToFail field + expect(hookValues[1].name).toBe('isAllowedToFail'); + expect(hookValues[1].type).toBe('boolean'); + expect(hookValues[1].default).toBe(false); + + // Verify first option has display condition + expect(hookValues[2].name).toBe('removeFromItem'); + expect(hookValues[2].displayOptions).toEqual({ + show: { + hookName: ['credentials.bearerToken'], + }, + }); + + // Verify second option merges existing displayOptions with hookName + expect(hookValues[3].name).toBe('advancedOption'); + expect(hookValues[3].displayOptions).toEqual({ + show: { + someOtherField: ['value'], + hookName: ['credentials.bearerToken'], + }, + }); + }); + }); + describe('setupHotReload', () => { let instance: LoadNodesAndCredentials; @@ -584,7 +835,7 @@ describe('LoadNodesAndCredentials', () => { }); beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock()); instance.loaders = { CUSTOM: mockLoader }; // Allow access to directory diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index d82e3fee07b..fd51aa617aa 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -98,6 +98,8 @@ export abstract class BaseCommand { process.once('SIGINT', this.onTerminationSignal('SIGINT')); this.nodeTypes = Container.get(NodeTypes); + + await this.executionContextHookRegistry.init(); await Container.get(LoadNodesAndCredentials).init(); await this.dbConnection diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a7515c9ad57..ed1c2133239 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -262,8 +262,6 @@ export class Start extends BaseCommand> { Container.get(MultiMainSetup).registerEventHandlers(); } - - await this.executionContextHookRegistry.init(); } async initOrchestration() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index e0b90fcb850..447b9308b77 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -115,8 +115,6 @@ export class Worker extends BaseCommand> { ); await this.moduleRegistry.initModules(this.instanceSettings.instanceType); - - await this.executionContextHookRegistry.init(); } async initEventBus() { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 14de45a3d24..0829e8ad845 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -14,6 +14,7 @@ import { LazyPackageDirectoryLoader, UnrecognizedCredentialTypeError, UnrecognizedNodeTypeError, + ExecutionContextHookRegistry, } from 'n8n-core'; import type { KnownNodesAndCredentials, @@ -57,6 +58,7 @@ export class LoadNodesAndCredentials { private readonly instanceSettings: InstanceSettings, private readonly globalConfig: GlobalConfig, private readonly moduleRegistry: ModuleRegistry, + private readonly executionContextHookRegistry: ExecutionContextHookRegistry, ) {} async init() { @@ -279,6 +281,128 @@ export class LoadNodesAndCredentials { }); } + private injectContextEstablishmentHooks() { + // Check if the feature is enabled via environment variable + const isEnabled = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; + + if (!isEnabled) { + this.logger.debug('Context establishment hooks feature is disabled'); + return; + } + + const triggerNodes = this.types.nodes.filter((node: INodeTypeDescription) => + node.group.includes('trigger'), + ); + + this.logger.debug( + `Injecting context establishment hooks for ${triggerNodes.length} trigger nodes`, + ); + + triggerNodes.forEach((node: INodeTypeDescription) => { + const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name); + + if (hooks.length > 0) { + this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`); + } + + // Only inject hook properties if there are applicable hooks + if (hooks.length === 0) return; + + // Create a fixedCollection with multipleValues for multiple hook selection + // Each hook becomes a separate item that can be added multiple times + const allHookValues: INodeProperties[] = [ + { + displayName: 'Hook', + name: 'hookName', + type: 'options', + options: hooks.map((hook) => { + const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name; + return { + name: displayName, + value: hook.hookDescription.name, + description: `Use ${displayName} hook`, + }; + }), + // No default - force user to explicitly select a hook + // This ensures hookName is always serialized in the workflow JSON + default: '', + description: 'Select which context establishment hook to use', + required: true, + }, + { + displayName: 'Allow Failure', + name: 'isAllowedToFail', + type: 'boolean', + default: false, + description: 'Whether to continue workflow execution if this hook fails', + }, + ]; + + // Add all hook-specific options with display conditions + for (const hook of hooks) { + const hookOptions = hook.hookDescription.options ?? []; + if (hookOptions.length > 0) { + for (const hookOption of hookOptions) { + // Add display condition to show only when this specific hook is selected + const enhancedOption: INodeProperties = { + ...hookOption, + displayOptions: { + ...hookOption.displayOptions, + show: { + ...hookOption.displayOptions?.show, + hookName: [hook.hookDescription.name], + }, + }, + }; + allHookValues.push(enhancedOption); + } + } + } + + // Create a hidden version property to track the hooks format version + const executionsHooksVersion: INodeProperties = { + displayName: 'Executions Hooks Version', + name: 'executionsHooksVersion', + type: 'hidden', + default: 1, + }; + + // Create the main context establishment hooks property as a fixedCollection + const contextHooksProperty: INodeProperties = { + displayName: 'Context Establishment Hooks', + name: 'contextEstablishmentHooks', + type: 'fixedCollection', + placeholder: 'Add Hook', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'hooks', + displayName: 'Hooks', + values: allHookValues, + }, + ], + description: + 'Add and configure context establishment hooks to extract data from trigger items. Learn more', + }; + + // Create a notice that always appears after the hooks collection + const contextHooksNotice: INodeProperties = { + displayName: + 'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. Learn more', + name: 'contextHooksNotice', + type: 'notice', + default: '', + }; + + node.properties.push(executionsHooksVersion); + node.properties.push(contextHooksProperty); + node.properties.push(contextHooksNotice); + }); + } + /** * Run a loader of source files of nodes and credentials in a directory. */ @@ -415,6 +539,8 @@ export class LoadNodesAndCredentials { this.injectCustomApiCallOptions(); + this.injectContextEstablishmentHooks(); + for (const postProcessor of this.postProcessors) { await postProcessor(); }