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>; 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 { 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> { if (!input || typeof input !== 'object') { return {}; } const record = input as Record; const normalized: Record = {}; 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; }