refactor(core): Separate license state from license service (#15097)

This commit is contained in:
Iván Ovejero
2025-05-06 09:43:08 +02:00
committed by GitHub
parent e86edf536f
commit ca0e7ffe3b
18 changed files with 365 additions and 104 deletions

View File

@@ -21,6 +21,9 @@
"dist/**/*"
],
"dependencies": {
"@n8n/constants": "workspace:^",
"@n8n/di": "workspace:^",
"n8n-workflow": "workspace:^",
"reflect-metadata": "catalog:"
},
"devDependencies": {

View File

@@ -1 +1,2 @@
export {};
export * from './license-state';
export * from './types';

View File

@@ -0,0 +1,192 @@
import { UNLIMITED_LICENSE_QUOTA, type BooleanLicenseFeature } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import type { FeatureReturnType, LicenseProvider } from './types';
class ProviderNotSetError extends UnexpectedError {
constructor() {
super('Cannot query license state because license provider has not been set');
}
}
@Service()
export class LicenseState {
licenseProvider: LicenseProvider | null = null;
setLicenseProvider(provider: LicenseProvider) {
this.licenseProvider = provider;
}
private assertProvider(): asserts this is { licenseProvider: LicenseProvider } {
if (!this.licenseProvider) throw new ProviderNotSetError();
}
// --------------------
// core queries
// --------------------
isLicensed(feature: BooleanLicenseFeature) {
this.assertProvider();
return this.licenseProvider.isLicensed(feature);
}
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
this.assertProvider();
return this.licenseProvider.getValue(feature);
}
// --------------------
// booleans
// --------------------
isSharingLicensed() {
return this.isLicensed('feat:sharing');
}
isLogStreamingLicensed() {
return this.isLicensed('feat:logStreaming');
}
isLdapLicensed() {
return this.isLicensed('feat:ldap');
}
isSamlLicensed() {
return this.isLicensed('feat:saml');
}
isApiKeyScopesLicensed() {
return this.isLicensed('feat:apiKeyScopes');
}
isAiAssistantLicensed() {
return this.isLicensed('feat:aiAssistant');
}
isAskAiLicensed() {
return this.isLicensed('feat:askAi');
}
isAiCreditsLicensed() {
return this.isLicensed('feat:aiCredits');
}
isAdvancedExecutionFiltersLicensed() {
return this.isLicensed('feat:advancedExecutionFilters');
}
isAdvancedPermissionsLicensed() {
return this.isLicensed('feat:advancedPermissions');
}
isDebugInEditorLicensed() {
return this.isLicensed('feat:debugInEditor');
}
isBinaryDataS3Licensed() {
return this.isLicensed('feat:binaryDataS3');
}
isMultiMainLicensed() {
return this.isLicensed('feat:multipleMainInstances');
}
isVariablesLicensed() {
return this.isLicensed('feat:variables');
}
isSourceControlLicensed() {
return this.isLicensed('feat:sourceControl');
}
isExternalSecretsLicensed() {
return this.isLicensed('feat:externalSecrets');
}
isWorkflowHistoryLicensed() {
return this.isLicensed('feat:workflowHistory');
}
isAPIDisabled() {
return this.isLicensed('feat:apiDisabled');
}
isWorkerViewLicensed() {
return this.isLicensed('feat:workerView');
}
isProjectRoleAdminLicensed() {
return this.isLicensed('feat:projectRole:admin');
}
isProjectRoleEditorLicensed() {
return this.isLicensed('feat:projectRole:editor');
}
isProjectRoleViewerLicensed() {
return this.isLicensed('feat:projectRole:viewer');
}
isCustomNpmRegistryLicensed() {
return this.isLicensed('feat:communityNodes:customRegistry');
}
isFoldersLicensed() {
return this.isLicensed('feat:folders');
}
isInsightsSummaryLicensed() {
return this.isLicensed('feat:insights:viewSummary');
}
isInsightsDashboardLicensed() {
return this.isLicensed('feat:insights:viewDashboard');
}
isInsightsHourlyDataLicensed() {
return this.isLicensed('feat:insights:viewHourlyData');
}
// --------------------
// integers
// --------------------
getMaxUsers() {
return this.getValue('quota:users') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxActiveWorkflows() {
return this.getValue('quota:activeWorkflows') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxVariables() {
return this.getValue('quota:maxVariables') ?? UNLIMITED_LICENSE_QUOTA;
}
getMaxAiCredits() {
return this.getValue('quota:aiCredits') ?? 0;
}
getWorkflowHistoryPruneQuota() {
return this.getValue('quota:workflowHistoryPrune') ?? UNLIMITED_LICENSE_QUOTA;
}
getInsightsMaxHistory() {
return this.getValue('quota:insights:maxHistoryDays') ?? 7;
}
getInsightsRetentionMaxAge() {
return this.getValue('quota:insights:retention:maxAgeDays') ?? 180;
}
getInsightsRetentionPruneInterval() {
return this.getValue('quota:insights:retention:pruneIntervalDays') ?? 24;
}
getMaxTeamProjects() {
return this.getValue('quota:maxTeamProjects') ?? 0;
}
}

View File

@@ -0,0 +1,15 @@
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
export type FeatureReturnType = Partial<
{
planName: string;
} & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean }
>;
export interface LicenseProvider {
/** Returns whether a feature is included in the user's license plan. */
isLicensed(feature: BooleanLicenseFeature): boolean;
/** Returns the value of a feature in the user's license plan, typically a boolean or integer. */
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T];
}

View File

@@ -108,15 +108,15 @@ describe('ControllerRegistry', () => {
});
it('should disallow when feature is missing', async () => {
license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(false);
license.isLicensed.calledWith('feat:sharing').mockReturnValue(false);
await agent.get('/rest/test/with-sharing').expect(403);
expect(license.isFeatureEnabled).toHaveBeenCalled();
expect(license.isLicensed).toHaveBeenCalled();
});
it('should allow when feature is available', async () => {
license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(true);
license.isLicensed.calledWith('feat:sharing').mockReturnValue(true);
await agent.get('/rest/test/with-sharing').expect(200);
expect(license.isFeatureEnabled).toHaveBeenCalled();
expect(license.isLicensed).toHaveBeenCalled();
});
});

View File

@@ -111,13 +111,13 @@ describe('License', () => {
});
test('check if feature is enabled', () => {
license.isFeatureEnabled(MOCK_FEATURE_FLAG);
license.isLicensed(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
test('check if sharing feature is enabled', () => {
license.isFeatureEnabled(MOCK_FEATURE_FLAG);
license.isLicensed(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
@@ -129,7 +129,7 @@ describe('License', () => {
});
test('check fetching feature values', async () => {
license.getFeatureValue(MOCK_FEATURE_FLAG);
license.getValue(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});

View File

@@ -1,4 +1,5 @@
import 'reflect-metadata';
import { LicenseState } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { Container } from '@n8n/di';
@@ -197,7 +198,7 @@ export abstract class BaseCommand extends Command {
);
}
const isLicensed = Container.get(License).isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3);
const isLicensed = Container.get(License).isLicensed(LICENSE_FEATURES.BINARY_DATA_S3);
if (!isLicensed) {
this.logger.error(
'No license found for S3 storage. \n Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.',
@@ -241,6 +242,8 @@ export abstract class BaseCommand extends Command {
this.license = Container.get(License);
await this.license.init();
Container.get(LicenseState).setLicenseProvider(this.license);
const { activationKey } = this.globalConfig.license;
if (activationKey) {

View File

@@ -106,7 +106,7 @@ export class ControllerRegistry {
private createLicenseMiddleware(feature: BooleanLicenseFeature): RequestHandler {
return (_req, res, next) => {
if (!this.license.isFeatureEnabled(feature)) {
if (!this.license.isLicensed(feature)) {
res.status(403).json({ status: 'error', message: 'Plan lacks license for this feature' });
return;
}

View File

@@ -153,8 +153,7 @@ export class E2EController {
private readonly userRepository: UserRepository,
private readonly authUserRepository: AuthUserRepository,
) {
license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false;
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
// Ugly hack to satisfy biome parser
const getFeatureValue = <T extends keyof FeatureReturnType>(
@@ -166,7 +165,7 @@ export class E2EController {
return UNLIMITED_LICENSE_QUOTA as FeatureReturnType[T];
}
};
license.getFeatureValue = getFeatureValue;
license.getValue = getFeatureValue;
license.getPlanName = () => 'Enterprise';
}

View File

@@ -1,3 +1,4 @@
import type { LicenseProvider } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import {
LICENSE_FEATURES,
@@ -28,7 +29,7 @@ export type FeatureReturnType = Partial<
>;
@Service()
export class License {
export class License implements LicenseProvider {
private manager: LicenseManager | undefined;
private isShuttingDown = false;
@@ -218,123 +219,135 @@ export class License {
this.logger.debug('License shut down');
}
isFeatureEnabled(feature: BooleanLicenseFeature) {
isLicensed(feature: BooleanLicenseFeature) {
return this.manager?.hasFeatureEnabled(feature) ?? false;
}
/** @deprecated Use `LicenseState.isSharingLicensed` instead. */
isSharingEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.SHARING);
return this.isLicensed(LICENSE_FEATURES.SHARING);
}
/** @deprecated Use `LicenseState.isLogStreamingLicensed` instead. */
isLogStreamingEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.LOG_STREAMING);
return this.isLicensed(LICENSE_FEATURES.LOG_STREAMING);
}
/** @deprecated Use `LicenseState.isLdapLicensed` instead. */
isLdapEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.LDAP);
return this.isLicensed(LICENSE_FEATURES.LDAP);
}
/** @deprecated Use `LicenseState.isSamlLicensed` instead. */
isSamlEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.SAML);
return this.isLicensed(LICENSE_FEATURES.SAML);
}
/** @deprecated Use `LicenseState.isApiKeyScopesLicensed` instead. */
isApiKeyScopesEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.API_KEY_SCOPES);
return this.isLicensed(LICENSE_FEATURES.API_KEY_SCOPES);
}
/** @deprecated Use `LicenseState.isAiAssistantLicensed` instead. */
isAiAssistantEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.AI_ASSISTANT);
return this.isLicensed(LICENSE_FEATURES.AI_ASSISTANT);
}
/** @deprecated Use `LicenseState.isAskAiLicensed` instead. */
isAskAiEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.ASK_AI);
return this.isLicensed(LICENSE_FEATURES.ASK_AI);
}
/** @deprecated Use `LicenseState.isAiCreditsLicensed` instead. */
isAiCreditsEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.AI_CREDITS);
return this.isLicensed(LICENSE_FEATURES.AI_CREDITS);
}
/** @deprecated Use `LicenseState.isAdvancedExecutionFiltersLicensed` instead. */
isAdvancedExecutionFiltersEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS);
return this.isLicensed(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS);
}
/** @deprecated Use `LicenseState.isAdvancedPermissionsLicensed` instead. */
isAdvancedPermissionsLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_PERMISSIONS);
return this.isLicensed(LICENSE_FEATURES.ADVANCED_PERMISSIONS);
}
/** @deprecated Use `LicenseState.isDebugInEditorLicensed` instead. */
isDebugInEditorLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.DEBUG_IN_EDITOR);
return this.isLicensed(LICENSE_FEATURES.DEBUG_IN_EDITOR);
}
/** @deprecated Use `LicenseState.isBinaryDataS3Licensed` instead. */
isBinaryDataS3Licensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3);
return this.isLicensed(LICENSE_FEATURES.BINARY_DATA_S3);
}
/** @deprecated Use `LicenseState.isMultiMainLicensed` instead. */
isMultiMainLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
return this.isLicensed(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
}
/** @deprecated Use `LicenseState.isVariablesLicensed` instead. */
isVariablesEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES);
return this.isLicensed(LICENSE_FEATURES.VARIABLES);
}
/** @deprecated Use `LicenseState.isSourceControlLicensed` instead. */
isSourceControlLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.SOURCE_CONTROL);
return this.isLicensed(LICENSE_FEATURES.SOURCE_CONTROL);
}
/** @deprecated Use `LicenseState.isExternalSecretsLicensed` instead. */
isExternalSecretsEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_SECRETS);
return this.isLicensed(LICENSE_FEATURES.EXTERNAL_SECRETS);
}
/** @deprecated Use `LicenseState.isWorkflowHistoryLicensed` instead. */
isWorkflowHistoryLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.WORKFLOW_HISTORY);
return this.isLicensed(LICENSE_FEATURES.WORKFLOW_HISTORY);
}
/** @deprecated Use `LicenseState.isAPIDisabled` instead. */
isAPIDisabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.API_DISABLED);
return this.isLicensed(LICENSE_FEATURES.API_DISABLED);
}
/** @deprecated Use `LicenseState.isWorkerViewLicensed` instead. */
isWorkerViewLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW);
return this.isLicensed(LICENSE_FEATURES.WORKER_VIEW);
}
/** @deprecated Use `LicenseState.isProjectRoleAdminLicensed` instead. */
isProjectRoleAdminLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_ADMIN);
return this.isLicensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN);
}
/** @deprecated Use `LicenseState.isProjectRoleEditorLicensed` instead. */
isProjectRoleEditorLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_EDITOR);
return this.isLicensed(LICENSE_FEATURES.PROJECT_ROLE_EDITOR);
}
/** @deprecated Use `LicenseState.isProjectRoleViewerLicensed` instead. */
isProjectRoleViewerLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER);
return this.isLicensed(LICENSE_FEATURES.PROJECT_ROLE_VIEWER);
}
/** @deprecated Use `LicenseState.isCustomNpmRegistryLicensed` instead. */
isCustomNpmRegistryEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
return this.isLicensed(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
}
/** @deprecated Use `LicenseState.isFoldersLicensed` instead. */
isFoldersEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.FOLDERS);
}
isInsightsSummaryEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.INSIGHTS_VIEW_SUMMARY);
}
isInsightsDashboardEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD);
}
isInsightsHourlyDataEnabled() {
return this.getFeatureValue(LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA);
return this.isLicensed(LICENSE_FEATURES.FOLDERS);
}
getCurrentEntitlements() {
return this.manager?.getCurrentEntitlements() ?? [];
}
getFeatureValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
getValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
return this.manager?.getFeatureValue(feature) as FeatureReturnType[T];
}
@@ -370,46 +383,54 @@ export class License {
}
// Helper functions for computed data
/** @deprecated Use `LicenseState` instead. */
getUsersLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
return this.getValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
}
/** @deprecated Use `LicenseState` instead. */
getTriggerLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
return this.getValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
}
/** @deprecated Use `LicenseState` instead. */
getVariablesLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
return this.getValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
}
/** @deprecated Use `LicenseState` instead. */
getAiCredits() {
return this.getFeatureValue(LICENSE_QUOTAS.AI_CREDITS) ?? 0;
return this.getValue(LICENSE_QUOTAS.AI_CREDITS) ?? 0;
}
/** @deprecated Use `LicenseState` instead. */
getWorkflowHistoryPruneLimit() {
return (
this.getFeatureValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA
);
return this.getValue(LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
}
/** @deprecated Use `LicenseState` instead. */
getInsightsMaxHistory() {
return this.getFeatureValue(LICENSE_QUOTAS.INSIGHTS_MAX_HISTORY_DAYS) ?? 7;
return this.getValue(LICENSE_QUOTAS.INSIGHTS_MAX_HISTORY_DAYS) ?? 7;
}
/** @deprecated Use `LicenseState` instead. */
getInsightsRetentionMaxAge() {
return this.getFeatureValue(LICENSE_QUOTAS.INSIGHTS_RETENTION_MAX_AGE_DAYS) ?? 180;
return this.getValue(LICENSE_QUOTAS.INSIGHTS_RETENTION_MAX_AGE_DAYS) ?? 180;
}
/** @deprecated Use `LicenseState` instead. */
getInsightsRetentionPruneInterval() {
return this.getFeatureValue(LICENSE_QUOTAS.INSIGHTS_RETENTION_PRUNE_INTERVAL_DAYS) ?? 24;
return this.getValue(LICENSE_QUOTAS.INSIGHTS_RETENTION_PRUNE_INTERVAL_DAYS) ?? 24;
}
/** @deprecated Use `LicenseState` instead. */
getTeamProjectLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0;
return this.getValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0;
}
getPlanName(): string {
return this.getFeatureValue('planName') ?? 'Community';
return this.getValue('planName') ?? 'Community';
}
getInfo(): string {
@@ -420,6 +441,7 @@ export class License {
return this.manager.toString();
}
/** @deprecated Use `LicenseState` instead. */
isWithinUsersLimit() {
return this.getUsersLimit() === UNLIMITED_LICENSE_QUOTA;
}

View File

@@ -1,8 +1,10 @@
import { LicenseState } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { AuthenticatedRequest } from '@/requests';
import { mockInstance } from '@test/mocking';
import { LicenseMocker } from '@test-integration/license';
import * as testDb from '@test-integration/test-db';
import { TypeToNumber } from '../database/entities/insights-shared';
@@ -12,6 +14,7 @@ import { InsightsController } from '../insights.controller';
// Initialize DB once for all tests
beforeAll(async () => {
await testDb.init();
new LicenseMocker().mockLicenseState(Container.get(LicenseState));
});
// Terminate DB once after all tests complete

View File

@@ -1,4 +1,5 @@
import type { InsightsDateRange } from '@n8n/api-types';
import type { LicenseState } from '@n8n/backend-common';
import type { Project } from '@n8n/db';
import type { WorkflowEntity } from '@n8n/db';
import type { IWorkflowDb } from '@n8n/db';
@@ -6,7 +7,6 @@ import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import type { License } from '@/license';
import { createTeamProject } from '@test-integration/db/projects';
import { createWorkflow } from '@test-integration/db/workflows';
import * as testDb from '@test-integration/test-db';
@@ -492,10 +492,10 @@ describe('getInsightsByTime', () => {
describe('getAvailableDateRanges', () => {
let insightsService: InsightsService;
let licenseMock: jest.Mocked<License>;
let licenseMock: jest.Mocked<LicenseState>;
beforeAll(() => {
licenseMock = mock<License>();
licenseMock = mock<LicenseState>();
insightsService = new InsightsService(
mock<InsightsByPeriodRepository>(),
mock<InsightsCompactionService>(),
@@ -506,7 +506,7 @@ describe('getAvailableDateRanges', () => {
test('returns correct ranges when hourly data is enabled and max history is unlimited', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsService.getAvailableDateRanges();
@@ -523,7 +523,7 @@ describe('getAvailableDateRanges', () => {
test('returns correct ranges when hourly data is enabled and max history is 365 days', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsService.getAvailableDateRanges();
@@ -540,7 +540,7 @@ describe('getAvailableDateRanges', () => {
test('returns correct ranges when hourly data is disabled and max history is 30 days', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = insightsService.getAvailableDateRanges();
@@ -557,7 +557,7 @@ describe('getAvailableDateRanges', () => {
test('returns correct ranges when max history is less than 7 days', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(5);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = insightsService.getAvailableDateRanges();
@@ -574,7 +574,7 @@ describe('getAvailableDateRanges', () => {
test('returns correct ranges when max history is 90 days and hourly data is enabled', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsService.getAvailableDateRanges();
@@ -592,10 +592,10 @@ describe('getAvailableDateRanges', () => {
describe('getMaxAgeInDaysAndGranularity', () => {
let insightsService: InsightsService;
let licenseMock: jest.Mocked<License>;
let licenseMock: jest.Mocked<LicenseState>;
beforeAll(() => {
licenseMock = mock<License>();
licenseMock = mock<LicenseState>();
insightsService = new InsightsService(
mock<InsightsByPeriodRepository>(),
mock<InsightsCompactionService>(),
@@ -606,7 +606,7 @@ describe('getMaxAgeInDaysAndGranularity', () => {
test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsService.getMaxAgeInDaysAndGranularity('month');
@@ -620,7 +620,7 @@ describe('getMaxAgeInDaysAndGranularity', () => {
test('throws an error if the date range is not available', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
expect(() => {
insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']);
@@ -629,7 +629,7 @@ describe('getMaxAgeInDaysAndGranularity', () => {
test('throws an error if the date range is not licensed', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
expect(() => {
insightsService.getMaxAgeInDaysAndGranularity('year');
@@ -638,7 +638,7 @@ describe('getMaxAgeInDaysAndGranularity', () => {
test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = insightsService.getMaxAgeInDaysAndGranularity('quarter');
@@ -652,7 +652,7 @@ describe('getMaxAgeInDaysAndGranularity', () => {
test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsService.getMaxAgeInDaysAndGranularity('day');

View File

@@ -3,12 +3,11 @@ import {
type InsightsDateRange,
INSIGHTS_DATE_RANGE_KEYS,
} from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { License } from '@/license';
import type { PeriodUnit, TypeUnit } from './database/entities/insights-shared';
import { NumberToType } from './database/entities/insights-shared';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
@@ -31,7 +30,7 @@ export class InsightsService {
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
private readonly compactionService: InsightsCompactionService,
private readonly collectionService: InsightsCollectionService,
private readonly license: License,
private readonly licenseState: LicenseState,
) {}
startBackgroundProcess() {
@@ -191,15 +190,15 @@ export class InsightsService {
*/
getAvailableDateRanges(): InsightsDateRange[] {
const maxHistoryInDays =
this.license.getInsightsMaxHistory() === -1
this.licenseState.getInsightsMaxHistory() === -1
? Number.MAX_SAFE_INTEGER
: this.license.getInsightsMaxHistory();
const isHourlyDateEnabled = this.license.isInsightsHourlyDataEnabled();
: this.licenseState.getInsightsMaxHistory();
const isHourlyDateLicensed = this.licenseState.isInsightsHourlyDataLicensed();
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
key,
licensed:
key === 'day' ? (isHourlyDateEnabled ?? false) : maxHistoryInDays >= keyRangeToDays[key],
key === 'day' ? (isHourlyDateLicensed ?? false) : maxHistoryInDays >= keyRangeToDays[key],
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
}));
}

View File

@@ -114,7 +114,7 @@ export const validLicenseWithUserQuota = (
export const isLicensed = (feature: BooleanLicenseFeature) => {
return async (_: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => {
if (Container.get(License).isFeatureEnabled(feature)) return next();
if (Container.get(License).isLicensed(feature)) return next();
return res.status(403).json({ message: new FeatureNotLicensedError(feature).message });
};

View File

@@ -1,4 +1,5 @@
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { GlobalConfig, SecurityConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { Container, Service } from '@n8n/di';
@@ -52,6 +53,7 @@ export class FrontendService {
private readonly pushConfig: PushConfig,
private readonly binaryDataConfig: BinaryDataConfig,
private readonly insightsService: InsightsService,
private readonly licenseState: LicenseState,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes();
@@ -324,7 +326,7 @@ export class FrontendService {
variables: this.license.isVariablesEnabled(),
sourceControl: this.license.isSourceControlLicensed(),
externalSecrets: this.license.isExternalSecretsEnabled(),
showNonProdBanner: this.license.isFeatureEnabled(LICENSE_FEATURES.SHOW_NON_PROD_BANNER),
showNonProdBanner: this.license.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER),
debugInEditor: this.license.isDebugInEditorLicensed(),
binaryDataS3: isS3Available && isS3Selected && isS3Licensed,
workflowHistory:
@@ -378,8 +380,8 @@ export class FrontendService {
Object.assign(this.settings.insights, {
enabled: this.modulesConfig.loadedModules.has('insights'),
summary: this.license.isInsightsSummaryEnabled(),
dashboard: this.license.isInsightsDashboardEnabled(),
summary: this.licenseState.isInsightsSummaryLicensed(),
dashboard: this.licenseState.isInsightsDashboardLicensed(),
dateRanges: this.insightsService.getAvailableDateRanges(),
});

View File

@@ -1,3 +1,4 @@
import type { LicenseProvider, LicenseState } from '@n8n/backend-common';
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
import type { License } from '@/license';
@@ -17,8 +18,17 @@ export class LicenseMocker {
private _defaultQuotas: Map<NumericLicenseFeature, number> = new Map();
mock(license: License) {
license.isFeatureEnabled = this.isFeatureEnabled.bind(this);
license.getFeatureValue = this.getFeatureValue.bind(this);
license.isLicensed = this.isFeatureEnabled.bind(this);
license.getValue = this.getFeatureValue.bind(this);
}
mockLicenseState(licenseState: LicenseState) {
const licenseProvider: LicenseProvider = {
isLicensed: this.isFeatureEnabled.bind(this),
getValue: this.getFeatureValue.bind(this),
};
licenseState.setLicenseProvider(licenseProvider);
}
reset() {

View File

@@ -1,3 +1,4 @@
import { LicenseState } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import cookieParser from 'cookie-parser';
@@ -125,6 +126,8 @@ export const setupTestServer = ({
config.set('userManagement.isInstanceOwnerSetUp', true);
testServer.license.mock(Container.get(License));
testServer.license.mockLicenseState(Container.get(LicenseState));
if (enabledFeatures) {
testServer.license.setDefaults({
features: enabledFeatures,

33
pnpm-lock.yaml generated
View File

@@ -387,6 +387,15 @@ importers:
packages/@n8n/backend-common:
dependencies:
'@n8n/constants':
specifier: workspace:^
version: link:../constants
'@n8n/di':
specifier: workspace:^
version: link:../di
n8n-workflow:
specifier: workspace:^
version: link:../../workflow
reflect-metadata:
specifier: 'catalog:'
version: 0.2.2
@@ -690,7 +699,7 @@ importers:
version: 4.3.0
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6))
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(e320b1d8e94e7308fefdef3743329630))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@@ -717,7 +726,7 @@ importers:
version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
'@langchain/community':
specifier: 'catalog:'
version: 0.3.24(d72b3dbd91eb98a3175f929d13e7c0a7)
version: 0.3.24(a23560be5fb93c23c5c4ed2a6b67082b)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
@@ -819,7 +828,7 @@ importers:
version: 23.0.1
langchain:
specifier: 0.3.11
version: 0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6)
version: 0.3.11(e320b1d8e94e7308fefdef3743329630)
lodash:
specifier: 'catalog:'
version: 4.17.21
@@ -16363,7 +16372,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(e320b1d8e94e7308fefdef3743329630))':
dependencies:
form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13)
@@ -16372,7 +16381,7 @@ snapshots:
zod: 3.24.1
optionalDependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
langchain: 0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6)
langchain: 0.3.11(e320b1d8e94e7308fefdef3743329630)
transitivePeerDependencies:
- encoding
@@ -16887,7 +16896,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.24(d72b3dbd91eb98a3175f929d13e7c0a7)':
'@langchain/community@0.3.24(a23560be5fb93c23c5c4ed2a6b67082b)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1)
'@ibm-cloud/watsonx-ai': 1.1.2
@@ -16898,7 +16907,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.3.2
js-yaml: 4.1.0
langchain: 0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6)
langchain: 0.3.11(e320b1d8e94e7308fefdef3743329630)
langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
uuid: 10.0.0
@@ -16913,7 +16922,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@browserbasehq/sdk': 2.0.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(e320b1d8e94e7308fefdef3743329630))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@@ -23107,7 +23116,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 18.16.16
'@types/tough-cookie': 4.0.2
axios: 1.8.3(debug@4.4.0)
axios: 1.8.3
camelcase: 6.3.0
debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.5
@@ -23117,7 +23126,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.8.3)
retry-axios: 2.6.0(axios@1.8.3(debug@4.4.0))
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@@ -24111,7 +24120,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.11(73c39badb3fd5b3eb4d1084b1fb22de6):
langchain@0.3.11(e320b1d8e94e7308fefdef3743329630):
dependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
'@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
@@ -26484,7 +26493,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.8.3):
retry-axios@2.6.0(axios@1.8.3(debug@4.4.0)):
dependencies:
axios: 1.8.3