fix(ai-builder): Refresh license in sdk when renewed (#22969)

This commit is contained in:
Mutasem Aldmour
2025-12-09 16:51:45 +01:00
committed by GitHub
parent 87e7f60f45
commit 776152dbe3
8 changed files with 279 additions and 17 deletions
@@ -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<InstanceSettings>({
instanceId: 'test-instance',
instanceType: 'main',
isLeader: true,
});
beforeEach(async () => {
jest.restoreAllMocks();
const globalConfig = mock<GlobalConfig>({
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>();
settingsRepository.findOne.mockResolvedValue({ value: 'test-cert-value' } as any);
const globalConfig = mock<GlobalConfig>({
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<GlobalConfig>({
+32
View File
@@ -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<void> {
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<void> {
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');
}
@@ -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<AiWorkflowBuilderService>();
(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<AiWorkflowBuilderService>();
(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<AiWorkflowBuilderService>();
(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 = {
@@ -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<AiAskRequestDto>();
@@ -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<AiWorkflowBuilderService> {
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(),
+5
View File
@@ -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) {
+12 -12
View File
@@ -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:
+1 -1
View File
@@ -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