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 { const handler = context.getHandler(); const controllerClass = context.getClass(); const metadata = this.reflector.getAllAndOverride( 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>, 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'; } }