// Domain permission checking against the portal OpenFGA verify endpoint. // Mirrors fingerprint-agent internal/permit: `domain:` first, then a // `domain:idid` fallback — either allow passes, and every failure closes. // Results are cached per operator phone for DOMAIN_TTL_MS. export const DOMAIN_TTL_MS = 5 * 60 * 1000; export const CACHE_FRESH = 'CACHE_FRESH'; /** Outbound OpenFGA verify timeout — a hung portal must not pin a request. */ export const VERIFY_TIMEOUT_MS = 5000; export function stripPhonePrefix(phone) { return (phone || '').replace(/^\+/, ''); } async function verifyOnce(fetchImpl, portalUrl, phone, object, relation = 'capture_biometric') { const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subsystem: 'idid', user: `user:${phone}`, relation, object, }), signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS), }); if (r.status !== 200) throw new Error(`verify status ${r.status}`); const out = await r.json(); if (!out?.ok) throw new Error('verify returned ok=false'); return !!out.allowed; } // true = allowed, false = denied; throws when neither object could answer. export async function checkDomain(fetchImpl, portalUrl, phone, code) { let answered = false; for (const object of [`domain:${code}`, 'domain:idid']) { try { const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric'); answered = true; if (allowed) return true; } catch { // try the fallback object; if neither answers we fail closed below } } if (!answered) throw new Error(`verify failed for domain:${code} and domain:idid`); return false; } /** * Single-object permission check for an arbitrary relation — fail closed: * returns true only for an explicit ok+allowed, false on denial, and throws * when the check itself fails. */ export async function checkPermission(fetchImpl, portalUrl, phone, { relation, object }) { return verifyOnce(fetchImpl, portalUrl, phone, object, relation); } /** * Cached `crop_image` permission on domain:idid for the cropper. Same shape as * permittedDomains: 5-minute per-phone cache, `force` bypasses the cache for * live checks and refuses while fresh, failures fail closed (denied). * Returns { allowed, checkedAt }. */ export async function cropPermission({ phone, portalUrl, fetchImpl = fetch, cache, ttlMs = DOMAIN_TTL_MS, now = Date.now, force = false, }) { const hit = cache.get(phone); const fresh = !!(hit && now() - hit.checkedAt < ttlMs); if (fresh && !force) { return { allowed: hit.allowed, checkedAt: hit.checkedAt }; } const checkedAt = now(); let allowed = false; try { allowed = await checkPermission(fetchImpl, portalUrl, phone, { relation: 'crop_image', object: 'domain:idid', }); } catch { allowed = false; // fail closed } cache.set(phone, { allowed, checkedAt }); return { allowed, checkedAt }; } /** * Permitted domain codes for phone, cached per phone for ttlMs. * force=true re-checks everything; while the cache is still fresh it refuses * with an error whose .code is CACHE_FRESH. Unanswered checks exclude the * domain (fail closed). Returns { codes, checkedAt }. */ export async function permittedDomains({ domains, phone, portalUrl, fetchImpl = fetch, cache, ttlMs = DOMAIN_TTL_MS, now = Date.now, force = false, }) { const hit = cache.get(phone); const fresh = !!(hit && now() - hit.checkedAt < ttlMs); if (fresh && !force) return { codes: hit.codes, checkedAt: hit.checkedAt }; if (fresh && force) { const e = new Error('permission cache is still fresh'); e.code = CACHE_FRESH; throw e; } const settled = await Promise.all( domains.map((d) => checkDomain(fetchImpl, portalUrl, phone, d.code) .then((ok) => (ok ? d.code : null)) .catch(() => null) ) ); const codes = settled.filter(Boolean); const checkedAt = now(); cache.set(phone, { codes, checkedAt }); return { codes, checkedAt }; }