Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
193 lines
7.7 KiB
JavaScript
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 };
|