first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,28 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'import-x/export': 'warn',
},
},
{
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/unbound-method': 'warn',
'import-x/no-duplicates': 'warn',
},
},
);
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@n8n/decorators",
"version": "1.11.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/express": "catalog:",
"@types/lodash": "catalog:",
"vitest": "catalog:",
"zod": "catalog:"
},
"dependencies": {
"@n8n/constants": "workspace:*",
"@n8n/di": "workspace:*",
"@n8n/permissions": "workspace:*",
"lodash": "catalog:",
"n8n-workflow": "workspace:*"
}
}
@@ -0,0 +1,153 @@
import { AssertionError, ok } from 'node:assert';
import { setFlagsFromString } from 'node:v8';
import { runInNewContext } from 'node:vm';
import { Memoized } from '../memoized';
describe('Memoized Decorator', () => {
class TestClass {
private computeCount = 0;
constructor(private readonly value: number = 42) {}
@Memoized
get expensiveComputation() {
this.computeCount++;
return this.value * 2;
}
getComputeCount() {
return this.computeCount;
}
}
it('should only compute the value once', () => {
const instance = new TestClass();
// First access should compute
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
// Second access should use cached value
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
// Third access should still use cached value
expect(instance.expensiveComputation).toBe(84);
expect(instance.getComputeCount()).toBe(1);
});
it('should cache values independently for different instances', () => {
const instance1 = new TestClass(10);
const instance2 = new TestClass(20);
expect(instance1.expensiveComputation).toBe(20);
expect(instance2.expensiveComputation).toBe(40);
expect(instance1.getComputeCount()).toBe(1);
expect(instance2.getComputeCount()).toBe(1);
});
it('should throw error when used on non-getter', () => {
expect(() => {
class InvalidClass {
// @ts-expect-error this code will fail at compile time and at runtime
@Memoized
normalProperty = 42;
}
new InvalidClass();
}).toThrow(AssertionError);
});
it('should make cached value non-enumerable', () => {
const instance = new TestClass();
instance.expensiveComputation; // Access to trigger caching
const propertyNames = Object.keys(instance);
expect(propertyNames).not.toContain('expensiveComputation');
});
it('should not allow reconfiguring the cached value', () => {
const instance = new TestClass();
instance.expensiveComputation; // Access to trigger caching
expect(() => {
Object.defineProperty(instance, 'expensiveComputation', {
value: 999,
configurable: true,
});
}).toThrow();
});
it('should work when child class references memoized getter in parent class', () => {
class ParentClass {
protected computeCount = 0;
@Memoized
get parentValue() {
this.computeCount++;
return 42;
}
getComputeCount() {
return this.computeCount;
}
}
class ChildClass extends ParentClass {
get childValue() {
return this.parentValue * 2;
}
}
const child = new ChildClass();
expect(child.childValue).toBe(84);
expect(child.getComputeCount()).toBe(1);
expect(child.childValue).toBe(84);
expect(child.getComputeCount()).toBe(1);
});
it('should have correct property descriptor after memoization', () => {
const instance = new TestClass();
// Before accessing (original getter descriptor)
const beforeDescriptor = Object.getOwnPropertyDescriptor(
TestClass.prototype,
'expensiveComputation',
);
expect(beforeDescriptor?.configurable).toBe(true);
expect(beforeDescriptor?.enumerable).toBe(false);
expect(typeof beforeDescriptor?.get).toBe('function');
expect(beforeDescriptor?.set).toBeUndefined();
// After accessing (memoized value descriptor)
instance.expensiveComputation; // Trigger memoization
const afterDescriptor = Object.getOwnPropertyDescriptor(instance, 'expensiveComputation');
expect(afterDescriptor?.configurable).toBe(false);
expect(afterDescriptor?.enumerable).toBe(false);
expect(afterDescriptor?.writable).toBe(false);
expect(afterDescriptor?.value).toBe(84);
expect(afterDescriptor?.get).toBeUndefined();
});
it('should not prevent garbage collection of instances', async () => {
setFlagsFromString('--expose_gc');
const gc = runInNewContext('gc') as unknown as () => void;
let instance: TestClass | undefined = new TestClass();
const weakRef = new WeakRef(instance);
instance.expensiveComputation;
// Remove the strong reference
instance = undefined;
// Wait for garbage collection, forcing it if needed
await new Promise((resolve) => setTimeout(resolve, 10));
gc();
const ref = weakRef.deref();
ok(!ref, 'GC did not collect the instance ref');
});
});
@@ -0,0 +1,219 @@
import { UnexpectedError } from 'n8n-workflow';
import { Redactable, RedactableError } from '../redactable';
describe('Redactable Decorator', () => {
class TestClass {
@Redactable()
methodWithUser(arg: {
user: {
id: string;
email?: string;
firstName?: string;
lastName?: string;
role: { slug: string };
};
}) {
return arg;
}
@Redactable('inviter')
methodWithInviter(arg: {
inviter: {
id: string;
email?: string;
firstName?: string;
lastName?: string;
role: { slug: string };
};
}) {
return arg;
}
@Redactable('invitee')
methodWithInvitee(arg: {
invitee: {
id: string;
email?: string;
firstName?: string;
lastName?: string;
role: { slug: string };
};
}) {
return arg;
}
@Redactable()
methodWithMultipleArgs(
firstArg: { something: string },
secondArg: {
user: {
id: string;
email?: string;
firstName?: string;
lastName?: string;
role: { slug: string };
};
},
) {
return { firstArg, secondArg };
}
@Redactable()
methodWithoutUser(arg: { something: string }) {
return arg;
}
}
let instance: TestClass;
beforeEach(() => {
instance = new TestClass();
});
describe('RedactableError', () => {
it('should extend UnexpectedError', () => {
const error = new RedactableError('user', 'testArg');
expect(error).toBeInstanceOf(UnexpectedError);
});
it('should have correct error message', () => {
const error = new RedactableError('user', 'testArg');
expect(error.message).toBe(
'Failed to find "user" property in argument "testArg". Please set the decorator `@Redactable()` only on `LogStreamingEventRelay` methods where the argument contains a "user" property.',
);
});
});
describe('@Redactable() decorator', () => {
it('should transform user properties in a method with a user argument', () => {
const input = {
user: {
id: '123',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: 'admin' },
},
};
const result = instance.methodWithUser(input);
expect(result.user).toEqual({
userId: '123',
_email: 'test@example.com',
_firstName: 'John',
_lastName: 'Doe',
globalRole: 'admin',
});
});
it('should transform inviter properties when fieldName is set to "inviter"', () => {
const input = {
inviter: {
id: '123',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: 'admin' },
},
};
const result = instance.methodWithInviter(input);
expect(result.inviter).toEqual({
userId: '123',
_email: 'test@example.com',
_firstName: 'John',
_lastName: 'Doe',
globalRole: 'admin',
});
});
it('should transform invitee properties when fieldName is set to "invitee"', () => {
const input = {
invitee: {
id: '123',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: 'admin' },
},
};
const result = instance.methodWithInvitee(input);
expect(result.invitee).toEqual({
userId: '123',
_email: 'test@example.com',
_firstName: 'John',
_lastName: 'Doe',
globalRole: 'admin',
});
});
it('should handle user object with missing optional properties', () => {
const input = {
user: {
id: '123',
role: { slug: 'admin' },
},
};
const result = instance.methodWithUser(input);
expect(result.user).toEqual({
userId: '123',
_email: undefined,
_firstName: undefined,
_lastName: undefined,
globalRole: 'admin',
});
});
it('should find user property in any argument', () => {
const firstArg = { something: 'test' };
const secondArg = {
user: {
id: '123',
email: 'test@example.com',
role: { slug: 'admin' },
},
};
const result = instance.methodWithMultipleArgs(firstArg, secondArg);
expect(result.secondArg.user).toEqual({
userId: '123',
_email: 'test@example.com',
_firstName: undefined,
_lastName: undefined,
globalRole: 'admin',
});
expect(result.firstArg).toEqual(firstArg);
});
it('should throw RedactableError when no user property is found', () => {
expect(() => {
instance.methodWithoutUser({ something: 'test' });
}).toThrow(RedactableError);
});
it('should correctly apply the original method', () => {
const spy = vi.spyOn(instance, 'methodWithUser');
const input = {
user: {
id: '123',
email: 'test@example.com',
role: { slug: 'admin' },
},
};
instance.methodWithUser(input);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
});
@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/require-await */
import { Container, Service } from '@n8n/di';
import type { AuthHandlerClass, IPasswordAuthHandler } from '../auth-handler';
import { AuthHandler, AuthHandlerEntryMetadata } from '../auth-handler-metadata';
describe('AuthHandlerEntryMetadata', () => {
let metadata: AuthHandlerEntryMetadata;
beforeEach(() => {
metadata = new AuthHandlerEntryMetadata();
});
it('should register handler classes', () => {
class TestHandler implements IPasswordAuthHandler<object> {
metadata = { name: 'test', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
metadata.register({ class: TestHandler as AuthHandlerClass });
expect(metadata.getClasses()).toContain(TestHandler);
});
it('should return all registered entries', () => {
class Handler1 implements IPasswordAuthHandler<object> {
metadata = { name: 'test1', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
class Handler2 implements IPasswordAuthHandler<object> {
metadata = { name: 'test2', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
metadata.register({ class: Handler1 as AuthHandlerClass });
metadata.register({ class: Handler2 as AuthHandlerClass });
const entries = metadata.getEntries();
expect(entries).toHaveLength(2);
});
it('should return all registered classes', () => {
class Handler1 implements IPasswordAuthHandler<object> {
metadata = { name: 'test1', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
class Handler2 implements IPasswordAuthHandler<object> {
metadata = { name: 'test2', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
metadata.register({ class: Handler1 as AuthHandlerClass });
metadata.register({ class: Handler2 as AuthHandlerClass });
const classes = metadata.getClasses();
expect(classes).toEqual([Handler1, Handler2]);
});
});
describe('@AuthHandler decorator', () => {
beforeEach(() => {
Container.reset();
});
it('should register handler in metadata', () => {
@AuthHandler()
class TestAuthHandler implements IPasswordAuthHandler<object> {
metadata = { name: 'test', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
const metadata = Container.get(AuthHandlerEntryMetadata);
const registeredClasses = metadata.getClasses();
expect(registeredClasses).toContain(TestAuthHandler);
});
it('should enable dependency injection', () => {
@AuthHandler()
class TestAuthHandler implements IPasswordAuthHandler<object> {
metadata = { name: 'test', type: 'password' as const };
readonly userClass = Object;
async handleLogin() {
return undefined;
}
}
const instance = Container.get(TestAuthHandler);
expect(instance).toBeInstanceOf(TestAuthHandler);
expect(instance.metadata.name).toBe('test');
expect(instance.metadata.type).toBe('password');
});
it('should support handlers with dependencies', () => {
@Service()
class Logger {
log(message: string) {
return message;
}
}
@AuthHandler()
class TestAuthHandler implements IPasswordAuthHandler<object> {
metadata = { name: 'test', type: 'password' as const };
constructor(private logger: Logger) {}
readonly userClass = Object;
async handleLogin() {
this.logger.log('login');
return undefined;
}
}
const instance = Container.get(TestAuthHandler);
expect(instance).toBeInstanceOf(TestAuthHandler);
});
});
@@ -0,0 +1,55 @@
import { Container, Service } from '@n8n/di';
import { AuthHandlerClass } from './auth-handler';
type AuthHandlerEntry = {
class: AuthHandlerClass;
};
/**
* Registry service for auth handler type discovery and instantiation.
* Handler classes decorated with @AuthHandler() are automatically registered.
*/
@Service()
export class AuthHandlerEntryMetadata {
private readonly authHandlerEntries: Set<AuthHandlerEntry> = new Set();
/** Registers an auth handler class. Called automatically by @AuthHandler() decorator. */
register(authHandlerEntry: AuthHandlerEntry) {
this.authHandlerEntries.add(authHandlerEntry);
}
/** Returns all registered handler entries as [index, entry] tuples. */
getEntries() {
return [...this.authHandlerEntries.entries()];
}
/** Returns all registered handler classes. */
getClasses() {
return [...this.authHandlerEntries.values()].map((entry) => entry.class);
}
}
/**
* Decorator to mark a class as an authentication handler.
* Automatically registers the handler for discovery and enables dependency injection.
*
* @example
* @AuthHandler()
* class LdapAuthHandler implements IPasswordAuthHandler {
* metadata = { name: 'ldap', type: 'password' };
* async handleLogin(loginId: string, password: string) { ... }
* }
*/
export const AuthHandler =
<T extends AuthHandlerClass>() =>
(target: T) => {
// Register handler class for discovery by registry
Container.get(AuthHandlerEntryMetadata).register({
class: target,
});
// Enable dependency injection for the handler class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,67 @@
import type { Constructable } from '@n8n/di';
/**
* Authentication type discriminator.
* Extend this union as new authentication methods are added (e.g., 'password' | 'oauth' | 'saml').
*/
export type AuthType = 'password';
/**
* Metadata describing an auth handler.
*/
export interface AuthHandlerMetadata {
/** Unique name of the auth handler (e.g., 'ldap', 'saml') */
name: string;
/** Type of authentication this handler provides */
type: AuthType;
}
/**
* Base interface for all authentication handlers.
* Contains common fields shared across all auth types.
*/
interface IAuthHandlerBase<TUser> {
/**
* Metadata identifying this auth handler.
*/
readonly metadata: AuthHandlerMetadata;
/** The user type returned by the auth handler */
readonly userClass: Constructable<TUser>;
/**
* Optional lifecycle hook called during handler initialization.
* Use this to set up connections, load config, etc.
*/
init?(): Promise<void>;
}
/**
* Interface for password-based authentication handlers.
* Implementations can provide custom authentication logic for different auth methods (LDAP, SAML, etc.).
*
* @template TUser - The user type returned by the auth handler (typically from @n8n/db)
*/
export interface IPasswordAuthHandler<TUser> extends IAuthHandlerBase<TUser> {
readonly metadata: AuthHandlerMetadata & { type: 'password' };
/**
* Handles login attempt with provided credentials.
*
* @param loginId - User identifier (email, username, LDAP ID, etc.)
* @param password - User password
* @returns User object if authentication succeeds, undefined otherwise
*/
handleLogin(loginId: string, password: string): Promise<TUser | undefined>;
}
/**
* Union type of all authentication handler interfaces.
* TypeScript uses the `metadata.type` field to discriminate between handler types.
*/
export type IAuthHandler<TUser = unknown> = IPasswordAuthHandler<TUser>;
/**
* Type helper for auth handler class constructors.
*/
export type AuthHandlerClass = Constructable<IAuthHandler>;
@@ -0,0 +1,13 @@
/**
* Authentication Handler Module
*
* Provides interfaces and infrastructure for authentication handlers.
* Handlers can implement custom authentication logic for different auth methods (LDAP, SAML, OAuth, etc.).
*
* The system uses a discriminated union pattern based on the `type` field in metadata:
* - 'password': Username/password authentication (IPasswordAuthHandler)
* - Future: 'oauth', 'saml', etc.
*/
export { AuthHandlerEntryMetadata, AuthHandler } from './auth-handler-metadata';
export type * from './auth-handler';
@@ -0,0 +1,116 @@
import { Container } from '@n8n/di';
import { z } from 'zod';
import { Command } from '../command';
import { CommandMetadata } from '../command-metadata';
describe('@Command decorator', () => {
let commandMetadata: CommandMetadata;
beforeEach(() => {
vi.resetAllMocks();
Container.reset();
commandMetadata = new CommandMetadata();
Container.set(CommandMetadata, commandMetadata);
});
it('should register command in CommandMetadata', () => {
@Command({
name: 'test',
description: 'Test command',
examples: ['example usage'],
flagsSchema: z.object({}),
})
class TestCommand {
async run() {}
}
const registeredCommands = commandMetadata.getEntries();
expect(registeredCommands).toHaveLength(1);
const [commandName, entry] = registeredCommands[0];
expect(commandName).toBe('test');
expect(entry.class).toBe(TestCommand);
});
it('should register multiple commands', () => {
@Command({
name: 'first-command',
description: 'First test command',
examples: ['example 1'],
flagsSchema: z.object({}),
})
class FirstCommand {
async run() {}
}
@Command({
name: 'second-command',
description: 'Second test command',
examples: ['example 2'],
flagsSchema: z.object({}),
})
class SecondCommand {
async run() {}
}
@Command({
name: 'third-command',
description: 'Third test command',
examples: ['example 3'],
flagsSchema: z.object({}),
})
class ThirdCommand {
async run() {}
}
const registeredCommands = commandMetadata.getEntries();
expect(registeredCommands).toHaveLength(3);
expect(commandMetadata.get('first-command')?.class).toBe(FirstCommand);
expect(commandMetadata.get('second-command')?.class).toBe(SecondCommand);
expect(commandMetadata.get('third-command')?.class).toBe(ThirdCommand);
});
it('should apply Service decorator', () => {
@Command({
name: 'test',
description: 'Test command',
examples: ['example usage'],
flagsSchema: z.object({}),
})
class TestCommand {
async run() {}
}
expect(Container.has(TestCommand)).toBe(true);
});
it('stores the command metadata correctly', () => {
const name = 'test-cmd';
const description = 'Test command description';
const examples = ['example 1', 'example 2'];
const flagsSchema = z.object({
flag1: z.string(),
flag2: z.boolean().optional(),
});
@Command({
name,
description,
examples,
flagsSchema,
})
class TestCommand {
async run() {}
}
const entry = commandMetadata.get(name);
expect(entry).toBeDefined();
expect(entry?.description).toBe(description);
expect(entry?.examples).toEqual(examples);
expect(entry?.flagsSchema).toBe(flagsSchema);
expect(entry?.class).toBe(TestCommand);
});
});
@@ -0,0 +1,20 @@
import { Service } from '@n8n/di';
import type { CommandEntry } from './types';
@Service()
export class CommandMetadata {
private readonly commands: Map<string, CommandEntry> = new Map();
register(name: string, entry: CommandEntry) {
this.commands.set(name, entry);
}
get(name: string) {
return this.commands.get(name);
}
getEntries() {
return [...this.commands.entries()];
}
}
@@ -0,0 +1,18 @@
import { Container, Service } from '@n8n/di';
import { CommandMetadata } from './command-metadata';
import type { CommandClass, CommandOptions } from './types';
export const Command =
({ name, description, examples, flagsSchema }: CommandOptions): ClassDecorator =>
(target) => {
const commandClass = target as unknown as CommandClass;
Container.get(CommandMetadata).register(name, {
description,
flagsSchema,
class: commandClass,
examples,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,3 @@
export { Command } from './command';
export { CommandMetadata } from './command-metadata';
export type { ICommand, CommandClass, CommandEntry } from './types';
@@ -0,0 +1,28 @@
import type { Constructable } from '@n8n/di';
import type { ZodObject, ZodTypeAny } from 'zod';
type FlagsSchema = ZodObject<Record<string, ZodTypeAny>>;
export type CommandOptions = {
name: string;
description: string;
examples?: string[];
flagsSchema?: FlagsSchema;
};
export type ICommand = {
flags?: object;
init?: () => Promise<void>;
run: () => Promise<void>;
catch?: (e: Error) => Promise<void>;
finally?: (e?: Error) => Promise<void>;
};
export type CommandClass = Constructable<ICommand>;
export type CommandEntry = {
class: CommandClass;
description: string;
examples?: string[];
flagsSchema?: FlagsSchema;
};
@@ -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(() => {
vi.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');
});
});
@@ -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);
};
@@ -0,0 +1,431 @@
import type { Constructable } from '@n8n/di';
import type {
INode,
INodeExecutionData,
INodeProperties,
PlaintextExecutionContext,
Workflow,
} 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: Workflow;
/**
* 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[] | null;
/**
* 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',
* 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
* }
*
* ```
*/
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;
/**
* 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[];
};
/**
* 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, if these are not provided in the response they are not modified
* 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;
/**
* Optional hook initialization method called during registry setup.
*
* Use this to perform one-time setup operations before the hook starts
* processing workflow executions (e.g., loading configuration, establishing
* connections, validating dependencies).
*
* **Lifecycle:**
* - Called once during application startup when ExecutionContextHookRegistry.init() runs
* - Can be called multiple times if the registry is reinitialized
* - Called AFTER DI container instantiates the hook but BEFORE registration in the registry
*
* **Error handling:**
* - If init() throws an error, the hook is NOT registered and will be unavailable
* - The error is logged but does not stop other hooks from initializing
* - Other hooks continue to load normally even if one fails
* - Only throw errors for failures that make the hook unusable
*
* **Best practices:**
* - Keep initialization fast (avoid blocking operations)
* - Don't allocate resources in constructor - do it here instead
* - Make init() idempotent (safe to call multiple times)
* - Throw errors only when the hook cannot function without successful initialization
* - Add logging for initialization steps to aid debugging
*
* @returns Promise that resolves when initialization is complete
* @throws Error if initialization fails and hook should not be registered
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class CustomHook implements IContextEstablishmentHook {
* hookDescription = { name: 'custom.hook' };
*
* private config!: ConfigData;
*
* async init() {
* // Load required configuration
* this.config = await this.loadConfig();
* if (!this.config) {
* throw new Error('Failed to load required configuration');
* }
* }
*
* async execute(options: ContextEstablishmentOptions) {
* // Config is guaranteed to be loaded if init() succeeded
* return this.processWithConfig(options, this.config);
* }
* }
* ```
*/
init?(): Promise<void>;
}
/**
* 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>;
@@ -0,0 +1,5 @@
export {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from './context-establishment-hook-metadata';
export type * from './context-establishment-hook';
@@ -0,0 +1,133 @@
import { Container } from '@n8n/di';
import { Body, Query, Param } from '../args';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import type { Controller } from '../types';
describe('Args Decorators', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
describe.each([
{ decorator: Body, type: 'Body', expectedArg: { type: 'body' } },
{ decorator: Query, type: 'Query', expectedArg: { type: 'query' } },
])('@$type decorator', ({ decorator, type, expectedArg }) => {
it(`should set ${type} arg at correct parameter index`, () => {
class TestController {
testMethod(@decorator _parameter: unknown) {}
}
const parameterIndex = 0;
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.args[parameterIndex]).toEqual(expectedArg);
});
it(`should handle multiple parameters with ${type}`, () => {
class TestController {
testMethod(_first: string, @decorator _second: unknown, _third: number) {}
}
const parameterIndex = 1;
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.args[parameterIndex]).toEqual(expectedArg);
});
});
describe('@Param decorator', () => {
it('should set param arg with key at correct parameter index', () => {
class TestController {
testMethod(@Param('id') _id: string) {}
}
const parameterIndex = 0;
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.args[parameterIndex]).toEqual({ type: 'param', key: 'id' });
});
it('should handle multiple Param decorators with different keys', () => {
class TestController {
testMethod(@Param('id') _id: string, @Param('userId') _userId: string) {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.args[0]).toEqual({ type: 'param', key: 'id' });
expect(routeMetadata.args[1]).toEqual({ type: 'param', key: 'userId' });
});
});
it('should work with all decorators combined', () => {
class TestController {
testMethod(@Body _body: unknown, @Query _query: unknown, @Param('id') _id: string) {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.args[0]).toEqual({ type: 'body' });
expect(routeMetadata.args[1]).toEqual({ type: 'query' });
expect(routeMetadata.args[2]).toEqual({ type: 'param', key: 'id' });
});
it('should work with complex parameter combinations', () => {
class TestController {
simpleMethod(@Body _body: unknown) {}
queryMethod(@Query _query: unknown) {}
mixedMethod(
@Param('id') _id: string,
_undecorated: number,
@Body _body: unknown,
@Query _query: unknown,
) {}
}
const simpleRouteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'simpleMethod',
);
const queryRouteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'queryMethod',
);
const mixedRouteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'mixedMethod',
);
expect(simpleRouteMetadata.args[0]).toEqual({ type: 'body' });
expect(queryRouteMetadata.args[0]).toEqual({ type: 'query' });
expect(mixedRouteMetadata.args[0]).toEqual({ type: 'param', key: 'id' });
expect(mixedRouteMetadata.args[1]).toBeUndefined(); // undecorated parameter
expect(mixedRouteMetadata.args[2]).toEqual({ type: 'body' });
expect(mixedRouteMetadata.args[3]).toEqual({ type: 'query' });
});
});
@@ -0,0 +1,135 @@
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import type { Controller, HandlerName } from '../types';
describe('ControllerRegistryMetadata', () => {
let registry: ControllerRegistryMetadata;
const TestController = class TestController {
async testHandler() {
return 'test';
}
anotherHandler() {}
} as Controller;
const AnotherController = class AnotherController {
async handler() {}
} as Controller;
beforeEach(() => {
registry = new ControllerRegistryMetadata();
});
describe('getControllerMetadata', () => {
it('should create and return default metadata for a new controller', () => {
const metadata = registry.getControllerMetadata(TestController);
expect(metadata).toEqual({
basePath: '/',
registerOnRootPath: false,
middlewares: [],
routes: expect.any(Map),
});
});
it('should return existing metadata for a registered controller', () => {
// Get metadata first time to register
const initialMetadata = registry.getControllerMetadata(TestController);
// Update metadata
initialMetadata.basePath = '/api';
initialMetadata.middlewares.push('auth');
// Get metadata second time
const metadata = registry.getControllerMetadata(TestController);
expect(metadata).toBe(initialMetadata);
expect(metadata.basePath).toBe('/api');
expect(metadata.middlewares).toEqual(['auth']);
});
});
describe('getRouteMetadata', () => {
it('should create and return default route metadata for a new handler', () => {
const handlerName: HandlerName = 'testHandler';
const routeMetadata = registry.getRouteMetadata(TestController, handlerName);
expect(routeMetadata).toEqual({
args: [],
});
});
it('should return existing route metadata for a registered handler', () => {
const handlerName: HandlerName = 'testHandler';
const initialRouteMetadata = registry.getRouteMetadata(TestController, handlerName);
initialRouteMetadata.method = 'get';
initialRouteMetadata.path = '/test';
initialRouteMetadata.args.push({ type: 'query' });
const routeMetadata = registry.getRouteMetadata(TestController, handlerName);
expect(routeMetadata).toBe(initialRouteMetadata);
expect(routeMetadata.method).toBe('get');
expect(routeMetadata.path).toBe('/test');
expect(routeMetadata.args).toEqual([{ type: 'query' }]);
});
});
describe('controllerClasses', () => {
it('should return an iterator of registered controller classes', () => {
registry.getControllerMetadata(TestController);
registry.getControllerMetadata(AnotherController);
const iteratorClasses = registry.controllerClasses;
const controllers = Array.from(iteratorClasses);
expect(controllers).toHaveLength(2);
expect(controllers).toContain(TestController);
expect(controllers).toContain(AnotherController);
});
it('should return an empty iterator when no controllers are registered', () => {
const iteratorClasses = registry.controllerClasses;
const controllers = Array.from(iteratorClasses);
expect(controllers).toHaveLength(0);
});
});
it('should handle complete controller and routes registration correctly', () => {
const controllerMetadata = registry.getControllerMetadata(TestController);
controllerMetadata.basePath = '/test-api';
controllerMetadata.middlewares = ['global'];
const route1 = registry.getRouteMetadata(TestController, 'testHandler');
route1.method = 'get';
route1.path = '/items';
route1.args = [{ type: 'query' }];
route1.middlewares = [() => {}];
route1.skipAuth = true;
const route2 = registry.getRouteMetadata(TestController, 'anotherHandler');
route2.method = 'post';
route2.path = '/items/:id';
route2.args = [{ type: 'param', key: 'id' }, { type: 'body' }];
const retrievedMetadata = registry.getControllerMetadata(TestController);
expect(retrievedMetadata.basePath).toBe('/test-api');
expect(retrievedMetadata.middlewares).toEqual(['global']);
expect(retrievedMetadata.routes.size).toBe(2);
const retrievedRoute1 = retrievedMetadata.routes.get('testHandler');
expect(retrievedRoute1?.method).toBe('get');
expect(retrievedRoute1?.path).toBe('/items');
expect(retrievedRoute1?.args).toEqual([{ type: 'query' }]);
expect(retrievedRoute1?.skipAuth).toBe(true);
expect(retrievedRoute1?.middlewares).toHaveLength(1);
const retrievedRoute2 = retrievedMetadata.routes.get('anotherHandler');
expect(retrievedRoute2?.method).toBe('post');
expect(retrievedRoute2?.path).toBe('/items/:id');
expect(retrievedRoute2?.args).toEqual([{ type: 'param', key: 'id' }, { type: 'body' }]);
});
});
@@ -0,0 +1,93 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { Licensed } from '../licensed';
import type { Controller } from '../types';
describe('@Licensed Decorator', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
it('should set license feature on route metadata', () => {
const licenseFeature: BooleanLicenseFeature = 'feat:variables';
class TestController {
@Licensed(licenseFeature)
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.licenseFeature).toBe(licenseFeature);
});
it('should work with different license features', () => {
class TestController {
@Licensed('feat:ldap')
ldapMethod() {}
@Licensed('feat:saml')
samlMethod() {}
@Licensed('feat:sharing')
sharingMethod() {}
}
const ldapMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'ldapMethod',
);
expect(ldapMetadata.licenseFeature).toBe('feat:ldap');
const samlMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'samlMethod',
);
expect(samlMetadata.licenseFeature).toBe('feat:saml');
const sharingMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'sharingMethod',
);
expect(sharingMetadata.licenseFeature).toBe('feat:sharing');
});
it('should work alongside other decorators', () => {
// Assuming we have a Get decorator imported
const Get = (path: string) => {
return (target: object, handlerName: string | symbol) => {
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.method = 'get';
routeMetadata.path = path;
};
};
class TestController {
@Get('/test')
@Licensed('feat:variables')
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.licenseFeature).toBe('feat:variables');
expect(routeMetadata.method).toBe('get');
expect(routeMetadata.path).toBe('/test');
});
});
@@ -0,0 +1,56 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { RestController } from '../rest-controller';
import type { Controller } from '../types';
describe('@RestController Decorator', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
Container.reset();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
it('should set default base path when no path provided', () => {
@RestController()
class TestController {}
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
expect(metadata.basePath).toBe('/');
expect(metadata.registerOnRootPath).toBe(false);
expect(Container.has(TestController)).toBe(true);
});
it('should set custom base path when provided', () => {
@RestController('/test')
class TestController {}
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
expect(metadata.basePath).toBe('/test');
expect(metadata.registerOnRootPath).toBe(false);
expect(Container.has(TestController)).toBe(true);
});
it('should register the controller in the registry', () => {
@RestController('/users')
class UsersController {}
@RestController('/projects')
class ProjectsController {}
const controllers = Array.from(controllerRegistryMetadata.controllerClasses);
expect(controllers).toEqual([UsersController, ProjectsController]);
expect(Container.has(UsersController)).toBe(true);
expect(Container.has(ProjectsController)).toBe(true);
expect(
controllerRegistryMetadata.getControllerMetadata(UsersController as Controller).basePath,
).toBe('/users');
expect(
controllerRegistryMetadata.getControllerMetadata(ProjectsController as Controller).basePath,
).toBe('/projects');
});
});
@@ -0,0 +1,55 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { RootLevelController } from '../root-level-controller';
import type { Controller } from '../types';
describe('@RootLevelController Decorator', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
Container.reset();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
it('should default to root path and register on root', () => {
@RootLevelController()
class TestController {}
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
expect(metadata.basePath).toBe('/');
expect(metadata.registerOnRootPath).toBe(true);
expect(Container.has(TestController)).toBe(true);
});
it('should accept custom base path', () => {
@RootLevelController('/foo')
class FooController {}
const metadata = controllerRegistryMetadata.getControllerMetadata(FooController as Controller);
expect(metadata.basePath).toBe('/foo');
expect(metadata.registerOnRootPath).toBe(true);
expect(Container.has(FooController)).toBe(true);
});
it('should register multiple controllers with their metadata', () => {
@RootLevelController('/users')
class UsersController {}
@RootLevelController('/projects')
class ProjectsController {}
const controllers = Array.from(controllerRegistryMetadata.controllerClasses);
expect(controllers).toEqual([UsersController, ProjectsController]);
expect(
controllerRegistryMetadata.getControllerMetadata(UsersController as Controller)
.registerOnRootPath,
).toBe(true);
expect(
controllerRegistryMetadata.getControllerMetadata(ProjectsController as Controller).basePath,
).toBe('/projects');
});
});
@@ -0,0 +1,170 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { createBodyKeyedRateLimiter } from '../rate-limit';
import { Get, Post, Put, Patch, Delete } from '../route';
import type { Controller } from '../types';
describe('Route Decorators', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
describe.each([
{ decorator: Get, method: 'Get' },
{ decorator: Post, method: 'Post' },
{ decorator: Put, method: 'Put' },
{ decorator: Patch, method: 'Patch' },
{ decorator: Delete, method: 'Delete' },
])('@$method decorator', ({ decorator, method }) => {
it('should set correct metadata with default options', () => {
class TestController {
@decorator('/test')
testMethod() {}
}
const handlerName = 'testMethod';
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
handlerName,
);
expect(routeMetadata.method).toBe(method.toLowerCase());
expect(routeMetadata.path).toBe('/test');
expect(routeMetadata.middlewares).toEqual([]);
expect(routeMetadata.usesTemplates).toBe(false);
expect(routeMetadata.skipAuth).toBe(false);
expect(routeMetadata.ipRateLimit).toBeUndefined();
});
it('should accept and apply route options', () => {
const middleware = () => {};
class TestController {
@decorator('/test', {
middlewares: [middleware],
usesTemplates: true,
skipAuth: true,
ipRateLimit: { limit: 10, windowMs: 60000 },
keyedRateLimit: createBodyKeyedRateLimiter<{ email: string }>({
limit: 10,
windowMs: 60000,
field: 'email',
}),
})
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.middlewares).toEqual([middleware]);
expect(routeMetadata.usesTemplates).toBe(true);
expect(routeMetadata.skipAuth).toBe(true);
expect(routeMetadata.ipRateLimit).toEqual({ limit: 10, windowMs: 60000 });
expect(routeMetadata.keyedRateLimit).toMatchObject({
limit: 10,
windowMs: 60000,
source: 'body',
field: 'email',
});
});
it('should work with boolean ipRateLimit option', () => {
class TestController {
@decorator('/test', { ipRateLimit: true })
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.ipRateLimit).toBe(true);
});
it('should work with multiple routes on the same controller', () => {
class TestController {
@decorator('/first')
firstMethod() {}
@decorator('/second', { skipAuth: true })
secondMethod() {}
}
const firstRouteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'firstMethod',
);
const secondRouteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'secondMethod',
);
expect(firstRouteMetadata.method).toBe(method.toLowerCase());
expect(firstRouteMetadata.path).toBe('/first');
expect(firstRouteMetadata.skipAuth).toBe(false);
expect(secondRouteMetadata.method).toBe(method.toLowerCase());
expect(secondRouteMetadata.path).toBe('/second');
expect(secondRouteMetadata.skipAuth).toBe(true);
});
});
it('should allow different HTTP methods on the same controller', () => {
class TestController {
@Get('/users')
getUsers() {}
@Post('/users')
createUser() {}
@Put('/users/:id')
updateUser() {}
@Delete('/users/:id')
deleteUser() {}
}
const getMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'getUsers',
);
const postMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'createUser',
);
const putMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'updateUser',
);
const deleteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'deleteUser',
);
expect(getMetadata.method).toBe('get');
expect(getMetadata.path).toBe('/users');
expect(postMetadata.method).toBe('post');
expect(postMetadata.path).toBe('/users');
expect(putMetadata.method).toBe('put');
expect(putMetadata.path).toBe('/users/:id');
expect(deleteMetadata.method).toBe('delete');
expect(deleteMetadata.path).toBe('/users/:id');
});
});
@@ -0,0 +1,188 @@
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { GlobalScope, ProjectScope } from '../scoped';
import type { Controller } from '../types';
describe('Scope Decorators', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
vi.resetAllMocks();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
describe('@GlobalScope', () => {
it('should set global scope on route metadata', () => {
const scope: Scope = 'user:read';
class TestController {
@GlobalScope(scope)
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.accessScope).toEqual({
scope,
globalOnly: true,
});
});
it('should work with different scopes', () => {
class TestController {
@GlobalScope('user:read')
readUserMethod() {}
@GlobalScope('user:create')
createUserMethod() {}
@GlobalScope('user:delete')
deleteUserMethod() {}
}
const readMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'readUserMethod',
);
const createMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'createUserMethod',
);
const deleteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'deleteUserMethod',
);
expect(readMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true });
expect(createMetadata.accessScope).toEqual({ scope: 'user:create', globalOnly: true });
expect(deleteMetadata.accessScope).toEqual({ scope: 'user:delete', globalOnly: true });
});
});
describe('@ProjectScope', () => {
it('should set project scope on route metadata', () => {
const scope: Scope = 'workflow:read';
class TestController {
@ProjectScope(scope)
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.accessScope).toEqual({
scope,
globalOnly: false,
});
});
it('should work with different scopes', () => {
class TestController {
@ProjectScope('workflow:read')
readWorkflowMethod() {}
@ProjectScope('workflow:create')
createWorkflowMethod() {}
@ProjectScope('workflow:delete')
deleteWorkflowMethod() {}
}
const readMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'readWorkflowMethod',
);
const createMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'createWorkflowMethod',
);
const deleteMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'deleteWorkflowMethod',
);
expect(readMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false });
expect(createMetadata.accessScope).toEqual({ scope: 'workflow:create', globalOnly: false });
expect(deleteMetadata.accessScope).toEqual({ scope: 'workflow:delete', globalOnly: false });
});
});
it('should work with both scope types on the same controller', () => {
class TestController {
@GlobalScope('user:read')
readUserMethod() {}
@ProjectScope('workflow:read')
readWorkflowMethod() {}
}
const userMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'readUserMethod',
);
const workflowMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'readWorkflowMethod',
);
expect(userMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true });
expect(workflowMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false });
});
it('should work alongside other decorators', () => {
// Assuming we have a Get decorator imported
const Get = (path: string) => {
return (target: object, handlerName: string | symbol) => {
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.method = 'get';
routeMetadata.path = path;
};
};
class TestController {
@Get('/users')
@GlobalScope('user:read')
getUsers() {}
@Get('/workflows')
@ProjectScope('workflow:read')
getWorkflows() {}
}
const usersMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'getUsers',
);
const workflowsMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'getWorkflows',
);
expect(usersMetadata.method).toBe('get');
expect(usersMetadata.path).toBe('/users');
expect(usersMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true });
expect(workflowsMetadata.method).toBe('get');
expect(workflowsMetadata.path).toBe('/workflows');
expect(workflowsMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false });
});
});
@@ -0,0 +1,23 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Arg, Controller } from './types';
const ArgDecorator =
(arg: Arg): ParameterDecorator =>
(target, handlerName, parameterIndex) => {
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.args[parameterIndex] = arg;
};
/** Injects the request body into the handler */
export const Body = ArgDecorator({ type: 'body' });
/** Injects the request query into the handler */
export const Query = ArgDecorator({ type: 'query' });
/** Injects a request parameter into the handler */
export const Param = (key: string) => ArgDecorator({ type: 'param', key });
@@ -0,0 +1,37 @@
import { Service } from '@n8n/di';
import type { Controller, ControllerMetadata, HandlerName, RouteMetadata } from './types';
@Service()
export class ControllerRegistryMetadata {
private registry = new Map<Controller, ControllerMetadata>();
getControllerMetadata(controllerClass: Controller) {
let metadata = this.registry.get(controllerClass);
if (!metadata) {
metadata = {
basePath: '/',
registerOnRootPath: false,
middlewares: [],
routes: new Map(),
};
this.registry.set(controllerClass, metadata);
}
return metadata;
}
getRouteMetadata(controllerClass: Controller, handlerName: HandlerName) {
const metadata = this.getControllerMetadata(controllerClass);
let route = metadata.routes.get(handlerName);
if (!route) {
route = {} as RouteMetadata;
route.args = [];
metadata.routes.set(handlerName, route);
}
return route;
}
get controllerClasses() {
return this.registry.keys();
}
}
@@ -0,0 +1,23 @@
export { Body, Query, Param } from './args';
export { RestController } from './rest-controller';
export { RootLevelController } from './root-level-controller';
export { Get, Post, Put, Patch, Delete, Head, Options } from './route';
export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';
export { Licensed } from './licensed';
export { GlobalScope, ProjectScope } from './scoped';
export type {
AccessScope,
Controller,
CorsOptions,
Method,
StaticRouterMetadata,
} from './types';
export {
type RateLimiterLimits,
type BodyKeyedRateLimiterConfig,
type UserKeyedRateLimiterConfig,
type KeyedRateLimiterConfig,
createBodyKeyedRateLimiter,
createUserKeyedRateLimiter,
} from './rate-limit';
@@ -0,0 +1,15 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller } from './types';
export const Licensed =
(licenseFeature: BooleanLicenseFeature): MethodDecorator =>
(target, handlerName) => {
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.licenseFeature = licenseFeature;
};
@@ -0,0 +1,11 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller } from './types';
export const Middleware = (): MethodDecorator => (target, handlerName) => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
target.constructor as Controller,
);
metadata.middlewares.push(String(handlerName));
};
@@ -0,0 +1,76 @@
export interface RateLimiterLimits {
/**
* The maximum number of requests to allow during the `window` before rate limiting the client.
* @default 5
*/
limit?: number;
/**
* How long we should remember the requests.
* @default 300_000 (5 minutes)
*/
windowMs?: number;
}
/**
* Configuration for extracting a key from the request body.
*/
export interface BodyKeyedRateLimiterConfig extends RateLimiterLimits {
/** How to extract key from request */
source: 'body';
/** The field name in the request body to use as the key */
field: string;
}
/**
* Configuration for extracting a key from the authenticated user.
*/
export interface UserKeyedRateLimiterConfig extends RateLimiterLimits {
/** How to extract key from request */
source: 'user';
}
export type KeyedRateLimiterConfig = BodyKeyedRateLimiterConfig | UserKeyedRateLimiterConfig;
/**
* Create a body keyed rate limiter configuration. This ends up creating
* a rate limiter that is keyed by the value of the specified field in the
* request body.
*
* @example
* createBodyKeyedRateLimiter<LoginRequestDto>({
* field: 'email',
* limit: 10,
* windowMs: 60000,
* });
*/
export const createBodyKeyedRateLimiter = <T extends object>({
limit,
windowMs,
field,
}: RateLimiterLimits & {
field: keyof T & string;
}): BodyKeyedRateLimiterConfig => ({
source: 'body',
limit,
windowMs,
field,
});
/**
* Create a user keyed rate limiter configuration. This ends up creating
* a rate limiter that is keyed by the authenticated user's ID.
*
* @example
* createUserKeyedRateLimiter({
* limit: 10,
* windowMs: 60000,
* });
*/
export const createUserKeyedRateLimiter = ({
limit,
windowMs,
}: RateLimiterLimits): UserKeyedRateLimiterConfig => ({
source: 'user',
limit,
windowMs,
});
@@ -0,0 +1,16 @@
import { Container, Service } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller } from './types';
export const RestController =
(basePath: `/${string}` = '/'): ClassDecorator =>
(target) => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
target as unknown as Controller,
);
metadata.basePath = basePath;
metadata.registerOnRootPath = false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,21 @@
import { Container, Service } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller } from './types';
/**
* Defines a controller that should be registered on the root path, without any prefix
* @param basePath defaults to `/`
* @returns ClassDecorator
*/
export const RootLevelController =
(basePath: `/${string}` = '/'): ClassDecorator =>
(target) => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
target as unknown as Controller,
);
metadata.basePath = basePath;
metadata.registerOnRootPath = true;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,56 @@
import { Container } from '@n8n/di';
import type { RequestHandler } from 'express';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { KeyedRateLimiterConfig, RateLimiterLimits } from './rate-limit';
import type { Controller, CorsOptions, Method } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
usesTemplates?: boolean;
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
skipAuth?: boolean;
allowSkipPreviewAuth?: boolean;
/** When this flag is set to true, the endpoint can be accessed without authentication */
allowUnauthenticated?: boolean;
/** When this flag is set to true, the auth cookie does not enforce MFA to be used in the token */
allowSkipMFA?: boolean;
/** When these options are set, calls to this endpoint are rate limited based on IP address */
ipRateLimit?: boolean | RateLimiterLimits;
/** When these options are set, calls to this endpoint are rate limited based on a key extracted from the request */
keyedRateLimit?: KeyedRateLimiterConfig;
/** When this flag is set to true, the endpoint is protected by API key */
apiKeyAuth?: boolean;
/** CORS options for the route, this does not handle preflight requests */
cors?: Partial<CorsOptions> | true;
}
const RouteFactory =
(method: Method) =>
(path: `/${string}`, options: RouteOptions = {}): MethodDecorator =>
(target, handlerName) => {
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.method = method;
routeMetadata.path = path;
routeMetadata.middlewares = options.middlewares ?? [];
routeMetadata.usesTemplates = options.usesTemplates ?? false;
routeMetadata.skipAuth = options.skipAuth ?? false;
routeMetadata.allowSkipPreviewAuth = options.allowSkipPreviewAuth ?? false;
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
routeMetadata.allowUnauthenticated = options.allowUnauthenticated ?? false;
routeMetadata.apiKeyAuth = options.apiKeyAuth ?? false;
routeMetadata.ipRateLimit = options.ipRateLimit;
routeMetadata.keyedRateLimit = options.keyedRateLimit;
routeMetadata.cors = options.cors;
};
export const Get = RouteFactory('get');
export const Post = RouteFactory('post');
export const Put = RouteFactory('put');
export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete');
export const Head = RouteFactory('head');
export const Options = RouteFactory('options');
@@ -0,0 +1,52 @@
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller } from './types';
const Scoped =
(scope: Scope, { globalOnly } = { globalOnly: false }): MethodDecorator =>
(target, handlerName) => {
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.accessScope = { scope, globalOnly };
};
/**
* Decorator for a controller method to ensure the user has a scope,
* checking only at the global level.
*
* To check only at project level as well, use the `@ProjectScope` decorator.
*
* @example
* ```ts
* @RestController()
* export class UsersController {
* @Delete('/:id')
* @GlobalScope('user:delete')
* async deleteUser(req, res) { ... }
* }
* ```
*/
export const GlobalScope = (scope: Scope) => Scoped(scope, { globalOnly: true });
/**
* Decorator for a controller method to ensure the user has a scope,
* checking first at project level and then at global level.
*
* To check only at global level, use the `@GlobalScope` decorator.
*
* @example
* ```ts
* @RestController()
* export class WorkflowController {
* @Get('/:workflowId')
* @GlobalScope('workflow:read')
* async getWorkflow(req, res) { ... }
* }
* ```
*/
export const ProjectScope = (scope: Scope) => Scoped(scope);
@@ -0,0 +1,78 @@
import type { BooleanLicenseFeature } from '@n8n/constants';
import type { Constructable } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { RequestHandler, Router } from 'express';
import type { KeyedRateLimiterConfig, RateLimiterLimits } from './rate-limit';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string };
export interface CorsOptions {
allowedOrigins: string[];
allowedMethods: Method[];
allowedHeaders: string[];
allowCredentials?: boolean;
maxAge?: number;
}
export type HandlerName = string;
export interface AccessScope {
scope: Scope;
globalOnly: boolean;
}
export interface RouteMetadata {
method: Method;
path: string;
middlewares: RequestHandler[];
usesTemplates: boolean;
skipAuth: boolean;
allowSkipPreviewAuth: boolean;
allowSkipMFA: boolean;
allowUnauthenticated: boolean;
apiKeyAuth: boolean;
cors?: Partial<CorsOptions> | true;
/** Whether to apply IP-based rate limiting to the route */
ipRateLimit?: boolean | RateLimiterLimits;
/** Whether to apply keyed rate limiting to the route */
keyedRateLimit?: KeyedRateLimiterConfig;
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
args: Arg[];
router?: Router;
}
/**
* Metadata for static routers mounted on a controller.
* Picks relevant fields from RouteMetadata and makes router required.
*/
export type StaticRouterMetadata = {
path: string;
router: Router;
} & Partial<
Pick<
RouteMetadata,
| 'skipAuth'
| 'allowSkipPreviewAuth'
| 'allowSkipMFA'
| 'middlewares'
| 'ipRateLimit'
| 'keyedRateLimit'
| 'licenseFeature'
| 'accessScope'
>
>;
export interface ControllerMetadata {
basePath: `/${string}`;
// If true, the controller will be registered on the root path without the any prefix
registerOnRootPath?: boolean;
middlewares: HandlerName[];
routes: Map<HandlerName, RouteMetadata>;
}
export type Controller = Constructable<object> &
Record<HandlerName, (...args: unknown[]) => Promise<unknown>>;
@@ -0,0 +1,344 @@
import { Container } from '@n8n/di';
import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { CredentialResolverConfiguration, ICredentialResolver } from '../credential-resolver';
import {
CredentialResolver,
CredentialResolverEntryMetadata,
} from '../credential-resolver-metadata';
describe('@CredentialResolver decorator', () => {
let resolverMetadata: CredentialResolverEntryMetadata;
beforeEach(() => {
vi.resetAllMocks();
resolverMetadata = new CredentialResolverEntryMetadata();
Container.set(CredentialResolverEntryMetadata, resolverMetadata);
});
it('should register resolver in CredentialResolverEntryMetadata', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'test.resolver',
description: 'Test resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
expect(registeredResolvers).toContain(TestResolver);
expect(registeredResolvers).toHaveLength(1);
});
it('should register multiple resolvers', () => {
@CredentialResolver()
class FirstResolver implements ICredentialResolver {
metadata = {
name: 'first.resolver',
description: 'First resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class SecondResolver implements ICredentialResolver {
metadata = {
name: 'second.resolver',
description: 'Second resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class ThirdResolver implements ICredentialResolver {
metadata = {
name: 'third.resolver',
description: 'Third resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
expect(registeredResolvers).toContain(FirstResolver);
expect(registeredResolvers).toContain(SecondResolver);
expect(registeredResolvers).toContain(ThirdResolver);
expect(registeredResolvers).toHaveLength(3);
});
it('should apply Service decorator', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'test.resolver',
description: 'Test resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
expect(Container.has(TestResolver)).toBe(true);
});
it('should allow instantiation of registered resolvers with accessible metadata', () => {
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'oauth.introspection',
description: 'OAuth introspection resolver',
displayName: 'OAuth Introspection',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const resolverInstance = Container.get(TestResolver);
expect(resolverInstance).toBeInstanceOf(TestResolver);
expect(resolverInstance.metadata).toEqual({
name: 'oauth.introspection',
description: 'OAuth introspection resolver',
displayName: 'OAuth Introspection',
});
expect(resolverInstance.metadata.name).toBe('oauth.introspection');
});
it('should register resolvers with different metadata', () => {
@CredentialResolver()
class OAuthResolver implements ICredentialResolver {
metadata = {
name: 'oauth.resolver',
description: 'OAuth-based credential resolver',
displayName: 'OAuth Resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
@CredentialResolver()
class TestResolver implements ICredentialResolver {
metadata = {
name: 'test.resolver',
description: 'Test resolver for testing',
displayName: 'Test Resolver',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const registeredResolvers = resolverMetadata.getClasses();
const oauthResolver = Container.get(OAuthResolver);
const testResolver = Container.get(TestResolver);
expect(registeredResolvers).toHaveLength(2);
expect(oauthResolver.metadata.name).toBe('oauth.resolver');
expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver');
expect(testResolver.metadata.name).toBe('test.resolver');
expect(testResolver.metadata.displayName).toBe('Test Resolver');
});
it('should support resolvers with configuration options', () => {
@CredentialResolver()
class ConfigurableResolver implements ICredentialResolver {
metadata = {
name: 'configurable.resolver',
description: 'Resolver with configuration options',
options: [
{
displayName: 'API Endpoint',
name: 'apiEndpoint',
type: 'string' as const,
default: '',
},
],
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const resolverInstance = Container.get(ConfigurableResolver);
expect(resolverInstance.metadata.options).toBeDefined();
expect(resolverInstance.metadata.options).toHaveLength(1);
expect(resolverInstance.metadata.options[0].name).toBe('apiEndpoint');
});
it('should support optional deleteSecret method', () => {
@CredentialResolver()
class ResolverWithDelete implements ICredentialResolver {
metadata = {
name: 'resolver.with.delete',
description: 'Resolver with delete support',
};
async getSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async setSecret(
_credentialId: string,
_context: ICredentialContext,
_data: ICredentialDataDecryptedObject,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async deleteSecret(
_credentialId: string,
_context: ICredentialContext,
_options: CredentialResolverConfiguration,
): Promise<void> {}
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
}
const withDelete = Container.get(ResolverWithDelete);
expect(withDelete.deleteSecret).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import { Container, Service } from '@n8n/di';
import { CredentialResolverClass } from './credential-resolver';
type CredentialResolverEntry = {
class: CredentialResolverClass;
};
/**
* Registry service for credential resolver type discovery and instantiation.
* Resolver classes decorated with @CredentialResolver() are automatically registered.
*/
@Service()
export class CredentialResolverEntryMetadata {
private readonly credentialResolverEntries: Set<CredentialResolverEntry> = new Set();
/** Registers a credential resolver class. Called automatically by @CredentialResolver() decorator. */
register(credentialResolverEntry: CredentialResolverEntry) {
this.credentialResolverEntries.add(credentialResolverEntry);
}
/** Returns all registered resolver entries as [index, entry] tuples. */
getEntries() {
return [...this.credentialResolverEntries.entries()];
}
/** Returns all registered resolver classes. */
getClasses() {
return [...this.credentialResolverEntries.values()].map((entry) => entry.class);
}
}
/**
* Decorator to mark a class as a credential resolver.
* Automatically registers the resolver for discovery and enables dependency injection.
*
* @example
* @CredentialResolver()
* class MyResolver implements ICredentialResolver { ... }
*/
export const CredentialResolver =
<T extends CredentialResolverClass>() =>
(target: T) => {
// Register resolver class for discovery by registry
Container.get(CredentialResolverEntryMetadata).register({
class: target,
});
// Enable dependency injection for the resolver class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,109 @@
import type { Constructable } from '@n8n/di';
import type {
ICredentialContext,
ICredentialDataDecryptedObject,
INodeProperties,
} from 'n8n-workflow';
/**
* Configuration object passed to resolver methods. Structure is defined by resolver type's metadata.options.
*/
export type CredentialResolverConfiguration = Record<string, unknown>;
export type CredentialResolverHandle = {
configuration: CredentialResolverConfiguration;
resolverName: string;
resolverId: string;
};
/**
* Metadata describing a credential resolver type for UI integration and discovery.
*/
export interface CredentialResolverMetadata {
/** Unique identifier for the resolver type */
name: string;
/** Human-readable description of what this resolver does */
description: string;
/** Optional display name shown in UI. Falls back to name if not provided. */
displayName?: string;
/** Configuration schema using n8n's INodeProperties format for dynamic form rendering */
options?: INodeProperties[];
}
/**
* Core interface for credential resolver implementations.
* Resolvers fetch credential data dynamically based on execution context and configuration.
*/
export interface ICredentialResolver {
/** Metadata for UI integration and resolver discovery */
metadata: CredentialResolverMetadata;
/**
* Retrieves credential data for a specific entity from the resolver's storage.
* @throws {CredentialResolverDataNotFoundError} When no data exists for the given context
* @throws {CredentialResolverError} For other resolver-specific errors
*/
getSecret(
credentialId: string,
context: ICredentialContext,
handle: CredentialResolverHandle,
): Promise<ICredentialDataDecryptedObject>;
/**
* Stores credential data for a specific entity in the resolver's storage.
* @throws {CredentialResolverError} When storage operation fails
*/
setSecret(
credentialId: string,
context: ICredentialContext,
data: ICredentialDataDecryptedObject,
handle: CredentialResolverHandle,
): Promise<void>;
/**
* Deletes credential data for a specific entity from the resolver's storage.
* Optional - not all resolvers support deletion.
* @throws {CredentialResolverError} When deletion operation fails
*/
deleteSecret?(
credentialId: string,
context: ICredentialContext,
handle: CredentialResolverHandle,
): Promise<void>;
/**
* Deletes all credential data for the resolver.
* Optional - not all resolvers support deletion.
* @throws {CredentialResolverError} When deletion operation fails
*/
deleteAllSecrets?(handle: CredentialResolverHandle): Promise<void>;
/**
* Validates resolver configuration before saving.
* Should verify connectivity, authentication, and configuration structure.
* @throws {CredentialResolverValidationError} When configuration is invalid
*/
validateOptions(options: CredentialResolverConfiguration): Promise<void>;
/**
* Validates if the userIdentity provided has access to the resolver capable credential
*
* @param context - The identity of the entity to validate access for
* @throws {CredentialResolverAccessValidationError} When access is invalid
*/
validateIdentity?(context: ICredentialContext, handle: CredentialResolverHandle): Promise<void>;
/**
* Runs initialization logic for the resolver. This might be called multiple times!
* Optional - not all resolvers require initialization.
*/
init?(): Promise<void>;
}
/**
* Type helper for credential resolver class constructors.
*/
export type CredentialResolverClass = Constructable<ICredentialResolver>;
@@ -0,0 +1,31 @@
/**
* Base error class for all credential resolver errors.
*/
export class CredentialResolverError extends Error {
constructor(message: string) {
super(message);
this.name = 'CredentialResolverError';
}
}
/**
* Thrown when no credential data exists for the requested credential and context combination.
* Indicates the entity has not stored credentials for this credential type.
*/
export class CredentialResolverDataNotFoundError extends CredentialResolverError {
constructor() {
super('No data found available for the requested credential and context combination.');
this.name = 'CredentialResolverDataNotFoundError';
}
}
/**
* Thrown when resolver configuration validation fails.
* Indicates invalid configuration values or unreachable external services.
*/
export class CredentialResolverValidationError extends CredentialResolverError {
constructor(message: string) {
super(`Credential resolver options validation failed: ${message}`);
this.name = 'CredentialResolverValidationError';
}
}
@@ -0,0 +1,13 @@
/**
* Credential Resolver Module
*
* Provides interfaces and infrastructure for dynamic credential resolution based on execution context.
* Resolvers fetch credential data at runtime from external storage based on entity identity.
*/
export {
CredentialResolverEntryMetadata,
CredentialResolver,
} from './credential-resolver-metadata';
export * from './errors';
export type * from './credential-resolver';
+37
View File
@@ -0,0 +1,37 @@
import debounce from 'lodash/debounce';
/**
* Debounce a class method using `lodash/debounce`.
*
* @param waitMs - Number of milliseconds to debounce method by.
*
* @example
* ```
* class MyClass {
* @Debounce(1000)
* async myMethod() {
* // debounced
* }
* }
* ```
*/
export const Debounce =
(waitMs: number): MethodDecorator =>
<T>(
_: object,
methodName: string | symbol,
originalDescriptor: PropertyDescriptor,
): TypedPropertyDescriptor<T> => ({
configurable: true,
get() {
const debouncedFn = debounce(originalDescriptor.value, waitMs);
Object.defineProperty(this, methodName, {
configurable: false,
value: debouncedFn,
});
return debouncedFn as T;
},
});
+7
View File
@@ -0,0 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
export class NonMethodError extends UnexpectedError {
constructor(name: string) {
super(`${name} must be a method on a class to use this decorator`);
}
}
@@ -0,0 +1,132 @@
import { Container, Service } from '@n8n/di';
import { NonMethodError } from '../../errors';
import { LifecycleMetadata } from '../lifecycle-metadata';
import { OnLifecycleEvent } from '../on-lifecycle-event';
describe('OnLifecycleEvent', () => {
let lifecycleMetadata: LifecycleMetadata;
beforeEach(() => {
lifecycleMetadata = new LifecycleMetadata();
Container.set(LifecycleMetadata, lifecycleMetadata);
vi.spyOn(lifecycleMetadata, 'register');
});
it('should register a method decorated with OnLifecycleEvent', () => {
@Service()
class TestService {
@OnLifecycleEvent('nodeExecuteBefore')
async handleNodeExecuteBefore() {}
}
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(1);
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
handlerClass: TestService,
methodName: 'handleNodeExecuteBefore',
eventName: 'nodeExecuteBefore',
});
});
it('should register methods for all lifecycle event types', () => {
@Service()
// @ts-expect-error Testing
class TestService {
@OnLifecycleEvent('nodeExecuteBefore')
async handleNodeExecuteBefore() {}
@OnLifecycleEvent('nodeExecuteAfter')
async handleNodeExecuteAfter() {}
@OnLifecycleEvent('workflowExecuteBefore')
async handleWorkflowExecuteBefore() {}
@OnLifecycleEvent('workflowExecuteAfter')
async handleWorkflowExecuteAfter() {}
@OnLifecycleEvent('workflowExecuteResume')
async handleWorkflowExecuteResume() {}
}
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(5);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'nodeExecuteBefore' }),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'nodeExecuteAfter' }),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'workflowExecuteBefore' }),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'workflowExecuteAfter' }),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({ eventName: 'workflowExecuteResume' }),
);
});
it('should register multiple handlers in the same class', () => {
@Service()
class TestService {
@OnLifecycleEvent('nodeExecuteBefore')
async handleNodeExecuteBefore1() {}
@OnLifecycleEvent('nodeExecuteBefore')
async handleNodeExecuteBefore2() {}
}
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(2);
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
handlerClass: TestService,
methodName: 'handleNodeExecuteBefore1',
eventName: 'nodeExecuteBefore',
});
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
handlerClass: TestService,
methodName: 'handleNodeExecuteBefore2',
eventName: 'nodeExecuteBefore',
});
});
it('should throw an error if the decorated target is not a method', () => {
expect(() => {
@Service()
class TestService {
// @ts-expect-error Testing invalid code
@OnLifecycleEvent('nodeExecuteBefore')
notAFunction = 'string';
}
new TestService();
}).toThrow(NonMethodError);
});
it('should register handlers from multiple service classes', () => {
@Service()
class FirstService {
@OnLifecycleEvent('nodeExecuteBefore')
async handleNodeExecuteBefore() {}
}
@Service()
class SecondService {
@OnLifecycleEvent('workflowExecuteAfter')
async handleWorkflowExecuteAfter() {}
}
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(2);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({
handlerClass: FirstService,
eventName: 'nodeExecuteBefore',
}),
);
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
expect.objectContaining({
handlerClass: SecondService,
eventName: 'workflowExecuteAfter',
}),
);
});
});
@@ -0,0 +1,10 @@
export { OnLifecycleEvent } from './on-lifecycle-event';
export type {
LifecycleContext,
NodeExecuteBeforeContext,
NodeExecuteAfterContext,
WorkflowExecuteBeforeContext,
WorkflowExecuteAfterContext,
WorkflowExecuteResumeContext,
} from './lifecycle-metadata';
export { LifecycleMetadata } from './lifecycle-metadata';
@@ -0,0 +1,89 @@
import { Service } from '@n8n/di';
import type {
IDataObject,
IRun,
IRunExecutionData,
ITaskData,
ITaskStartedData,
IWorkflowBase,
Workflow,
} from 'n8n-workflow';
import type { Class } from '../types';
export type LifecycleHandlerClass = Class<
Record<string, (ctx: LifecycleContext) => Promise<void> | void>
>;
export type NodeExecuteBeforeContext = {
type: 'nodeExecuteBefore';
workflow: IWorkflowBase;
nodeName: string;
taskData: ITaskStartedData;
};
export type NodeExecuteAfterContext = {
type: 'nodeExecuteAfter';
workflow: IWorkflowBase;
nodeName: string;
taskData: ITaskData;
executionData: IRunExecutionData;
};
export type WorkflowExecuteBeforeContext = {
type: 'workflowExecuteBefore';
workflow: IWorkflowBase;
workflowInstance: Workflow;
executionData?: IRunExecutionData;
executionId: string;
};
export type WorkflowExecuteAfterContext = {
type: 'workflowExecuteAfter';
workflow: IWorkflowBase;
runData: IRun;
newStaticData: IDataObject;
executionId: string;
};
export type WorkflowExecuteResumeContext = {
type: 'workflowExecuteResume';
workflow: IWorkflowBase;
workflowInstance: Workflow;
executionData: IRunExecutionData;
executionId: string;
};
/** Context arg passed to a lifecycle event handler method. */
export type LifecycleContext =
| NodeExecuteBeforeContext
| NodeExecuteAfterContext
| WorkflowExecuteBeforeContext
| WorkflowExecuteAfterContext
| WorkflowExecuteResumeContext;
type LifecycleHandler = {
/** Class holding the method to call on a lifecycle event. */
handlerClass: LifecycleHandlerClass;
/** Name of the method to call on a lifecycle event. */
methodName: string;
/** Name of the lifecycle event to listen to. */
eventName: LifecycleEvent;
};
export type LifecycleEvent = LifecycleContext['type'];
@Service()
export class LifecycleMetadata {
private readonly handlers: LifecycleHandler[] = [];
register(handler: LifecycleHandler) {
this.handlers.push(handler);
}
getHandlers(): LifecycleHandler[] {
return this.handlers;
}
}
@@ -0,0 +1,38 @@
import { Container } from '@n8n/di';
import type { LifecycleEvent, LifecycleHandlerClass } from './lifecycle-metadata';
import { LifecycleMetadata } from './lifecycle-metadata';
import { NonMethodError } from '../errors';
/**
* Decorator that registers a method to be called when a specific lifecycle event occurs.
* For more information, see `execution-lifecycle-hooks.ts` in `cli` and `core`.
*
* @example
*
* ```ts
* @Service()
* class MyService {
* @OnLifecycleEvent('workflowExecuteAfter')
* async handleEvent(ctx: WorkflowExecuteAfterContext) {
* // ...
* }
* }
* ```
*/
export const OnLifecycleEvent =
(eventName: LifecycleEvent): MethodDecorator =>
(prototype, propertyKey, descriptor) => {
const handlerClass = prototype.constructor as LifecycleHandlerClass;
const methodName = String(propertyKey);
if (typeof descriptor?.value !== 'function') {
throw new NonMethodError(`${handlerClass.name}.${methodName}()`);
}
Container.get(LifecycleMetadata).register({
handlerClass,
methodName,
eventName,
});
};
+16
View File
@@ -0,0 +1,16 @@
export * from './controller';
export * from './command';
export { Debounce } from './debounce';
export * from './execution-lifecycle';
export { Memoized } from './memoized';
export * from './auth-handler';
export * from './context-establishment';
export * from './credential-resolver';
export * from './module';
export * from './multi-main';
export * from './pubsub';
export { Redactable } from './redactable';
export * from './shutdown';
export * from './module/module-metadata';
export type { TimedOptions } from './timed';
export { Timed } from './timed';
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert';
/**
* A decorator that implements memoization for class property getters.
*
* The decorated getter will only be executed once and its value cached for subsequent access
*
* @example
* class Example {
* @Memoized
* get computedValue() {
* // This will only run once and the result will be cached
* return heavyComputation();
* }
* }
*
* @throws If decorator is used on something other than a getter
*/
export function Memoized<T = unknown>(
target: object,
propertyKey: string | symbol,
descriptor?: TypedPropertyDescriptor<T>,
): TypedPropertyDescriptor<T> {
const originalGetter = descriptor?.get;
assert(originalGetter, '@Memoized can only be used on getters');
// Replace the original getter for the first call
descriptor.get = function (this: typeof target.constructor): T {
const value = originalGetter.call(this);
// Add a property on the class instance to stop reading from the getter on class prototype
Object.defineProperty(this, propertyKey, {
value,
configurable: false,
enumerable: false,
writable: false,
});
return value;
};
return descriptor;
}
@@ -0,0 +1,67 @@
import { Container } from '@n8n/di';
import type { ModuleInterface } from '../module';
import { BackendModule } from '../module';
import { ModuleMetadata } from '../module-metadata';
describe('@BackendModule decorator', () => {
let moduleMetadata: ModuleMetadata;
beforeEach(() => {
vi.resetAllMocks();
moduleMetadata = new ModuleMetadata();
Container.set(ModuleMetadata, moduleMetadata);
});
it('should register module in ModuleMetadata', () => {
@BackendModule({ name: 'test' })
class TestModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getClasses();
expect(registeredModules).toContain(TestModule);
expect(registeredModules).toHaveLength(1);
});
it('should register multiple modules', () => {
@BackendModule({ name: 'test-1' })
class FirstModule implements ModuleInterface {}
@BackendModule({ name: 'test-2' })
class SecondModule implements ModuleInterface {}
@BackendModule({ name: 'test-3' })
class ThirdModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getClasses();
expect(registeredModules).toContain(FirstModule);
expect(registeredModules).toContain(SecondModule);
expect(registeredModules).toContain(ThirdModule);
expect(registeredModules).toHaveLength(3);
});
it('should apply Service decorator', () => {
@BackendModule({ name: 'test' })
class TestModule implements ModuleInterface {}
expect(Container.has(TestModule)).toBe(true);
});
it('stores the test name and licenseFlag flag in the metadata', () => {
const name = 'test';
const licenseFlag = 'feat:ldap';
@BackendModule({ name, licenseFlag })
class TestModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getEntries();
expect(registeredModules).toHaveLength(1);
const [moduleName, options] = registeredModules[0];
expect(moduleName).toBe(name);
expect(options.licenseFlag).toBe(licenseFlag);
expect(options.class).toBe(TestModule);
});
});
@@ -0,0 +1,3 @@
export type { ModuleInterface, EntityClass, ModuleSettings, ModuleContext } from './module';
export { BackendModule } from './module';
export { ModuleMetadata } from './module-metadata';
@@ -0,0 +1,35 @@
import type { InstanceType } from '@n8n/constants';
import { Service } from '@n8n/di';
import type { LicenseFlag, ModuleClass } from './module';
/**
* Internal representation of a registered module.
* For field descriptions, see {@link BackendModuleOptions}.
*/
type ModuleEntry = {
class: ModuleClass;
licenseFlag?: LicenseFlag | LicenseFlag[];
instanceTypes?: InstanceType[];
};
@Service()
export class ModuleMetadata {
private readonly modules: Map<string, ModuleEntry> = new Map();
register(moduleName: string, moduleEntry: ModuleEntry) {
this.modules.set(moduleName, moduleEntry);
}
get(moduleName: string) {
return this.modules.get(moduleName);
}
getEntries() {
return [...this.modules.entries()];
}
getClasses() {
return [...this.modules.values()].map((entry) => entry.class);
}
}
@@ -0,0 +1,118 @@
import type { LICENSE_FEATURES, InstanceType } from '@n8n/constants';
import { Container, Service, type Constructable } from '@n8n/di';
import { ModuleMetadata } from './module-metadata';
/**
* Structurally similar (not identical) interface to typeorm's `BaseEntity`
* to prevent importing `@n8n/typeorm` into `@n8n/decorators`.
*/
export interface BaseEntity {
hasId(): boolean;
save(options?: unknown): Promise<this>;
remove(options?: unknown): Promise<this>;
softRemove(options?: unknown): Promise<this>;
recover(options?: unknown): Promise<this>;
reload(): Promise<void>;
}
export interface TimestampedIdEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
export interface TimestampedEntity {
createdAt: Date;
updatedAt: Date;
}
export type EntityClass = new () => BaseEntity | TimestampedIdEntity | TimestampedEntity;
export type ModuleSettings = Record<string, unknown>;
export type ModuleContext = Record<string, unknown>;
export interface ModuleInterface {
init?(): Promise<void>;
shutdown?(): Promise<void>;
commands?(): Promise<void>;
/**
* Return a list of entities to register with the typeorm database connection.
*
* @example [ InsightsByPeriod, InsightsMetadata, InsightsRaw ]
*/
entities?(): Promise<EntityClass[]>;
/**
* Return an object with settings to send to the client via `/module-settings`.
*
* @example { summary: true, dashboard: false }
*/
settings?(): Promise<ModuleSettings>;
/**
* Return an object to merge into workflow context, a.k.a. `WorkflowExecuteAdditionalData`.
* This object will be namespaced under the module name set by `@BackendModule('name')`.
*
* @example
* ```ts
* // at Module.context()
* { proxy: Container.get(InsightsProxyService) }
*
* // at callsite
* additionalData.insights.proxy.method()
* ```
*
* For type safety, add the module context to `IWorkflowExecuteAdditionalData`.
*
* ```ts
* export interface IWorkflowExecuteAdditionalData {
* insights?: {
* proxy: { method: () => void };
* };
* }
* ```
*/
context?(): Promise<ModuleContext>;
/**
* Return a path to a dir to load nodes and credentials from.
*
* @returns Path to a dir to load nodes and credentials from. `null` to skip.
* @example '/Users/nathan/.n8n/nodes/node_modules'
*/
loadDir?(): Promise<string | null>;
}
export type ModuleClass = Constructable<ModuleInterface>;
export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES];
export type BackendModuleOptions = {
/** Canonical name of the backend module. Use kebab-case.*/
name: string;
/**
* If present, initialize the module only if the instance has access to a licensed feature.
* Multiple license flags use `OR` logic, i.e. at least one must be licensed.
*/
licenseFlag?: LicenseFlag | LicenseFlag[];
/** If present, initialize the module only if the instance type is one of the specified types. */
instanceTypes?: InstanceType[];
};
export const BackendModule =
(opts: BackendModuleOptions): ClassDecorator =>
(target) => {
Container.get(ModuleMetadata).register(opts.name, {
class: target as unknown as ModuleClass,
licenseFlag: opts?.licenseFlag,
instanceTypes: opts?.instanceTypes,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};
@@ -0,0 +1,196 @@
import { Container, Service } from '@n8n/di';
import { EventEmitter } from 'node:events';
import { NonMethodError } from '../../errors';
import {
MultiMainMetadata,
LEADER_TAKEOVER_EVENT_NAME,
LEADER_STEPDOWN_EVENT_NAME,
} from '../multi-main-metadata';
import { OnLeaderStepdown, OnLeaderTakeover } from '../on-multi-main-event';
class MockMultiMainSetup extends EventEmitter {
registerEventHandlers() {
const handlers = Container.get(MultiMainMetadata).getHandlers();
for (const { eventHandlerClass, methodName, eventName } of handlers) {
const instance = Container.get(eventHandlerClass);
this.on(eventName, async () => {
return await instance[methodName].call(instance);
});
}
}
}
let multiMainSetup: MockMultiMainSetup;
let metadata: MultiMainMetadata;
beforeEach(() => {
Container.reset();
metadata = new MultiMainMetadata();
Container.set(MultiMainMetadata, metadata);
multiMainSetup = new MockMultiMainSetup();
});
it('should register methods decorated with @OnLeaderTakeover', () => {
vi.spyOn(metadata, 'register');
@Service()
class TestService {
@OnLeaderTakeover()
async handleLeaderTakeover() {}
}
expect(metadata.register).toHaveBeenCalledWith({
eventName: LEADER_TAKEOVER_EVENT_NAME,
methodName: 'handleLeaderTakeover',
eventHandlerClass: TestService,
});
});
it('should register methods decorated with @OnLeaderStepdown', () => {
vi.spyOn(metadata, 'register');
@Service()
class TestService {
@OnLeaderStepdown()
async handleLeaderStepdown() {}
}
expect(metadata.register).toHaveBeenCalledTimes(1);
expect(metadata.register).toHaveBeenCalledWith({
eventName: LEADER_STEPDOWN_EVENT_NAME,
methodName: 'handleLeaderStepdown',
eventHandlerClass: TestService,
});
});
it('should throw an error if the decorated target is not a method', () => {
expect(() => {
@Service()
class TestService {
// @ts-expect-error Testing invalid code
@OnLeaderTakeover()
notAFunction = 'string';
}
new TestService();
}).toThrowError(NonMethodError);
});
it('should call decorated methods when events are emitted', async () => {
@Service()
class TestService {
takeoverCalled = false;
stepdownCalled = false;
@OnLeaderTakeover()
async handleLeaderTakeover() {
this.takeoverCalled = true;
}
@OnLeaderStepdown()
async handleLeaderStepdown() {
this.stepdownCalled = true;
}
}
const testService = Container.get(TestService);
vi.spyOn(testService, 'handleLeaderTakeover');
vi.spyOn(testService, 'handleLeaderStepdown');
multiMainSetup.registerEventHandlers();
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
multiMainSetup.emit(LEADER_STEPDOWN_EVENT_NAME);
expect(testService.handleLeaderTakeover).toHaveBeenCalledTimes(1);
expect(testService.handleLeaderStepdown).toHaveBeenCalledTimes(1);
expect(testService.takeoverCalled).toBe(true);
expect(testService.stepdownCalled).toBe(true);
});
it('should register multiple handlers for the same event', async () => {
@Service()
class TestService {
firstHandlerCalled = false;
secondHandlerCalled = false;
@OnLeaderTakeover()
async firstHandler() {
this.firstHandlerCalled = true;
}
@OnLeaderTakeover()
async secondHandler() {
this.secondHandlerCalled = true;
}
}
const testService = Container.get(TestService);
multiMainSetup.registerEventHandlers();
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
expect(testService.firstHandlerCalled).toBe(true);
expect(testService.secondHandlerCalled).toBe(true);
});
it('should register handlers from multiple service classes', async () => {
@Service()
class FirstService {
handlerCalled = false;
@OnLeaderTakeover()
async handleTakeover() {
this.handlerCalled = true;
}
}
@Service()
class SecondService {
handlerCalled = false;
@OnLeaderTakeover()
async handleTakeover() {
this.handlerCalled = true;
}
}
const firstService = Container.get(FirstService);
const secondService = Container.get(SecondService);
multiMainSetup.registerEventHandlers();
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
expect(firstService.handlerCalled).toBe(true);
expect(secondService.handlerCalled).toBe(true);
});
it('should handle async methods correctly', async () => {
@Service()
class TestService {
result = '';
@OnLeaderTakeover()
async handleLeaderTakeover() {
await new Promise((resolve) => setTimeout(resolve, 10));
this.result = 'completed';
}
}
const testService = Container.get(TestService);
multiMainSetup.registerEventHandlers();
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(testService.result).toBe('completed');
});
@@ -0,0 +1,2 @@
export { MultiMainMetadata } from './multi-main-metadata';
export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event';
@@ -0,0 +1,23 @@
import { Service } from '@n8n/di';
import type { EventHandler } from '../types';
export const LEADER_TAKEOVER_EVENT_NAME = 'leader-takeover';
export const LEADER_STEPDOWN_EVENT_NAME = 'leader-stepdown';
export type MultiMainEvent = typeof LEADER_TAKEOVER_EVENT_NAME | typeof LEADER_STEPDOWN_EVENT_NAME;
type MultiMainEventHandler = EventHandler<MultiMainEvent>;
@Service()
export class MultiMainMetadata {
private readonly handlers: MultiMainEventHandler[] = [];
register(handler: MultiMainEventHandler) {
this.handlers.push(handler);
}
getHandlers(): MultiMainEventHandler[] {
return this.handlers;
}
}
@@ -0,0 +1,61 @@
import { Container } from '@n8n/di';
import type { MultiMainEvent } from './multi-main-metadata';
import {
LEADER_TAKEOVER_EVENT_NAME,
LEADER_STEPDOWN_EVENT_NAME,
MultiMainMetadata,
} from './multi-main-metadata';
import { NonMethodError } from '../errors';
import type { EventHandlerClass } from '../types';
const OnMultiMainEvent =
(eventName: MultiMainEvent): MethodDecorator =>
(prototype, propertyKey, descriptor) => {
const eventHandlerClass = prototype.constructor as EventHandlerClass;
const methodName = String(propertyKey);
if (typeof descriptor?.value !== 'function') {
throw new NonMethodError(`${eventHandlerClass.name}.${methodName}()`);
}
Container.get(MultiMainMetadata).register({
eventHandlerClass,
methodName,
eventName,
});
};
/**
* Decorator that registers a method to be called when this main instance becomes the leader.
*
* @example
*
* ```ts
* @Service()
* class MyService {
* @OnLeaderTakeover()
* async startDoingThings() {
* // ...
* }
* }
* ```
*/
export const OnLeaderTakeover = () => OnMultiMainEvent(LEADER_TAKEOVER_EVENT_NAME);
/**
* Decorator that registers a method to be called when this main instance stops being the leader.
*
* @example
*
* ```ts
* @Service()
* class MyService {
* @OnLeaderStepdown()
* async stopDoingThings() {
* // ...
* }
* }
* ```
*/
export const OnLeaderStepdown = () => OnMultiMainEvent(LEADER_STEPDOWN_EVENT_NAME);
@@ -0,0 +1,55 @@
import { Container, Service } from '@n8n/di';
import { NonMethodError } from '../../errors';
import { OnPubSubEvent } from '../on-pubsub-event';
import { PubSubMetadata } from '../pubsub-metadata';
describe('@OnPubSubEvent', () => {
let metadata: PubSubMetadata;
beforeEach(() => {
Container.reset();
metadata = new PubSubMetadata();
Container.set(PubSubMetadata, metadata);
});
it('should register methods decorated with @OnPubSubEvent', () => {
vi.spyOn(metadata, 'register');
@Service()
class TestService {
@OnPubSubEvent('reload-external-secrets-providers')
async reloadProviders() {}
@OnPubSubEvent('restart-event-bus', { instanceType: 'worker' })
async restartEventBus() {}
}
expect(metadata.register).toHaveBeenNthCalledWith(1, {
eventName: 'reload-external-secrets-providers',
methodName: 'reloadProviders',
eventHandlerClass: TestService,
});
expect(metadata.register).toHaveBeenNthCalledWith(2, {
eventName: 'restart-event-bus',
methodName: 'restartEventBus',
eventHandlerClass: TestService,
filter: { instanceType: 'worker' },
});
});
it('should throw an error if the decorated target is not a method', () => {
expect(() => {
@Service()
class TestService {
// @ts-expect-error Testing invalid code
@OnPubSubEvent('reload-external-secrets-providers')
notAFunction = 'string';
}
new TestService();
}).toThrowError(NonMethodError);
});
});
@@ -0,0 +1,3 @@
export type { PubSubEventName } from './pubsub-metadata';
export { PubSubMetadata } from './pubsub-metadata';
export { OnPubSubEvent } from './on-pubsub-event';
@@ -0,0 +1,43 @@
import { Container } from '@n8n/di';
import { PubSubMetadata } from './pubsub-metadata';
import type { PubSubEventName, PubSubEventFilter } from './pubsub-metadata';
import { NonMethodError } from '../errors';
import type { EventHandlerClass } from '../types';
/**
* Decorator that registers a method to be called when a specific PubSub event occurs.
* Optionally filters event handling based on instance type and role.
*
* @param eventName - The PubSub event to listen for
* @param filter - Optional filter to limit event handling to specific instance types or roles
*
* @example
*
* ```ts
* @Service()
* class MyService {
* @OnPubSubEvent('community-package-install', { instanceType: 'main', instanceRole: 'leader' })
* async handlePackageInstall() {
* // Handle community package installation
* }
* }
* ```
*/
export const OnPubSubEvent =
(eventName: PubSubEventName, filter?: PubSubEventFilter): MethodDecorator =>
(prototype, propertyKey, descriptor) => {
const eventHandlerClass = prototype.constructor as EventHandlerClass;
const methodName = String(propertyKey);
if (typeof descriptor?.value !== 'function') {
throw new NonMethodError(`${eventHandlerClass.name}.${methodName}()`);
}
Container.get(PubSubMetadata).register({
eventHandlerClass,
methodName,
eventName,
filter,
});
};
@@ -0,0 +1,55 @@
import type { InstanceRole, InstanceType } from '@n8n/constants';
import { Service } from '@n8n/di';
import type { EventHandler } from '../types';
export type PubSubEventName =
| 'add-webhooks-triggers-and-pollers'
| 'remove-triggers-and-pollers'
| 'clear-test-webhooks'
| 'display-workflow-activation'
| 'display-workflow-deactivation'
| 'display-workflow-activation-error'
| 'community-package-install'
| 'community-package-uninstall'
| 'community-package-update'
| 'get-worker-status'
| 'reload-external-secrets-providers'
| 'reload-license'
| 'reload-oidc-config'
| 'reload-saml-config'
| 'reload-overwrite-credentials'
| 'response-to-get-worker-status'
| 'restart-event-bus'
| 'relay-execution-lifecycle-event'
| 'relay-chat-stream-event'
| 'relay-chat-human-message'
| 'relay-chat-message-edit'
| 'reload-sso-provisioning-configuration'
| 'reload-source-control-config'
| 'cancel-test-run';
export type PubSubEventFilter =
| {
instanceType: 'main';
instanceRole?: Omit<InstanceRole, 'unset'>;
}
| {
instanceType: Omit<InstanceType, 'main'>;
instanceRole?: never;
};
type PubSubEventHandler = EventHandler<PubSubEventName> & { filter?: PubSubEventFilter };
@Service()
export class PubSubMetadata {
private readonly handlers: PubSubEventHandler[] = [];
register(handler: PubSubEventHandler) {
this.handlers.push(handler);
}
getHandlers(): PubSubEventHandler[] {
return this.handlers;
}
}
@@ -0,0 +1,68 @@
import { UnexpectedError } from 'n8n-workflow';
type UserLike = {
id: string;
email?: string;
firstName?: string;
lastName?: string;
role?: {
slug: string;
};
};
export class RedactableError extends UnexpectedError {
constructor(fieldName: string, args: string) {
super(
`Failed to find "${fieldName}" property in argument "${args.toString()}". Please set the decorator \`@Redactable()\` only on \`LogStreamingEventRelay\` methods where the argument contains a "${fieldName}" property.`,
);
}
}
function toRedactable(userLike: UserLike) {
return {
userId: userLike.id,
_email: userLike.email,
_firstName: userLike.firstName,
_lastName: userLike.lastName,
globalRole: userLike.role?.slug,
};
}
type FieldName = 'user' | 'inviter' | 'invitee';
/**
* Mark redactable properties in a `{ user: UserLike }` field in an `LogStreamingEventRelay`
* method arg. These properties will be later redacted by the log streaming
* destination based on user prefs. Only for `n8n.audit.*` logs.
*
* Also transform `id` to `userId` and `role` to `globalRole`.
*
* @example
*
* { id: '123'; email: 'test@example.com', role: 'some-role' } ->
* { userId: '123'; _email: 'test@example.com', globalRole: 'some-role' }
*/
export const Redactable =
(fieldName: FieldName = 'user'): MethodDecorator =>
(_target, _propertyName, propertyDescriptor: PropertyDescriptor) => {
// eslint-disable-next-line @typescript-eslint/no-restricted-types
const originalMethod = propertyDescriptor.value as Function;
type MethodArgs = Array<{ [fieldName: string]: UserLike }>;
propertyDescriptor.value = function (...args: MethodArgs) {
const index = args.findIndex((arg) => arg[fieldName] !== undefined);
if (index === -1) throw new RedactableError(fieldName, args.toString());
const userLike = args[index]?.[fieldName];
// @ts-expect-error Transformation
if (userLike) args[index][fieldName] = toRedactable(userLike);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return originalMethod.apply(this, args);
};
return propertyDescriptor;
};
@@ -0,0 +1,98 @@
import { Container, Service } from '@n8n/di';
import { OnShutdown } from '../on-shutdown';
import { ShutdownMetadata } from '../shutdown-metadata';
describe('OnShutdown', () => {
let shutdownMetadata: ShutdownMetadata;
beforeEach(() => {
shutdownMetadata = new ShutdownMetadata();
Container.set(ShutdownMetadata, shutdownMetadata);
vi.spyOn(shutdownMetadata, 'register');
});
it('should register a methods that is decorated with OnShutdown', () => {
@Service()
class TestClass {
@OnShutdown()
async onShutdown() {}
}
expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'onShutdown',
serviceClass: TestClass,
});
});
it('should register multiple methods in the same class', () => {
@Service()
class TestClass {
@OnShutdown()
async one() {}
@OnShutdown()
async two() {}
}
expect(shutdownMetadata.register).toHaveBeenCalledTimes(2);
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'one',
serviceClass: TestClass,
});
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
methodName: 'two',
serviceClass: TestClass,
});
});
it('should use the given priority', () => {
// @ts-expect-error We are checking the decorator.
class TestClass {
@OnShutdown(10)
async onShutdown() {
// Will be called when the app is shutting down
}
}
expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
// @ts-expect-error We are checking internal parts of the shutdown service
expect(shutdownMetadata.handlersByPriority[10].length).toEqual(1);
});
it('should throw an error if the decorated member is not a function', () => {
expect(() => {
@Service()
class TestClass {
// @ts-expect-error Testing invalid code
@OnShutdown()
onShutdown = 'not a function';
}
new TestClass();
}).toThrow('TestClass.onShutdown() must be a method on TestClass to use "OnShutdown"');
});
it('should throw if the priority is invalid', () => {
expect(() => {
@Service()
class TestClass {
@OnShutdown(201)
async onShutdown() {}
}
new TestClass();
}).toThrow('Invalid shutdown priority. Please set it between 0 and 200.');
expect(() => {
@Service()
class TestClass {
@OnShutdown(-1)
async onShutdown() {}
}
new TestClass();
}).toThrow('Invalid shutdown priority. Please set it between 0 and 200.');
});
});
@@ -0,0 +1,3 @@
export const LOWEST_SHUTDOWN_PRIORITY = 0;
export const DEFAULT_SHUTDOWN_PRIORITY = 100;
export const HIGHEST_SHUTDOWN_PRIORITY = 200;
@@ -0,0 +1,8 @@
export {
HIGHEST_SHUTDOWN_PRIORITY,
DEFAULT_SHUTDOWN_PRIORITY,
LOWEST_SHUTDOWN_PRIORITY,
} from './constants';
export { ShutdownMetadata } from './shutdown-metadata';
export { OnShutdown } from './on-shutdown';
export type { ShutdownHandler, ShutdownServiceClass } from './types';
@@ -0,0 +1,41 @@
import { Container } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import { DEFAULT_SHUTDOWN_PRIORITY } from './constants';
import { ShutdownMetadata } from './shutdown-metadata';
import type { ShutdownServiceClass } from './types';
/**
* Decorator that registers a method as a shutdown hook. The method will
* be called when the application is shutting down.
*
* Priority is used to determine the order in which the hooks are called.
*
* NOTE: Requires also @Service() decorator to be used on the class.
*
* @example
* ```ts
* @Service()
* class MyClass {
* @OnShutdown()
* async shutdown() {
* // Will be called when the app is shutting down
* }
* }
* ```
*/
export const OnShutdown =
(priority = DEFAULT_SHUTDOWN_PRIORITY): MethodDecorator =>
(prototype, propertyKey, descriptor) => {
const serviceClass = prototype.constructor as ShutdownServiceClass;
const methodName = String(propertyKey);
// TODO: assert that serviceClass is decorated with @Service
if (typeof descriptor?.value === 'function') {
Container.get(ShutdownMetadata).register(priority, { serviceClass, methodName });
} else {
const name = `${serviceClass.name}.${methodName}()`;
throw new UnexpectedError(
`${name} must be a method on ${serviceClass.name} to use "OnShutdown"`,
);
}
};
@@ -0,0 +1,31 @@
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { HIGHEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY } from './constants';
import type { ShutdownHandler } from './types';
@Service()
export class ShutdownMetadata {
private handlersByPriority: ShutdownHandler[][] = [];
register(priority: number, handler: ShutdownHandler) {
if (priority < LOWEST_SHUTDOWN_PRIORITY || priority > HIGHEST_SHUTDOWN_PRIORITY) {
throw new UserError(
`Invalid shutdown priority. Please set it between ${LOWEST_SHUTDOWN_PRIORITY} and ${HIGHEST_SHUTDOWN_PRIORITY}.`,
{ extra: { priority } },
);
}
if (!this.handlersByPriority[priority]) this.handlersByPriority[priority] = [];
this.handlersByPriority[priority].push(handler);
}
getHandlersByPriority(): ShutdownHandler[][] {
return this.handlersByPriority;
}
clear() {
this.handlersByPriority = [];
}
}
@@ -0,0 +1,9 @@
import type { Class } from '../types';
type ShutdownHandlerFn = () => Promise<void> | void;
export type ShutdownServiceClass = Class<Record<string, ShutdownHandlerFn>>;
export interface ShutdownHandler {
serviceClass: ShutdownServiceClass;
methodName: string;
}
+42
View File
@@ -0,0 +1,42 @@
export interface TimedOptions {
/** Duration (in ms) above which to log a warning. Defaults to `100`. */
threshold?: number;
/** Whether to include method parameters in the log. Defaults to `false`. */
logArgs?: boolean;
}
interface Logger {
warn(message: string, meta?: object): void;
}
/**
* Factory to create decorators to warn when method calls exceed a duration threshold.
*/
export const Timed =
(logger: Logger, msg = 'Slow method call') =>
(options: TimedOptions = {}): MethodDecorator =>
(_target, propertyKey, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;
const thresholdMs = options.threshold ?? 100;
const logArgs = options.logArgs ?? false;
descriptor.value = async function (...args: unknown[]) {
const methodName = `${this.constructor.name}.${String(propertyKey)}`;
const start = performance.now();
const result = await originalMethod.apply(this, args);
const durationMs = performance.now() - start;
if (durationMs > thresholdMs) {
logger.warn(msg, {
method: methodName,
durationMs: Math.round(durationMs),
thresholdMs,
params: logArgs ? args : '[hidden]',
});
}
return result;
};
return descriptor;
};
+14
View File
@@ -0,0 +1,14 @@
export type Class<T = object, A extends unknown[] = unknown[]> = new (...args: A) => T;
type EventHandlerFn = () => Promise<void> | void;
export type EventHandlerClass = Class<Record<string, EventHandlerFn>>;
export type EventHandler<T extends string> = {
/** Class holding the method to call on an event. */
eventHandlerClass: EventHandlerClass;
/** Name of the method to call on an event. */
methodName: string;
/** Name of the event to listen to. */
eventName: T;
};
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "vitest/globals"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../workflow/tsconfig.build.esm.json" },
{ "path": "../constants/tsconfig.build.json" },
{ "path": "../di/tsconfig.build.json" },
{ "path": "../permissions/tsconfig.build.json" }
]
}
@@ -0,0 +1,5 @@
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
export default createVitestConfigWithDecorators({
coveragePathIgnorePatterns: ['index.ts'],
});