mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(ai-builder): Refresh license in sdk when renewed (#22969)
This commit is contained in:
@@ -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>({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+12
-12
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user