diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index 3404ac7b843..62687c81389 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -1,5 +1,6 @@ import { mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; +import type { SettingsRepository } from '@n8n/db'; import { LicenseManager } from '@n8n_io/license-sdk'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; @@ -282,6 +283,97 @@ describe('License', () => { }); describe('License', () => { + describe('onCertRefresh', () => { + let license: License; + const instanceSettings = mock({ + instanceId: 'test-instance', + instanceType: 'main', + isLeader: true, + }); + + beforeEach(async () => { + jest.restoreAllMocks(); + const globalConfig = mock({ + license: licenseConfig, + multiMainSetup: { enabled: false }, + }); + license = new License(mockLogger(), instanceSettings, mock(), mock(), globalConfig); + await license.init(); + }); + + it('should register callback and call it on license reload', async () => { + const callback = jest.fn(); + license.onCertRefresh(callback); + + await license.reload(); + + expect(callback).toHaveBeenCalledWith(''); + }); + + it('should call multiple registered callbacks', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + license.onCertRefresh(callback1); + license.onCertRefresh(callback2); + + await license.reload(); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should return unsubscribe function that removes callback', async () => { + const callback = jest.fn(); + const unsubscribe = license.onCertRefresh(callback); + + unsubscribe(); + await license.reload(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should continue calling other callbacks if one throws', async () => { + const errorCallback = jest.fn().mockImplementation(() => { + throw new Error('Callback error'); + }); + const callback2 = jest.fn(); + + license.onCertRefresh(errorCallback); + license.onCertRefresh(callback2); + + await license.reload(); + + expect(errorCallback).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + }); + + it('should pass the loaded certificate to callbacks', async () => { + const settingsRepository = mock(); + settingsRepository.findOne.mockResolvedValue({ value: 'test-cert-value' } as any); + + const globalConfig = mock({ + license: licenseConfig, + multiMainSetup: { enabled: false }, + }); + license = new License( + mockLogger(), + instanceSettings, + settingsRepository, + mock(), + globalConfig, + ); + await license.init(); + + const callback = jest.fn(); + license.onCertRefresh(callback); + + await license.reload(); + + expect(callback).toHaveBeenCalledWith('test-cert-value'); + }); + }); + describe('init', () => { it('when leader main with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => { const globalConfig = mock({ diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index d6cbc91aaff..caa9baf612d 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -30,12 +30,16 @@ export type FeatureReturnType = Partial< } & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean } >; +type LicenseRefreshCallback = (cert: string) => void; + @Service() export class License implements LicenseProvider { private manager: LicenseManager | undefined; private isShuttingDown = false; + private refreshCallbacks: LicenseRefreshCallback[] = []; + constructor( private readonly logger: Logger, private readonly instanceSettings: InstanceSettings, @@ -141,10 +145,12 @@ export class License implements LicenseProvider { private async onFeatureChange() { void this.broadcastReloadLicenseCommand(); + await this.notifyRefreshCallbacks(); } private async onLicenseRenewed() { void this.broadcastReloadLicenseCommand(); + await this.notifyRefreshCallbacks(); } private async broadcastReloadLicenseCommand() { @@ -167,6 +173,31 @@ export class License implements LicenseProvider { ); } + /** + * Register a callback to be notified when license certificate is refreshed. + * Returns an unsubscribe function. + */ + onCertRefresh(refreshCallback: LicenseRefreshCallback): () => void { + this.refreshCallbacks.push(refreshCallback); + return () => { + const index = this.refreshCallbacks.indexOf(refreshCallback); + if (index > -1) { + this.refreshCallbacks.splice(index, 1); + } + }; + } + + private async notifyRefreshCallbacks(): Promise { + const cert = await this.loadCertStr(); + for (const refreshCallback of this.refreshCallbacks) { + try { + refreshCallback(cert); + } catch (error) { + this.logger.error('Error in license refresh callback', { error }); + } + } + } + async activate(activationKey: string, eulaUri?: string): Promise { if (!this.manager) { return; @@ -182,6 +213,7 @@ export class License implements LicenseProvider { return; } await this.manager.reload(); + await this.notifyRefreshCallbacks(); this.logger.debug('License reloaded'); } diff --git a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts index c3e6824bf35..2d6680bde88 100644 --- a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts @@ -508,6 +508,92 @@ describe('WorkflowBuilderService', () => { }); }); + describe('license certificate refresh', () => { + it('should register for license certificate updates when client is created', async () => { + mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com'; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(mockLicense.onCertRefresh).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update client license cert when callback is invoked', async () => { + mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com'; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + // Capture the callback passed to onCertRefresh + let capturedCallback: ((cert: string) => void) | undefined; + (mockLicense.onCertRefresh as jest.Mock).mockImplementation((cb: (cert: string) => void) => { + capturedCallback = cb; + return () => {}; + }); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(capturedCallback).toBeDefined(); + + // Get the mocked client instance + const mockClientInstance = MockedAiAssistantClient.mock.instances[0]; + + // Invoke the callback with a new cert + capturedCallback!('new-cert-value'); + + expect(mockClientInstance.updateLicenseCert).toHaveBeenCalledWith('new-cert-value'); + }); + + it('should not register for license updates when no baseUrl is configured', async () => { + mockConfig.aiAssistant.baseUrl = ''; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(mockLicense.onCertRefresh).not.toHaveBeenCalled(); + }); + }); + describe('getBuilderInstanceCredits', () => { it('should return builder instance credits', async () => { const expectedCredits = { diff --git a/packages/cli/src/services/__tests__/ai.service.test.ts b/packages/cli/src/services/__tests__/ai.service.test.ts index dbdcaa3e718..45dafc8debb 100644 --- a/packages/cli/src/services/__tests__/ai.service.test.ts +++ b/packages/cli/src/services/__tests__/ai.service.test.ts @@ -109,6 +109,48 @@ describe('AiService', () => { }); }); + describe('license certificate refresh', () => { + it('should register for license certificate updates on init', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + await aiService.init(); + + expect(license.onCertRefresh).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update client license cert when callback is invoked', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + // Capture the callback passed to onCertRefresh + let capturedCallback: ((cert: string) => void) | undefined; + license.onCertRefresh.mockImplementation((cb: (cert: string) => void) => { + capturedCallback = cb; + return () => {}; + }); + + await aiService.init(); + + expect(capturedCallback).toBeDefined(); + + // Invoke the callback with a new cert + capturedCallback!('new-cert-value'); + + expect(client.updateLicenseCert).toHaveBeenCalledWith('new-cert-value'); + }); + + it('should not register for license updates when AI assistant is disabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await aiService.init(); + + expect(license.onCertRefresh).not.toHaveBeenCalled(); + }); + }); + describe('askAi', () => { const payload = mock(); diff --git a/packages/cli/src/services/ai-workflow-builder.service.ts b/packages/cli/src/services/ai-workflow-builder.service.ts index 356b66c979b..1deff55e2e5 100644 --- a/packages/cli/src/services/ai-workflow-builder.service.ts +++ b/packages/cli/src/services/ai-workflow-builder.service.ts @@ -23,6 +23,8 @@ import { Telemetry } from '@/telemetry'; export class WorkflowBuilderService { private service: AiWorkflowBuilderService | undefined; + private client: AiAssistantClient | undefined; + constructor( private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly license: License, @@ -36,20 +38,23 @@ export class WorkflowBuilderService { private async getService(): Promise { if (!this.service) { - let client: AiAssistantClient | undefined; - // Create AiAssistantClient if baseUrl is configured const baseUrl = this.config.aiAssistant.baseUrl; if (baseUrl) { const licenseCert = await this.license.loadCertStr(); const consumerId = this.license.getConsumerId(); - client = new AiAssistantClient({ + this.client = new AiAssistantClient({ licenseCert, consumerId, baseUrl, n8nVersion: N8N_VERSION, }); + + // Register for license certificate updates + this.license.onCertRefresh((cert) => { + this.client?.updateLicenseCert(cert); + }); } // Create callback that uses the push service @@ -75,7 +80,7 @@ export class WorkflowBuilderService { this.service = new AiWorkflowBuilderService( nodeTypeDescriptions, - client, + this.client, this.logger, this.instanceSettings.instanceId, this.urlService.getInstanceBaseUrl(), diff --git a/packages/cli/src/services/ai.service.ts b/packages/cli/src/services/ai.service.ts index 32c6c8b0f9f..b3d11cffaa0 100644 --- a/packages/cli/src/services/ai.service.ts +++ b/packages/cli/src/services/ai.service.ts @@ -39,6 +39,11 @@ export class AiService { baseUrl, logLevel, }); + + // Register for license certificate updates + this.licenseService.onCertRefresh((cert) => { + this.client?.updateLicenseCert(cert); + }); } async chat(payload: AiChatRequestDto, user: IUser) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af1898c3d36..77669f9878d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ catalogs: specifier: 0.3.20-15 version: 0.3.20-15 '@n8n_io/ai-assistant-sdk': - specifier: 1.18.0 - version: 1.18.0 + specifier: 1.19.1 + version: 1.19.1 '@sentry/node': specifier: ^9.42.1 version: 9.42.1 @@ -396,7 +396,7 @@ importers: version: link:../utils '@n8n_io/ai-assistant-sdk': specifier: 'catalog:' - version: 1.18.0 + version: 1.19.1 csv-parse: specifier: 5.5.0 version: 5.5.0 @@ -1534,7 +1534,7 @@ importers: version: link:../@n8n/utils '@n8n_io/ai-assistant-sdk': specifier: 'catalog:' - version: 1.18.0 + version: 1.19.1 '@n8n_io/license-sdk': specifier: 2.24.1 version: 2.24.1 @@ -6339,8 +6339,8 @@ packages: engines: {node: '>=18.10', pnpm: '>=9.6'} hasBin: true - '@n8n_io/ai-assistant-sdk@1.18.0': - resolution: {integrity: sha512-lWsGJ5AniMd/s6FqtQuCS5YJ/iWIA53GjdLieQWgpjwmc52zOAIhfpPGbI5tmm0kpvExYpGDvVRH8UzOHlt9QA==} + '@n8n_io/ai-assistant-sdk@1.19.1': + resolution: {integrity: sha512-oZZlXxyDOOPuHztrHUfoigZrhEY/iwSr9dH23j8jdzNTPiexRQlUly1kTE6v5DNLv9EwBVZ4e5FalOVUMtGzHg==} engines: {node: '>=20.15', pnpm: '>=8.14'} '@n8n_io/license-sdk@2.24.1': @@ -17915,8 +17915,8 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.1.5: - resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==} + vue-component-type-helpers@3.1.6: + resolution: {integrity: sha512-lqPXjac98uz7zh8fxHxJPJDP4SKeaYn2Fx4kQLCBthMXm9VsmPdzUWOGWMcXN7reJ9IkcBiOFxsFS/Po7dARiw==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -21101,7 +21101,7 @@ snapshots: sharp: 0.33.5 ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) zod: 3.25.67 - zod-to-json-schema: 3.24.6(zod@3.25.67) + zod-to-json-schema: 3.25.0(zod@3.25.67) transitivePeerDependencies: - bufferutil - encoding @@ -22728,7 +22728,7 @@ snapshots: acorn: 8.12.1 acorn-walk: 8.3.4 - '@n8n_io/ai-assistant-sdk@1.18.0': {} + '@n8n_io/ai-assistant-sdk@1.19.1': {} '@n8n_io/license-sdk@2.24.1': dependencies: @@ -24907,7 +24907,7 @@ snapshots: storybook: 9.1.7(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@5.0.10)(vite@7.0.0(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3)) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.2) - vue-component-type-helpers: 3.1.5 + vue-component-type-helpers: 3.1.6 '@stylistic/eslint-plugin@5.0.0(eslint@9.29.0(jiti@2.6.1))': dependencies: @@ -36924,7 +36924,7 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.1.5: {} + vue-component-type-helpers@3.1.6: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03a80a440e7..911d935926f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ catalog: '@langchain/core': 1.1.0 '@langchain/openai': 1.1.3 '@n8n/typeorm': 0.3.20-15 - '@n8n_io/ai-assistant-sdk': 1.18.0 + '@n8n_io/ai-assistant-sdk': 1.19.1 '@sentry/node': ^9.42.1 '@types/basic-auth': ^1.1.3 '@types/express': ^5.0.1