From 2f7a979fe42f7b1b1ee2587d2ede0bb5e3f302cd Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 2 Dec 2025 10:22:47 +0100 Subject: [PATCH] chore(core): Credential resolver interface and decorator (#22584) --- .../__tests__/credential-resolver.test.ts | 344 ++++++++++++++++++ .../credential-resolver-metadata.ts | 52 +++ .../credential-resolver.ts | 88 +++++ .../src/credential-resolver/errors.ts | 31 ++ .../src/credential-resolver/index.ts | 13 + packages/@n8n/decorators/src/index.ts | 1 + 6 files changed, 529 insertions(+) create mode 100644 packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts create mode 100644 packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts create mode 100644 packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts create mode 100644 packages/@n8n/decorators/src/credential-resolver/errors.ts create mode 100644 packages/@n8n/decorators/src/credential-resolver/index.ts diff --git a/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts b/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts new file mode 100644 index 00000000000..e876e945513 --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts @@ -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(() => { + jest.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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class SecondResolver implements ICredentialResolver { + metadata = { + name: 'second.resolver', + description: 'Second resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class ThirdResolver implements ICredentialResolver { + metadata = { + name: 'third.resolver', + description: 'Third resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class StubResolver implements ICredentialResolver { + metadata = { + name: 'stub.resolver', + description: 'Stub resolver for testing', + displayName: 'Stub Resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const registeredResolvers = resolverMetadata.getClasses(); + const oauthResolver = Container.get(OAuthResolver); + const stubResolver = Container.get(StubResolver); + + expect(registeredResolvers).toHaveLength(2); + expect(oauthResolver.metadata.name).toBe('oauth.resolver'); + expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver'); + expect(stubResolver.metadata.name).toBe('stub.resolver'); + expect(stubResolver.metadata.displayName).toBe('Stub 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + 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 { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async deleteSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const withDelete = Container.get(ResolverWithDelete); + + expect(withDelete.deleteSecret).toBeDefined(); + }); +}); diff --git a/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts b/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts new file mode 100644 index 00000000000..cd7c2e4704b --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts @@ -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 = 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 = + () => + (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); + }; diff --git a/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts b/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts new file mode 100644 index 00000000000..5da67969887 --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts @@ -0,0 +1,88 @@ +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; + +/** + * 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, + options: CredentialResolverConfiguration, + ): Promise; + + /** + * 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, + options: CredentialResolverConfiguration, + ): Promise; + + /** + * 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, + options: CredentialResolverConfiguration, + ): Promise; + + /** + * Validates resolver configuration before saving. + * Should verify connectivity, authentication, and configuration structure. + * @throws {CredentialResolverValidationError} When configuration is invalid + */ + validateOptions(options: CredentialResolverConfiguration): Promise; + + /** + * Runs initialization logic for the resolver. This might be called multiple times! + * Optional - not all resolvers require initialization. + */ + init?(): Promise; +} + +/** + * Type helper for credential resolver class constructors. + */ +export type CredentialResolverClass = Constructable; diff --git a/packages/@n8n/decorators/src/credential-resolver/errors.ts b/packages/@n8n/decorators/src/credential-resolver/errors.ts new file mode 100644 index 00000000000..e6f999012c0 --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/errors.ts @@ -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'; + } +} diff --git a/packages/@n8n/decorators/src/credential-resolver/index.ts b/packages/@n8n/decorators/src/credential-resolver/index.ts new file mode 100644 index 00000000000..d2f662ee01c --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/index.ts @@ -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'; diff --git a/packages/@n8n/decorators/src/index.ts b/packages/@n8n/decorators/src/index.ts index f872195d472..45fd624f944 100644 --- a/packages/@n8n/decorators/src/index.ts +++ b/packages/@n8n/decorators/src/index.ts @@ -4,6 +4,7 @@ export { Debounce } from './debounce'; export * from './execution-lifecycle'; export { Memoized } from './memoized'; export * from './context-establishment'; +export * from './credential-resolver'; export * from './module'; export * from './multi-main'; export * from './pubsub';