8.7 KiB
API-Driven Feature Configuration - Implementation Plan
Goal
Create a single source of truth for feature configuration that both API and Website use, eliminating duplication and ensuring security.
Current State
- Website: Uses
NEXT_PUBLIC_GRIDPILOT_MODE=alphawith hardcoded feature list - API: Uses
NODE_ENVwithfeatures.config.ts - Problem: Two systems, risk of inconsistency, security gaps
Target State
- Single Source:
apps/api/src/config/features.config.ts - Website: Reads features from API endpoint
- Result: One config file, automatic synchronization, secure by default
Implementation Steps
Step 1: Create API Feature Endpoint
File: apps/api/src/features/features.controller.ts
import { Controller, Get } from '@nestjs/common';
import { loadFeatureConfig } from '../config/feature-loader';
@Controller('features')
export class FeaturesController {
@Get()
async getFeatures() {
const result = await loadFeatureConfig();
return {
features: result.features,
loadedFrom: result.loadedFrom,
timestamp: new Date().toISOString(),
};
}
}
File: apps/api/src/features/features.module.ts
import { Module } from '@nestjs/common';
import { FeaturesController } from './features.controller';
@Module({
controllers: [FeaturesController],
})
export class FeaturesModule {}
Update: apps/api/src/app.module.ts
import { FeaturesModule } from './features/features.module';
@Module({
imports: [
// ... existing modules
FeaturesModule,
],
})
export class AppModule {}
Step 2: Update Website FeatureFlagService
Replace: apps/website/lib/feature/FeatureFlagService.ts
/**
* FeatureFlagService - Reads features from API
*
* Single Source of Truth: API /api/features endpoint
*/
export class FeatureFlagService {
private flags: Set<string>;
constructor(flags: string[]) {
this.flags = new Set(flags);
}
isEnabled(flag: string): boolean {
return this.flags.has(flag);
}
getEnabledFlags(): string[] {
return Array.from(this.flags);
}
/**
* Load features from API - Single Source of Truth
*/
static async fromAPI(): Promise<FeatureFlagService> {
try {
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const response = await fetch(`${baseUrl}/features`, {
cache: 'no-store', // Always get fresh data
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const flags = Object.keys(data.features).filter(
key => data.features[key] === 'enabled'
);
return new FeatureFlagService(flags);
} catch (error) {
console.error('Failed to load features from API:', error);
// Fallback: empty service (secure by default)
return new FeatureFlagService([]);
}
}
/**
* Mock for testing/local development
*/
static mock(flags: string[] = []): FeatureFlagService {
return new FeatureFlagService(flags);
}
}
// Client-side context interface
export interface FeatureFlagContextType {
isEnabled: (flag: string) => boolean;
getEnabledFlags: () => string[];
}
// Default mock for client-side when API unavailable
export const mockFeatureFlags = new FeatureFlagService([
'driver_profiles',
'team_profiles',
'wallets',
'sponsors',
'team_feature',
'alpha_features'
]);
Step 3: Update Website to Use API Features
Update: apps/website/lib/services/home/getHomeData.ts
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
export async function getHomeData() {
const container = ContainerManager.getInstance().getContainer();
const sessionService = container.get<SessionService>(SESSION_SERVICE_TOKEN);
const landingService = container.get<LandingService>(LANDING_SERVICE_TOKEN);
const session = await sessionService.getSession();
if (session) {
redirect('/dashboard');
}
// Load features from API (single source of truth)
const featureService = await FeatureFlagService.fromAPI();
const isAlpha = featureService.isEnabled('alpha_features');
const discovery = await landingService.getHomeDiscovery();
return {
isAlpha,
upcomingRaces: discovery.upcomingRaces,
topLeagues: discovery.topLeagues,
teams: discovery.teams,
};
}
Update: apps/website/app/layout.tsx
// Remove: import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
// Remove: const featureService = FeatureFlagService.fromEnv();
// Remove: const enabledFlags = featureService.getEnabledFlags();
// Keep only: <FeatureFlagProvider flags={enabledFlags}>
// Instead, wrap the app to load features from API
export default async function RootLayout({ children }) {
// Load features from API
const featureService = await FeatureFlagService.fromAPI();
const enabledFlags = featureService.getEnabledFlags();
return (
<html>
<body>
<FeatureFlagProvider flags={enabledFlags}>
{children}
</FeatureFlagProvider>
</body>
</html>
);
}
Step 4: Remove Website-Specific Config
Delete: apps/website/lib/mode.ts (no longer needed)
Delete: apps/website/lib/feature/FeatureFlagService.ts (replaced)
Delete: apps/website/lib/feature/FeatureFlagService.test.ts (replaced)
Update: apps/website/lib/config/env.ts
// Remove NEXT_PUBLIC_GRIDPILOT_MODE from schema
const publicEnvSchema = z.object({
// NEXT_PUBLIC_GRIDPILOT_MODE: z.enum(['pre-launch', 'alpha']).optional(), // REMOVE
NEXT_PUBLIC_SITE_URL: urlOptional,
NEXT_PUBLIC_API_BASE_URL: urlOptional,
// ... other env vars
});
Step 5: Update Environment Files
Update: .env.development
# Remove:
# NEXT_PUBLIC_GRIDPILOT_MODE=alpha
# Keep:
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
Update: .env.production
# Remove:
# NEXT_PUBLIC_GRIDPILOT_MODE=beta
# Keep:
NEXT_PUBLIC_API_BASE_URL=https://api.gridpilot.com
Step 6: Update Tests
Update: apps/api/src/config/integration.test.ts
// Add test for feature endpoint
describe('Features API Endpoint', () => {
it('should return all features from API', async () => {
process.env.NODE_ENV = 'development';
const result = await loadFeatureConfig();
// Verify API returns all expected features
expect(result.features['platform.dashboard']).toBe('enabled');
expect(result.features['alpha_features']).toBe('enabled');
});
});
Update: apps/website/lib/feature/FeatureFlagService.test.ts
// Test API loading
describe('FeatureFlagService.fromAPI()', () => {
it('should load features from API endpoint', async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
features: {
'platform.dashboard': 'enabled',
'alpha_features': 'enabled',
},
}),
});
const service = await FeatureFlagService.fromAPI();
expect(service.isEnabled('platform.dashboard')).toBe(true);
});
});
Step 7: Update Documentation
Update: docs/FEATURE_ARCHITECTURE.md
# Feature Architecture - API-Driven
## Single Source of Truth
**Location**: `apps/api/src/config/features.config.ts`
## How It Works
1. **API Config** defines all features and their states
2. **API Endpoint** `/features` exposes current configuration
3. **Website** loads features from API at runtime
4. **PolicyService** uses API config for security enforcement
## Benefits
- ✅ One file to maintain
- ✅ Automatic synchronization
- ✅ Runtime feature changes
- ✅ Security-first (API controls everything)
- ✅ No duplication
## Feature States (Same as before)
- `enabled` = Fully available
- `disabled` = Completely blocked
- `coming_soon` = Visible but not functional
- `hidden` = Invisible
## Environment Mapping
| Environment | API Config | Website Behavior |
|-------------|------------|------------------|
| Development | All enabled | Full platform access |
| Test | All enabled | Full testing |
| Staging | Controlled | Beta features visible |
| Production | Stable only | Production features only |
Migration Checklist
- Create API endpoint
- Update FeatureFlagService to use API
- Update website components to use new service
- Remove old website config files
- Update environment files
- Update tests
- Update documentation
- Test end-to-end
- Verify security (no leaks)
Expected Results
Before: Two configs, risk of mismatch, manual sync After: One config, automatic sync, secure by default
Files Changed: ~8 files Files Deleted: ~3 files Time Estimate: 2-3 hours