feat: YOUreka Tools express app — landing page + fpagent enrollment wizard
- tools/: express host (hardcoded config, YOUreka Auth cookie session, OpenFGA domain filtering with 5-min cache, a2loft proxies) - /fingerprint wizard: domain picker, two-finger capture via fpagent Socket.IO, OTP-validated lookup/save, domain refresh control - node --test suite (27 tests, fake portal/PocketBase/a2loft upstreams) - Dockerfile (node:22-alpine, non-root, healthcheck) - README rewritten; node_modules gitignored Field-verified end to end: portal login, permitted-domain filtering, real U.are.U 4500 captures, cancel-preserves-capture, a2loft save.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// 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(/^\+/, '');
|
||||
}
|
||||
|
||||
async function verifyOnce(fetchImpl, portalUrl, phone, object) {
|
||||
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: 'capture_biometric',
|
||||
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 {
|
||||
const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user