feat(core): Implement EULA acceptance handling in license activation process (#21095)

Co-authored-by: Marc Littlemore <MarcL@users.noreply.github.com>
This commit is contained in:
Csaba Tuncsik 2025-10-28 16:21:40 +01:00 committed by GitHub
parent e450b72314
commit ef9d9f43ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 202 additions and 21 deletions

View File

@ -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:",

View File

@ -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 () => {

View File

@ -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<Response>({
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(),
}),
);
});
});

View File

@ -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);
});
});

View File

@ -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<string, unknown>;
/**
* Creates an instance of ResponseError.
* Must be used inside a block with `ResponseHelper.send()`.

View File

@ -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';
}
}

View File

@ -166,12 +166,12 @@ export class License implements LicenseProvider {
);
}
async activate(activationKey: string): Promise<void> {
async activate(activationKey: string, eulaUri?: string): Promise<void> {
if (!this.manager) {
return;
}
await this.manager.activate(activationKey);
await this.manager.activate(activationKey, eulaUri);
this.logger.debug('License activated');
}

View File

@ -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));

View File

@ -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();
}

View File

@ -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';
}
}

View File

@ -225,7 +225,7 @@ export declare namespace NodeRequest {
// ----------------------------------
export declare namespace LicenseRequest {
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string; eulaUri?: string }, {}>;
}
// ----------------------------------

View File

@ -69,6 +69,7 @@ interface ErrorResponse {
message: string;
hint?: string;
stacktrace?: string;
meta?: Record<string, unknown>;
}
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) {

View File

@ -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: