godcrm/backend/services/verification/validateConfig.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

193 lines
7.7 KiB
JavaScript

// ADR-0011 · Verification column — config validator (C-1).
//
// Canonical ADR-0011 config shape (preferred):
// {
// available_methods: string[], // subset of VALID_METHODS — required
// required_methods: number, // N-of-M, 1..|available_methods|
// cooldown_seconds?: number, // default 300 — per ADR §Config
// ttl_seconds?: number, // optional, null = no TTL
// locks_on_statuses?: string[],
// unlocks_on_statuses?: string[],
// guards?: string[],
// policy?: 'all' | 'any_n', // default 'any_n'
// rate_limit?: { window_seconds: number, max_attempts: number },
// method_config?: object
// }
//
// Phase A legacy shape (back-compat):
// {
// method: 'totp'|'captcha'|'sms'|'email',
// cooldown_ms?: number,
// ttl_ms?: number,
// ...
// }
// Coerced to canonical as available_methods=[method], required_methods=1.
//
// The validator also normalizes legacy `cooldown_ms`/`ttl_ms` into canonical
// `cooldown_seconds`/`ttl_seconds`. Both are kept on the normalized output
// (`cooldown_ms` derived from `cooldown_seconds`) so downstream code can read
// whichever field it was already using.
const VALID_METHODS = ['totp', 'captcha', 'sms', 'email'];
const VALID_POLICIES = ['all', 'any_n'];
const DEFAULT_COOLDOWN_SECONDS = 300;
/**
* @param {object} config
* @returns {{ ok: true, normalized: object } | { ok: false, error: string }}
*/
export function validateVerificationConfig(config) {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return { ok: false, error: 'config must be an object' };
}
// ---------- available_methods / required_methods (N-of-M) ----------
let available = config.available_methods;
let required = config.required_methods;
if (!Array.isArray(available)) {
// Legacy: accept single `method`.
if (typeof config.method === 'string') {
if (!VALID_METHODS.includes(config.method)) {
return { ok: false, error: `method must be one of: ${VALID_METHODS.join(', ')}` };
}
available = [config.method];
required = required === undefined || required === null ? 1 : required;
} else {
return { ok: false, error: `available_methods (array) is required — one or more of: ${VALID_METHODS.join(', ')}` };
}
} else {
if (available.length === 0) {
return { ok: false, error: 'available_methods must be a non-empty array' };
}
for (const m of available) {
if (typeof m !== 'string' || !VALID_METHODS.includes(m)) {
return { ok: false, error: `available_methods entries must be one of: ${VALID_METHODS.join(', ')}` };
}
}
}
// dedupe while preserving order
available = Array.from(new Set(available));
// required_methods default = 1
if (required === undefined || required === null) required = 1;
if (typeof required !== 'number' || !Number.isInteger(required)) {
return { ok: false, error: 'required_methods must be an integer' };
}
if (required < 1) {
return { ok: false, error: 'required_methods must be >= 1' };
}
if (required > available.length) {
return { ok: false, error: `required_methods (${required}) cannot exceed available_methods length (${available.length})` };
}
// ---------- cooldown ----------
let cooldownSeconds = DEFAULT_COOLDOWN_SECONDS;
if (config.cooldown_seconds !== undefined && config.cooldown_seconds !== null) {
if (typeof config.cooldown_seconds !== 'number' || config.cooldown_seconds < 0 || !Number.isFinite(config.cooldown_seconds)) {
return { ok: false, error: 'cooldown_seconds must be a non-negative finite number' };
}
cooldownSeconds = config.cooldown_seconds;
} else if (config.cooldown_ms !== undefined && config.cooldown_ms !== null) {
// Phase A legacy field
if (typeof config.cooldown_ms !== 'number' || config.cooldown_ms < 0 || !Number.isFinite(config.cooldown_ms)) {
return { ok: false, error: 'cooldown_ms must be a non-negative finite number' };
}
cooldownSeconds = Math.round(config.cooldown_ms / 1000);
}
// ---------- ttl ----------
let ttlSeconds = null;
if (config.ttl_seconds !== undefined && config.ttl_seconds !== null) {
if (typeof config.ttl_seconds !== 'number' || config.ttl_seconds <= 0 || !Number.isFinite(config.ttl_seconds)) {
return { ok: false, error: 'ttl_seconds must be a positive finite number' };
}
ttlSeconds = config.ttl_seconds;
} else if (config.ttl_ms !== undefined && config.ttl_ms !== null) {
if (typeof config.ttl_ms !== 'number' || config.ttl_ms <= 0 || !Number.isFinite(config.ttl_ms)) {
return { ok: false, error: 'ttl_ms must be a positive finite number' };
}
ttlSeconds = config.ttl_ms / 1000;
}
// ---------- locks / unlocks / guards ----------
const locks = validateStringArray(config.locks_on_statuses, 'locks_on_statuses');
if (locks.error) return locks;
const unlocks = validateStringArray(config.unlocks_on_statuses, 'unlocks_on_statuses');
if (unlocks.error) return unlocks;
const guards = validateStringArray(config.guards, 'guards', /* allowEmpty= */ false);
if (guards.error) return guards;
// ---------- policy ----------
let policy = 'any_n';
if (config.policy !== undefined && config.policy !== null) {
if (!VALID_POLICIES.includes(config.policy)) {
return { ok: false, error: `policy must be one of: ${VALID_POLICIES.join(', ')}` };
}
policy = config.policy;
}
// ---------- rate_limit ----------
let rateLimit = null;
if (config.rate_limit !== undefined && config.rate_limit !== null) {
const rl = config.rate_limit;
if (typeof rl !== 'object' || Array.isArray(rl)) {
return { ok: false, error: 'rate_limit must be an object { window_seconds, max_attempts }' };
}
if (typeof rl.window_seconds !== 'number' || rl.window_seconds <= 0) {
return { ok: false, error: 'rate_limit.window_seconds must be a positive number' };
}
if (typeof rl.max_attempts !== 'number' || !Number.isInteger(rl.max_attempts) || rl.max_attempts <= 0) {
return { ok: false, error: 'rate_limit.max_attempts must be a positive integer' };
}
rateLimit = { window_seconds: rl.window_seconds, max_attempts: rl.max_attempts };
}
// ---------- method_config ----------
let methodConfig = {};
if (config.method_config !== undefined && config.method_config !== null) {
if (typeof config.method_config !== 'object' || Array.isArray(config.method_config)) {
return { ok: false, error: 'method_config must be an object' };
}
methodConfig = config.method_config;
}
return {
ok: true,
normalized: {
available_methods: available,
required_methods: required,
// legacy mirror for callers that read `method` (first available)
method: available[0],
cooldown_seconds: cooldownSeconds,
cooldown_ms: cooldownSeconds * 1000,
ttl_seconds: ttlSeconds,
ttl_ms: ttlSeconds == null ? null : ttlSeconds * 1000,
locks_on_statuses: locks.value,
unlocks_on_statuses: unlocks.value,
guards: guards.value,
policy,
rate_limit: rateLimit,
method_config: methodConfig,
},
};
}
function validateStringArray(raw, name, allowEmpty = true) {
if (raw === undefined || raw === null) return { value: [] };
if (!Array.isArray(raw)) {
return { error: `${name} must be an array of strings`, ok: false };
}
for (const s of raw) {
if (typeof s !== 'string') {
return { error: `${name} entries must be strings`, ok: false };
}
if (!allowEmpty && s.length === 0) {
return { error: `${name} entries must be non-empty strings`, ok: false };
}
}
return { value: raw };
}
export { VALID_METHODS, VALID_POLICIES, DEFAULT_COOLDOWN_SECONDS };