diff --git a/cypress/e2e/env-feature-flags.cy.ts b/cypress/e2e/env-feature-flags.cy.ts
new file mode 100644
index 0000000000..55bbfb78d7
--- /dev/null
+++ b/cypress/e2e/env-feature-flags.cy.ts
@@ -0,0 +1,37 @@
+describe('Environment Feature Flags', () => {
+ it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => {
+ cy.setEnvFeatureFlags({
+ N8N_ENV_FEAT_TEST: true,
+ });
+ cy.signinAsOwner();
+ cy.intercept('GET', '/rest/settings').as('getSettings');
+ cy.visit('/');
+ cy.wait('@getSettings').then((interception) => {
+ expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
+ expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
+ 'true',
+ );
+ });
+ });
+
+ it('should reset feature flags at runtime', () => {
+ cy.setEnvFeatureFlags({
+ N8N_ENV_FEAT_TEST: true,
+ });
+ cy.signinAsOwner();
+ cy.intercept('GET', '/rest/settings').as('getSettings');
+ cy.visit('/');
+ cy.wait('@getSettings').then((interception) => {
+ expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
+ 'true',
+ );
+ });
+
+ cy.clearEnvFeatureFlags();
+ cy.visit('/');
+ cy.wait('@getSettings').then((interception) => {
+ expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
+ expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.be.undefined;
+ });
+ });
+});
diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js
index 597388d413..db50583b71 100755
--- a/cypress/scripts/run-e2e.js
+++ b/cypress/scripts/run-e2e.js
@@ -14,6 +14,14 @@ function runTests(options) {
process.env.E2E_TESTS = 'true';
process.env.NODE_OPTIONS = '--dns-result-order=ipv4first';
+ // Automatically pass through any N8N_ENV_FEAT_* environment variables
+ Object.keys(process.env).forEach((key) => {
+ if (key.startsWith('N8N_ENV_FEAT_')) {
+ // These are already in process.env and will be inherited by the spawned process
+ console.log(`Passing through environment feature flag: ${key}=${process.env[key]}`);
+ }
+ });
+
if (options.customEnv) {
Object.keys(options.customEnv).forEach((key) => {
process.env[key] = options.customEnv[key];
@@ -21,7 +29,11 @@ function runTests(options) {
}
const cmd = `start-server-and-test ${options.startCommand} ${options.url} '${options.testCommand}'`;
- const testProcess = spawn(cmd, [], { stdio: 'inherit', shell: true });
+ const testProcess = spawn(cmd, [], {
+ stdio: 'inherit',
+ shell: true,
+ env: process.env, // TODO: Maybe pass only the necessary environment variables instead of all
+ });
// Listen for termination signals to properly kill the test process
process.on('SIGINT', () => {
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index 0417bf71f7..91e9f540c7 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -1,5 +1,5 @@
import 'cypress-real-events';
-import type { FrontendSettings } from '@n8n/api-types';
+import type { FrontendSettings, N8nEnvFeatFlags } from '@n8n/api-types';
import FakeTimers from '@sinonjs/fake-timers';
import {
@@ -115,6 +115,25 @@ Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature,
Cypress.Commands.add('enableQueueMode', () => setQueueMode(true));
Cypress.Commands.add('disableQueueMode', () => setQueueMode(false));
+const setEnvFeatureFlags = (flags: N8nEnvFeatFlags) =>
+ cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`, {
+ flags,
+ });
+
+const getEnvFeatureFlags = () =>
+ cy.request('GET', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`);
+
+// Environment feature flags commands (using E2E API)
+Cypress.Commands.add('setEnvFeatureFlags', (flags: N8nEnvFeatFlags) =>
+ setEnvFeatureFlags(flags).then((response) => response.body.data),
+);
+Cypress.Commands.add('clearEnvFeatureFlags', () =>
+ setEnvFeatureFlags({}).then((response) => response.body.data),
+);
+Cypress.Commands.add('getEnvFeatureFlags', () =>
+ getEnvFeatureFlags().then((response) => response.body.data),
+);
+
Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => {
if (Cypress.isBrowser('chrome')) {
cy.wrap(
diff --git a/cypress/support/index.ts b/cypress/support/index.ts
index 19fd0497c6..784e8b6001 100644
--- a/cypress/support/index.ts
+++ b/cypress/support/index.ts
@@ -1,7 +1,7 @@
// Load type definitions that come with Cypress module
///
-import type { FrontendSettings, PushPayload, PushType } from '@n8n/api-types';
+import type { FrontendSettings, PushPayload, PushType, N8nEnvFeatFlags } from '@n8n/api-types';
Cypress.Keyboard.defaults({
keystrokeDelay: 0,
@@ -51,6 +51,11 @@ declare global {
enableQueueMode(): void;
disableQueueMode(): void;
changeQuota(feature: string, value: number): void;
+ setEnvFeatureFlags(
+ flags: N8nEnvFeatFlags,
+ ): Chainable<{ success: boolean; flags?: N8nEnvFeatFlags; error?: string }>;
+ clearEnvFeatureFlags(): Chainable<{ success: boolean; flags: N8nEnvFeatFlags }>;
+ getEnvFeatureFlags(): Chainable;
waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable;
diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts
index d84952f427..2d4adf9cdd 100644
--- a/packages/@n8n/api-types/src/frontend-settings.ts
+++ b/packages/@n8n/api-types/src/frontend-settings.ts
@@ -202,6 +202,7 @@ export interface FrontendSettings {
/** Backend modules that were initialized during startup. */
activeModules: string[];
+ envFeatureFlags: N8nEnvFeatFlags;
}
export type FrontendModuleSettings = {
@@ -218,3 +219,6 @@ export type FrontendModuleSettings = {
dateRanges: InsightsDateRange[];
};
};
+
+export type N8nEnvFeatFlagValue = boolean | string | number | undefined;
+export type N8nEnvFeatFlags = Record<`N8N_ENV_FEAT_${Uppercase}`, N8nEnvFeatFlagValue>;
diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts
index 1d6b041c1c..55b5ef622f 100644
--- a/packages/cli/src/controllers/e2e.controller.ts
+++ b/packages/cli/src/controllers/e2e.controller.ts
@@ -3,7 +3,7 @@ import { Logger } from '@n8n/backend-common';
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import { SettingsRepository, UserRepository } from '@n8n/db';
-import { Patch, Post, RestController } from '@n8n/decorators';
+import { Get, Patch, Post, RestController } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { Request } from 'express';
import { v4 as uuid } from 'uuid';
@@ -17,6 +17,7 @@ import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service';
+import { FrontendService } from '@/services/frontend.service';
import { PasswordUtility } from '@/services/password.utility';
if (!inE2ETests) {
@@ -154,6 +155,7 @@ export class E2EController {
private readonly passwordUtility: PasswordUtility,
private readonly eventBus: MessageEventBus,
private readonly userRepository: UserRepository,
+ private readonly frontendService: FrontendService,
) {
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
@@ -207,6 +209,47 @@ export class E2EController {
return { success: true, message: `Queue mode set to ${config.getEnv('executions.mode')}` };
}
+ @Get('/env-feature-flags', { skipAuth: true })
+ async getEnvFeatureFlags() {
+ const currentFlags = this.frontendService.getSettings().envFeatureFlags;
+ return currentFlags;
+ }
+
+ @Patch('/env-feature-flags', { skipAuth: true })
+ async setEnvFeatureFlags(req: Request<{}, {}, { flags: Record }>) {
+ const { flags } = req.body;
+
+ // Validate that all flags start with N8N_ENV_FEAT_
+ for (const key of Object.keys(flags)) {
+ if (!key.startsWith('N8N_ENV_FEAT_')) {
+ return {
+ success: false,
+ message: `Invalid flag key: ${key}. Must start with N8N_ENV_FEAT_`,
+ };
+ }
+ }
+
+ // Clear existing N8N_ENV_FEAT_ environment variables
+ for (const key of Object.keys(process.env)) {
+ if (key.startsWith('N8N_ENV_FEAT_')) {
+ delete process.env[key];
+ }
+ }
+
+ // Set new environment variables
+ for (const [key, value] of Object.entries(flags)) {
+ process.env[key] = value;
+ }
+
+ // Return the current environment feature flags
+ const currentFlags = this.frontendService.getSettings().envFeatureFlags;
+ return {
+ success: true,
+ message: 'Environment feature flags updated',
+ flags: currentFlags,
+ };
+ }
+
private resetFeatures() {
for (const feature of Object.keys(this.enabledFeatures)) {
this.enabledFeatures[feature as BooleanLicenseFeature] = false;
diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts
new file mode 100644
index 0000000000..cf5207c27f
--- /dev/null
+++ b/packages/cli/src/services/__tests__/frontend.service.test.ts
@@ -0,0 +1,269 @@
+import { mock } from 'jest-mock-extended';
+import type { GlobalConfig, SecurityConfig } from '@n8n/config';
+import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
+import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
+import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
+import type { CredentialTypes } from '@/credential-types';
+import type { CredentialsOverwrites } from '@/credentials-overwrites';
+import type { License } from '@/license';
+import type { UserManagementMailer } from '@/user-management/email';
+import type { UrlService } from '@/services/url.service';
+import type { PushConfig } from '@/push/push.config';
+import type { MfaService } from '@/mfa/mfa.service';
+
+import { FrontendService } from '@/services/frontend.service';
+
+describe('FrontendService', () => {
+ let originalEnv: NodeJS.ProcessEnv;
+
+ beforeEach(() => {
+ originalEnv = process.env;
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ describe('envFeatureFlags functionality', () => {
+ const createMockService = () => {
+ const globalConfig = mock({
+ database: { type: 'sqlite' },
+ endpoints: { rest: 'rest' },
+ diagnostics: { enabled: false },
+ templates: { enabled: false, host: '' },
+ nodes: { communityPackages: { enabled: false } },
+ tags: { disabled: false },
+ logging: { level: 'info' },
+ hiringBanner: { enabled: false },
+ versionNotifications: {
+ enabled: false,
+ endpoint: '',
+ whatsNewEnabled: false,
+ whatsNewEndpoint: '',
+ infoUrl: '',
+ },
+ personalization: { enabled: false },
+ defaultLocale: 'en',
+ auth: { cookie: { secure: false } },
+ generic: { releaseChannel: 'stable', timezone: 'UTC' },
+ publicApi: { path: 'api', swaggerUiDisabled: false },
+ workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
+ executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
+ hideUsagePage: false,
+ license: { tenantId: 1 },
+ mfa: { enabled: false },
+ deployment: { type: 'default' },
+ workflowHistory: { enabled: false },
+ partialExecutions: { version: 1 },
+ path: '',
+ sso: {
+ ldap: { loginEnabled: false },
+ saml: { loginEnabled: false },
+ oidc: { loginEnabled: false },
+ },
+ });
+
+ const logger = mock();
+ const instanceSettings = mock({
+ isDocker: false,
+ instanceId: 'test-instance',
+ isMultiMain: false,
+ hostId: 'test-host',
+ staticCacheDir: '/tmp/test-cache',
+ });
+
+ const loadNodesAndCredentials = mock({
+ addPostProcessor: jest.fn(),
+ types: {
+ credentials: [],
+ nodes: [],
+ },
+ });
+
+ const binaryDataConfig = mock({
+ mode: 'default',
+ availableModes: ['default'],
+ });
+
+ const credentialTypes = mock({
+ getParentTypes: jest.fn().mockReturnValue([]),
+ });
+
+ const credentialsOverwrites = mock({
+ getAll: jest.fn().mockReturnValue({}),
+ });
+
+ const license = mock({
+ getUsersLimit: jest.fn().mockReturnValue(100),
+ getPlanName: jest.fn().mockReturnValue('Community'),
+ getConsumerId: jest.fn().mockReturnValue('test-consumer'),
+ isSharingEnabled: jest.fn().mockReturnValue(false),
+ isLogStreamingEnabled: jest.fn().mockReturnValue(false),
+ isLdapEnabled: jest.fn().mockReturnValue(false),
+ isSamlEnabled: jest.fn().mockReturnValue(false),
+ isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
+ isVariablesEnabled: jest.fn().mockReturnValue(false),
+ isSourceControlLicensed: jest.fn().mockReturnValue(false),
+ isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
+ isLicensed: jest.fn().mockReturnValue(false),
+ isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
+ isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
+ isWorkerViewLicensed: jest.fn().mockReturnValue(false),
+ isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
+ isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
+ getVariablesLimit: jest.fn().mockReturnValue(0),
+ getTeamProjectLimit: jest.fn().mockReturnValue(0),
+ isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
+ isAiAssistantEnabled: jest.fn().mockReturnValue(false),
+ isAskAiEnabled: jest.fn().mockReturnValue(false),
+ isAiCreditsEnabled: jest.fn().mockReturnValue(false),
+ getAiCredits: jest.fn().mockReturnValue(0),
+ isFoldersEnabled: jest.fn().mockReturnValue(false),
+ });
+
+ const mailer = mock({
+ isEmailSetUp: false,
+ });
+
+ const urlService = mock({
+ getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
+ getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
+ });
+
+ const securityConfig = mock({
+ blockFileAccessToN8nFiles: false,
+ });
+
+ const pushConfig = mock({
+ backend: 'websocket',
+ });
+
+ const licenseState = mock({
+ isOidcLicensed: jest.fn().mockReturnValue(false),
+ isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
+ getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
+ });
+
+ const moduleRegistry = mock({
+ getActiveModules: jest.fn().mockReturnValue([]),
+ });
+
+ const mfaService = mock({
+ isMFAEnforced: jest.fn().mockReturnValue(false),
+ });
+
+ return new FrontendService(
+ globalConfig,
+ logger,
+ loadNodesAndCredentials,
+ credentialTypes,
+ credentialsOverwrites,
+ license,
+ mailer,
+ instanceSettings,
+ urlService,
+ securityConfig,
+ pushConfig,
+ binaryDataConfig,
+ licenseState,
+ moduleRegistry,
+ mfaService,
+ );
+ };
+
+ describe('collectEnvFeatureFlags', () => {
+ it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => {
+ process.env = {
+ N8N_ENV_FEAT_TEST_FLAG: 'true',
+ N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
+ N8N_ENV_FEAT_NUMERIC_FLAG: '123',
+ REGULAR_ENV_VAR: 'should-not-be-included',
+ N8N_OTHER_PREFIX: 'should-not-be-included',
+ };
+
+ const service = createMockService();
+ const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
+ const result = collectEnvFeatureFlags();
+
+ expect(result).toEqual({
+ N8N_ENV_FEAT_TEST_FLAG: 'true',
+ N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
+ N8N_ENV_FEAT_NUMERIC_FLAG: '123',
+ });
+ });
+
+ it('should return empty object when no N8N_ENV_FEAT_ variables are set', () => {
+ process.env = {
+ REGULAR_ENV_VAR: 'value',
+ N8N_OTHER_PREFIX: 'value',
+ };
+
+ const service = createMockService();
+ const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
+ const result = collectEnvFeatureFlags();
+
+ expect(result).toEqual({});
+ });
+
+ it('should filter out undefined environment variable values', () => {
+ process.env = {
+ N8N_ENV_FEAT_DEFINED_FLAG: 'true',
+ N8N_ENV_FEAT_UNDEFINED_FLAG: undefined,
+ };
+
+ const service = createMockService();
+ const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
+ const result = collectEnvFeatureFlags();
+
+ expect(result).toEqual({
+ N8N_ENV_FEAT_DEFINED_FLAG: 'true',
+ // N8N_ENV_FEAT_UNDEFINED_FLAG should be filtered out
+ });
+ });
+ });
+
+ describe('settings integration', () => {
+ it('should include envFeatureFlags in initial settings', () => {
+ process.env = {
+ N8N_ENV_FEAT_INIT_FLAG: 'true',
+ N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
+ };
+
+ const service = createMockService();
+
+ expect(service.settings.envFeatureFlags).toEqual({
+ N8N_ENV_FEAT_INIT_FLAG: 'true',
+ N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
+ });
+ });
+
+ it('should refresh envFeatureFlags when getSettings is called', () => {
+ process.env = {
+ N8N_ENV_FEAT_INITIAL_FLAG: 'true',
+ };
+
+ const service = createMockService();
+
+ // Verify initial state
+ expect(service.settings.envFeatureFlags).toEqual({
+ N8N_ENV_FEAT_INITIAL_FLAG: 'true',
+ });
+
+ // Change environment
+ process.env = {
+ N8N_ENV_FEAT_INITIAL_FLAG: 'false',
+ N8N_ENV_FEAT_NEW_FLAG: 'true',
+ };
+
+ // getSettings should refresh the flags
+ const settings = service.getSettings();
+
+ expect(settings.envFeatureFlags).toEqual({
+ N8N_ENV_FEAT_INITIAL_FLAG: 'false',
+ N8N_ENV_FEAT_NEW_FLAG: 'true',
+ });
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts
index dcc78600a4..40acd807cd 100644
--- a/packages/cli/src/services/frontend.service.ts
+++ b/packages/cli/src/services/frontend.service.ts
@@ -1,4 +1,4 @@
-import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
+import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig, SecurityConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
@@ -66,6 +66,18 @@ export class FrontendService {
}
}
+ private collectEnvFeatureFlags(): N8nEnvFeatFlags {
+ const envFeatureFlags: N8nEnvFeatFlags = {};
+
+ for (const [key, value] of Object.entries(process.env)) {
+ if (key.startsWith('N8N_ENV_FEAT_') && value !== undefined) {
+ envFeatureFlags[key as keyof N8nEnvFeatFlags] = value;
+ }
+ }
+
+ return envFeatureFlags;
+ }
+
private initSettings() {
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
const restEndpoint = this.globalConfig.endpoints.rest;
@@ -260,6 +272,7 @@ export class FrontendService {
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
},
activeModules: this.moduleRegistry.getActiveModules(),
+ envFeatureFlags: this.collectEnvFeatureFlags(),
};
}
@@ -404,6 +417,9 @@ export class FrontendService {
// Refresh evaluation settings
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();
+ // Refresh environment feature flags
+ this.settings.envFeatureFlags = this.collectEnvFeatureFlags();
+
return this.settings;
}
diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts
index 6681c02f16..ed5bdb6982 100644
--- a/packages/frontend/editor-ui/src/__tests__/defaults.ts
+++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts
@@ -153,4 +153,5 @@ export const defaultSettings: FrontendSettings = {
quota: 0,
},
activeModules: [],
+ envFeatureFlags: {},
};
diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts
new file mode 100644
index 0000000000..5662b83ce4
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts
@@ -0,0 +1,163 @@
+import { createTestingPinia } from '@pinia/testing';
+import type { FrontendSettings, N8nEnvFeatFlags, N8nEnvFeatFlagValue } from '@n8n/api-types';
+import { createComponentRenderer } from '@/__tests__/render';
+import { mockedStore, type MockedStore } from '@/__tests__/utils';
+import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
+import { useSettingsStore } from '@/stores/settings.store';
+
+const renderComponent = createComponentRenderer(EnvFeatureFlag);
+
+describe('EnvFeatureFlag', () => {
+ let settingsStore: MockedStore;
+ const originalEnv = { ...import.meta.env };
+
+ beforeEach(() => {
+ Object.keys(import.meta.env).forEach((key) => {
+ if (key.startsWith('N8N_ENV_FEAT_')) {
+ delete (import.meta.env as N8nEnvFeatFlags)[key as keyof N8nEnvFeatFlags];
+ }
+ });
+ createTestingPinia();
+
+ settingsStore = mockedStore(useSettingsStore);
+ settingsStore.settings = {
+ envFeatureFlags: {},
+ } as FrontendSettings;
+ });
+
+ afterEach(() => {
+ Object.assign(import.meta.env, originalEnv);
+ });
+
+ test.each<[N8nEnvFeatFlagValue, Uppercase, boolean]>([
+ // Truthy values that should render content
+ ['true', 'TEST_FLAG', true],
+ ['enabled', 'TEST_FLAG', true],
+ ['yes', 'TEST_FLAG', true],
+ ['1', 'TEST_FLAG', true],
+ ['on', 'TEST_FLAG', true],
+ [true, 'TEST_FLAG', true],
+
+ // Falsy values that should not render content
+ ['false', 'TEST_FLAG', false],
+ [false, 'TEST_FLAG', false],
+ ['', 'TEST_FLAG', false],
+ [undefined, 'TEST_FLAG', false],
+ [0, 'TEST_FLAG', false],
+ ])(
+ 'should %s render slot content when feature flag value is %s',
+ (value, flagName, shouldRender) => {
+ const envKey: keyof N8nEnvFeatFlags = `N8N_ENV_FEAT_${flagName}`;
+
+ settingsStore.settings.envFeatureFlags = {
+ [envKey]: value,
+ };
+
+ const { container } = renderComponent({
+ props: {
+ name: flagName,
+ },
+ slots: {
+ default: 'Feature content
',
+ },
+ });
+
+ if (shouldRender) {
+ expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
+ } else {
+ expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
+ }
+ },
+ );
+
+ it('should work with different flag names', () => {
+ settingsStore.settings.envFeatureFlags = {
+ N8N_ENV_FEAT_WORKFLOW_DIFFS: 'true',
+ N8N_ENV_FEAT_ANOTHER_FEATURE: 'false',
+ };
+
+ const { container: container1 } = renderComponent({
+ props: {
+ name: 'WORKFLOW_DIFFS',
+ },
+ slots: {
+ default: 'Feature 1
',
+ },
+ });
+
+ const { container: container2 } = renderComponent({
+ props: {
+ name: 'ANOTHER_FEATURE',
+ },
+ slots: {
+ default: 'Feature 2
',
+ },
+ });
+
+ expect(container1.querySelector('[data-testid="feature-1"]')).toBeTruthy();
+ expect(container2.querySelector('[data-testid="feature-2"]')).toBeNull();
+ });
+
+ describe('runtime vs build-time priority', () => {
+ it('should prioritize runtime settings over build-time env vars', () => {
+ // Set build-time env var
+ (import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
+
+ // Set runtime setting to override
+ settingsStore.settings.envFeatureFlags = {
+ N8N_ENV_FEAT_TEST_FLAG: 'false',
+ };
+
+ const { container } = renderComponent({
+ props: {
+ name: 'TEST_FLAG',
+ },
+ slots: {
+ default: 'Feature content
',
+ },
+ });
+
+ // Should use runtime value (false) over build-time value (true)
+ expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
+ });
+
+ it('should fallback to build-time env vars when runtime settings are not available', () => {
+ // Set build-time env var
+ (import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
+
+ // Runtime settings are empty
+ settingsStore.settings.envFeatureFlags = {};
+
+ const { container } = renderComponent({
+ props: {
+ name: 'TEST_FLAG',
+ },
+ slots: {
+ default: 'Feature content
',
+ },
+ });
+
+ // Should use build-time value
+ expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
+ });
+
+ it('should return false when neither runtime nor build-time values are set', () => {
+ // No runtime setting
+ settingsStore.settings.envFeatureFlags = {};
+
+ // No build-time env var (already cleared in beforeEach)
+
+ const { container } = renderComponent({
+ props: {
+ name: 'TEST_FLAG',
+ },
+ slots: {
+ default: 'Feature content
',
+ },
+ });
+
+ // Should default to false
+ expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue
new file mode 100644
index 0000000000..c33e69bda1
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue
@@ -0,0 +1,27 @@
+
diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts b/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts
new file mode 100644
index 0000000000..833b6e7b6b
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts
@@ -0,0 +1,30 @@
+import { computed } from 'vue';
+import type { N8nEnvFeatFlags } from '@n8n/api-types';
+import { useSettingsStore } from '@/stores/settings.store';
+
+export const useEnvFeatureFlag = () => {
+ const settingsStore = useSettingsStore();
+
+ const check = computed(() => (flag: Uppercase): boolean => {
+ const key = `N8N_ENV_FEAT_${flag}` as const;
+
+ // Settings provided by the backend take precedence over build-time or runtime flags
+ const settingsProvidedEnvFeatFlag = settingsStore.settings.envFeatureFlags?.[key];
+ if (settingsProvidedEnvFeatFlag !== undefined) {
+ return settingsProvidedEnvFeatFlag !== 'false' && !!settingsProvidedEnvFeatFlag;
+ }
+
+ // "Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective."
+ // See https://vite.dev/guide/env-and-mode.html
+ const buildTimeValue = (import.meta.env as N8nEnvFeatFlags)[key];
+ if (buildTimeValue !== undefined) {
+ return buildTimeValue !== 'false' && !!buildTimeValue;
+ }
+
+ return false;
+ });
+
+ return {
+ check,
+ };
+};
diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts
index 4818e93bcb..a42c492f6a 100644
--- a/packages/frontend/editor-ui/vite.config.mts
+++ b/packages/frontend/editor-ui/vite.config.mts
@@ -141,7 +141,7 @@ export default mergeConfig(
plugins,
resolve: { alias },
base: publicPath,
- envPrefix: 'VUE',
+ envPrefix: ['VUE', 'N8N_ENV_FEAT'],
css: {
preprocessorOptions: {
scss: {