334 lines
8.7 KiB
Markdown
334 lines
8.7 KiB
Markdown
# 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=alpha` with hardcoded feature list
|
|
- **API**: Uses `NODE_ENV` with `features.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`
|
|
```typescript
|
|
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`
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { FeaturesController } from './features.controller';
|
|
|
|
@Module({
|
|
controllers: [FeaturesController],
|
|
})
|
|
export class FeaturesModule {}
|
|
```
|
|
|
|
**Update**: `apps/api/src/app.module.ts`
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
/**
|
|
* 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`
|
|
```typescript
|
|
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`
|
|
```typescript
|
|
// 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`
|
|
```typescript
|
|
// 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`
|
|
```bash
|
|
# Remove:
|
|
# NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
|
|
|
# Keep:
|
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
|
```
|
|
|
|
**Update**: `.env.production`
|
|
```bash
|
|
# 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`
|
|
```typescript
|
|
// 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`
|
|
```typescript
|
|
// 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`
|
|
```markdown
|
|
# 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 |