feat(cropper): move the Image Cropper into the tools app at /cropper
- Smart Photo Editor ported from id-image-bot: queue/by-ID picture loading, vue-croppa editing, carvekit background cleanup, nodered review flow, a2loft save — restyled to the tools design language - unified login: YOUreka session identity, phone prefilled, OTP only at save (a2loft's requirement); artist-login step removed - crop_image @ domain:idid OpenFGA gate on every cropper route: cached 5-min on reads, live on save, fail closed - credentials (minio/azure/nodered/carvekit) via env, sole exception to hardcoded config; 503 when unconfigured; native FormData replaces form-data; new deps minio + azure-storage - 10 new tests (35 total) with fake minio/queue/nodered/removebg/a2loft
This commit is contained in:
+46
-3
@@ -9,14 +9,14 @@ export function stripPhonePrefix(phone) {
|
||||
return (phone || '').replace(/^\+/, '');
|
||||
}
|
||||
|
||||
async function verifyOnce(fetchImpl, portalUrl, phone, object) {
|
||||
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: 'capture_biometric',
|
||||
relation,
|
||||
object,
|
||||
}),
|
||||
});
|
||||
@@ -31,7 +31,7 @@ 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);
|
||||
const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric');
|
||||
answered = true;
|
||||
if (allowed) return true;
|
||||
} catch {
|
||||
@@ -42,6 +42,49 @@ export async function checkDomain(fetchImpl, portalUrl, phone, code) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user