Files
gridpilot.gg/apps/api/src/domain/policy/PolicyService.ts

229 lines
5.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
export type OperationalMode = 'normal' | 'maintenance' | 'test';
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
export type ActionType = 'view' | 'mutate';
export type PolicySnapshot = {
readonly policyVersion: number;
readonly operationalMode: OperationalMode;
readonly maintenanceAllowlist: {
readonly view: readonly string[];
readonly mutate: readonly string[];
};
readonly capabilities: Readonly<Record<string, FeatureState>>;
readonly loadedFrom: 'env' | 'file' | 'defaults';
readonly loadedAtIso: string;
};
const DEFAULT_POLICY_VERSION = 1;
const DEFAULT_CACHE_MS = 5_000;
type RawPolicySnapshot = Partial<{
policyVersion: number;
operationalMode: string;
maintenanceAllowlist: Partial<{
view: unknown;
mutate: unknown;
}>;
capabilities: unknown;
}>;
@Injectable()
export class PolicyService {
private cache:
| {
snapshot: PolicySnapshot;
expiresAtMs: number;
}
| null = null;
async getSnapshot(): Promise<PolicySnapshot> {
const now = Date.now();
if (this.cache && now < this.cache.expiresAtMs) {
return this.cache.snapshot;
}
const cacheMs = parseCacheMs(process.env.GRIDPILOT_POLICY_CACHE_MS);
const loadedAtIso = new Date(now).toISOString();
const { raw, loadedFrom } = await this.loadRawSnapshot();
const snapshot = normalizeSnapshot(raw, loadedFrom, loadedAtIso);
this.cache = {
snapshot,
expiresAtMs: now + cacheMs,
};
return snapshot;
}
private async loadRawSnapshot(): Promise<{
raw: RawPolicySnapshot;
loadedFrom: PolicySnapshot['loadedFrom'];
}> {
const policyPath = process.env.GRIDPILOT_POLICY_PATH;
if (policyPath) {
const rawJson = await readFile(policyPath, 'utf8');
return {
raw: JSON.parse(rawJson) as RawPolicySnapshot,
loadedFrom: 'file',
};
}
const anyEnvConfigured =
Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) ||
Boolean(process.env.GRIDPILOT_FEATURES_JSON) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
const raw: RawPolicySnapshot = {};
const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE;
if (operationalMode) {
raw.operationalMode = operationalMode;
}
const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON);
if (capabilities) {
raw.capabilities = capabilities;
}
const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW);
const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
if (maintenanceAllowView || maintenanceAllowMutate) {
raw.maintenanceAllowlist = {
...(maintenanceAllowView ? { view: maintenanceAllowView } : {}),
...(maintenanceAllowMutate ? { mutate: maintenanceAllowMutate } : {}),
};
}
return {
raw,
loadedFrom: anyEnvConfigured ? 'env' : 'defaults',
};
}
}
function normalizeSnapshot(
raw: RawPolicySnapshot,
loadedFrom: PolicySnapshot['loadedFrom'],
loadedAtIso: string,
): PolicySnapshot {
const operationalMode = parseOperationalMode(raw.operationalMode);
const maintenanceAllowlistView = normalizeStringArray(raw.maintenanceAllowlist?.view);
const maintenanceAllowlistMutate = normalizeStringArray(raw.maintenanceAllowlist?.mutate);
const capabilities = normalizeCapabilities(raw.capabilities);
return {
policyVersion:
typeof raw.policyVersion === 'number' ? raw.policyVersion : DEFAULT_POLICY_VERSION,
operationalMode,
maintenanceAllowlist: {
view: maintenanceAllowlistView,
mutate: maintenanceAllowlistMutate,
},
capabilities,
loadedFrom,
loadedAtIso,
};
}
function parseCacheMs(cacheMsRaw: string | undefined): number {
if (!cacheMsRaw) {
return DEFAULT_CACHE_MS;
}
const parsed = Number(cacheMsRaw);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_CACHE_MS;
}
return parsed;
}
function parseOperationalMode(raw: string | undefined): OperationalMode {
switch (raw) {
case 'normal':
case 'maintenance':
case 'test':
return raw;
default:
return 'normal';
}
}
function normalizeCapabilities(input: unknown): Readonly<Record<string, FeatureState>> {
if (!input || typeof input !== 'object') {
return {};
}
const record = input as Record<string, unknown>;
const normalized: Record<string, FeatureState> = {};
for (const [key, value] of Object.entries(record)) {
if (!key) {
continue;
}
const state = parseFeatureState(value);
if (state) {
normalized[key] = state;
}
}
return normalized;
}
function parseFeatureState(value: unknown): FeatureState | null {
if (typeof value !== 'string') {
return null;
}
switch (value) {
case 'enabled':
case 'disabled':
case 'coming_soon':
case 'hidden':
return value;
default:
return null;
}
}
function parseFeaturesJson(raw: string | undefined): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function parseCsvList(raw: string | undefined): readonly string[] | undefined {
if (!raw) {
return undefined;
}
const items = raw
.split(',')
.map((v) => v.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
function normalizeStringArray(value: unknown): readonly string[] {
if (!Array.isArray(value)) {
return [];
}
const normalized = value
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
return normalized;
}