Files
gridpilot.gg/apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
2026-01-16 21:44:26 +01:00

91 lines
2.5 KiB
TypeScript

import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ActionType, FeatureState, PolicyService } from './PolicyService';
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
@Injectable()
export class FeatureAvailabilityGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly policyService: PolicyService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controllerClass = context.getClass();
const metadata =
this.reflector.getAllAndOverride<FeatureAvailabilityMetadata | undefined>(
FEATURE_AVAILABILITY_METADATA_KEY,
[handler, controllerClass],
) ?? null;
if (!metadata) {
return true;
}
const snapshot = await this.policyService.getSnapshot();
const decision = evaluate(snapshot, metadata);
if (decision.allow) {
return true;
}
if (decision.reason === 'maintenance') {
throw new ServiceUnavailableException('Service temporarily unavailable');
}
throw new NotFoundException('Not Found');
}
}
function evaluate(
snapshot: Awaited<ReturnType<PolicyService['getSnapshot']>>,
metadata: FeatureAvailabilityMetadata,
): Evaluation {
if (snapshot.operationalMode === 'maintenance') {
const allowlist = metadata.actionType === 'mutate'
? snapshot.maintenanceAllowlist.mutate
: snapshot.maintenanceAllowlist.view;
if (!allowlist.includes(metadata.capabilityKey)) {
return { allow: false, reason: 'maintenance' };
}
}
const state = snapshot.capabilities[metadata.capabilityKey] ?? 'hidden';
if (state === 'enabled') {
return { allow: true };
}
// Coming soon is treated as "not found" on the public API for now (no disclosure).
if (state === 'coming_soon') {
return { allow: false, reason: 'coming_soon' };
}
if (state === 'disabled' || state === 'hidden') {
return { allow: false, reason: state };
}
return { allow: false, reason: 'not_configured' };
}
export function inferActionTypeFromHttpMethod(method: string): ActionType {
switch (method.toUpperCase()) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
return 'view';
default:
return 'mutate';
}
}