mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
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:
parent
e450b72314
commit
ef9d9f43ce
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
53
packages/cli/src/__tests__/response-helper.test.ts
Normal file
53
packages/cli/src/__tests__/response-helper.test.ts
Normal 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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()`.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ export declare namespace NodeRequest {
|
|||
// ----------------------------------
|
||||
|
||||
export declare namespace LicenseRequest {
|
||||
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
|
||||
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string; eulaUri?: string }, {}>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user