chore(core): Interface definition for context establishment hooks (#22073)

This commit is contained in:
Andreas Fitzek 2025-11-20 12:26:02 +01:00 committed by GitHub
parent 4232093bb9
commit dcea7a9d5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 731 additions and 0 deletions

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -0,0 +1,148 @@
import { Container } from '@n8n/di';
import type {
ContextEstablishmentOptions,
ContextEstablishmentResult,
IContextEstablishmentHook,
} from '../context-establishment-hook';
import {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from '../context-establishment-hook-metadata';
describe('@ContextEstablishmentHook decorator', () => {
let hookMetadata: ContextEstablishmentHookMetadata;
beforeEach(() => {
jest.resetAllMocks();
hookMetadata = new ContextEstablishmentHookMetadata();
Container.set(ContextEstablishmentHookMetadata, hookMetadata);
});
it('should register hook in ContextEstablishmentHookMetadata', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(TestHook);
expect(registeredHooks).toHaveLength(1);
});
it('should register multiple hooks', () => {
@ContextEstablishmentHook()
class FirstHook implements IContextEstablishmentHook {
hookDescription = { name: 'first.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class SecondHook implements IContextEstablishmentHook {
hookDescription = { name: 'second.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ThirdHook implements IContextEstablishmentHook {
hookDescription = { name: 'third.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(FirstHook);
expect(registeredHooks).toContain(SecondHook);
expect(registeredHooks).toContain(ThirdHook);
expect(registeredHooks).toHaveLength(3);
});
it('should apply Service decorator', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
expect(Container.has(TestHook)).toBe(true);
});
it('should allow instantiation of registered hooks with accessible hookDescription', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const hookInstance = Container.get(TestHook);
expect(hookInstance).toBeInstanceOf(TestHook);
expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' });
expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken');
});
it('should register hooks with different description names', () => {
@ContextEstablishmentHook()
class BearerTokenHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ApiKeyHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.apiKey' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
const bearerTokenHook = Container.get(BearerTokenHook);
const apiKeyHook = Container.get(ApiKeyHook);
expect(registeredHooks).toHaveLength(2);
expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken');
expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey');
});
});

View File

@ -0,0 +1,200 @@
import { Container, Service } from '@n8n/di';
import { ContextEstablishmentHookClass } from './context-establishment-hook';
/**
* Registry entry for a context establishment hook.
*
* This is a lightweight wrapper around the hook class constructor that can be
* extended in the future to include additional metadata if needed (e.g., module
* source, registration timestamp, feature flags, license flags).
*
* @internal
*/
type ContextEstablishmentHookEntry = {
/** The hook class constructor for DI container instantiation */
class: ContextEstablishmentHookClass;
};
/**
* Low-level metadata registry for context establishment hooks.
*
* This service acts as a simple collection of registered hook classes that gets
* populated automatically by the @ContextEstablishmentHook decorator at module
* load time. It serves as the foundation for the higher-level Hook Registry.
*
* **Architecture:**
* ```
* Decorator ContextEstablishmentHookMetadata Hook Registry Execution Engine
* (registration) (collection) (discovery) (execution)
* ```
*
* @see ContextEstablishmentHook decorator for automatic registration
* @see IContextEstablishmentHook for hook interface
*/
@Service()
export class ContextEstablishmentHookMetadata {
/**
* Internal collection of registered hook classes.
*
* Uses Set for efficient deduplication (though duplicate registration
* should not occur with proper decorator usage).
*/
private readonly contextEstablishmentHooks: Set<ContextEstablishmentHookEntry> = new Set();
/**
* Registers a hook class in the metadata collection.
*
* Called automatically by the @ContextEstablishmentHook decorator during
* module loading. Should not be called directly by application code.
*
* **Note:** This method does not validate uniqueness or check for naming
* conflicts. Validation happens later in the Hook Registry.
*
* @param hookEntry - The hook class entry to register
*
* @internal Called by decorator only
*/
register(hookEntry: ContextEstablishmentHookEntry) {
this.contextEstablishmentHooks.add(hookEntry);
}
/**
* Retrieves all registered hook entries.
*
* Returns an array of [index, entry] tuples compatible with Set.entries().
* Primarily used for debugging or low-level iteration.
*
* **Prefer getClasses()** for most use cases as it returns just the classes.
*
* @returns Array of [index, entry] tuples from the internal Set
*
* @example
* ```typescript
* const entries = metadata.getEntries();
* for (const [index, entry] of entries) {
* console.log(`Hook ${index}:`, entry.class.name);
* }
* ```
*/
getEntries() {
return [...this.contextEstablishmentHooks.entries()];
}
/**
* Retrieves all registered hook classes.
*
* This is the primary method used by the Hook Registry to obtain hook classes
* for instantiation and indexing. Returns just the class constructors without
* the wrapper entry objects.
*
* **Usage pattern:**
* ```typescript
* const classes = metadata.getClasses();
* const hooks = classes.map(HookClass => Container.get(HookClass));
* const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h]));
* ```
*
* @returns Array of hook class constructors ready for DI instantiation
*
* @example
* ```typescript
* @Service()
* export class HookRegistry {
* constructor(
* private metadata: ContextEstablishmentHookMetadata,
* private container: Container
* ) {
* const hookClasses = metadata.getClasses();
* this.hooks = hookClasses.map(cls => container.get(cls));
* }
* }
* ```
*/
getClasses() {
return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class);
}
}
/**
* Class decorator for context establishment hooks.
*
* This decorator performs two critical functions:
* 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery
* 2. **Enables DI** by applying @Service() to make the hook injectable
*
* The decorator executes at module load time (when the class is defined), ensuring
* all hooks are registered before the application starts. This enables automatic
* discovery without manual registration code.
*
* **Registration flow:**
* ```
* @ContextEstablishmentHook() // 1. Decorator executes
* export class BearerTokenHook // 2. Class is defined
*
* ContextEstablishmentHookMetadata // 3. Hook class registered in metadata
*
* @Service() // 4. DI container registration
*
* Hook is discoverable & injectable // 5. Ready for use
* ```
*
* **Design pattern:**
* This follows the declarative registration pattern used throughout n8n for
* extensibility (similar to node registration). Hooks self-register without
* requiring central registration files or manual imports.
*
* **Requirements:**
* - Decorated class MUST implement IContextEstablishmentHook
* - Decorated class MUST have a hookDescription property with unique name
*
* **Important notes:**
* - No decorator parameters needed (hook metadata lives on hook instance)
* - Hooks are registered as singletons via @Service()
* - Registration happens eagerly at module load, not lazily
* - Duplicate decoration of the same class is safe (Set deduplicates)
*
* @see IContextEstablishmentHook for interface requirements
* @see ContextEstablishmentHookMetadata for underlying registry
* @see HookDescription for hook metadata structure
*
* @example
* ```typescript
* // Basic hook registration:
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Extract bearer token from Authorization header
* const token = this.extractToken(options.triggerItem);
* return {
* triggerItem: this.removeAuthHeader(options.triggerItem),
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*
* @returns A class decorator function that registers and enables DI for the hook
*/
export const ContextEstablishmentHook =
<T extends ContextEstablishmentHookClass>() =>
(target: T) => {
// Register hook class in metadata for discovery by Hook Registry
Container.get(ContextEstablishmentHookMetadata).register({
class: target,
});
// Enable dependency injection for the hook class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};

View File

@ -0,0 +1,328 @@
import type { Constructable } from '@n8n/di';
import type {
INode,
INodeExecutionData,
PlaintextExecutionContext,
IWorkflowBase,
} from 'n8n-workflow';
/**
* Input parameters passed to a context establishment hook during execution.
*
* Hooks receive the current workflow state and extract information from
* trigger items to build the execution context (e.g., credentials, environment).
* All hooks work with plaintext (decrypted) context for runtime operations.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentOptions = {
/** The trigger node that initiated the workflow execution */
triggerNode: INode;
/** The complete workflow definition */
workflow: IWorkflowBase;
/**
* Trigger items from the workflow execution start.
* This array represents items as modified by previous hooks in the chain.
* Hooks can extract data from these items and optionally modify them
* (e.g., removing sensitive headers before storage).
*/
triggerItems: INodeExecutionData[];
/**
* The plaintext execution context built so far.
* Includes base context plus results from any previously executed hooks.
* Contains decrypted credential data for runtime operations.
*
* @see PlaintextExecutionContext for security considerations
*/
context: PlaintextExecutionContext;
/**
* Hook-specific configuration provided by the trigger node.
* Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook).
*/
options?: Record<string, unknown>;
};
/**
* Result returned by a context establishment hook after execution.
*
* Hooks can modify trigger items (e.g., remove sensitive headers) and
* contribute partial context updates that get merged into the execution context.
* All context data is in plaintext form during hook execution.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentResult = {
/**
* The potentially modified trigger items.
* If undefined, the original trigger items are preserved unchanged.
*
* Common use case: Removing sensitive data (e.g., Authorization headers)
* before storing items in execution history.
*
* @example
* ```typescript
* // Remove Authorization header from trigger items
* const modifiedItems = options.triggerItems.map(item => ({
* ...item,
* json: {
* ...item.json,
* headers: {
* ...item.json.headers,
* authorization: undefined
* }
* }
* }));
* return { triggerItems: modifiedItems, contextUpdate: { ... } };
* ```
*/
triggerItems?: INodeExecutionData[];
/**
* Partial context update to merge into the execution context.
* If undefined, no context updates are applied.
*
* Contains only this hook's contributions (e.g., credentials data).
* Multiple hooks' updates are merged sequentially during execution.
* Context data is in plaintext form and will be encrypted before persistence.
*
* @example
* ```typescript
* // Add credential context from bearer token
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: {
* version: 1,
* identity: extractedToken,
* metadata: { source: 'bearer-token' }
* }
* }
* };
* ```
*/
contextUpdate?: Partial<PlaintextExecutionContext>;
};
/**
* Metadata describing a context establishment hook.
*
* This object carries self-describing information about the hook that enables
* runtime discovery, lookup, and instantiation. Each hook instance serves as
* the single source of truth for its own metadata.
*
* **Design rationale:**
* - Hook instances are self-describing (no external configuration files)
* - Name lookup happens at runtime via Registry, not during registration
* - Description can be extended without changing decorator or registry internals
* - Supports future features like versioning, schema validation, and categorization
*
* **Future extensions** may include:
* - `version?: string` - Semantic version of hook implementation for compatibility checks
* - `configSchema?: ZodSchema` - Validation schema for hook-specific options
* - `tags?: string[]` - Categorization tags for grouping and filtering
* - `applicableTriggers?: string[]` - Cached list of compatible trigger node types
* - `deprecated?: boolean | string` - Deprecation status and migration guidance
*
* @see IContextEstablishmentHook.hookDescription
* @see ContextEstablishmentHookMetadata for registration mechanism
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* // ... hook implementation
* }
*
* ```
*/
export type HookDescription = {
/**
* Unique identifier for this hook type.
*
* Used by the Hook Registry (to be implemented) to index and retrieve
* hook instances at runtime. Must be unique across all registered hooks.
*
* **Naming convention**: Use namespaced names like 'credentials.bearerToken'
* or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions.
*
* **Usage contexts:**
* - Trigger node configuration specifies hooks by name
* - Hook Registry uses name as lookup key
* - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`)
* - Logging and debugging references hooks by name
* - Error messages include hook name for troubleshooting
*
* **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2'
* if breaking changes are needed, though this is not required initially.
*
* @example 'credentials.bearerToken'
* @example 'credentials.apiKey'
* @example 'envVars.tenantConfig'
* @example 'audit.requestMetadata'
*/
name: string;
};
/**
* Interface for context establishment hooks that extract data from trigger
* items and extend the execution context during workflow initialization.
*
* @see ContextEstablishmentOptions - Input parameters
* @see ContextEstablishmentResult - Output structure
* @see PlaintextExecutionContext - Runtime context type with decrypted data
*/
export interface IContextEstablishmentHook {
/**
* Self-describing metadata for this hook instance.
*
* Provides the unique name and future metadata used by the Hook Registry
* for discovery, lookup, and validation. This property makes each hook
* instance self-contained and discoverable without external configuration.
*
* @see HookDescription for detailed metadata structure and future extensions
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Hook implementation
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*/
hookDescription: HookDescription;
/**
* Executes the hook to extract context data from trigger information.
*
* **Implementation requirements:**
* 1. Extract relevant data from trigger items (headers, body, query params, etc.)
* 2. Optionally modify trigger items to remove sensitive data
* 3. Return partial context updates to merge into execution context
* 4. Throw errors for unrecoverable failures (stops workflow execution)
*
* **Execution order:**
* Hooks execute sequentially in the order configured by the trigger node.
* Each hook receives:
* - Trigger items as modified by all previous hooks
* - Context with updates from all previous hooks
*
* **Context handling:**
* - Input context is plaintext (PlaintextExecutionContext) for runtime operations
* - Output updates are plaintext and will be encrypted before persistence
* - Never log or expose plaintext context outside hook execution
*
* **Error handling:**
* - Throw errors if required data is missing (e.g., expected header not found)
* - Use descriptive error messages for debugging
* - Errors stop workflow execution (fail-fast approach)
*
* @param options - Input parameters including trigger node, workflow, items, and current context
* @returns Promise resolving to modified trigger items and context updates
* @throws Error if hook execution fails (stops workflow execution)
*
* @example
* ```typescript
* async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
* const removeHeader = options.options?.removeFromItem ?? true;
*
* // Extract data
* const token = this.extractToken(options.triggerItems);
* if (!token) {
* throw new Error('Bearer token not found in Authorization header');
* }
*
* // Optionally modify items
* const modifiedItems = removeHeader
* ? this.removeAuthHeader(options.triggerItems)
* : undefined;
*
* // Return context update
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
* ```
*/
execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult>;
/**
* Method to determine if this hook is applicable to a specific trigger node type.
*
* **Use cases:**
* - **UI filtering**: Show only relevant hooks for a trigger type in node configuration
* - **Validation**: Prevent incompatible hook configurations at save time
* - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection
* - **Documentation**: Generate trigger-specific hook documentation
*
* **Implementation notes:**
* - Return true if the hook can extract meaningful data from this trigger type
* - Consider transport layer (HTTP, AMQP, manual, etc.)
* - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens)
*
* @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook')
* @returns true if this hook can be used with the given trigger node type
*
* @example
* ```typescript
* // Hook only works with HTTP-based triggers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return [
* 'n8n-nodes-base.webhook',
* 'n8n-nodes-base.formTrigger',
* 'n8n-nodes-base.httpRequest'
* ].includes(nodeType);
* }
* ```
*
* @example
* ```typescript
* // Hook works with any trigger that has HTTP headers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return nodeType.includes('webhook') || nodeType.includes('http');
* }
* ```
*/
isApplicableToTriggerNode(nodeType: string): boolean;
}
/**
* Type representing the constructor/class of a context establishment hook.
*
* Used by the dependency injection container to register and instantiate
* hook classes at runtime. Works with the @ContextEstablishmentHook decorator.
*
* @see IContextEstablishmentHook
* @see ContextEstablishmentHook decorator in './index.ts'
*
* @example
* ```typescript
* import { Container } from '@n8n/di';
* import type { ContextEstablishmentHookClass } from './context-establishment-hook';
*
* const HookClass: ContextEstablishmentHookClass = BearerTokenHook;
* const hookInstance = Container.get(HookClass);
* ```
*/
export type ContextEstablishmentHookClass = Constructable<IContextEstablishmentHook>;

View File

@ -0,0 +1,5 @@
export {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from './context-establishment-hook-metadata';
export type * from './context-establishment-hook';

View File

@ -88,6 +88,55 @@ export const ExecutionContextSchema = z
*/
export type IExecutionContext = z.output<typeof ExecutionContextSchema>;
/**
* Runtime representation of execution context with decrypted credential data.
*
* This type is identical to IExecutionContext except the `credentials` field
* contains the decrypted ICredentialContext object instead of an encrypted string.
*
* **Usage contexts:**
* - Hook execution: Hooks work with plaintext context to extract/merge credential data
* - Credential resolution: Resolvers need decrypted identity tokens
* - Internal processing: Runtime operations that need access to credential context
*
* **Security notes:**
* - Never persist this type to database - use IExecutionContext with encrypted credentials
* - Never expose in API responses or logs
* - Only exists in-memory during workflow execution
* - Should be cleared from memory after use
*
* **Lifecycle:**
* 1. Load IExecutionContext from storage (credentials encrypted)
* 2. Decrypt credentials field PlaintextExecutionContext (runtime only)
* 3. Use for hook execution, credential resolution, etc.
* 4. Encrypt credentials IExecutionContext before persistence
*
* @see IExecutionContext - Persisted form with encrypted credentials
* @see ICredentialContext - Decrypted credential structure
* @see IExecutionContextUpdate - Partial updates during hook execution
*
* @example
* ```typescript
* // During hook execution:
* const plaintextContext: PlaintextExecutionContext = {
* ...context,
* credentials: decryptCredentials(context.credentials) // Decrypt for runtime use
* };
*
* // Hook can now access plaintext credential data
* const identity = plaintextContext.credentials?.identity;
*
* // Before storage, re-encrypt:
* const storableContext: IExecutionContext = {
* ...plaintextContext,
* credentials: encryptCredentials(plaintextContext.credentials)
* };
* ```
*/
export type PlaintextExecutionContext = Omit<IExecutionContext, 'credentials'> & {
credentials?: ICredentialContext;
};
const safeParse = <T extends ZodType>(value: string | object, schema: T) => {
const typeName = schema.meta()?.title ?? 'Object';
try {