2026-09-30 23:12:51 +01:00
|
|
|
// Domain permission checking against the portal OpenFGA verify endpoint.
|
|
|
|
|
// Mirrors fingerprint-agent internal/permit: `domain:<code>` 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';
|
|
|
|
|
|
|
|
|
|
export function stripPhonePrefix(phone) {
|
|
|
|
|
return (phone || '').replace(/^\+/, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 01:13:30 +01:00
|
|
|
async function verifyOnce(fetchImpl, portalUrl, phone, object, relation = 'capture_biometric') {
|
2026-09-30 23:12:51 +01:00
|
|
|
const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
subsystem: 'idid',
|
|
|
|
|
user: `user:${phone}`,
|
2026-10-01 01:13:30 +01:00
|
|
|
relation,
|
2026-09-30 23:12:51 +01:00
|
|
|
object,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
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 {
|
2026-10-01 01:13:30 +01:00
|
|
|
const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric');
|
2026-09-30 23:12:51 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 01:13:30 +01:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
}
|