diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c3382e81e7..412f8a48f3b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,7 +109,7 @@ "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "catalog:", "@n8n_io/ai-assistant-sdk": "catalog:", - "@n8n_io/license-sdk": "2.23.0", + "@n8n_io/license-sdk": "2.24.1", "@rudderstack/rudder-sdk-node": "2.1.4", "@parcel/watcher": "^2.5.1", "@sentry/node": "catalog:", diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index c4170279ce3..3404ac7b843 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -103,7 +103,14 @@ describe('License', () => { test('attempts to activate license with provided key', async () => { await license.activate(MOCK_ACTIVATION_KEY); - expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY); + expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY, undefined); + }); + + test('attempts to activate license with eulaUri', async () => { + const eulaUri = 'https://n8n.io/legal/eula/'; + await license.activate(MOCK_ACTIVATION_KEY, eulaUri); + + expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY, eulaUri); }); test('renews license', async () => { diff --git a/packages/cli/src/__tests__/response-helper.test.ts b/packages/cli/src/__tests__/response-helper.test.ts new file mode 100644 index 00000000000..1981210c178 --- /dev/null +++ b/packages/cli/src/__tests__/response-helper.test.ts @@ -0,0 +1,53 @@ +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; + +import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; +import { sendErrorResponse } from '@/response-helper'; + +describe('sendErrorResponse', () => { + let mockResponse: Response; + + beforeEach(() => { + mockResponse = mock({ + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }); + }); + + it('should include meta field for LicenseEulaRequiredError', () => { + const eulaUrl = 'https://n8n.io/legal/eula/'; + const error = new LicenseEulaRequiredError('License activation requires EULA acceptance', { + eulaUrl, + }); + + sendErrorResponse(mockResponse, error); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 400, + message: 'License activation requires EULA acceptance', + meta: { eulaUrl }, + }), + ); + }); + + it('should not include meta field for regular errors', () => { + const error = new Error('Regular error'); + + sendErrorResponse(mockResponse, error); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 0, + message: 'Regular error', + }), + ); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.not.objectContaining({ + meta: expect.anything(), + }), + ); + }); +}); diff --git a/packages/cli/src/errors/response-errors/__tests__/license-eula-required.error.test.ts b/packages/cli/src/errors/response-errors/__tests__/license-eula-required.error.test.ts new file mode 100644 index 00000000000..e5201efd891 --- /dev/null +++ b/packages/cli/src/errors/response-errors/__tests__/license-eula-required.error.test.ts @@ -0,0 +1,23 @@ +import { LicenseEulaRequiredError } from '../license-eula-required.error'; + +describe('LicenseEulaRequiredError', () => { + it('should create error with correct message and meta', () => { + const eulaUrl = 'https://n8n.io/legal/eula/'; + const error = new LicenseEulaRequiredError('License activation requires EULA acceptance', { + eulaUrl, + }); + + expect(error.message).toBe('License activation requires EULA acceptance'); + expect(error.meta.eulaUrl).toBe(eulaUrl); + expect(error.httpStatusCode).toBe(400); + expect(error.name).toBe('LicenseEulaRequiredError'); + }); + + it('should be instance of Error', () => { + const error = new LicenseEulaRequiredError('Test message', { + eulaUrl: 'https://example.com', + }); + + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/packages/cli/src/errors/response-errors/abstract/response.error.ts b/packages/cli/src/errors/response-errors/abstract/response.error.ts index 3d96ff1f62b..4a8f604ce6f 100644 --- a/packages/cli/src/errors/response-errors/abstract/response.error.ts +++ b/packages/cli/src/errors/response-errors/abstract/response.error.ts @@ -4,6 +4,14 @@ import { BaseError } from 'n8n-workflow'; * Special Error which allows to return also an error code and http status code */ export abstract class ResponseError extends BaseError { + /** + * Optional metadata to be included in the error response. + * This allows errors to include additional structured data beyond the standard + * message, code, and hint fields. For example, LicenseEulaRequiredError uses + * this to include the EULA URL that must be accepted. + */ + readonly meta?: Record; + /** * Creates an instance of ResponseError. * Must be used inside a block with `ResponseHelper.send()`. diff --git a/packages/cli/src/errors/response-errors/license-eula-required.error.ts b/packages/cli/src/errors/response-errors/license-eula-required.error.ts new file mode 100644 index 00000000000..64222349ee0 --- /dev/null +++ b/packages/cli/src/errors/response-errors/license-eula-required.error.ts @@ -0,0 +1,25 @@ +import { ResponseError } from './abstract/response.error'; + +/** + * Error thrown when license activation requires EULA acceptance. + * + * This error is returned when the license server requires explicit EULA acceptance + * before activating a license. The error includes metadata containing the EULA URL + * that the user must accept. + * + * @example + * ```typescript + * throw new LicenseEulaRequiredError('License activation requires EULA acceptance', { + * eulaUrl: 'https://n8n.io/legal/eula/' + * }); + * ``` + */ +export class LicenseEulaRequiredError extends ResponseError { + constructor( + message: string, + readonly meta: { eulaUrl: string }, + ) { + super(message, 400); + this.name = 'LicenseEulaRequiredError'; + } +} diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index d935b68b37f..1bb5100fc7e 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -166,12 +166,12 @@ export class License implements LicenseProvider { ); } - async activate(activationKey: string): Promise { + async activate(activationKey: string, eulaUri?: string): Promise { if (!this.manager) { return; } - await this.manager.activate(activationKey); + await this.manager.activate(activationKey, eulaUri); this.logger.debug('License activated'); } diff --git a/packages/cli/src/license/__tests__/license.service.test.ts b/packages/cli/src/license/__tests__/license.service.test.ts index f1894e8fbf7..1d9fe9c2aab 100644 --- a/packages/cli/src/license/__tests__/license.service.test.ts +++ b/packages/cli/src/license/__tests__/license.service.test.ts @@ -65,6 +65,28 @@ describe('LicenseService', () => { }); describe('activateLicense', () => { + it('should activate license without eulaUri', async () => { + license.activate.mockResolvedValueOnce(); + await licenseService.activateLicense('activation-key'); + expect(license.activate).toHaveBeenCalledWith('activation-key', undefined); + }); + + it('should activate license with eulaUri', async () => { + license.activate.mockResolvedValueOnce(); + await licenseService.activateLicense('activation-key', 'https://n8n.io/legal/eula/'); + expect(license.activate).toHaveBeenCalledWith('activation-key', 'https://n8n.io/legal/eula/'); + }); + + it('should throw LicenseEulaRequiredError when EULA_REQUIRED error occurs', async () => { + const eulaError = new LicenseError('EULA_REQUIRED'); + (eulaError as any).info = { eula: { uri: 'https://n8n.io/legal/eula/' } }; + license.activate.mockRejectedValueOnce(eulaError); + + await expect(licenseService.activateLicense('activation-key')).rejects.toThrow( + 'License activation requires EULA acceptance', + ); + }); + Object.entries(LicenseErrors).forEach(([errorId, message]) => it(`should handle ${errorId} error`, async () => { license.activate.mockRejectedValueOnce(new LicenseError(errorId)); diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index e4cd9d75b61..d1f1a8ff535 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -58,8 +58,8 @@ export class LicenseController { @Post('/activate') @GlobalScope('license:manage') async activateLicense(req: LicenseRequest.Activate) { - const { activationKey } = req.body; - await this.licenseService.activateLicense(activationKey); + const { activationKey, eulaUri } = req.body; + await this.licenseService.activateLicense(activationKey, eulaUri); return await this.getTokenAndData(); } diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 7f2ebbc37f3..149d2825c0c 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -6,12 +6,11 @@ import axios, { AxiosError } from 'axios'; import { ensureError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; import { EventService } from '@/events/event.service'; import { License } from '@/license'; import { UrlService } from '@/services/url.service'; -type LicenseError = Error & { errorId?: keyof typeof LicenseErrors }; - export const LicenseErrors = { SCHEMA_VALIDATION: 'Activation key is in the wrong format', RESERVATION_EXHAUSTED: 'Activation key has been used too many times', @@ -110,22 +109,47 @@ export class LicenseService { return this.license.getManagementJwt(); } - async activateLicense(activationKey: string) { + async activateLicense(activationKey: string, eulaUri?: string) { try { - await this.license.activate(activationKey); + await this.license.activate(activationKey, eulaUri); } catch (e) { - const message = this.mapErrorMessage(e as LicenseError, 'activate'); + // Check if this is a EULA_REQUIRED error from license server + if (this.isEulaRequiredError(e)) { + throw new LicenseEulaRequiredError('License activation requires EULA acceptance', { + eulaUrl: e.info.eula.uri, + }); + } + + const message = this.mapErrorMessage(ensureError(e), 'activate'); throw new BadRequestError(message); } } + private isEulaRequiredError( + error: unknown, + ): error is Error & { errorId: string; info: { eula: { uri: string } } } { + return ( + error instanceof Error && + 'errorId' in error && + error.errorId === 'EULA_REQUIRED' && + 'info' in error && + typeof error.info === 'object' && + error.info !== null && + 'eula' in error.info && + typeof error.info.eula === 'object' && + error.info.eula !== null && + 'uri' in error.info.eula && + typeof error.info.eula.uri === 'string' + ); + } + async renewLicense() { if (this.license.getPlanName() === 'Community') return; // unlicensed, nothing to renew try { await this.license.renew(); } catch (e) { - const message = this.mapErrorMessage(e as LicenseError, 'renew'); + const message = this.mapErrorMessage(ensureError(e), 'renew'); this.eventService.emit('license-renewal-attempted', { success: false }); throw new BadRequestError(message); @@ -134,12 +158,21 @@ export class LicenseService { this.eventService.emit('license-renewal-attempted', { success: true }); } - private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') { - let message = error.errorId && LicenseErrors[error.errorId]; + private mapErrorMessage(error: Error, action: 'activate' | 'renew') { + let message: string | undefined; + + if (this.isLicenseError(error) && error.errorId in LicenseErrors) { + message = LicenseErrors[error.errorId as keyof typeof LicenseErrors]; + } + if (!message) { message = `Failed to ${action} license: ${error.message}`; this.logger.error(message, { stack: error.stack ?? 'n/a' }); } return message; } + + private isLicenseError(error: Error): error is Error & { errorId: string } { + return 'errorId' in error && typeof error.errorId === 'string'; + } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 275ded8d284..db3fa8746ef 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -225,7 +225,7 @@ export declare namespace NodeRequest { // ---------------------------------- export declare namespace LicenseRequest { - type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>; + type Activate = AuthenticatedRequest<{}, {}, { activationKey: string; eulaUri?: string }, {}>; } // ---------------------------------- diff --git a/packages/cli/src/response-helper.ts b/packages/cli/src/response-helper.ts index d367cac2e7a..747609be290 100644 --- a/packages/cli/src/response-helper.ts +++ b/packages/cli/src/response-helper.ts @@ -69,6 +69,7 @@ interface ErrorResponse { message: string; hint?: string; stacktrace?: string; + meta?: Record; } export function sendErrorResponse(res: Response, error: Error) { @@ -114,6 +115,9 @@ export function sendErrorResponse(res: Response, error: Error) { if (error.hint) { response.hint = error.hint; } + if (error.meta) { + response.meta = error.meta; + } } if (error instanceof NodeApiError) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a19c50ca100..77eb964475c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1516,8 +1516,8 @@ importers: specifier: 'catalog:' version: 1.17.0 '@n8n_io/license-sdk': - specifier: 2.23.0 - version: 2.23.0 + specifier: 2.24.1 + version: 2.24.1 '@parcel/watcher': specifier: ^2.5.1 version: 2.5.1 @@ -6101,8 +6101,8 @@ packages: resolution: {integrity: sha512-Zwfgf9N4aK9klCVC15xHL8R5ID8h9f6OAlW6fPJRV00cmBjX2gD8ZYaX92A9iGiKpmW5YG3mxPU7XTFVexB7wQ==} engines: {node: '>=20.15', pnpm: '>=8.14'} - '@n8n_io/license-sdk@2.23.0': - resolution: {integrity: sha512-WsABHT9yDgz672It1T/B9jfl3EDcCQ7b68HaiB2q0k5u2vIKyDa9HYQQUlPbYoqhzj+kaEpaTVcQt734AvdxbQ==} + '@n8n_io/license-sdk@2.24.1': + resolution: {integrity: sha512-nrITEzOmFonFXD5XtzHjpY/s1kpKksYQoY5HBqhzRkGq3ldEO3nLKDl6RG9AEtkKKlKkid3lzviZJCIDINZlQw==} engines: {node: '>=18.12.1'} '@n8n_io/riot-tmpl@4.0.1': @@ -16896,6 +16896,10 @@ packages: resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} engines: {node: '>=20.18.1'} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -21479,12 +21483,12 @@ snapshots: '@n8n_io/ai-assistant-sdk@1.17.0': {} - '@n8n_io/license-sdk@2.23.0': + '@n8n_io/license-sdk@2.24.1': dependencies: crypto-js: 4.2.0 node-machine-id: 1.1.12 node-rsa: 1.1.1 - undici: 7.10.0 + undici: 7.16.0 '@n8n_io/riot-tmpl@4.0.1': dependencies: @@ -34704,6 +34708,8 @@ snapshots: undici@7.10.0: {} + undici@7.16.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: