Skip to content

Commit bece4af

Browse files
committed
feat(backend): gate experimental modules with feature flags
1 parent 4099904 commit bece4af

12 files changed

Lines changed: 338 additions & 1 deletion

backend/docs/feature-flags.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Backend Feature Flags Runbook
2+
3+
## Purpose
4+
Feature flags gate experimental backend routes at the API edge so unfinished modules are fully unreachable when disabled.
5+
6+
Safe default: any missing flag is treated as `false`.
7+
8+
## Configuration
9+
Set `FEATURE_FLAGS_JSON` as a JSON object at process start:
10+
11+
```bash
12+
FEATURE_FLAGS_JSON='{"experimental.oracleHooks":false,"experimental.betaCalculators":false}'
13+
```
14+
15+
Optional response strategy for disabled routes:
16+
17+
- `FEATURE_FLAGS_DISABLED_STATUS=404` (default, hides route existence)
18+
- `FEATURE_FLAGS_DISABLED_STATUS=403` (explicitly forbidden)
19+
20+
## Current Flags
21+
| Flag name | Default | Owner | Meaning |
22+
|---|---|---|---|
23+
| `experimental.oracleHooks` | `false` | Backend Platform Team | Enables `/experimental/oracle-hooks/*` ingestion hooks for oracle event experiments. |
24+
| `experimental.betaCalculators` | `false` | Underwriting Engine Team | Enables `/experimental/beta-calculators/*` premium preview APIs under validation. |
25+
26+
## Lifecycle
27+
1. Creation: add a single-purpose flag and owner in this document before merging code.
28+
2. Enablement: turn on only in non-production first, monitor error rate and access logs.
29+
3. Promotion: after stable metrics and review, enable for production rollout with change ticket.
30+
4. Removal: once fully adopted, delete guard/decorator usage and remove the flag from env + docs.
31+
32+
## Operational Guardrails
33+
- Feature flags do not replace authentication or authorization controls.
34+
- Keep total flag count low to avoid long-lived configuration sprawl.
35+
- Disabled-access attempts are logged (`FeatureFlagsGuard`) for metrics pipelines.

backend/src/app.module.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { QuoteModule } from './quote/quote.module';
1616
import { PolicyModule } from './policy/policy.module';
1717
import { NotificationsModule } from './notifications/notifications.module';
1818
import { TxModule } from './tx/tx.module';
19+
import { FeatureFlagsModule } from './feature-flags/feature-flags.module';
20+
import { OracleHooksController } from './experimental/oracle-hooks.controller';
21+
import { BetaCalculatorsController } from './experimental/beta-calculators.controller';
1922

2023
@Module({
2124
imports: [
@@ -42,7 +45,8 @@ import { TxModule } from './tx/tx.module';
4245
PolicyModule,
4346
NotificationsModule,
4447
TxModule,
48+
FeatureFlagsModule,
4549
],
50+
controllers: [OracleHooksController, BetaCalculatorsController],
4651
})
4752
export class AppModule {}
48-
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { IsNumber, Min } from 'class-validator';
2+
3+
export class BetaCalculatorDto {
4+
@IsNumber()
5+
@Min(0)
6+
basePremium!: number;
7+
8+
@IsNumber()
9+
@Min(0)
10+
riskMultiplier!: number;
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Body, Controller, Post } from '@nestjs/common';
2+
import { BetaCalculatorDto } from '../dto/beta-calculator.dto';
3+
import { Feature } from '../feature-flags/feature.decorator';
4+
5+
@Controller('experimental/beta-calculators')
6+
@Feature('experimental.betaCalculators')
7+
export class BetaCalculatorsController {
8+
@Post('premium-preview')
9+
premiumPreview(@Body() body: BetaCalculatorDto) {
10+
return {
11+
premium: Number((body.basePremium * body.riskMultiplier).toFixed(2)),
12+
};
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Body, Controller, Post } from '@nestjs/common';
2+
import { Feature } from '../feature-flags/feature.decorator';
3+
4+
@Controller('experimental/oracle-hooks')
5+
@Feature('experimental.oracleHooks')
6+
export class OracleHooksController {
7+
@Post('ingest')
8+
ingest(@Body() body: Record<string, unknown>) {
9+
return {
10+
accepted: true,
11+
receivedKeys: Object.keys(body || {}),
12+
};
13+
}
14+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const FEATURE_METADATA_KEY = 'feature_flag_name';
2+
3+
export const FEATURE_FLAGS_JSON_ENV = 'FEATURE_FLAGS_JSON';
4+
export const FEATURE_FLAGS_DISABLED_STATUS_ENV = 'FEATURE_FLAGS_DISABLED_STATUS';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { FeatureFlagsService } from './feature-flags.service';
3+
4+
@Injectable()
5+
export class FeatureFlagsBootstrap implements OnModuleInit {
6+
private readonly logger = new Logger(FeatureFlagsBootstrap.name);
7+
8+
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
9+
10+
onModuleInit() {
11+
const flags = this.featureFlagsService.getFlags();
12+
this.logger.log(
13+
`Feature flags loaded at boot: ${Object.keys(flags).length} configured flag(s)`,
14+
);
15+
}
16+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
Logger,
7+
NotFoundException,
8+
} from '@nestjs/common';
9+
import { Reflector } from '@nestjs/core';
10+
import { FEATURE_METADATA_KEY } from './constants';
11+
import { FeatureFlagsService } from './feature-flags.service';
12+
13+
@Injectable()
14+
export class FeatureFlagsGuard implements CanActivate {
15+
private readonly logger = new Logger(FeatureFlagsGuard.name);
16+
17+
constructor(
18+
private readonly reflector: Reflector,
19+
private readonly featureFlagsService: FeatureFlagsService,
20+
) {}
21+
22+
canActivate(context: ExecutionContext): boolean {
23+
const featureName = this.reflector.getAllAndOverride<string>(
24+
FEATURE_METADATA_KEY,
25+
[context.getHandler(), context.getClass()],
26+
);
27+
28+
if (!featureName || this.featureFlagsService.isEnabled(featureName)) {
29+
return true;
30+
}
31+
32+
const request = context.switchToHttp().getRequest();
33+
this.logger.warn(
34+
`Blocked disabled feature access feature=${featureName} method=${request.method} path=${request.url} ip=${request.ip}`,
35+
);
36+
37+
if (this.featureFlagsService.getDisabledStatusCode() === 403) {
38+
throw new ForbiddenException('Feature disabled');
39+
}
40+
41+
throw new NotFoundException('Feature disabled');
42+
}
43+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { FeatureFlagsService } from './feature-flags.service';
3+
import { FeatureFlagsGuard } from './feature-flags.guard';
4+
import { FeatureFlagsBootstrap } from './feature-flags.bootstrap';
5+
6+
@Module({
7+
providers: [FeatureFlagsService, FeatureFlagsGuard, FeatureFlagsBootstrap],
8+
exports: [FeatureFlagsService, FeatureFlagsGuard],
9+
})
10+
export class FeatureFlagsModule {}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import {
3+
FEATURE_FLAGS_DISABLED_STATUS_ENV,
4+
FEATURE_FLAGS_JSON_ENV,
5+
} from './constants';
6+
7+
type FeatureMap = Record<string, boolean>;
8+
9+
@Injectable()
10+
export class FeatureFlagsService {
11+
private readonly logger = new Logger(FeatureFlagsService.name);
12+
private readonly featureMap: FeatureMap;
13+
private readonly disabledStatusCode: 403 | 404;
14+
15+
constructor() {
16+
this.featureMap = this.parseFlags(process.env[FEATURE_FLAGS_JSON_ENV]);
17+
this.disabledStatusCode =
18+
process.env[FEATURE_FLAGS_DISABLED_STATUS_ENV] === '403' ? 403 : 404;
19+
}
20+
21+
isEnabled(featureName: string): boolean {
22+
return this.featureMap[featureName] === true;
23+
}
24+
25+
getDisabledStatusCode(): 403 | 404 {
26+
return this.disabledStatusCode;
27+
}
28+
29+
getFlags(): FeatureMap {
30+
return { ...this.featureMap };
31+
}
32+
33+
private parseFlags(rawValue: string | undefined): FeatureMap {
34+
if (!rawValue) {
35+
return {};
36+
}
37+
38+
try {
39+
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
40+
return Object.entries(parsed).reduce<FeatureMap>((acc, [key, value]) => {
41+
acc[key] = value === true;
42+
return acc;
43+
}, {});
44+
} catch (error) {
45+
const details = error instanceof Error ? error.message : String(error);
46+
this.logger.error(
47+
`${FEATURE_FLAGS_JSON_ENV} is not valid JSON; defaulting all features to disabled. ${details}`,
48+
);
49+
return {};
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)