diff --git a/Dockerfile b/Dockerfile
index 4634e7d..9977bed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,7 @@ WORKDIR /app
COPY tools/package.json tools/package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
-COPY tools/server.js tools/yrka-auth.js tools/permit.js ./
+COPY tools/server.js tools/yrka-auth.js tools/permit.js tools/cropper.js ./
COPY tools/public ./public
USER node
diff --git a/README.md b/README.md
index 1e32524..483ddd0 100644
--- a/README.md
+++ b/README.md
@@ -64,15 +64,39 @@ Everything is hardcoded in `tools/server.js` by design — port `4173`, portal
catalog. Change them there; there are no environment variables or config
files.
+## The Image Cropper
+
+The `/cropper` page (Smart Photo Editor) lets operators review, crop and save
+identity photos. It reuses the YOUreka session for identity, requires the
+`crop_image` permission on `domain:idid` for every proxied action (verified
+against the portal's OpenFGA endpoint — live on save, 5-minute cached on
+reads), and keeps the a2loft OTP requirement only on the save step.
+
+Its service credentials are the sole environment variables this app uses
+(same names the old `id-image-bot` used — an existing `.env` is drop-in):
+
+```sh
+MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
+AZURE_STORAGE_CONNECTION_STRING=…
+NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
+CARVEKIT_API_KEY=…
+```
+
+Without them the rest of the app works and `/cropper` routes answer `503`.
+Run locally with `cp tools/.env.example tools/.env` + your values and
+`node --env-file=tools/.env tools/server.js` (or `docker run --env-file …`).
+
## Tests
```sh
cd tools
-npm test # node --test — 27 tests, no network or hardware required
+npm test # node --test — 35 tests, no network or hardware required
```
## Docker
+## Docker
+
The app is stateless, so the container only serves the web front-end; fpagent
keeps running on the operator's laptop (the wizard connects to
`http://127.0.0.1:9000` from the browser, wherever this container lives).
diff --git a/tools/.env.example b/tools/.env.example
new file mode 100644
index 0000000..2187fc9
--- /dev/null
+++ b/tools/.env.example
@@ -0,0 +1,9 @@
+# Image Cropper service credentials — the ONLY environment variables this app
+# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
+# Without these the rest of the app works and /cropper answers 503.
+MINIO_ACCESS_KEY=
+MINIO_SECRET_KEY=
+AZURE_STORAGE_CONNECTION_STRING=
+NODERED_HTTP_USERNAME=
+NODERED_HTTP_PASSWORD=
+CARVEKIT_API_KEY=
diff --git a/tools/cropper.js b/tools/cropper.js
new file mode 100644
index 0000000..cede308
--- /dev/null
+++ b/tools/cropper.js
@@ -0,0 +1,336 @@
+// Image Cropper — namespaced router ported from id-image-bot's web surface.
+// Every route sits behind the YOUreka session (requireApi) and the crop_image
+// OpenFGA gate; reads may use the 5-minute cached result, the a2loft save
+// always verifies live. Minio/queue/nodered/removebg stay external — this only
+// proxies. All clients are injectable for tests.
+import { Router } from 'express';
+import { createRequire } from 'node:module';
+import { stripPhonePrefix, cropPermission } from './permit.js';
+
+// minio/azure-storage keep-alive timers would hold the process open even when
+// the cropper is unconfigured, so they load only when credentials exist.
+const require = createRequire(import.meta.url);
+
+const QUEUE_NAME = 'id-images';
+const BUCKET = 'files';
+const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
+
+/**
+ * Builds the real cropper clients from the environment. Returns null when any
+ * credential is missing — the router then answers 503 and nothing else in the
+ * app is affected. URLs are hardcoded here; only credentials come from env.
+ */
+export function buildCropperClients(env = process.env) {
+ const {
+ MINIO_ACCESS_KEY,
+ MINIO_SECRET_KEY,
+ AZURE_STORAGE_CONNECTION_STRING,
+ NODERED_HTTP_USERNAME,
+ NODERED_HTTP_PASSWORD,
+ CARVEKIT_API_KEY,
+ } = env;
+ if (
+ !MINIO_ACCESS_KEY ||
+ !MINIO_SECRET_KEY ||
+ !AZURE_STORAGE_CONNECTION_STRING ||
+ !NODERED_HTTP_USERNAME ||
+ !NODERED_HTTP_PASSWORD ||
+ !CARVEKIT_API_KEY
+ ) {
+ return null;
+ }
+ const { Client: MinioClient } = require('minio');
+ const azure = require('azure-storage');
+ return {
+ minio: new MinioClient({
+ endPoint: 'storage.a2loft.net',
+ useSSL: true,
+ accessKey: MINIO_ACCESS_KEY,
+ secretKey: MINIO_SECRET_KEY,
+ }),
+ queue: azure.createQueueService(AZURE_STORAGE_CONNECTION_STRING),
+ noderedAuth: {
+ username: NODERED_HTTP_USERNAME,
+ password: NODERED_HTTP_PASSWORD,
+ },
+ carvekitKey: CARVEKIT_API_KEY,
+ };
+}
+
+export function createCropperRouter(cfg) {
+ const {
+ a2loftBase,
+ portalUrl,
+ fetchImpl = globalThis.fetch,
+ now = Date.now,
+ ttlMs,
+ minio,
+ queue,
+ noderedAuth,
+ carvekitKey,
+ removebgUrl = REMOVEBG_URL,
+ noderedBase = 'https://nodered.a2loft.net',
+ requireApi,
+ cache: cropCache,
+ } = cfg;
+
+ const router = Router();
+ router.use(requireApi);
+
+ const cropGate = async (req, res, next) => {
+ try {
+ const { allowed } = await cropPermission({
+ phone: stripPhonePrefix(req.session.phone),
+ portalUrl,
+ fetchImpl,
+ cache: cropCache,
+ ttlMs,
+ now,
+ });
+ if (allowed) return next();
+ res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' });
+ } catch {
+ res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' });
+ }
+ };
+ router.use(cropGate);
+
+ const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
+ const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
+
+ const getMessage = () =>
+ new Promise((resolve, reject) =>
+ queue.getMessage(QUEUE_NAME, { visibilityTimeout: 2 * 60 }, (error, message) =>
+ error ? reject(error) : resolve(message)
+ )
+ );
+ const deleteMessage = (messageId, popReceipt) =>
+ new Promise((resolve, reject) =>
+ queue.deleteMessage(QUEUE_NAME, messageId, popReceipt, (error, result) =>
+ error ? reject(error) : resolve(result)
+ )
+ );
+
+ const requireOtp = (req, res) => {
+ if (!req.query.otp) {
+ res.status(400).json({ ok: false, message: 'OTP is required.' });
+ return false;
+ }
+ return true;
+ };
+
+ // ── pictures ────────────────────────────────────────────────────────────
+ router.get('/picture/next', async (req, res) => {
+ let message;
+ try {
+ message = await getMessage();
+ } catch (error) {
+ return res.json({ ok: false, message: error.message });
+ }
+ if (!message) {
+ return res.json({ ok: false, message: 'Queue is currently empty.' });
+ }
+ const data = JSON.parse(message.messageText);
+ const { messageId, popReceipt, dequeueCount } = message;
+ res.json({
+ ok: true,
+ data: { message_id: messageId, pop_receipt: popReceipt, dequeue_count: dequeueCount, message: data },
+ });
+ });
+
+ router.get('/picture/load/:id', async (req, res) => {
+ try {
+ const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(req.params.id)}`);
+ const { data } = await r.json();
+ if (!data) return res.json({ ok: false, message: 'No upload with the specified id.' });
+ const { upload_id, file_name, caption, domain, raw_file_id } = data;
+ return res.json({
+ ok: true,
+ data: {
+ message_id: 'custom',
+ pop_receipt: 'custom',
+ dequeue_count: 0,
+ message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false },
+ },
+ });
+ } catch (error) {
+ return res.json({ ok: false, message: error.message });
+ }
+ });
+
+ router.get('/picture/review/:review_id', async (req, res) => {
+ try {
+ const review = await (
+ await fetchImpl(nodered(`/idid/picture/review/${encodeURIComponent(req.params.review_id)}`), {
+ headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` },
+ })
+ ).json();
+ if (!review.ok) {
+ return res.json({ ok: false, message: 'Invalid picture review id specified.' });
+ }
+ const agents = await (
+ await fetchImpl(nodered('/unical/agents'), {
+ headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` },
+ })
+ ).json();
+ const agent = (Array.isArray(agents) ? agents : []).find(
+ (u) => u.phoneHashes.includes(parseInt(review.data.agentPhoneHash))
+ );
+ res.json({ ok: agent !== undefined, data: agent });
+ } catch (error) {
+ res.json({ ok: false, message: error.message });
+ }
+ });
+
+ router.get('/picture/:file_name', async (req, res) => {
+ const file_name = req.params.file_name.replace(/_/gi, '/');
+ try {
+ const url = await minio.presignedGetObject(BUCKET, file_name);
+ const r = await fetchImpl(url);
+ const buf = Buffer.from(await r.arrayBuffer());
+ res.json({ ok: true, data: `data:image/png;base64,${buf.toString('base64')}` });
+ } catch (error) {
+ res.json({ ok: false, message: error.message });
+ }
+ });
+
+ router.post('/picture/review', async (req, res) => {
+ const {
+ message: { pop_receipt, message_id },
+ deleteFromQueue,
+ ...body
+ } = req.body;
+ try {
+ const r = await fetchImpl(nodered('/zeebe/picture/review'), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}`,
+ },
+ body: JSON.stringify(body),
+ });
+ const data = await r.json();
+ if (!data.ok) {
+ return res.json({ ok: false, message: 'Reject process failed.' });
+ }
+ if (deleteFromQueue) {
+ if (pop_receipt === 'custom') {
+ return res.json({ ok: true, message: 'Operation successful.' });
+ }
+ const result = await deleteMessage(message_id, pop_receipt);
+ if (result.isSuccessful) {
+ return res.json({ ok: true, message: 'Operation successful.' });
+ }
+ return res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` });
+ }
+ res.json({ ok: true, message: 'Operation successful.' });
+ } catch (error) {
+ res.json({ ok: false, message: error.message });
+ }
+ });
+
+ router.post('/dequeue', async (req, res) => {
+ const {
+ message: { pop_receipt, message_id },
+ } = req.body;
+ try {
+ const result = await deleteMessage(message_id, pop_receipt);
+ if (result.isSuccessful) {
+ return res.json({ ok: true, message: 'Operation successful.' });
+ }
+ res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` });
+ } catch (error) {
+ res.json({ ok: false, message: error.message });
+ }
+ });
+
+ // ── cleanup proxy ────────────────────────────────────────────────────────
+ router.post('/api/removebg', async (req, res) => {
+ try {
+ const fd = new FormData();
+ fd.append('image_file_b64', req.body.image_file_b64);
+ const r = await fetchImpl(removebgUrl, {
+ method: 'POST',
+ headers: { 'x-api-key': carvekitKey },
+ body: fd,
+ });
+ if (!r.ok) {
+ const text = await r.text();
+ return res.status(r.status).json({ ok: false, message: text || 'Cleanup failed.' });
+ }
+ res.set('Content-Type', r.headers.get('content-type') || 'image/png');
+ res.send(Buffer.from(await r.arrayBuffer()));
+ } catch (error) {
+ res.status(500).json({ ok: false, message: error.message });
+ }
+ });
+
+ router.get('/api/baseUrl', (req, res) => {
+ res.json({ ok: true, data: base });
+ });
+
+ // ── save (the one a2loft call that needs the operator OTP) ──────────────
+ router.post('/profile/picture', async (req, res) => {
+ if (!requireOtp(req, res)) return;
+ let live;
+ try {
+ live = await cropPermission({
+ phone: stripPhonePrefix(req.session.phone),
+ portalUrl,
+ fetchImpl,
+ cache: cropCache,
+ ttlMs,
+ now,
+ force: true, // saving always verifies live
+ });
+ } catch {
+ live = { allowed: false };
+ }
+ if (!live.allowed) {
+ return res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' });
+ }
+
+ const {
+ pictureDataUrl,
+ phone,
+ requiresCleaning,
+ message: {
+ pop_receipt,
+ message_id,
+ message: { upload_id, domain, agentDeviceHash },
+ },
+ } = req.body;
+ const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
+ try {
+ const r = await fetchImpl(
+ `${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`,
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ pictureDataUrl,
+ phone,
+ uploadId: upload_id,
+ agentDeviceHash,
+ requiresCleaning: requiresCleaning == null ? true : requiresCleaning,
+ }),
+ }
+ );
+ const data = await r.json();
+ if (!data.ok) return res.json(data);
+ if (pop_receipt === 'custom') {
+ return res.json({ ok: true, message: 'Custom operation successful' });
+ }
+ const result = await deleteMessage(message_id, pop_receipt);
+ if (result.isSuccessful) {
+ res.json({ ok: true, message: 'Operation successful.' });
+ } else {
+ res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` });
+ }
+ } catch {
+ res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
+ }
+ });
+
+ return router;
+}
diff --git a/tools/package-lock.json b/tools/package-lock.json
index 3c4274a..adc6d0e 100644
--- a/tools/package-lock.json
+++ b/tools/package-lock.json
@@ -8,10 +8,24 @@
"name": "yreka-tools",
"version": "1.0.0",
"dependencies": {
+ "azure-storage": "^2.10.7",
"express": "^4.21.2",
+ "minio": "^8.0.7",
"pocketbase": "^0.27.0"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
+ "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -25,12 +39,156 @@
"node": ">= 0.6"
}
},
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "node_modules/assert-plus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/aws-sign2": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/aws4": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
+ "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
+ "license": "MIT"
+ },
+ "node_modules/azure-storage": {
+ "version": "2.10.7",
+ "resolved": "https://registry.npmjs.org/azure-storage/-/azure-storage-2.10.7.tgz",
+ "integrity": "sha512-4oeFGtn3Ziw/fGs/zkoIpKKtygnCVIcZwzJ7UQzKTxhkGQqVCByOFbYqMGYR3L+wOsunX9lNfD0jc51SQuKSSA==",
+ "deprecated": "Please note: newer packages @azure/storage-blob, @azure/storage-queue and @azure/storage-file are available as of November 2019 and @azure/data-tables is available as of June 2021. While the legacy azure-storage package will continue to receive critical bug fixes, we strongly encourage you to upgrade. Migration guide can be found: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/storage/MigrationGuide.md",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "browserify-mime": "^1.2.9",
+ "extend": "^3.0.2",
+ "json-edm-parser": "~0.1.2",
+ "json-schema": "~0.4.0",
+ "md5.js": "^1.3.4",
+ "readable-stream": "^2.0.0",
+ "request": "^2.86.0",
+ "underscore": "^1.12.1",
+ "uuid": "^3.0.0",
+ "validator": "^13.7.0",
+ "xml2js": "~0.2.8",
+ "xmlbuilder": "^9.0.7"
+ },
+ "engines": {
+ "node": ">= 0.8.26"
+ }
+ },
+ "node_modules/bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
+ "node_modules/block-stream2": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
+ "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/block-stream2/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -55,6 +213,26 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/browser-or-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
+ "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
+ "license": "MIT"
+ },
+ "node_modules/browserify-mime": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/browserify-mime/-/browserify-mime-1.2.9.tgz",
+ "integrity": "sha512-uz+ItyJXBLb6wgon1ELEiVowJBEsy03PUWGRQU7cxxx9S+DW2hujPp+DaMYEOClRPzsn7NB99NtJ6pGnt8y+CQ=="
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -64,6 +242,24 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -93,6 +289,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -129,6 +343,24 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/dashdash": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+ "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -138,6 +370,41 @@
"ms": "2.0.0"
}
},
+ "node_modules/decode-uri-component": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
+ "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -171,6 +438,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecc-jsbn": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+ "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -231,6 +508,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@ -277,6 +560,81 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/extsprintf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+ "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
+ "engines": [
+ "node >=0.6.0"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-xml-builder": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
+ "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.11.2",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
+ "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^3.0.1",
+ "fast-xml-builder": "^1.2.0",
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.2",
+ "xml-naming": "^0.3.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/filter-obj": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -295,6 +653,44 @@
"node": ">= 0.8"
}
},
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/forever-agent": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.6",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 0.12"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -359,6 +755,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/getpass": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+ "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "^1.0.0"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -371,6 +776,41 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/har-schema": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+ "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/har-validator": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
+ "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
+ "deprecated": "this library is no longer supported",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.3",
+ "har-schema": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -383,6 +823,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hash-base": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz",
+ "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -415,6 +885,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-signature": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+ "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "^1.0.0",
+ "jsprim": "^1.2.2",
+ "sshpk": "^1.7.0"
+ },
+ "engines": {
+ "node": ">=0.8",
+ "npm": ">=1.3.7"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -442,6 +927,126 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "license": "MIT"
+ },
+ "node_modules/is-unsafe": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
+ "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isstream": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
+ "license": "MIT"
+ },
+ "node_modules/jsbn": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
+ "license": "MIT"
+ },
+ "node_modules/json-edm-parser": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/json-edm-parser/-/json-edm-parser-0.1.2.tgz",
+ "integrity": "sha512-J1U9mk6lf8dPULcaMwALXB6yel3cJyyhk9Z8FQ4sMwiazNwjaUhegIcpZyZFNMvLRtnXwh+TkCjX9uYUObBBYA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonparse": "~1.2.0"
+ }
+ },
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC"
+ },
+ "node_modules/jsonparse": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz",
+ "integrity": "sha512-LkDEYtKnPFI9hQ/IURETe6F1dUH80cbRkaF6RaViSwoSNPwaxQpi6TgJGvJKyLQ2/9pQW+XCxK3hBoR44RAjkg==",
+ "engines": [
+ "node >= 0.2.0"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/jsprim": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
+ "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "1.0.0",
+ "extsprintf": "1.3.0",
+ "json-schema": "0.4.0",
+ "verror": "1.10.0"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -451,6 +1056,17 @@
"node": ">= 0.4"
}
},
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "license": "MIT",
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -511,6 +1127,70 @@
"node": ">= 0.6"
}
},
+ "node_modules/minio": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
+ "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.4",
+ "block-stream2": "^2.1.0",
+ "browser-or-node": "^2.1.1",
+ "buffer-crc32": "^1.0.0",
+ "eventemitter3": "^5.0.1",
+ "fast-xml-parser": "^5.3.4",
+ "ipaddr.js": "^2.0.1",
+ "lodash": "^4.17.21",
+ "mime-types": "^2.1.35",
+ "query-string": "^7.1.3",
+ "stream-json": "^1.8.0",
+ "through2": "^4.0.2",
+ "xml2js": "^0.5.0 || ^0.6.2"
+ },
+ "engines": {
+ "node": "^16 || ^18 || >=20"
+ }
+ },
+ "node_modules/minio/node_modules/ipaddr.js": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
+ "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/minio/node_modules/sax": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/minio/node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/minio/node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -526,6 +1206,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/oauth-sign": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -559,18 +1248,54 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT"
+ },
"node_modules/pocketbase": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.3.tgz",
"integrity": "sha512-n2rtQrg3kTF44066B+2eVvQdiYRGZ+RI7isIZqmAptfElLCSDIZrEKLhSrIQA2k5pvQM29Gx5JqGv5n+gtm6dg==",
"license": "MIT"
},
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -588,6 +1313,27 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/psl": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/lupomontero"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@ -604,6 +1350,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/query-string": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
+ "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
+ "license": "MIT",
+ "dependencies": {
+ "decode-uri-component": "^0.2.2",
+ "filter-obj": "^1.1.0",
+ "split-on-first": "^1.0.0",
+ "strict-uri-encode": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -628,6 +1392,68 @@
"node": ">= 0.8"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/request": {
+ "version": "2.88.2",
+ "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
+ "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
+ "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "aws-sign2": "~0.7.0",
+ "aws4": "^1.8.0",
+ "caseless": "~0.12.0",
+ "combined-stream": "~1.0.6",
+ "extend": "~3.0.2",
+ "forever-agent": "~0.6.1",
+ "form-data": "~2.3.2",
+ "har-validator": "~5.1.3",
+ "http-signature": "~1.2.0",
+ "is-typedarray": "~1.0.0",
+ "isstream": "~0.1.2",
+ "json-stringify-safe": "~5.0.1",
+ "mime-types": "~2.1.19",
+ "oauth-sign": "~0.9.0",
+ "performance-now": "^2.1.0",
+ "qs": "~6.5.2",
+ "safe-buffer": "^5.1.2",
+ "tough-cookie": "~2.5.0",
+ "tunnel-agent": "^0.6.0",
+ "uuid": "^3.3.2"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/request/node_modules/qs": {
+ "version": "6.5.5",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz",
+ "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -654,6 +1480,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/sax": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz",
+ "integrity": "sha512-c0YL9VcSfcdH3F1Qij9qpYJFpKFKMXNOkLWFssBL3RuF7ZS8oZhllR2rWlCRjDTJsfq3R6wbSsaRU6o0rkEdNw==",
+ "license": "BSD"
+ },
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -699,6 +1531,23 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -777,6 +1626,40 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/split-on-first": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/sshpk": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
+ "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asn1": "~0.2.3",
+ "assert-plus": "^1.0.0",
+ "bcrypt-pbkdf": "^1.0.0",
+ "dashdash": "^1.12.0",
+ "ecc-jsbn": "~0.1.1",
+ "getpass": "^0.1.1",
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.0.2",
+ "tweetnacl": "~0.14.0"
+ },
+ "bin": {
+ "sshpk-conv": "bin/sshpk-conv",
+ "sshpk-sign": "bin/sshpk-sign",
+ "sshpk-verify": "bin/sshpk-verify"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -786,6 +1669,103 @@
"node": ">= 0.8"
}
},
+ "node_modules/stream-chain": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
+ "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stream-json": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
+ "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "stream-chain": "^2.2.5"
+ }
+ },
+ "node_modules/strict-uri-encode": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/strnum": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
+ "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
+ "node_modules/through2": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
+ "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "3"
+ }
+ },
+ "node_modules/through2/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/to-buffer": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
+ "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
+ "license": "MIT",
+ "dependencies": {
+ "isarray": "^2.0.5",
+ "safe-buffer": "^5.2.1",
+ "typed-array-buffer": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/to-buffer/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -795,6 +1775,37 @@
"node": ">=0.6"
}
},
+ "node_modules/tough-cookie": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+ "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "psl": "^1.1.28",
+ "punycode": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+ "license": "Unlicense"
+ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -808,6 +1819,26 @@
"node": ">= 0.6"
}
},
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/underscore": {
+ "version": "1.13.8",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -817,6 +1848,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -826,6 +1872,25 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "license": "MIT",
+ "bin": {
+ "uuid": "bin/uuid"
+ }
+ },
+ "node_modules/validator": {
+ "version": "13.15.35",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
+ "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -834,6 +1899,79 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/verror": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+ "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
+ "engines": [
+ "node >=0.6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "assert-plus": "^1.0.0",
+ "core-util-is": "1.0.2",
+ "extsprintf": "^1.2.0"
+ }
+ },
+ "node_modules/verror/node_modules/core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
+ "license": "MIT"
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.24",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz",
+ "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/xml-naming": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/xml2js": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz",
+ "integrity": "sha512-ZHZBIAO55GHCn2jBYByVPHvHS+o3j8/a/qmpEe6kxO3cTnTCWC3Htq9RYJ5G4XMwMMClD2QkXA9SNdPadLyn3Q==",
+ "dependencies": {
+ "sax": "0.5.x"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "9.0.7",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
+ "integrity": "sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
}
}
}
diff --git a/tools/package.json b/tools/package.json
index fa41ac0..82d5874 100644
--- a/tools/package.json
+++ b/tools/package.json
@@ -9,7 +9,9 @@
"test": "node --test"
},
"dependencies": {
+ "azure-storage": "^2.10.7",
"express": "^4.21.2",
+ "minio": "^8.0.7",
"pocketbase": "^0.27.0"
}
}
diff --git a/tools/permit.js b/tools/permit.js
index 4c18007..7d54d70 100644
--- a/tools/permit.js
+++ b/tools/permit.js
@@ -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
diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js
new file mode 100644
index 0000000..0f125b3
--- /dev/null
+++ b/tools/public/assets/cropper/app.js
@@ -0,0 +1,270 @@
+/* Image Cropper — review, crop, clean and save identity photos.
+ Identity comes from the YOUreka session; the only operator credential is
+ the save-time OTP (required by a2loft). */
+/* global Vue, axios, toastr, Croppa */
+Vue.use(Croppa);
+(function () {
+ 'use strict';
+
+ var REVIEW_OPTIONS = [
+ 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
+ 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
+ 'IMAGE: Blurry ',
+ 'IMAGE: Improper lighting ',
+ 'IMAGE: Not coloured.',
+ 'IMAGE: Scanned photo',
+ 'IMAGE: Has no adequate space to allow for proper cropping ',
+ 'IMAGE: Not taken on a recommended background.',
+ 'IMAGE: Photo is a selfie',
+ 'DRESSING: Unacceptable dress. ',
+ 'HEAD: Skewed (leaning away from the midline - vertically or horizontally)',
+ 'EYES: Not looking into the camera (looking downward, upward, or sideward)',
+ 'EYES: Not properly opened',
+ 'EARS: Not visible ',
+ 'EARS: Both ears are not equally visible (face not aligned central)',
+ 'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)',
+ 'GLASSES: Subject is wearing glasses ',
+ 'JUST WRING: Goes against almost all acceptable passport criteria.',
+ 'Others not specified.',
+ ];
+
+ var app = new Vue({
+ el: '#app',
+ data: {
+ loading: true,
+ session: null,
+ message: {
+ message: { caption: null, file_url: null, domain: '' },
+ },
+ agent: null,
+ process: null,
+ reviewed: false,
+ accepted: false,
+ cleanedImage: null,
+ requiresCleaning: '',
+ otp: '',
+ reviewOptions: REVIEW_OPTIONS.map(function (name, index) {
+ return { index: index + 1, name: name };
+ }),
+ selectedReviewOptions: [],
+ otherReviewOption: '',
+ busy: { load: false, clean: false, review: false, save: false },
+ myCroppa: {},
+ },
+
+ computed: {
+ initials: function () {
+ var n = (this.session && (this.session.firstName || this.session.phone)) || '?';
+ return n.slice(0, 2).toUpperCase();
+ },
+ canReview: function () {
+ if (this.busy.review) return false;
+ if (this.accepted) return true;
+ return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
+ },
+ canSave: function () {
+ return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
+ },
+ },
+
+ created: function () {
+ var self = this;
+ axios.get('/auth/session').then(function (r) {
+ self.session = r.data;
+ self.loading = false;
+ }).catch(function (err) {
+ if (err.response && err.response.status === 401) {
+ window.location.href = '/auth/login';
+ return;
+ }
+ self.loading = false;
+ toastr.error('Could not load your session. Please refresh the page.', 'Oops!');
+ });
+ },
+
+ methods: {
+ reset: function () {
+ this.message = { message: { caption: null, file_url: null, domain: '' } };
+ this.agent = null;
+ this.process = null;
+ this.reviewed = false;
+ this.accepted = false;
+ this.cleanedImage = null;
+ this.selectedReviewOptions = [];
+ this.otherReviewOption = '';
+ this.requiresCleaning = '';
+ this.otp = '';
+ },
+
+ loadPicture: async function (mode) {
+ var self = this;
+ this.busy.load = true;
+ try {
+ var uploadId = null;
+ if (mode === 'by-id') {
+ uploadId = prompt('Enter picture upload ID');
+ if (!uploadId) return;
+ }
+ var { data } = await axios.get(
+ uploadId ? '/cropper/picture/load/' + encodeURIComponent(uploadId) : '/cropper/picture/next'
+ );
+ if (!data.ok) {
+ this.reset(); // nothing (more) to review — back to the load state
+ toastr.error(data.message, 'Oops!');
+ return;
+ }
+ this.message = data.data;
+ var image = await axios.get(
+ '/cropper/picture/' + encodeURIComponent(this.message.message.file_name.replace(/\//gi, '_'))
+ );
+ if (!image.data.ok) {
+ this.reset();
+ toastr.error("Couldn't load the attached image. Please try again.", 'Oops!');
+ return;
+ }
+ this.message.message.file_url = image.data.data;
+ this.cleanedImage = null;
+ this.agent = null;
+ this.process = this.message.message.process || null;
+ if (this.message.message.upload_id) {
+ var review = await axios.get(
+ '/cropper/picture/review/' + encodeURIComponent(this.message.message.upload_id)
+ );
+ this.agent = review.data.ok ? review.data.data : null;
+ }
+ } catch ({ message }) {
+ toastr.error(message, 'Operation Failed');
+ } finally {
+ this.reviewed = false;
+ this.accepted = false;
+ this.selectedReviewOptions = [];
+ this.otherReviewOption = '';
+ this.requiresCleaning = '';
+ this.otp = '';
+ this.busy.load = false;
+ }
+ },
+
+ cleanImage: async function () {
+ var self = this;
+ this.busy.clean = true;
+ try {
+ var dataUrl = this.myCroppa.generateDataUrl('image/jpeg');
+ var response = await fetch('/cropper/api/removebg', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image_file_b64: dataUrl.split(',')[1] }),
+ });
+ if (!response.ok) {
+ var err = await response.json().catch(function () { return {}; });
+ throw new Error(err.message || 'Failed to clean image.');
+ }
+ var contentType = response.headers.get('content-type') || '';
+ if (contentType.indexOf('image/') !== 0) {
+ throw new Error('Image cleanup service returned an invalid response.');
+ }
+ var blob = await response.blob();
+ var fr = new FileReader();
+ fr.onload = function () { self.cleanedImage = fr.result; };
+ fr.readAsDataURL(blob);
+ } catch ({ message }) {
+ toastr.error(message, 'Image Cleanup Failed');
+ } finally {
+ this.busy.clean = false;
+ }
+ },
+
+ reviewPicture: async function () {
+ var self = this;
+ if (!this.canReview) return;
+ if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) {
+ toastr.error('Please indicate why this image is not good enough.', 'Operation Failed');
+ return;
+ }
+ this.busy.review = true;
+ var options = [...this.selectedReviewOptions, this.otherReviewOption].filter(function (o) {
+ return o && o.length > 0;
+ });
+ var reviewMessage = this.accepted ? null : options.join('\n\n');
+ try {
+ var hasProcess = !!this.message.message.process;
+ var data = {};
+ if (hasProcess) {
+ var { domain, reference } = this.message.message.process.variables;
+ data.process = {
+ correlationKey: domain + '-' + reference,
+ name: 'PictureReviewed',
+ variables: { ok: this.accepted, reviewRejectMessage: reviewMessage },
+ };
+ } else {
+ data.raw = { phone: this.message.message.caption, reviewRejectMessage: reviewMessage };
+ }
+ var { data: result } = await axios.post('/cropper/picture/review', {
+ deleteFromQueue: true,
+ message: this.message,
+ mode: hasProcess ? 'process' : 'raw',
+ ...data,
+ });
+ if (!result.ok) {
+ toastr.error(result.message, 'Operation Failed');
+ return;
+ }
+ if (reviewMessage) {
+ toastr.success('Profile owner has been notified of the review.', 'Review Sent');
+ }
+ this.reset();
+ } catch ({ message }) {
+ toastr.error(message, 'Operation Failed');
+ } finally {
+ this.busy.review = false;
+ }
+ },
+
+ removeFromQueue: async function () {
+ var self = this;
+ if (!confirm('Are you sure you want to dequeue?')) return;
+ this.busy.review = true;
+ try {
+ var { data } = await axios.post('/cropper/dequeue', { message: this.message });
+ if (!data.ok) {
+ toastr.error(data.message, 'Not Removed');
+ return;
+ }
+ toastr.success(data.message, 'Removed!');
+ this.reset();
+ } catch ({ message }) {
+ toastr.error(message, 'Not Removed');
+ } finally {
+ this.busy.review = false;
+ }
+ },
+
+ saveChanges: async function () {
+ var self = this;
+ if (!this.canSave) return;
+ this.busy.save = true;
+ try {
+ var { data } = await axios.post(
+ '/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
+ {
+ pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'),
+ phone: this.message.message.caption,
+ message: this.message,
+ requiresCleaning: this.requiresCleaning !== 'No',
+ }
+ );
+ if (!data.ok) {
+ toastr.error(data.message, 'Not Saved');
+ return;
+ }
+ toastr.success('Picture saved for ' + this.message.message.caption, 'Saved');
+ this.reset();
+ } catch ({ message }) {
+ toastr.error(message, 'Not Saved');
+ } finally {
+ this.busy.save = false;
+ }
+ },
+ },
+ });
+})();
diff --git a/tools/public/assets/cropper/index.html b/tools/public/assets/cropper/index.html
new file mode 100644
index 0000000..37a5737
--- /dev/null
+++ b/tools/public/assets/cropper/index.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+ Image Cropper · YOUreka Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Image Cropper
+
Review, crop and save identity photos
+
+
+
+
+
+
+ 1
+
Load a picture
+ Working on {{ message.message.caption }} · {{ message.message.domain }}
+
+
+
+
+
+
+
+
+
+
+
+ 2
+
Review picture
+ Accept, or reject with reasons
+
+
+
+ - Phone
- {{ message.message.caption }}
+ - Domain
- {{ message.message.domain }}
+ - Agent
- {{ agent.name }} {{ agent.phone }}
+
+
- Process
+ - {{ message.message.process.variables.name }} {{ message.message.process.variables.environment }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![cleaned preview]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Accept picture
+
Reject picture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading your session…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/public/index.html b/tools/public/index.html
index cb84720..5189a51 100644
--- a/tools/public/index.html
+++ b/tools/public/index.html
@@ -30,14 +30,14 @@
-
+
Image Cropper
- Prepare and crop identity photos to the dimensions required by YOUreka profiles.
+ Review, crop and save identity photos submitted for YOUreka ID domain profiles.
Open Image Cropper
-
+
diff --git a/tools/server.js b/tools/server.js
index d70702a..84b286b 100644
--- a/tools/server.js
+++ b/tools/server.js
@@ -10,7 +10,8 @@ import express from 'express';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
-import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS } from './permit.js';
+import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
+import { createCropperRouter, buildCropperClients } from './cropper.js';
// ── Hardcoded configuration ──────────────────────────────────────────────────
export const PORT = 4173;
@@ -57,9 +58,9 @@ function readProfile(raw) {
}
}
-function errorPage(res, title, message) {
+function errorPage(res, status, title, message) {
res
- .status(502)
+ .status(status)
.type('html')
.send(`
@@ -103,11 +104,12 @@ export function createApp(overrides = {}) {
};
const domainCodes = new Set(cfg.domains.map((d) => d.code));
const domainCache = new Map(); // phone → { codes, checkedAt }
+ const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt }
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag
- app.use(express.json());
+ app.use(express.json({ limit: '50mb' })); // cropper pictures travel as data URLs
const setSessionCookie = (req, res, value) =>
res.cookie('session', value, {
@@ -174,28 +176,29 @@ export function createApp(overrides = {}) {
});
const data = await r.json().catch(() => null);
if (!r.ok || !data?.ok || !data.loginUrl) {
- return errorPage(res, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.');
+ return errorPage(res, 502, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.');
}
res.redirect(data.loginUrl);
} catch {
- errorPage(res, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.');
+ errorPage(res, 502, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.');
}
});
app.get('/auth/callback', async (req, res) => {
const { sessionId } = req.query;
- if (!sessionId) return errorPage(res, 'Login failed', 'Missing login session. Please start again.');
+ if (!sessionId) return errorPage(res, 401, 'Login failed', 'Missing login session. Please start again.');
try {
const r = await cfg.fetch(
`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie`
);
const data = await r.json().catch(() => null);
if (!r.ok || !data?.ok) {
- return errorPage(res, 'Login failed', 'Authentication failed. The link may have expired — please try again.');
+ return errorPage(res, 401, 'Login failed', 'Authentication failed. The link may have expired — please try again.');
}
if (data.status === 'Initialized' || data.status === 'TimedOut') {
return errorPage(
res,
+ 401,
'Login failed',
data.status === 'TimedOut'
? 'Your login session timed out. Please start again.'
@@ -204,7 +207,7 @@ export function createApp(overrides = {}) {
}
const sessionValue = extractPbCookieValue(data.cookie || '');
if (!sessionValue) {
- return errorPage(res, 'Login failed', 'The portal did not return a usable session. Please try again.');
+ return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.');
}
setSessionCookie(req, res, sessionValue);
setProfileCookie(req, res, {
@@ -214,7 +217,7 @@ export function createApp(overrides = {}) {
});
res.redirect('/fingerprint');
} catch {
- errorPage(res, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
+ errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
}
});
@@ -279,6 +282,56 @@ export function createApp(overrides = {}) {
res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html'));
});
+ // ── cropper page + namespaced API (session + crop_image required) ────────
+ const cropGateContext = {
+ portalUrl: cfg.portalUrl,
+ fetchImpl: cfg.fetch,
+ cache: cropCache,
+ ttlMs: cfg.ttlMs,
+ now: cfg.now,
+ };
+ const pageCropGate = async (req, res, next) => {
+ let allowed = false;
+ try {
+ ({ allowed } = await cropPermission({
+ phone: stripPhonePrefix(req.session.phone),
+ ...cropGateContext,
+ }));
+ } catch {
+ allowed = false; // fail closed
+ }
+ if (!allowed) {
+ return errorPage(
+ res,
+ 403,
+ 'Not permitted',
+ 'You are not permitted to crop images. Contact your administrator if you believe this is a mistake.'
+ );
+ }
+ next();
+ };
+ app.get('/cropper', requirePage, pageCropGate, (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html'));
+ });
+ const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
+ if (cropperClients) {
+ app.use(
+ '/cropper',
+ createCropperRouter({
+ ...cropperClients,
+ a2loftBase: cfg.a2loftBase,
+ removebgUrl: cfg.removebgUrl,
+ noderedBase: cfg.noderedBase,
+ ...cropGateContext,
+ requireApi,
+ })
+ );
+ } else {
+ app.use('/cropper', (req, res) =>
+ res.status(503).json({ ok: false, message: 'The image cropper is not configured on this deployment.' })
+ );
+ }
+
// ── a2loft proxies: same-origin, phone injected from the session ──────────
const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
diff --git a/tools/test/cropper.test.js b/tools/test/cropper.test.js
new file mode 100644
index 0000000..e08f4d8
--- /dev/null
+++ b/tools/test/cropper.test.js
@@ -0,0 +1,420 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createApp } from '../server.js';
+import {
+ makeJwt,
+ makePbCookie,
+ startServer,
+ stopServer,
+ urlOf,
+ cookieValue,
+} from './helpers.js';
+
+// Fake portal: web-auth + openfga verify with a configurable crop_image verdict.
+async function startFakePortal({ cropAllowed = true } = {}) {
+ const state = { verifyCalls: 0 };
+ const server = await startServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ const json = (status, body) => {
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ if (req.method === 'POST' && req.url === '/api/web/auth') {
+ return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login' });
+ }
+ if (req.url.startsWith('/api/web/auth/s1/cookie')) {
+ return json(200, {
+ ok: true,
+ sessionId: 's1',
+ status: 'Activated',
+ cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
+ phone: '+2348068287222',
+ firstName: 'Ada',
+ lastName: 'Obi',
+ });
+ }
+ if (req.method === 'POST' && req.url === '/api/openfga/verify') {
+ state.verifyCalls += 1;
+ const body = JSON.parse(raw);
+ // capture_biometric checks (session endpoint) always pass; crop_image
+ // follows the configured verdict.
+ const allowed = body.relation === 'crop_image' ? cropAllowed : true;
+ return json(200, { ok: true, allowed });
+ }
+ json(404, { ok: false });
+ });
+ });
+ return { server, state, stop: () => stopServer(server) };
+}
+
+// Fake a2loft: dims load, save echo; records calls.
+async function startFakeA2loft() {
+ const calls = [];
+ const server = await startServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
+ const json = (body) => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ if (req.url.startsWith('/identity/dims/picture/')) {
+ return json({
+ ok: true,
+ data: {
+ upload_id: 'up-1',
+ file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
+ caption: '08068287222',
+ domain: 'ucth',
+ raw_file_id: 'raw-1',
+ },
+ });
+ }
+ if (req.url.includes('/profile/picture')) {
+ return json({ ok: true, message: 'Picture uploaded.' });
+ }
+ json({ ok: true });
+ });
+ });
+ return { calls, server, stop: () => stopServer(server) };
+}
+
+// Fake minio + azure queue — plain objects exposing just what the router uses.
+const fakeMinio = () => ({
+ presignedGetObject: async (_bucket, name) => `https://storage.example/${name}`,
+});
+const fakeQueue = () => {
+ const calls = { getMessage: 0, deleteMessage: 0 };
+ const item = {
+ messageId: 'm-1',
+ popReceipt: 'pop-1',
+ dequeueCount: 1,
+ messageText: JSON.stringify({
+ upload_id: 'up-1',
+ file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
+ caption: '08068287222',
+ domain: 'ucth',
+ raw_file_id: 'raw-1',
+ }),
+ };
+ return {
+ calls,
+ // azure-storage callback signatures
+ getMessage: (_queue, _opts, cb) => {
+ calls.getMessage += 1;
+ cb(null, item);
+ },
+ deleteMessage: (_queue, _id, _pop, cb) => {
+ calls.deleteMessage += 1;
+ cb(null, { isSuccessful: true });
+ },
+ };
+};
+
+// Fake removebg + nodered upstreams.
+async function startFakeRemovebg() {
+ const calls = [];
+ const server = await startServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ calls.push({ url: req.url, key: req.headers['x-api-key'] });
+ res.writeHead(200, { 'content-type': 'image/png' });
+ res.end('PNGDATA');
+ });
+ });
+ return { calls, server, stop: () => stopServer(server) };
+}
+
+async function startFakeNodered() {
+ const calls = [];
+ const server = await startServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization });
+ const json = (body) => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ if (req.url === '/idid/picture/review/up-1') {
+ return json({ ok: true, data: { agentPhoneHash: '12345' } });
+ }
+ if (req.url === '/unical/agents') {
+ return json([{ phoneHashes: [12345], name: 'Agent A', phone: '+2340000000000' }]);
+ }
+ if (req.url === '/zeebe/picture/review') {
+ return json({ ok: true });
+ }
+ json({ ok: false });
+ });
+ });
+ return { calls, server, stop: () => stopServer(server) };
+}
+
+async function boot({ cropAllowed = true, cropperClients } = {}) {
+ const portal = await startFakePortal({ cropAllowed });
+ const a2loft = await startFakeA2loft();
+ const removebg = await startFakeRemovebg();
+ const nodered = await startFakeNodered();
+ // presigned URLs from the fake minio must not hit the real network
+ const testFetch = async (url, init) => {
+ if (String(url).startsWith('https://storage.example/')) {
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'image/png' },
+ arrayBuffer: async () => Buffer.from('PNGDATA'),
+ json: async () => ({}),
+ text: async () => 'PNGDATA',
+ };
+ }
+ return fetch(url, init);
+ };
+ const app = createApp({
+ portalUrl: urlOf(portal.server),
+ a2loftBase: urlOf(a2loft.server),
+ removebgUrl: urlOf(removebg.server),
+ noderedBase: urlOf(nodered.server),
+ fetch: testFetch,
+ cropperClients: cropperClients !== undefined ? cropperClients : {
+ minio: fakeMinio(),
+ queue: fakeQueue(),
+ noderedAuth: { username: 'nr', password: 'nr-secret' },
+ carvekitKey: 'carve-key',
+ },
+ });
+ const httpServer = await new Promise((resolve) => {
+ const s = app.listen(0, '127.0.0.1', () => resolve(s));
+ });
+ const base = urlOf(httpServer);
+ const stop = async () => {
+ await new Promise((r) => httpServer.close(r));
+ await portal.stop();
+ await a2loft.stop();
+ await removebg.stop();
+ await nodered.stop();
+ };
+ return { base, a2loft, removebg, nodered, portal, stop };
+}
+
+const manual = { redirect: 'manual' };
+
+async function login(base) {
+ const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
+ assert.equal(res.status, 302);
+ return `session=${cookieValue(res, 'session')}; user_profile=${cookieValue(res, 'user_profile')}`;
+}
+
+test('unconfigured deployment: cropper answers 503, other tools unaffected', async () => {
+ const { base, stop } = await boot({ cropperClients: null });
+ try {
+ const cookies = await login(base);
+ const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
+ assert.equal(res.status, 503);
+ const landing = await fetch(`${base}/`);
+ assert.equal(landing.status, 200);
+ } finally {
+ await stop();
+ }
+});
+
+test('crop gate: permitted operator loads the page; denied operator does not', async () => {
+ const ok = await boot({ cropAllowed: true });
+ try {
+ const cookies = await login(ok.base);
+ const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } });
+ assert.equal(page.status, 200);
+ assert.match(await page.text(), /Smart Photo Editor|Image Cropper/);
+ } finally {
+ await ok.stop();
+ }
+ const denied = await boot({ cropAllowed: false });
+ try {
+ const cookies = await login(denied.base);
+ const page = await fetch(`${denied.base}/cropper`, { headers: { cookie: cookies } });
+ assert.equal(page.status, 403);
+ assert.match(await page.text(), /not permitted/i);
+ } finally {
+ await denied.stop();
+ }
+});
+
+test('picture routes are gated and proxied; reads use the cached gate', async () => {
+ const { base, portal, stop } = await boot();
+ try {
+ const cookies = await login(base);
+ const before = portal.state.verifyCalls;
+ const next = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } });
+ assert.equal(next.status, 200);
+ const body = await next.json();
+ assert.equal(body.ok, true);
+ assert.equal(body.data.message.upload_id, 'up-1');
+
+ // load-by-id goes to a2loft dims; image fetch returns a data URL
+ const loaded = await fetch(`${base}/cropper/picture/load/up-1`, { headers: { cookie: cookies } });
+ assert.equal((await loaded.json()).ok, true);
+ const image = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, {
+ headers: { cookie: cookies },
+ });
+ const imageBody = await image.json();
+ assert.equal(imageBody.ok, true);
+ assert.match(imageBody.data, /^data:image\/png;base64,/);
+
+ // several reads, but only one crop verify within the TTL
+ assert.equal(portal.state.verifyCalls - before, 1);
+ } finally {
+ await stop();
+ }
+});
+
+test('removebg is gated and proxies the cleanup service', async () => {
+ const denied = await boot({ cropAllowed: false });
+ try {
+ const cookies = await login(denied.base);
+ const res = await fetch(`${denied.base}/cropper/api/removebg`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify({ image_file_b64: 'QUJD' }),
+ });
+ assert.equal(res.status, 403);
+ assert.equal(denied.removebg.calls.length, 0, 'cleanup service not contacted');
+ } finally {
+ await denied.stop();
+ }
+ const ok = await boot({ cropAllowed: true });
+ try {
+ const cookies = await login(ok.base);
+ const res = await fetch(`${ok.base}/cropper/api/removebg`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify({ image_file_b64: 'QUJD' }),
+ });
+ assert.equal(res.status, 200);
+ assert.equal(await res.text(), 'PNGDATA');
+ assert.equal(ok.removebg.calls[0].key, 'carve-key');
+ } finally {
+ await ok.stop();
+ }
+});
+
+test('save verifies the gate live and proxies to a2loft with session identity', async () => {
+ const { base, a2loft, portal, stop } = await boot();
+ try {
+ const cookies = await login(base);
+ // prime the cache with a denied verdict for the reads, then flip the
+ // portal to allowed — the live save check must see the new verdict.
+ const payload = {
+ pictureDataUrl: 'data:image/jpeg;base64,QUJD',
+ phone: '08068287222',
+ requiresCleaning: true,
+ message: {
+ pop_receipt: 'pop-1',
+ message_id: 'm-1',
+ message: {
+ upload_id: 'up-1',
+ domain: 'ucth',
+ agentDeviceHash: 'agent-1',
+ caption: '08068287222',
+ file_url: null,
+ },
+ },
+ };
+ const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ assert.equal(res.status, 200);
+ const body = await res.json();
+ assert.equal(body.ok, true);
+
+ const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
+ assert.ok(save, 'a2loft save contacted');
+ const u = new URL(save.url, 'http://x');
+ assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
+ assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
+ assert.equal(u.searchParams.get('otp'), '778899');
+ assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
+ assert.equal(save.body.uploadId, 'up-1');
+ assert.ok(portal.state.verifyCalls > 0);
+ } finally {
+ await stop();
+ }
+});
+
+test('save refuses without an OTP and without the crop permission', async () => {
+ const { base, a2loft, stop } = await boot({ cropAllowed: false });
+ try {
+ const cookies = await login(base);
+ const payload = {
+ pictureDataUrl: 'data:image/jpeg;base64,QUJD',
+ phone: '08068287222',
+ message: {
+ pop_receipt: 'custom',
+ message_id: 'm-1',
+ message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
+ },
+ };
+ const noOtp = await fetch(`${base}/cropper/profile/picture`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ // permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec
+ assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`);
+ const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ assert.equal(denied.status, 403);
+ assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'a2loft never contacted');
+ } finally {
+ await stop();
+ }
+});
+
+test('review and dequeue proxy with the queued message', async () => {
+ const { base, nodered, stop } = await boot();
+ try {
+ const cookies = await login(base);
+ const agent = await fetch(`${base}/cropper/picture/review/up-1`, { headers: { cookie: cookies } });
+ const agentBody = await agent.json();
+ assert.equal(agentBody.ok, true, JSON.stringify(agentBody));
+ assert.equal(agentBody.data.name, 'Agent A');
+
+ const review = await fetch(`${base}/cropper/picture/review`, {
+ method: 'POST',
+ headers: { cookie: cookies, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ deleteFromQueue: true,
+ message: { message_id: 'm-1', pop_receipt: 'pop-1', message: { domain: 'ucth' } },
+ mode: 'raw',
+ raw: { phone: '08068287222', reviewRejectMessage: 'blurry' },
+ }),
+ });
+ const reviewBody = await review.json();
+ assert.equal(reviewBody.ok, true, JSON.stringify(reviewBody));
+ assert.ok(nodered.calls.find((c) => c.url === '/zeebe/picture/review'));
+ } finally {
+ await stop();
+ }
+});
+
+test('baseUrl exposes the identity-prefixed a2loft base', async () => {
+ const { base, stop } = await boot();
+ try {
+ const cookies = await login(base);
+ const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
+ const body = await res.json();
+ assert.equal(body.ok, true);
+ assert.match(body.data, /\/identity$/);
+ assert.ok(body.data.startsWith('http://127.0.0.1:'));
+ } finally {
+ await stop();
+ }
+});
diff --git a/tools/test/server.test.js b/tools/test/server.test.js
index 113d38d..5636153 100644
--- a/tools/test/server.test.js
+++ b/tools/test/server.test.js
@@ -124,8 +124,8 @@ test('landing page links both tools', async () => {
const res = await fetch(`${base}/`);
const html = await res.text();
assert.equal(res.status, 200);
- assert.match(html, /smart-pxa\.yrka\.app/);
- assert.match(html, /\/fingerprint/);
+ assert.match(html, /href="\/cropper"/);
+ assert.match(html, /href="\/fingerprint"/);
} finally {
await stop();
}
@@ -148,7 +148,7 @@ test('auth callback rejects unredeemable sessions without setting cookies', asyn
const { base, stop } = await bootApp({}, { cookieStatus: 'TimedOut' });
try {
const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
- assert.equal(res.status, 502);
+ assert.equal(res.status, 401);
assert.equal(cookieValue(res, 'session'), undefined);
const html = await res.text();
assert.match(html, /timed out/i);