229 lines
5.7 KiB
TypeScript
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;
|
|
} |