From 3fb0ae4e3e81cc86bb71577519e05e94d00aafbd Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" Date: Fri, 2 Oct 2026 13:36:13 +0000 Subject: [PATCH] fix: read frontend entitlements and trigger quotas from license state --- .../license/__tests__/license.service.test.ts | 19 ++++- packages/cli/src/license/license.service.ts | 3 +- .../__tests__/frontend.service.test.ts | 75 ++++++++++------- packages/cli/src/services/frontend.service.ts | 80 +++++++------------ 4 files changed, 93 insertions(+), 84 deletions(-) diff --git a/packages/cli/src/license/__tests__/license.service.test.ts b/packages/cli/src/license/__tests__/license.service.test.ts index 02b44fbffae..bcbed44faea 100644 --- a/packages/cli/src/license/__tests__/license.service.test.ts +++ b/packages/cli/src/license/__tests__/license.service.test.ts @@ -2,6 +2,7 @@ import type { LicenseState } from '@n8n/backend-common'; import { markHttpRequestError } from '@n8n/backend-network'; import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; import type { EventService } from '@n8n/backend-services'; +import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import type { WorkflowRepository } from '@n8n/db'; import type { TEntitlement } from '@n8n_io/license-sdk'; import { AxiosError } from 'axios'; @@ -31,7 +32,7 @@ describe('LicenseService', () => { ); license.getMainPlan.mockReturnValue(entitlement); - license.getTriggerLimit.mockReturnValue(400); + licenseState.getMaxActiveWorkflows.mockReturnValue(400); license.getPlanName.mockReturnValue('Test Plan'); licenseState.getMaxWorkflowsWithEvaluations.mockReturnValue(2); workflowRepository.getActiveTriggerCount.mockResolvedValue(7); @@ -66,6 +67,22 @@ describe('LicenseService', () => { }, }); }); + + it('should return the unlimited active workflow quota', async () => { + licenseState.getMaxActiveWorkflows.mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA); + + const data = await licenseService.getLicenseData(); + + expect(data.usage.activeWorkflowTriggers.limit).toBe(UNLIMITED_LICENSE_QUOTA); + }); + + it('should return a zero active workflow quota', async () => { + licenseState.getMaxActiveWorkflows.mockReturnValueOnce(0); + + const data = await licenseService.getLicenseData(); + + expect(data.usage.activeWorkflowTriggers.limit).toBe(0); + }); }); describe('activateLicense', () => { diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index d85144141be..a6c60ccb29b 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -51,8 +51,7 @@ export class LicenseService { usage: { activeWorkflowTriggers: { value: triggerCount, - // oxlint-disable-next-line typescript/no-deprecated - Tech debt, tests fail if we move to this.licenseState.getMaxActiveWorkflows - limit: this.license.getTriggerLimit(), + limit: this.licenseState.getMaxActiveWorkflows(), warningThreshold: 0.8, }, workflowsHavingEvaluations: { diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index 08531cd739e..497e5a33116 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -1,6 +1,7 @@ import type { Mock } from 'vitest'; import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import type { GlobalConfig, SecurityConfig } from '@n8n/config'; +import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import type { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; @@ -122,30 +123,9 @@ describe('FrontendService', () => { }); const license = mock({ - getUsersLimit: vi.fn().mockReturnValue(100), getPlanName: vi.fn().mockReturnValue('Community'), getConsumerId: vi.fn().mockReturnValue('test-consumer'), - isSharingEnabled: vi.fn().mockReturnValue(false), - isLogStreamingEnabled: vi.fn().mockReturnValue(false), - isLdapEnabled: vi.fn().mockReturnValue(false), - isSamlEnabled: vi.fn().mockReturnValue(false), - isAdvancedExecutionFiltersEnabled: vi.fn().mockReturnValue(false), - isVariablesEnabled: vi.fn().mockReturnValue(false), - isSourceControlLicensed: vi.fn().mockReturnValue(false), - isExternalSecretsEnabled: vi.fn().mockReturnValue(false), isLicensed: vi.fn().mockReturnValue(false), - isDebugInEditorLicensed: vi.fn().mockReturnValue(false), - isWorkerViewLicensed: vi.fn().mockReturnValue(false), - isAdvancedPermissionsLicensed: vi.fn().mockReturnValue(false), - - getVariablesLimit: vi.fn().mockReturnValue(0), - getTeamProjectLimit: vi.fn().mockReturnValue(0), - isBinaryDataS3Licensed: vi.fn().mockReturnValue(false), - isAiAssistantEnabled: vi.fn().mockReturnValue(false), - isAskAiEnabled: vi.fn().mockReturnValue(false), - isAiCreditsEnabled: vi.fn().mockReturnValue(false), - getAiCredits: vi.fn().mockReturnValue(0), - isFoldersEnabled: vi.fn().mockReturnValue(false), }); const mailer = mock({ @@ -168,6 +148,12 @@ describe('FrontendService', () => { }); const licenseState = mock({ + getMaxUsers: vi.fn().mockReturnValue(100), + getMaxVariables: vi.fn().mockReturnValue(0), + getMaxTeamProjects: vi.fn().mockReturnValue(0), + getMaxAiCredits: vi.fn().mockReturnValue(0), + isLdapLicensed: vi.fn().mockReturnValue(false), + isSamlLicensed: vi.fn().mockReturnValue(false), isOidcLicensed: vi.fn().mockReturnValue(false), isMFAEnforcementLicensed: vi.fn().mockReturnValue(false), isOtelCustomSpanAttributesLicensed: vi.fn().mockReturnValue(false), @@ -280,6 +266,38 @@ describe('FrontendService', () => { ); }); + it('should expose licensed features and quotas from license state', async () => { + licenseState.isSharingLicensed.mockReturnValueOnce(true); + licenseState.isSamlLicensed.mockReturnValueOnce(true).mockReturnValueOnce(true); + licenseState.isVariablesLicensed.mockReturnValueOnce(true).mockReturnValueOnce(true); + licenseState.isFoldersLicensed.mockReturnValueOnce(true); + licenseState.getMaxUsers.mockReturnValueOnce(25).mockReturnValueOnce(25); + licenseState.getMaxVariables.mockReturnValueOnce(50); + licenseState.getMaxTeamProjects.mockReturnValueOnce(3); + const { service } = createMockService(); + + const settings = await service.getSettings(); + + expect(settings.enterprise).toMatchObject({ sharing: true, saml: true, variables: true }); + expect(settings.userManagement.quota).toBe(25); + expect(settings.variables.limit).toBe(50); + expect(settings.enterprise.projects.team.limit).toBe(3); + expect(settings.folders.enabled).toBe(true); + }); + + it('should expose unlicensed features with unlimited user quotas', async () => { + licenseState.getMaxUsers + .mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA) + .mockReturnValueOnce(UNLIMITED_LICENSE_QUOTA); + const { service } = createMockService(); + + const settings = await service.getSettings(); + + expect(settings.userManagement.quota).toBe(UNLIMITED_LICENSE_QUOTA); + expect(settings.enterprise.sharing).toBeFalsy(); + expect(settings.folders.enabled).toBeFalsy(); + }); + it('should expose excluded node types from NODES_EXCLUDE', async () => { globalConfig.nodes.exclude = ['n8n-nodes-base.executeWorkflow']; const { service } = createMockService(); @@ -325,8 +343,8 @@ describe('FrontendService', () => { it('should surface the assistant Cloud UBB entitlement when the AI Assistant is enabled and entitled', async () => { globalConfig.aiAssistant.baseUrl = 'https://ai-assistant.n8n.io'; licenseState.isAiAssistantCloudUbbEntitlementLicensed.mockReturnValue(true); - const { service, license } = createMockService(); - license.isAiAssistantEnabled.mockReturnValue(true); + licenseState.isAiAssistantLicensed.mockReturnValueOnce(true); + const { service } = createMockService(); const settings = await service.getSettings(); @@ -336,8 +354,7 @@ describe('FrontendService', () => { it('should keep the assistant Cloud UBB entitlement off when the AI Assistant is disabled', async () => { globalConfig.aiAssistant.baseUrl = ''; licenseState.isAiAssistantCloudUbbEntitlementLicensed.mockReturnValue(true); - const { service, license } = createMockService(); - license.isAiAssistantEnabled.mockReturnValue(false); + const { service } = createMockService(); const settings = await service.getSettings(); @@ -852,9 +869,9 @@ describe('FrontendService', () => { }); it('should set aiBuilder.enabled to true when license has feat:aiBuilder', async () => { - const { service, license } = createMockService(); + const { service } = createMockService(); - license.isLicensed.mockImplementation((feature) => { + licenseState.isLicensed.mockImplementation((feature) => { return feature === 'feat:aiBuilder'; }); @@ -864,9 +881,9 @@ describe('FrontendService', () => { }); it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', async () => { - const { service, license } = createMockService(); + const { service } = createMockService(); - license.isLicensed.mockReturnValue(false); + licenseState.isLicensed.mockReturnValue(false); const settings = await service.getSettings(); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index fef5d06cd08..4e68b0b1763 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -262,8 +262,7 @@ export class FrontendService { this.globalConfig.personalization.enabled && this.globalConfig.diagnostics.enabled, defaultLocale: this.globalConfig.defaultLocale, userManagement: { - // oxlint-disable-next-line typescript/no-deprecated - quota: this.license.getUsersLimit(), + quota: this.licenseState.getMaxUsers(), showSetupOnFirstLoad: await this.getShowSetupOnFirstLoad(), smtpSetup: this.mailer.isEmailSetUp, authenticationMethod: getCurrentAuthenticationMethod(), @@ -484,8 +483,7 @@ export class FrontendService { // refresh user management status Object.assign(this.settings.userManagement, { - // oxlint-disable-next-line typescript/no-deprecated - quota: this.license.getUsersLimit(), + quota: this.licenseState.getMaxUsers(), authenticationMethod: getCurrentAuthenticationMethod(), showSetupOnFirstLoad: await this.getShowSetupOnFirstLoad(), }); @@ -514,15 +512,11 @@ export class FrontendService { const isS3Selected = this.binaryDataConfig.mode === 's3'; const isS3Available = this.binaryDataConfig.availableModes.includes('s3'); - // oxlint-disable-next-line typescript/no-deprecated - const isS3Licensed = this.license.isBinaryDataS3Licensed(); - // oxlint-disable-next-line typescript/no-deprecated - const isAiAssistantEnabled = this.license.isAiAssistantEnabled(); - // oxlint-disable-next-line typescript/no-deprecated - const isAskAiEnabled = this.license.isAskAiEnabled(); - // oxlint-disable-next-line typescript/no-deprecated - const isAiCreditsEnabled = this.license.isAiCreditsEnabled(); - const isAiBuilderEnabled = this.license.isLicensed(LICENSE_FEATURES.AI_BUILDER); + const isS3Licensed = this.licenseState.isBinaryDataS3Licensed(); + const isAiAssistantEnabled = this.licenseState.isAiAssistantLicensed(); + const isAskAiEnabled = this.licenseState.isAskAiLicensed(); + const isAiCreditsEnabled = this.licenseState.isAiCreditsLicensed(); + const isAiBuilderEnabled = this.licenseState.isLicensed(LICENSE_FEATURES.AI_BUILDER); this.settings.license.planName = this.license.getPlanName(); this.settings.license.consumerId = this.license.getConsumerId(); @@ -537,36 +531,25 @@ export class FrontendService { // refresh enterprise status Object.assign(this.settings.enterprise, { - // oxlint-disable-next-line typescript/no-deprecated - sharing: this.license.isSharingEnabled(), - // oxlint-disable-next-line typescript/no-deprecated - logStreaming: this.license.isLogStreamingEnabled(), - // oxlint-disable-next-line typescript/no-deprecated - ldap: this.license.isLdapEnabled(), - // oxlint-disable-next-line typescript/no-deprecated - saml: this.license.isSamlEnabled(), + sharing: this.licenseState.isSharingLicensed(), + logStreaming: this.licenseState.isLogStreamingLicensed(), + ldap: this.licenseState.isLdapLicensed(), + saml: this.licenseState.isSamlLicensed(), oidc: this.licenseState.isOidcLicensed(), mfaEnforcement: this.licenseState.isMFAEnforcementLicensed(), provisioning: false, // temporarily disabled until this feature is ready for release - // oxlint-disable-next-line typescript/no-deprecated - advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(), - // oxlint-disable-next-line typescript/no-deprecated - variables: this.license.isVariablesEnabled(), - // oxlint-disable-next-line typescript/no-deprecated - sourceControl: this.license.isSourceControlLicensed(), - // oxlint-disable-next-line typescript/no-deprecated - externalSecrets: this.license.isExternalSecretsEnabled(), - showNonProdBanner: this.license.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER), - // oxlint-disable-next-line typescript/no-deprecated - debugInEditor: this.license.isDebugInEditorLicensed(), + advancedExecutionFilters: this.licenseState.isAdvancedExecutionFiltersLicensed(), + variables: this.licenseState.isVariablesLicensed(), + sourceControl: this.licenseState.isSourceControlLicensed(), + externalSecrets: this.licenseState.isExternalSecretsLicensed(), + showNonProdBanner: this.licenseState.isLicensed(LICENSE_FEATURES.SHOW_NON_PROD_BANNER), + debugInEditor: this.licenseState.isDebugInEditorLicensed(), binaryDataS3: isS3Available && isS3Selected && isS3Licensed, - // oxlint-disable-next-line typescript/no-deprecated - workerView: this.license.isWorkerViewLicensed(), - // oxlint-disable-next-line typescript/no-deprecated - advancedPermissions: this.license.isAdvancedPermissionsLicensed(), + workerView: this.licenseState.isWorkerViewLicensed(), + advancedPermissions: this.licenseState.isAdvancedPermissionsLicensed(), workflowDiffs: this.licenseState.isWorkflowDiffsLicensed(), - namedVersions: this.license.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS), + namedVersions: this.licenseState.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS), customRoles: this.licenseState.isCustomRolesLicensed(), personalSpacePolicy: this.licenseState.isPersonalSpacePolicyLicensed(), dataRedaction: this.licenseState.isDataRedactionLicensed(), @@ -577,16 +560,14 @@ export class FrontendService { this.settings.workerPools.enabled = this.globalConfig.queue.workerPool.enabled && this.licenseState.isWorkerPoolsLicensed(); - // oxlint-disable-next-line typescript/no-deprecated - if (this.license.isLdapEnabled()) { + if (this.licenseState.isLdapLicensed()) { Object.assign(this.settings.sso.ldap, { loginLabel: this.globalConfig.sso.ldap.loginLabel, loginEnabled: this.globalConfig.sso.ldap.loginEnabled, }); } - // oxlint-disable-next-line typescript/no-deprecated - if (this.license.isSamlEnabled()) { + if (this.licenseState.isSamlLicensed()) { Object.assign(this.settings.sso.saml, { loginLabel: getSamlLoginLabel(), loginEnabled: this.globalConfig.sso.saml.loginEnabled, @@ -599,10 +580,8 @@ export class FrontendService { }); } - // oxlint-disable-next-line typescript/no-deprecated - if (this.license.isVariablesEnabled()) { - // oxlint-disable-next-line typescript/no-deprecated - this.settings.variables.limit = this.license.getVariablesLimit(); + if (this.licenseState.isVariablesLicensed()) { + this.settings.variables.limit = this.licenseState.getMaxVariables(); } if (isAiAssistantEnabled) { @@ -620,8 +599,7 @@ export class FrontendService { if (isAiCreditsEnabled) { this.settings.aiCredits.enabled = isAiCreditsEnabled; - // oxlint-disable-next-line typescript/no-deprecated - this.settings.aiCredits.credits = this.license.getAiCredits(); + this.settings.aiCredits.credits = this.licenseState.getMaxAiCredits(); this.settings.aiCredits.setup = !!this.globalConfig.aiAssistant.baseUrl; } @@ -632,7 +610,7 @@ export class FrontendService { if (isAiGatewayEnabled) { this.settings.aiGateway = { enabled: true, - budget: this.license.getValue(LICENSE_QUOTAS.AI_GATEWAY_BUDGET) ?? 0, + budget: this.licenseState.getValue(LICENSE_QUOTAS.AI_GATEWAY_BUDGET) ?? 0, cloudUbbEnabled: this.licenseState.isAiGatewayCloudUbbLicensed(), }; } @@ -658,11 +636,9 @@ export class FrontendService { this.settings.binaryDataMode = this.binaryDataConfig.mode; - // oxlint-disable-next-line typescript/no-deprecated - this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit(); + this.settings.enterprise.projects.team.limit = this.licenseState.getMaxTeamProjects(); - // oxlint-disable-next-line typescript/no-deprecated - this.settings.folders.enabled = this.license.isFoldersEnabled(); + this.settings.folders.enabled = this.licenseState.isFoldersLicensed(); // Refresh evaluation settings this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();