feature flags

This commit is contained in:
2026-01-06 12:40:58 +01:00
parent 6aad7897db
commit c55ef731a1
11 changed files with 517 additions and 30 deletions

View File

@@ -0,0 +1,110 @@
/**
* Test file for the new feature flag configuration system
*
* Run with: npm test -- feature-loader.test.ts
*/
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { loadFeatureConfig, isFeatureEnabled, getFeatureState, getAllFeatures } from './feature-loader';
import { FlattenedFeatures, FeatureState } from './feature-types';
describe('Feature Flag Configuration', () => {
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
// Reset environment if needed
});
afterAll(() => {
// Restore original environment
process.env.NODE_ENV = originalEnv;
});
describe('loadFeatureConfig', () => {
it('should load development config when NODE_ENV=development', async () => {
process.env.NODE_ENV = 'development';
const result = await loadFeatureConfig();
expect(result.loadedFrom).toBe('config-file');
expect(result.features).toBeDefined();
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['admin.dashboard']).toBe('enabled');
});
it('should load test config when NODE_ENV=test', async () => {
process.env.NODE_ENV = 'test';
const result = await loadFeatureConfig();
expect(result.loadedFrom).toBe('config-file');
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['admin.dashboard']).toBe('enabled');
});
it('should load production config when NODE_ENV=production', async () => {
process.env.NODE_ENV = 'production';
const result = await loadFeatureConfig();
expect(result.loadedFrom).toBe('config-file');
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['sponsors.management']).toBe('disabled');
});
it('should handle invalid environment', async () => {
process.env.NODE_ENV = 'invalid-env';
await expect(loadFeatureConfig()).rejects.toThrow('Invalid environment');
});
});
describe('isFeatureEnabled', () => {
it('should return true for enabled features', () => {
const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' };
expect(isFeatureEnabled(features, 'sponsors.portal')).toBe(true);
});
it('should return false for disabled features', () => {
const features: FlattenedFeatures = { 'sponsors.portal': 'disabled' };
expect(isFeatureEnabled(features, 'sponsors.portal')).toBe(false);
});
it('should return false for non-existent features', () => {
const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' };
expect(isFeatureEnabled(features, 'nonexistent.feature')).toBe(false);
});
});
describe('getFeatureState', () => {
it('should return correct state for existing features', () => {
const features: FlattenedFeatures = { 'sponsors.portal': 'coming_soon' };
expect(getFeatureState(features, 'sponsors.portal')).toBe('coming_soon');
});
it('should return disabled for non-existent features', () => {
const features: FlattenedFeatures = { 'sponsors.portal': 'enabled' };
expect(getFeatureState(features, 'nonexistent')).toBe('disabled');
});
});
describe('getAllFeatures', () => {
it('should return all features for current environment', () => {
process.env.NODE_ENV = 'development';
const features = getAllFeatures();
expect(features).toBeDefined();
expect(Object.keys(features).length).toBeGreaterThan(0);
});
});
describe('Flattening behavior', () => {
it('should flatten nested feature configurations', async () => {
process.env.NODE_ENV = 'development';
const result = await loadFeatureConfig();
// Should flatten nested objects
expect(result.features['sponsors.portal']).toBeDefined();
expect(result.features['sponsors.dashboard']).toBeDefined();
expect(result.features['admin.dashboard']).toBeDefined();
});
});
});

View File

@@ -0,0 +1,129 @@
import { featureConfig } from './features.config';
import {
FeatureFlagConfig,
FlattenedFeatures,
ConfigLoadResult,
FeatureState
} from './feature-types';
/**
* Default configuration path relative to project root
*/
const DEFAULT_CONFIG_PATH = 'apps/api/src/config/features.config.ts';
/**
* Flattens nested feature configuration into a simple key-value map
*
* Example:
* Input: { sponsors: { portal: 'enabled' } }
* Output: { 'sponsors.portal': 'enabled' }
*/
function flattenFeatures(
config: Record<string, any>,
prefix: string = ''
): FlattenedFeatures {
const flattened: FlattenedFeatures = {};
for (const [key, value] of Object.entries(config)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Recursively flatten nested objects
Object.assign(flattened, flattenFeatures(value, fullKey));
} else if (isFeatureState(value)) {
// Assign feature state
flattened[fullKey] = value;
}
}
return flattened;
}
/**
* Type guard for FeatureState
*/
function isFeatureState(value: unknown): value is FeatureState {
return (
value === 'enabled' ||
value === 'disabled' ||
value === 'coming_soon' ||
value === 'hidden'
);
}
/**
* Gets the current environment
*/
function getEnvironment(): string {
return process.env.NODE_ENV || 'development';
}
/**
* Validates that the environment exists in the config
*/
function validateEnvironment(
env: string
): env is keyof FeatureFlagConfig {
const validEnvs = ['development', 'test', 'staging', 'production'];
if (!validEnvs.includes(env)) {
throw new Error(
`Invalid environment: "${env}". Valid environments: ${validEnvs.join(', ')}`
);
}
return true;
}
/**
* Loads feature configuration from the TypeScript config file
*/
export async function loadFeatureConfig(): Promise<ConfigLoadResult> {
const env = getEnvironment();
if (validateEnvironment(env)) {
const envConfig = featureConfig[env];
const flattened = flattenFeatures(envConfig);
return {
features: flattened,
loadedFrom: 'config-file',
configPath: DEFAULT_CONFIG_PATH,
};
}
// Fallback to defaults
return {
features: {},
loadedFrom: 'defaults',
};
}
/**
* Get a specific feature state
*/
export function getFeatureState(
features: FlattenedFeatures,
featurePath: string
): FeatureState {
return features[featurePath] || 'disabled';
}
/**
* Check if a feature is enabled
*/
export function isFeatureEnabled(
features: FlattenedFeatures,
featurePath: string
): boolean {
return getFeatureState(features, featurePath) === 'enabled';
}
/**
* Get all features for debugging
*/
export function getAllFeatures(): FlattenedFeatures {
const env = getEnvironment();
if (validateEnvironment(env)) {
return flattenFeatures(featureConfig[env]);
}
return {};
}

View File

@@ -0,0 +1,53 @@
/**
* Feature Flag Configuration Types
*
* Provides type-safe configuration for feature flags across different environments
*/
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
/**
* Individual feature configuration
*/
export interface FeatureConfig {
[featureName: string]: FeatureState | FeatureConfig;
}
/**
* Environment-specific feature configurations
*/
export interface EnvironmentConfig {
[featureGroup: string]: FeatureConfig;
}
/**
* Complete feature flag configuration
*/
export interface FeatureFlagConfig {
development: EnvironmentConfig;
test: EnvironmentConfig;
staging: EnvironmentConfig;
production: EnvironmentConfig;
}
/**
* Flattened feature map for runtime lookup
* Example: { 'sponsors.portal': 'enabled', 'admin.dashboard': 'enabled' }
*/
export type FlattenedFeatures = Record<string, FeatureState>;
/**
* Configuration loader result
*/
export interface ConfigLoadResult {
features: FlattenedFeatures;
loadedFrom: 'config-file' | 'env-var' | 'defaults';
configPath?: string;
}
/**
* Legacy JSON format for backward compatibility
*/
export interface LegacyFeatureConfig {
[key: string]: FeatureState | LegacyFeatureConfig;
}

View File

@@ -0,0 +1,82 @@
import { FeatureFlagConfig } from './feature-types';
/**
* Example feature flag configuration
*
* Copy this file to features.config.ts and customize for your needs
*
* Feature states:
* - 'enabled': Feature is fully available
* - 'disabled': Feature is turned off
* - 'coming_soon': Feature is in development, shown as "coming soon"
* - 'hidden': Feature is hidden from users
*/
export const featureConfig: FeatureFlagConfig = {
development: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'enabled',
experimental: 'coming_soon',
},
},
test: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'disabled',
experimental: 'disabled',
},
},
staging: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'coming_soon',
experimental: 'hidden',
},
},
production: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'disabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'disabled',
},
beta: {
newUI: 'disabled',
experimental: 'hidden',
},
},
};

View File

@@ -0,0 +1,79 @@
import { FeatureFlagConfig } from './feature-types';
/**
* Feature flag configuration for all environments
* This provides type safety, IntelliSense, and environment-specific settings
*/
export const featureConfig: FeatureFlagConfig = {
// Development environment - features for local development
development: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'enabled', // Enable new UI for testing
experimental: 'coming_soon',
},
},
// Test environment - features for automated tests
test: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'disabled',
experimental: 'disabled',
},
},
// Staging environment - features for pre-production testing
staging: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'coming_soon', // Ready for testing but not fully rolled out
experimental: 'hidden',
},
},
// Production environment - stable features only
production: {
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'disabled', // Feature not ready yet
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'disabled', // Feature not ready yet
},
beta: {
newUI: 'disabled',
experimental: 'hidden',
},
},
};

View File

@@ -0,0 +1,43 @@
/**
* Integration test to verify the feature flag system works end-to-end
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { loadFeatureConfig, isFeatureEnabled, getFeatureState } from './feature-loader';
describe('Feature Flag Integration Test', () => {
it('should load config and provide correct feature states', async () => {
// Set test environment
process.env.NODE_ENV = 'test';
const result = await loadFeatureConfig();
// Verify config loaded from file
expect(result.loadedFrom).toBe('config-file');
expect(result.configPath).toBe('apps/api/src/config/features.config.ts');
// Verify specific features from our config
expect(result.features['sponsors.portal']).toBe('enabled');
expect(result.features['sponsors.dashboard']).toBe('enabled');
expect(result.features['admin.dashboard']).toBe('enabled');
expect(result.features['beta.newUI']).toBe('disabled');
// Verify utility functions work
expect(isFeatureEnabled(result.features, 'sponsors.portal')).toBe(true);
expect(isFeatureEnabled(result.features, 'beta.newUI')).toBe(false);
expect(getFeatureState(result.features, 'sponsors.portal')).toBe('enabled');
expect(getFeatureState(result.features, 'nonexistent')).toBe('disabled');
});
it('should work with different environments', async () => {
// Test development environment
process.env.NODE_ENV = 'development';
const devResult = await loadFeatureConfig();
expect(devResult.features['beta.newUI']).toBe('enabled'); // dev has beta enabled
// Test production environment
process.env.NODE_ENV = 'production';
const prodResult = await loadFeatureConfig();
expect(prodResult.features['beta.newUI']).toBe('disabled'); // prod has beta disabled
});
});

View File

@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { loadFeatureConfig } from '../../config/feature-loader';
import { FlattenedFeatures } from '../../config/feature-types';
export type OperationalMode = 'normal' | 'maintenance' | 'test'; export type OperationalMode = 'normal' | 'maintenance' | 'test';
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden'; export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
@@ -72,24 +74,18 @@ export class PolicyService {
}; };
} }
const anyEnvConfigured = // Load from TypeScript config file
Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) || const configResult = await loadFeatureConfig();
Boolean(process.env.GRIDPILOT_FEATURES_JSON) || const raw: RawPolicySnapshot = {
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) || capabilities: convertFlattenedToCapabilities(configResult.features),
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); };
const raw: RawPolicySnapshot = {};
// Include other env vars if set
const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE; const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE;
if (operationalMode) { if (operationalMode) {
raw.operationalMode = operationalMode; raw.operationalMode = operationalMode;
} }
const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON);
if (capabilities) {
raw.capabilities = capabilities;
}
const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW); const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW);
const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE); const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
@@ -102,7 +98,7 @@ export class PolicyService {
return { return {
raw, raw,
loadedFrom: anyEnvConfigured ? 'env' : 'defaults', loadedFrom: configResult.loadedFrom === 'config-file' ? 'file' : 'defaults',
}; };
} }
} }
@@ -191,16 +187,6 @@ function parseFeatureState(value: unknown): FeatureState | null {
} }
} }
function parseFeaturesJson(raw: string | undefined): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function parseCsvList(raw: string | undefined): readonly string[] | undefined { function parseCsvList(raw: string | undefined): readonly string[] | undefined {
if (!raw) { if (!raw) {
@@ -227,3 +213,10 @@ function normalizeStringArray(value: unknown): readonly string[] {
return normalized; return normalized;
} }
// NEW: Helper function to convert flattened features to capabilities
function convertFlattenedToCapabilities(
flattened: FlattenedFeatures
): Record<string, FeatureState> {
return flattened as Record<string, FeatureState>;
}

View File

@@ -20,7 +20,6 @@ declare global {
GRIDPILOT_POLICY_CACHE_MS?: string; GRIDPILOT_POLICY_CACHE_MS?: string;
GRIDPILOT_POLICY_PATH?: string; GRIDPILOT_POLICY_PATH?: string;
GRIDPILOT_OPERATIONAL_MODE?: string; GRIDPILOT_OPERATIONAL_MODE?: string;
GRIDPILOT_FEATURES_JSON?: string;
GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string; GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string;
GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string; GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string;

View File

@@ -30,7 +30,6 @@ services:
- GRIDPILOT_API_PERSISTENCE=postgres - GRIDPILOT_API_PERSISTENCE=postgres
- GRIDPILOT_API_BOOTSTRAP=true - GRIDPILOT_API_BOOTSTRAP=true
- GRIDPILOT_API_FORCE_RESEED=true - GRIDPILOT_API_FORCE_RESEED=true
- GRIDPILOT_FEATURES_JSON={"sponsors.portal":"enabled","admin.dashboard":"enabled"}
- DATABASE_URL=postgres://gridpilot_e2e_user:gridpilot_e2e_pass@db:5432/gridpilot_e2e - DATABASE_URL=postgres://gridpilot_e2e_user:gridpilot_e2e_pass@db:5432/gridpilot_e2e
- POSTGRES_DB=gridpilot_e2e - POSTGRES_DB=gridpilot_e2e
- POSTGRES_USER=gridpilot_e2e_user - POSTGRES_USER=gridpilot_e2e_user

View File

@@ -46,7 +46,6 @@ services:
- GRIDPILOT_API_PERSISTENCE=postgres - GRIDPILOT_API_PERSISTENCE=postgres
- GRIDPILOT_API_BOOTSTRAP=true - GRIDPILOT_API_BOOTSTRAP=true
- GRIDPILOT_API_FORCE_RESEED=true - GRIDPILOT_API_FORCE_RESEED=true
- GRIDPILOT_FEATURES_JSON={"sponsors.portal":"enabled","admin.dashboard":"enabled"}
- DATABASE_URL=postgres://gridpilot_test_user:gridpilot_test_pass@db:5432/gridpilot_test - DATABASE_URL=postgres://gridpilot_test_user:gridpilot_test_pass@db:5432/gridpilot_test
- POSTGRES_DB=gridpilot_test - POSTGRES_DB=gridpilot_test
- POSTGRES_USER=gridpilot_test_user - POSTGRES_USER=gridpilot_test_user

View File

@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture'; import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000'; const WEBSITE_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
@@ -538,4 +538,5 @@ test.describe('Website Pages - TypeORM Integration', () => {
expect(detailBodyText).toBeTruthy(); expect(detailBodyText).toBeTruthy();
expect(detailBodyText?.length).toBeGreaterThan(50); expect(detailBodyText?.length).toBeGreaterThan(50);
}); });
}); });