diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index 7313aab7a1..cfebdecbf1 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -21,6 +21,9 @@ "dist/**/*" ], "dependencies": { + "@n8n/constants": "workspace:^", + "@n8n/di": "workspace:^", + "n8n-workflow": "workspace:^", "reflect-metadata": "catalog:" }, "devDependencies": { diff --git a/packages/@n8n/backend-common/src/index.ts b/packages/@n8n/backend-common/src/index.ts index cb0ff5c3b5..45d8721e66 100644 --- a/packages/@n8n/backend-common/src/index.ts +++ b/packages/@n8n/backend-common/src/index.ts @@ -1 +1,2 @@ -export {}; +export * from './license-state'; +export * from './types'; diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts new file mode 100644 index 0000000000..ad050763cf --- /dev/null +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -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(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; + } +} diff --git a/packages/@n8n/backend-common/src/types.ts b/packages/@n8n/backend-common/src/types.ts new file mode 100644 index 0000000000..ea79ab7342 --- /dev/null +++ b/packages/@n8n/backend-common/src/types.ts @@ -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(feature: T): FeatureReturnType[T]; +} diff --git a/packages/cli/src/__tests__/controller.registry.test.ts b/packages/cli/src/__tests__/controller.registry.test.ts index e11ec88df4..7bde813316 100644 --- a/packages/cli/src/__tests__/controller.registry.test.ts +++ b/packages/cli/src/__tests__/controller.registry.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index 6217531892..051378a58d 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -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); }); diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 6bb1055d70..4e34d9dd4e 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -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) { diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 4078172b8f..4503c3c311 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -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; } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 97b6f2cfc8..b5cd0ce0df 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -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 = ( @@ -166,7 +165,7 @@ export class E2EController { return UNLIMITED_LICENSE_QUOTA as FeatureReturnType[T]; } }; - license.getFeatureValue = getFeatureValue; + license.getValue = getFeatureValue; license.getPlanName = () => 'Enterprise'; } diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 9e192d2f6e..6c3a143ccb 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -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(feature: T): FeatureReturnType[T] { + getValue(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; } diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index f1fdcf8598..29d8b6df9e 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -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 diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index f0ba7113a6..5ed4a7e65d 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -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; + let licenseMock: jest.Mocked; beforeAll(() => { - licenseMock = mock(); + licenseMock = mock(); insightsService = new InsightsService( mock(), mock(), @@ -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; + let licenseMock: jest.Mocked; beforeAll(() => { - licenseMock = mock(); + licenseMock = mock(); insightsService = new InsightsService( mock(), mock(), @@ -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'); diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 58d9924312..1984d44e19 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -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', })); } diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index 4610ab6c80..24df8a0e9b 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -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 }); }; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 0387c0ca68..7f2286b476 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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(), }); diff --git a/packages/cli/test/integration/shared/license.ts b/packages/cli/test/integration/shared/license.ts index 846197bf69..4b3ec6c923 100644 --- a/packages/cli/test/integration/shared/license.ts +++ b/packages/cli/test/integration/shared/license.ts @@ -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 = 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() { diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 1f6aacbba5..402efa7c69 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ac988f344..46a31755b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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