Skip to content

Commit ef9d9f4

Browse files
cstuncsikMarcL
andauthored
feat(core): Implement EULA acceptance handling in license activation process (#21095)
Co-authored-by: Marc Littlemore <[email protected]>
1 parent e450b72 commit ef9d9f4

File tree

13 files changed

+202
-21
lines changed

13 files changed

+202
-21
lines changed

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
"@n8n/task-runner": "workspace:*",
110110
"@n8n/typeorm": "catalog:",
111111
"@n8n_io/ai-assistant-sdk": "catalog:",
112-
"@n8n_io/license-sdk": "2.23.0",
112+
"@n8n_io/license-sdk": "2.24.1",
113113
"@rudderstack/rudder-sdk-node": "2.1.4",
114114
"@parcel/watcher": "^2.5.1",
115115
"@sentry/node": "catalog:",

packages/cli/src/__tests__/license.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,14 @@ describe('License', () => {
103103
test('attempts to activate license with provided key', async () => {
104104
await license.activate(MOCK_ACTIVATION_KEY);
105105

106-
expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY);
106+
expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY, undefined);
107+
});
108+
109+
test('attempts to activate license with eulaUri', async () => {
110+
const eulaUri = 'https://n8n.io/legal/eula/';
111+
await license.activate(MOCK_ACTIVATION_KEY, eulaUri);
112+
113+
expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY, eulaUri);
107114
});
108115

109116
test('renews license', async () => {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Response } from 'express';
2+
import { mock } from 'jest-mock-extended';
3+
4+
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
5+
import { sendErrorResponse } from '@/response-helper';
6+
7+
describe('sendErrorResponse', () => {
8+
let mockResponse: Response;
9+
10+
beforeEach(() => {
11+
mockResponse = mock<Response>({
12+
status: jest.fn().mockReturnThis(),
13+
json: jest.fn().mockReturnThis(),
14+
});
15+
});
16+
17+
it('should include meta field for LicenseEulaRequiredError', () => {
18+
const eulaUrl = 'https://n8n.io/legal/eula/';
19+
const error = new LicenseEulaRequiredError('License activation requires EULA acceptance', {
20+
eulaUrl,
21+
});
22+
23+
sendErrorResponse(mockResponse, error);
24+
25+
expect(mockResponse.status).toHaveBeenCalledWith(400);
26+
expect(mockResponse.json).toHaveBeenCalledWith(
27+
expect.objectContaining({
28+
code: 400,
29+
message: 'License activation requires EULA acceptance',
30+
meta: { eulaUrl },
31+
}),
32+
);
33+
});
34+
35+
it('should not include meta field for regular errors', () => {
36+
const error = new Error('Regular error');
37+
38+
sendErrorResponse(mockResponse, error);
39+
40+
expect(mockResponse.status).toHaveBeenCalledWith(500);
41+
expect(mockResponse.json).toHaveBeenCalledWith(
42+
expect.objectContaining({
43+
code: 0,
44+
message: 'Regular error',
45+
}),
46+
);
47+
expect(mockResponse.json).toHaveBeenCalledWith(
48+
expect.not.objectContaining({
49+
meta: expect.anything(),
50+
}),
51+
);
52+
});
53+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { LicenseEulaRequiredError } from '../license-eula-required.error';
2+
3+
describe('LicenseEulaRequiredError', () => {
4+
it('should create error with correct message and meta', () => {
5+
const eulaUrl = 'https://n8n.io/legal/eula/';
6+
const error = new LicenseEulaRequiredError('License activation requires EULA acceptance', {
7+
eulaUrl,
8+
});
9+
10+
expect(error.message).toBe('License activation requires EULA acceptance');
11+
expect(error.meta.eulaUrl).toBe(eulaUrl);
12+
expect(error.httpStatusCode).toBe(400);
13+
expect(error.name).toBe('LicenseEulaRequiredError');
14+
});
15+
16+
it('should be instance of Error', () => {
17+
const error = new LicenseEulaRequiredError('Test message', {
18+
eulaUrl: 'https://example.com',
19+
});
20+
21+
expect(error).toBeInstanceOf(Error);
22+
});
23+
});

packages/cli/src/errors/response-errors/abstract/response.error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { BaseError } from 'n8n-workflow';
44
* Special Error which allows to return also an error code and http status code
55
*/
66
export abstract class ResponseError extends BaseError {
7+
/**
8+
* Optional metadata to be included in the error response.
9+
* This allows errors to include additional structured data beyond the standard
10+
* message, code, and hint fields. For example, LicenseEulaRequiredError uses
11+
* this to include the EULA URL that must be accepted.
12+
*/
13+
readonly meta?: Record<string, unknown>;
14+
715
/**
816
* Creates an instance of ResponseError.
917
* Must be used inside a block with `ResponseHelper.send()`.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ResponseError } from './abstract/response.error';
2+
3+
/**
4+
* Error thrown when license activation requires EULA acceptance.
5+
*
6+
* This error is returned when the license server requires explicit EULA acceptance
7+
* before activating a license. The error includes metadata containing the EULA URL
8+
* that the user must accept.
9+
*
10+
* @example
11+
* ```typescript
12+
* throw new LicenseEulaRequiredError('License activation requires EULA acceptance', {
13+
* eulaUrl: 'https://n8n.io/legal/eula/'
14+
* });
15+
* ```
16+
*/
17+
export class LicenseEulaRequiredError extends ResponseError {
18+
constructor(
19+
message: string,
20+
readonly meta: { eulaUrl: string },
21+
) {
22+
super(message, 400);
23+
this.name = 'LicenseEulaRequiredError';
24+
}
25+
}

packages/cli/src/license.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,12 @@ export class License implements LicenseProvider {
166166
);
167167
}
168168

169-
async activate(activationKey: string): Promise<void> {
169+
async activate(activationKey: string, eulaUri?: string): Promise<void> {
170170
if (!this.manager) {
171171
return;
172172
}
173173

174-
await this.manager.activate(activationKey);
174+
await this.manager.activate(activationKey, eulaUri);
175175
this.logger.debug('License activated');
176176
}
177177

packages/cli/src/license/__tests__/license.service.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ describe('LicenseService', () => {
6565
});
6666

6767
describe('activateLicense', () => {
68+
it('should activate license without eulaUri', async () => {
69+
license.activate.mockResolvedValueOnce();
70+
await licenseService.activateLicense('activation-key');
71+
expect(license.activate).toHaveBeenCalledWith('activation-key', undefined);
72+
});
73+
74+
it('should activate license with eulaUri', async () => {
75+
license.activate.mockResolvedValueOnce();
76+
await licenseService.activateLicense('activation-key', 'https://n8n.io/legal/eula/');
77+
expect(license.activate).toHaveBeenCalledWith('activation-key', 'https://n8n.io/legal/eula/');
78+
});
79+
80+
it('should throw LicenseEulaRequiredError when EULA_REQUIRED error occurs', async () => {
81+
const eulaError = new LicenseError('EULA_REQUIRED');
82+
(eulaError as any).info = { eula: { uri: 'https://n8n.io/legal/eula/' } };
83+
license.activate.mockRejectedValueOnce(eulaError);
84+
85+
await expect(licenseService.activateLicense('activation-key')).rejects.toThrow(
86+
'License activation requires EULA acceptance',
87+
);
88+
});
89+
6890
Object.entries(LicenseErrors).forEach(([errorId, message]) =>
6991
it(`should handle ${errorId} error`, async () => {
7092
license.activate.mockRejectedValueOnce(new LicenseError(errorId));

packages/cli/src/license/license.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ export class LicenseController {
5858
@Post('/activate')
5959
@GlobalScope('license:manage')
6060
async activateLicense(req: LicenseRequest.Activate) {
61-
const { activationKey } = req.body;
62-
await this.licenseService.activateLicense(activationKey);
61+
const { activationKey, eulaUri } = req.body;
62+
await this.licenseService.activateLicense(activationKey, eulaUri);
6363
return await this.getTokenAndData();
6464
}
6565

packages/cli/src/license/license.service.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@ import axios, { AxiosError } from 'axios';
66
import { ensureError } from 'n8n-workflow';
77

88
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
9+
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
910
import { EventService } from '@/events/event.service';
1011
import { License } from '@/license';
1112
import { UrlService } from '@/services/url.service';
1213

13-
type LicenseError = Error & { errorId?: keyof typeof LicenseErrors };
14-
1514
export const LicenseErrors = {
1615
SCHEMA_VALIDATION: 'Activation key is in the wrong format',
1716
RESERVATION_EXHAUSTED: 'Activation key has been used too many times',
@@ -110,22 +109,47 @@ export class LicenseService {
110109
return this.license.getManagementJwt();
111110
}
112111

113-
async activateLicense(activationKey: string) {
112+
async activateLicense(activationKey: string, eulaUri?: string) {
114113
try {
115-
await this.license.activate(activationKey);
114+
await this.license.activate(activationKey, eulaUri);
116115
} catch (e) {
117-
const message = this.mapErrorMessage(e as LicenseError, 'activate');
116+
// Check if this is a EULA_REQUIRED error from license server
117+
if (this.isEulaRequiredError(e)) {
118+
throw new LicenseEulaRequiredError('License activation requires EULA acceptance', {
119+
eulaUrl: e.info.eula.uri,
120+
});
121+
}
122+
123+
const message = this.mapErrorMessage(ensureError(e), 'activate');
118124
throw new BadRequestError(message);
119125
}
120126
}
121127

128+
private isEulaRequiredError(
129+
error: unknown,
130+
): error is Error & { errorId: string; info: { eula: { uri: string } } } {
131+
return (
132+
error instanceof Error &&
133+
'errorId' in error &&
134+
error.errorId === 'EULA_REQUIRED' &&
135+
'info' in error &&
136+
typeof error.info === 'object' &&
137+
error.info !== null &&
138+
'eula' in error.info &&
139+
typeof error.info.eula === 'object' &&
140+
error.info.eula !== null &&
141+
'uri' in error.info.eula &&
142+
typeof error.info.eula.uri === 'string'
143+
);
144+
}
145+
122146
async renewLicense() {
123147
if (this.license.getPlanName() === 'Community') return; // unlicensed, nothing to renew
124148

125149
try {
126150
await this.license.renew();
127151
} catch (e) {
128-
const message = this.mapErrorMessage(e as LicenseError, 'renew');
152+
const message = this.mapErrorMessage(ensureError(e), 'renew');
129153

130154
this.eventService.emit('license-renewal-attempted', { success: false });
131155
throw new BadRequestError(message);
@@ -134,12 +158,21 @@ export class LicenseService {
134158
this.eventService.emit('license-renewal-attempted', { success: true });
135159
}
136160

137-
private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') {
138-
let message = error.errorId && LicenseErrors[error.errorId];
161+
private mapErrorMessage(error: Error, action: 'activate' | 'renew') {
162+
let message: string | undefined;
163+
164+
if (this.isLicenseError(error) && error.errorId in LicenseErrors) {
165+
message = LicenseErrors[error.errorId as keyof typeof LicenseErrors];
166+
}
167+
139168
if (!message) {
140169
message = `Failed to ${action} license: ${error.message}`;
141170
this.logger.error(message, { stack: error.stack ?? 'n/a' });
142171
}
143172
return message;
144173
}
174+
175+
private isLicenseError(error: Error): error is Error & { errorId: string } {
176+
return 'errorId' in error && typeof error.errorId === 'string';
177+
}
145178
}

0 commit comments

Comments
 (0)