From f03a75b9d2d5e7a6534fb6177eb979bf996f19e7 Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Wed, 30 Sep 2026 23:12:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20YOUreka=20Tools=20express=20app=20?= =?UTF-8?q?=E2=80=94=20landing=20page=20+=20fpagent=20enrollment=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/: express host (hardcoded config, YOUreka Auth cookie session, OpenFGA domain filtering with 5-min cache, a2loft proxies) - /fingerprint wizard: domain picker, two-finger capture via fpagent Socket.IO, OTP-validated lookup/save, domain refresh control - node --test suite (27 tests, fake portal/PocketBase/a2loft upstreams) - Dockerfile (node:22-alpine, non-root, healthcheck) - README rewritten; node_modules gitignored Field-verified end to end: portal login, permitted-domain filtering, real U.are.U 4500 captures, cancel-preserves-capture, a2loft save. --- .dockerignore | 9 + .gitignore | 3 +- Dockerfile | 21 + README.md | 101 +++- tools/package-lock.json | 839 ++++++++++++++++++++++++++++++ tools/package.json | 15 + tools/permit.js | 80 +++ tools/public/assets/fp/app.js | 286 ++++++++++ tools/public/assets/fp/index.html | 169 ++++++ tools/public/assets/style.css | 188 +++++++ tools/public/index.html | 64 +++ tools/server.js | 350 +++++++++++++ tools/test/helpers.js | 72 +++ tools/test/permit.test.js | 144 +++++ tools/test/server.test.js | 265 ++++++++++ tools/test/yrka-auth.test.js | 92 ++++ tools/yrka-auth.js | 101 ++++ 17 files changed, 2785 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 tools/package-lock.json create mode 100644 tools/package.json create mode 100644 tools/permit.js create mode 100644 tools/public/assets/fp/app.js create mode 100644 tools/public/assets/fp/index.html create mode 100644 tools/public/assets/style.css create mode 100644 tools/public/index.html create mode 100644 tools/server.js create mode 100644 tools/test/helpers.js create mode 100644 tools/test/permit.test.js create mode 100644 tools/test/server.test.js create mode 100644 tools/test/yrka-auth.test.js create mode 100644 tools/yrka-auth.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..43e87f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.opencode +.vscode +openspec +tools/node_modules +tools/test +Dockerfile +.dockerignore +README.md diff --git a/.gitignore b/.gitignore index 91ca5bc..714dbb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .config.json -.pyc \ No newline at end of file +.pyc +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4634e7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# YOUreka Tools — landing page + /fingerprint enrollment wizard. +# Stateless: sessions live in the operator's cookie, and fpagent keeps running +# on the field laptop (the wizard reaches it at http://127.0.0.1:9000 from the +# browser, so the container location does not matter). +# +# docker build -t yreka-tools . +# docker run -d -p 4173:4173 --name yreka-tools yreka-tools +FROM node:22-alpine +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/public ./public + +USER node +EXPOSE 4173 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \ + CMD wget -qO- http://127.0.0.1:4173/ >/dev/null || exit 1 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 3e5f369..1e32524 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,93 @@ -# YOUreka IDID Fingerprint Service +# YOUreka Tools — Fingerprint Service -This service uses the libfprint library (version 2 and newwer) to capture fingerprints for profiles in YOUreka ID Domains. +A small Express app that hosts the YOUreka tools landing page and the +**Fingerprint Agent** enrollment wizard. Fingerprint capture is performed by +the local [`fpagent`](../fingerprint-agent) binary (Socket.IO on +`127.0.0.1:9000`); this app provides the login (YOUreka Auth), the permitted +domain selection, and the a2loft integration that stores the enrolled +fingerprints. -## Dependencies +## Layout -## Debian Packages: -1. sudo apt install libfprint-2-dev -2. sudo apt install python3-pip +``` +tools/ +├── server.js # Express app: routes, session middleware, proxies +├── yrka-auth.js # YOUreka Auth cookie session → PocketBase (authRefresh) +├── permit.js # OpenFGA domain permission checks + 5-minute cache +├── public/ +│ ├── index.html # landing page (Image Cropper + Fingerprint Agent) +│ └── assets/ +│ ├── style.css +│ └── fp/ # /fingerprint enrollment wizard (Vue 2 via CDN) +└── test/ # node --test suite (fake portal / PocketBase / a2loft) +``` + +## Running + +Prerequisites: Node.js 22+, `fpagent` installed and running on this laptop +(see the fingerprint-agent repository for installation), the U.are.U 4500 +scanner attached. + +```sh +cd tools +npm install +npm start # → http://localhost:4173 +``` + +Open http://localhost:4173 and sign in with your YOUreka account. + +> fpagent must allow the tools origin. On a dev machine run it with: +> `fpagent -allowed-origins http://localhost:4173` + +## How enrollment works + +1. **Sign in** — YOUreka Auth (portal web-auth redirect). The PocketBase + session is kept in an httpOnly cookie and refreshed server-side. +2. **Pick a domain** — the hardcoded catalog (UNICAL, Metropolitan Hotel, UCTH, + UNICAL Radio International, YOUreka) is filtered against the same portal + OpenFGA endpoint fpagent uses (`capture_biometric` per domain). Results are + cached for 5 minutes; the **Refresh domains** control unlocks once the + cache expires. fpagent re-authorizes every capture regardless. +3. **Locate the staff profile** — reference + OTP, validated by + `apis.a2loft.net` through a same-origin proxy (the operator's phone comes + from the session). +4. **Capture** — right thumb and right index finger, one capture at a time, + with per-scan progress and cancel; the PocketBase token, domain and + reference travel with every `fprint-enroll` to fpagent. +5. **Save** — both templates are posted to a2loft (with a fresh OTP). + +## Configuration + +Everything is hardcoded in `tools/server.js` by design — port `4173`, portal +`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft +`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the domain +catalog. Change them there; there are no environment variables or config +files. + +## Tests + +```sh +cd tools +npm test # node --test — 27 tests, no network or hardware required +``` + +## 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). + +```sh +docker build -t yreka-tools . +docker run -d -p 4173:4173 --name yreka-tools yreka-tools +``` + +Behind a TLS-terminating proxy the app honours `X-Forwarded-Proto` +(`trust proxy` is enabled), so session cookies get the `Secure` flag +automatically. + +Field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500): login → +permitted-domain filtering → staff lookup → two scanner captures with cancel +path → save → reset, all verified end to end via Playwright. -## PIP Packages -1. pip3 install python-socketio -2. pip3 install simple-websocket -3. pip3 install flask -4. pip3 install requests -# How to run -```python3 app.py``` \ No newline at end of file diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..3c4274a --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,839 @@ +{ + "name": "yreka-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yreka-tools", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2", + "pocketbase": "^0.27.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "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", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..fa41ac0 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,15 @@ +{ + "name": "yreka-tools", + "version": "1.0.0", + "private": true, + "description": "YOUreka Tools — landing page and Fingerprint Agent enrollment wizard (capture via fpagent)", + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "pocketbase": "^0.27.0" + } +} diff --git a/tools/permit.js b/tools/permit.js new file mode 100644 index 0000000..4c18007 --- /dev/null +++ b/tools/permit.js @@ -0,0 +1,80 @@ +// Domain permission checking against the portal OpenFGA verify endpoint. +// Mirrors fingerprint-agent internal/permit: `domain:` first, then a +// `domain:idid` fallback — either allow passes, and every failure closes. +// Results are cached per operator phone for DOMAIN_TTL_MS. +export const DOMAIN_TTL_MS = 5 * 60 * 1000; +export const CACHE_FRESH = 'CACHE_FRESH'; + +export function stripPhonePrefix(phone) { + return (phone || '').replace(/^\+/, ''); +} + +async function verifyOnce(fetchImpl, portalUrl, phone, object) { + const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + subsystem: 'idid', + user: `user:${phone}`, + relation: 'capture_biometric', + object, + }), + }); + if (r.status !== 200) throw new Error(`verify status ${r.status}`); + const out = await r.json(); + if (!out?.ok) throw new Error('verify returned ok=false'); + return !!out.allowed; +} + +// true = allowed, false = denied; throws when neither object could answer. +export async function checkDomain(fetchImpl, portalUrl, phone, code) { + let answered = false; + for (const object of [`domain:${code}`, 'domain:idid']) { + try { + const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object); + answered = true; + if (allowed) return true; + } catch { + // try the fallback object; if neither answers we fail closed below + } + } + if (!answered) throw new Error(`verify failed for domain:${code} and domain:idid`); + return false; +} + +/** + * Permitted domain codes for phone, cached per phone for ttlMs. + * force=true re-checks everything; while the cache is still fresh it refuses + * with an error whose .code is CACHE_FRESH. Unanswered checks exclude the + * domain (fail closed). Returns { codes, checkedAt }. + */ +export async function permittedDomains({ + domains, + phone, + portalUrl, + fetchImpl = fetch, + cache, + ttlMs = DOMAIN_TTL_MS, + now = Date.now, + force = false, +}) { + const hit = cache.get(phone); + const fresh = !!(hit && now() - hit.checkedAt < ttlMs); + if (fresh && !force) return { codes: hit.codes, checkedAt: hit.checkedAt }; + if (fresh && force) { + const e = new Error('permission cache is still fresh'); + e.code = CACHE_FRESH; + throw e; + } + const settled = await Promise.all( + domains.map((d) => + checkDomain(fetchImpl, portalUrl, phone, d.code) + .then((ok) => (ok ? d.code : null)) + .catch(() => null) + ) + ); + const codes = settled.filter(Boolean); + const checkedAt = now(); + cache.set(phone, { codes, checkedAt }); + return { codes, checkedAt }; +} diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js new file mode 100644 index 0000000..52ea78b --- /dev/null +++ b/tools/public/assets/fp/app.js @@ -0,0 +1,286 @@ +/* Fingerprint Agent — enrollment wizard. Captures run through the local + fpagent binary; artifacts are saved via the app's a2loft proxy. */ +/* global Vue, axios, swal, io */ +(function () { + 'use strict'; + + var MAX_STAGE = 5; // the U.are.U 4500 reports five enrollment stages + + function newFinger() { + return { status: 'idle', stage: 0, id: '', fingerprint: '', image: '' }; + } + + var app = new Vue({ + el: '#app', + data: { + loading: true, + session: null, + tick: Date.now(), + socket: null, + agent: { connected: false, version: '', captureAgentId: '' }, + domain: '', + reference: '', + otpLookup: '', + otpSave: '', + staff: null, + busy: { lookup: false, save: false }, + refreshing: false, + pageError: '', + fingers: { + 'right-thumb': newFinger(), + 'right-index-finger': newFinger(), + }, + fingerLabels: { 'right-thumb': 'Right thumb', 'right-index-finger': 'Right index finger' }, + capturing: null, // finger currently being captured (fpagent serves one at a time) + cancelling: false, + prevCapture: null, // previous good capture for the finger being recaptured + }, + + computed: { + initials: function () { + var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; + return n.slice(0, 2).toUpperCase(); + }, + maxStage: function () { return MAX_STAGE; }, + bothCaptured: function () { + return this.fingers['right-thumb'].status === 'done' && + this.fingers['right-index-finger'].status === 'done'; + }, + canLookup: function () { + return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup; + }, + canSave: function () { + return this.bothCaptured && !!this.otpSave && !this.busy.save; + }, + refreshUnlocksAt: function () { + var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000; + return ((this.session && this.session.domainsCheckedAt) || 0) + ttl; + }, + refreshEnabled: function () { + return !!this.session && !this.refreshing && this.tick >= this.refreshUnlocksAt; + }, + refreshEtaLabel: function () { + var s = Math.max(0, Math.ceil((this.refreshUnlocksAt - this.tick) / 1000)); + var m = Math.floor(s / 60); + return m > 0 ? 'in ' + m + ' min' : 'in ' + s + ' s'; + }, + domainsLastCheckedLabel: function () { + if (!this.session || !this.session.domainsCheckedAt) return ''; + return new Date(this.session.domainsCheckedAt).toLocaleTimeString(); + }, + }, + + created: function () { + var self = this; + this.loadSession() + .then(function () { + self.connectAgent(); + }) + .catch(function () { /* loadSession redirects on 401 */ }); + setInterval(function () { self.tick = Date.now(); }, 1000); + }, + + methods: { + loadSession: function () { + var self = this; + return axios.get('/auth/session').then(function (r) { + self.session = r.data; + if (self.session.domains.length) self.domain = self.session.domains[0].code; + self.loading = false; + }).catch(function (err) { + if (err.response && err.response.status === 401) { + window.location.href = '/auth/login'; + return; + } + self.loading = false; + self.pageError = 'Could not load your session. Please refresh the page.'; + }); + }, + + refreshDomains: function () { + var self = this; + if (!this.refreshEnabled) return; + this.refreshing = true; + axios.post('/auth/domains/refresh').then(function (r) { + self.session.domains = r.data.domains; + self.session.domainsCheckedAt = r.data.domainsCheckedAt; + if (self.session.domains.length && self.session.domains.indexOf(self.domain) < 0) { + self.domain = self.session.domains[0].code; + } + if (!self.session.domains.length) { + swal('No permitted domains', 'You are not permitted to capture fingerprints for any domain.', 'info'); + } + }).catch(function (err) { + var msg = (err.response && err.response.data && err.response.data.message) || + 'Could not refresh domain permissions.'; + swal('Refresh failed', msg, 'error'); + }).finally(function () { + self.refreshing = false; + }); + }, + + connectAgent: function () { + var self = this; + this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 }); + + this.socket.on('connect', function () { self.agent.connected = true; }); + this.socket.on('disconnect', function () { self.agent.connected = false; }); + this.socket.on('connect_error', function () { self.agent.connected = false; }); + + this.socket.on('agent-hello', function (data) { + self.agent.version = (data && data.version) || ''; + self.agent.captureAgentId = (data && data.captureAgentId) || ''; + }); + + this.socket.on('fprint-enroll-status', function (data) { + if (!self.capturing) return; + self.fingers[self.capturing].stage = (data && data.stage) || 0; + }); + + this.socket.on('fprint-enroll-done', function (data) { + var finger = self.capturing; + if (!finger) return; + var wasCancelling = self.cancelling; + self.capturing = null; + self.cancelling = false; + var f = self.fingers[finger]; + + if (data && data.ok) { + f.status = 'done'; + f.stage = MAX_STAGE; + f.id = data.id || ''; + f.fingerprint = data.fingerprint || ''; + f.image = data.image || ''; + self.prevCapture = null; + return; + } + + // Failure or cancellation. A recapture attempt that did not complete + // restores the previous good capture instead of dropping it; a + // cancelled first attempt returns to idle. + var restored = self.prevCapture && self.prevCapture.finger === finger; + if (restored) { + var p = self.prevCapture; + f.status = 'done'; + f.stage = MAX_STAGE; + f.id = p.id; + f.fingerprint = p.fingerprint; + f.image = p.image; + self.prevCapture = null; + } else if (wasCancelling) { + f.status = 'idle'; + f.stage = 0; + } else { + f.status = 'failed'; + f.stage = 0; + } + swal( + wasCancelling ? 'Capture cancelled' : 'Capture failed', + wasCancelling + ? (restored ? 'The previous capture for the ' + self.fingerLabels[finger].toLowerCase() + ' was kept.' : 'No changes were saved for the ' + self.fingerLabels[finger].toLowerCase() + '.') + : (data && data.message) || 'The capture did not complete.', + wasCancelling ? 'info' : 'error' + ); + }); + }, + + capture: function (finger) { + if (this.capturing || !this.socket || !this.domain || !this.reference) return; + // Preserve a previous good capture so a cancelled/failed recapture + // can restore it instead of destroying it. + var old = this.fingers[finger]; + this.prevCapture = old.id + ? { finger: finger, id: old.id, fingerprint: old.fingerprint, image: old.image } + : null; + this.fingers[finger] = newFinger(); + this.fingers[finger].status = 'scanning'; + this.capturing = finger; + this.cancelling = false; + this.socket.emit('fprint-enroll', { + token: this.session.token, + domain: this.domain, + finger: finger, + reference: this.reference, + }); + }, + + cancelCapture: function () { + if (!this.capturing || !this.socket) return; + this.cancelling = true; + this.socket.emit('fprint-enroll-stop', {}); + }, + + lookup: function () { + var self = this; + if (!this.canLookup) return; + this.busy.lookup = true; + axios.post( + '/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference), + null, + { params: { otp: this.otpLookup } } + ).then(function (r) { + if (!r.data.ok) { + swal('Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')', 'error'); + return; + } + self.staff = r.data.data; + }).catch(function (err) { + var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; + swal('Lookup failed', msg, 'error'); + }).finally(function () { + self.busy.lookup = false; + }); + }, + + save: function () { + var self = this; + if (!this.canSave) return; + this.busy.save = true; + var fingerprints = Object.keys(this.fingers).map(function (finger) { + return { finger: finger, fingerprint: self.fingers[finger].fingerprint }; + }); + axios.post( + '/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints', + { fingerprints: fingerprints }, + { params: { otp: this.otpSave } } + ).then(function (r) { + if (r.data.ok) { + swal('Saved', 'Fingerprints saved for ' + self.staff.name + ' (reference ' + self.staff.reference + ').', 'success'); + self.newEnrollment(); + } else { + swal('Not saved', 'Operation failed. ' + (r.data.message || ''), 'error'); + } + }).catch(function (err) { + var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; + swal('Save failed', msg, 'error'); + }).finally(function () { + self.busy.save = false; + }); + }, + + newEnrollment: function () { + this.staff = null; + this.reference = ''; + this.otpLookup = ''; + this.otpSave = ''; + this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() }; + this.capturing = null; + this.cancelling = false; + this.prevCapture = null; + }, + + fingerBadge: function (finger) { + switch (this.fingers[finger].status) { + case 'scanning': return { cls: 'badge-brand', text: 'Scanning' }; + case 'done': return { cls: 'badge-ok', text: 'Captured' }; + case 'failed': return { cls: 'badge-err', text: 'Failed' }; + default: return { cls: 'badge-idle', text: 'Pending' }; + } + }, + + progressPct: function (stage) { + return Math.round((Math.min(stage, MAX_STAGE) / MAX_STAGE) * 100) + '%'; + }, + }, + }); +})(); diff --git a/tools/public/assets/fp/index.html b/tools/public/assets/fp/index.html new file mode 100644 index 0000000..4dd7736 --- /dev/null +++ b/tools/public/assets/fp/index.html @@ -0,0 +1,169 @@ + + + + + + Fingerprint Agent · YOUreka Tools + + + + + + +
+ + +
+
+
+

Fingerprint Agent

+

+ Enrollment wizard for staff fingerprints on this laptop + · fpagent {{ agent.version }} +

+
+
+ +
{{ pageError }}
+ + +
+
+ 1 +

Locate profile

+ Permissions checked {{ domainsLastCheckedLabel }} +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +

+ You are not permitted to capture fingerprints for any domain. Contact your domain administrator if you believe this is a mistake. +

+
+ + +
+
+ 2 +

Capture fingerprints

+ Two fingers are required · place the finger on the scanner each scan +
+ +
+
Name
{{ staff.name }}
+
Reference
{{ staff.reference }}
+
Domain reference
{{ staff.domainReference }}
+
Rank
{{ staff.domainRankDescription }}
+
Prior enrollments
{{ staff.fingerPrintCaptureCount }}
+
+ +
+
+
+

{{ label }}

+ {{ fingerBadge(finger).text }} +
+
+
+
+

Scan {{ Math.min(fingers[finger].stage, maxStage) }} of {{ maxStage }} — place the {{ label.toLowerCase() }} on the scanner.

+

Enrollment ID {{ fingers[finger].id }}

+

The last attempt failed. You can try again.

+ +
+ + + +
+
+
+
+ + +
+
+ 3 +

Save enrollment

+ Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }}) +
+
+
+ + +
+ +
+
+
+ +
+
+
Loading your session…
+
+
+ +
+
+ Capture agent {{ agent.captureAgentId || 'connecting…' }} + Signed in as {{ session.phone }} +
+
+ + + + + +
+ + diff --git a/tools/public/assets/style.css b/tools/public/assets/style.css new file mode 100644 index 0000000..3e73919 --- /dev/null +++ b/tools/public/assets/style.css @@ -0,0 +1,188 @@ +/* YOUreka Tools — shared styling (Bootstrap 4 base + this overlay) */ +:root { + --ink: #1a2340; + --muted: #64748b; + --faint: #94a3b8; + --line: #e4e8f1; + --bg: #f4f6fb; + --surface: #ffffff; + --brand: #3b4fd8; + --brand-dark: #2f3eb0; + --brand-soft: #eef1fe; + --ok: #15803d; + --ok-soft: #e8f7ee; + --warn: #b45309; + --warn-soft: #fdf3e3; + --err: #b91c1c; + --err-soft: #fdeaea; + --radius: 14px; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -12px rgba(16, 24, 40, 0.12); +} + +html, body { height: 100%; } +body.app { + background: + radial-gradient(1200px 400px at 80% -10%, rgba(59, 79, 216, 0.08), transparent 60%), + var(--bg); + color: var(--ink); + font-size: 14.5px; + min-height: 100vh; + display: flex; + flex-direction: column; +} +[v-cloak] { display: none; } + +/* ── navbar ─────────────────────────────────────────────── */ +.nav { + background: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.65rem 0; +} +.nav .container { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.brand { display: inline-flex; align-items: center; gap: 0.6rem; font-weight: 700; color: var(--ink); text-decoration: none; } +.brand:hover { text-decoration: none; color: var(--ink); } +.brand-mark { + width: 32px; height: 32px; border-radius: 9px; + background: linear-gradient(135deg, var(--brand), var(--brand-dark)); + color: #fff; display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 auto; +} +.brand-mark svg { width: 18px; height: 18px; } +.brand small { color: var(--muted); font-weight: 500; } +.nav-right { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } +.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--muted); } +.user-chip .avatar { + width: 26px; height: 26px; border-radius: 50%; + background: var(--brand-soft); color: var(--brand); + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; +} + +/* ── layout ─────────────────────────────────────────────── */ +main.container { flex: 1 0 auto; } +.narrow { max-width: 560px; } +.center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; } +.page-head { margin: 2.2rem 0 1.4rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.page-head h1 { font-size: 1.45rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; } +.page-head .sub { color: var(--muted); margin: 0.25rem 0 0; } + +/* ── cards ──────────────────────────────────────────────── */ +.card, .tool-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.panel { padding: 1.35rem 1.5rem; margin-bottom: 1.25rem; } +.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; } +.step-chip { + width: 24px; height: 24px; border-radius: 7px; flex: 0 0 auto; + background: var(--brand-soft); color: var(--brand); + font-size: 12.5px; font-weight: 700; + display: inline-flex; align-items: center; justify-content: center; +} +.panel-head h2 { font-size: 1.02rem; font-weight: 700; margin: 0; } +.panel-head .hint { color: var(--faint); font-size: 12.5px; margin-left: auto; } + +/* landing tool cards */ +.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.25rem; } +.tool-card { display: flex; flex-direction: column; padding: 1.5rem; color: inherit; text-decoration: none; transition: border-color .15s ease, transform .15s ease; } +.tool-card:hover { text-decoration: none; color: inherit; border-color: var(--brand); transform: translateY(-2px); } +.tool-icon { + width: 44px; height: 44px; border-radius: 12px; margin-bottom: 0.9rem; + display: inline-flex; align-items: center; justify-content: center; + background: var(--brand-soft); color: var(--brand); +} +.tool-icon svg { width: 22px; height: 22px; } +.tool-card h3 { font-size: 1.05rem; font-weight: 700; margin: 0 0 0.3rem; } +.tool-card p { color: var(--muted); margin: 0 0 1rem; } +.tool-card .go { margin-top: auto; color: var(--brand); font-weight: 600; font-size: 13.5px; display: inline-flex; align-items: center; gap: 0.3rem; } + +/* hero */ +.hero { padding: 3.2rem 0 2.2rem; } +.hero h1 { font-size: 1.9rem; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 0.4rem; } +.hero p { color: var(--muted); max-width: 560px; margin: 0; } + +/* ── forms ──────────────────────────────────────────────── */ +label { font-weight: 600; font-size: 13px; color: var(--ink); margin-bottom: 0.3rem; } +.form-control { + border-color: var(--line); border-radius: 10px; height: 42px; + box-shadow: none; font-size: 14.5px; color: var(--ink); +} +.form-control:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.12); } +select.form-control { appearance: auto; } + +/* ── buttons ────────────────────────────────────────────── */ +.btn { border-radius: 10px; font-weight: 600; font-size: 14px; padding: 0.55rem 1.1rem; border: 1px solid transparent; } +.btn:focus { box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.18); } +.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; } +.btn-primary:hover, .btn-primary:focus { background: var(--brand-dark); border-color: var(--brand-dark); color: #fff; } +.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink); } +.btn-ghost:hover { background: var(--bg); border-color: var(--faint); color: var(--ink); } +.btn-danger-ghost { background: transparent; border-color: #f3c6c6; color: var(--err); } +.btn-danger-ghost:hover { background: var(--err-soft); border-color: var(--err); color: var(--err); } +.btn[disabled], .btn.disabled { opacity: 0.55; pointer-events: none; } +.btn-block { display: flex; width: 100%; align-items: center; justify-content: center; } +.linklike { background: none; border: none; padding: 0; color: var(--brand); font-weight: 600; cursor: pointer; font-size: 13px; } +.linklike[disabled] { color: var(--faint); cursor: default; } + +/* ── badges & status ────────────────────────────────────── */ +.badge-soft { + display: inline-flex; align-items: center; gap: 0.35rem; + border-radius: 999px; padding: 0.2rem 0.65rem; + font-size: 12px; font-weight: 600; line-height: 1.4; +} +.badge-soft .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.badge-ok { background: var(--ok-soft); color: var(--ok); } +.badge-warn { background: var(--warn-soft); color: var(--warn); } +.badge-err { background: var(--err-soft); color: var(--err); } +.badge-idle { background: #eef2f7; color: var(--muted); } +.badge-brand { background: var(--brand-soft); color: var(--brand); } +.badge-live .dot { animation: pulse 1.6s ease-in-out infinite; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } + +/* ── details grid ───────────────────────────────────────── */ +.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0.9rem 1.4rem; margin: 0; } +.details > div { min-width: 0; } +.details dt { font-size: 12px; font-weight: 600; color: var(--faint); text-transform: uppercase; letter-spacing: 0.04em; } +.details dd { margin: 0.15rem 0 0; font-weight: 600; overflow-wrap: anywhere; } + +/* ── finger capture panels ──────────────────────────────── */ +.finger-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; } +@media (max-width: 720px) { .finger-grid { grid-template-columns: 1fr; } } +.finger { border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem; } +.finger.capturing { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.10); } +.finger.done { border-color: #bfe3cd; background: #fbfefc; } +.finger-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } +.finger-head h3 { font-size: 0.95rem; font-weight: 700; margin: 0; } +.finger .meta { color: var(--faint); font-size: 12.5px; margin-top: 0.6rem; overflow-wrap: anywhere; } + +.progress { height: 8px; border-radius: 999px; background: #edf0f7; overflow: hidden; } +.progress-bar { background: var(--brand); border-radius: 999px; transition: width .25s ease; } +.progress-bar.striped { + background-image: linear-gradient(45deg, rgba(255,255,255,.25) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; + animation: stripes 0.9s linear infinite; +} +@keyframes stripes { from { background-position: 1rem 0; } to { background-position: 0 0; } } + +/* ── alerts ─────────────────────────────────────────────── */ +.alert-soft { border-radius: 10px; border: 1px solid var(--err-soft); background: var(--err-soft); color: var(--err); font-size: 13.5px; padding: 0.6rem 0.9rem; } + +/* ── footer ─────────────────────────────────────────────── */ +footer.foot { flex-shrink: 0; padding: 1.4rem 0 1.8rem; color: var(--faint); font-size: 12.5px; } +footer.foot .container { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +footer.foot a { color: var(--muted); } + +/* ── message card (auth errors) ─────────────────────────── */ +.message-card { padding: 2.2rem; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; } +.message-card h1 { font-size: 1.2rem; font-weight: 700; margin: 0; } +.message-icon { width: 52px; height: 52px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; } +.message-icon.error { background: var(--err-soft); color: var(--err); } +.message-card .btn { margin-top: 0.5rem; min-width: 180px; } + +.muted { color: var(--muted); } +.faint { color: var(--faint); font-size: 12.5px; } +.mt-1 { margin-top: 0.75rem; } +.mt-2 { margin-top: 1.25rem; } +.row-gap { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } diff --git a/tools/public/index.html b/tools/public/index.html new file mode 100644 index 0000000..cb84720 --- /dev/null +++ b/tools/public/index.html @@ -0,0 +1,64 @@ + + + + + + YOUreka Tools + + + + + + + +
+
+

YOUreka Tools

+

Field utilities for YOUreka ID Domains. Sign in with your YOUreka account to use the tools that require it.

+
+ + +
+ +
+
+ YOUreka IDID · Fingerprint Service + Scan captures run locally via the fpagent agent — no scanner data leaves this machine except the enrolled artifacts you save. +
+
+ + diff --git a/tools/server.js b/tools/server.js new file mode 100644 index 0000000..d70702a --- /dev/null +++ b/tools/server.js @@ -0,0 +1,350 @@ +// YOUreka Tools — Express host for the tools landing page and the +// /fingerprint enrollment wizard, whose captures run through the local +// fpagent binary (Socket.IO on 127.0.0.1:9000). +// +// Every endpoint and the port are hardcoded in this file by explicit design +// decision (openspec change fpagent-tools-ui): no env vars, no config files. +// createApp() accepts overrides so tests can point the app at fake upstreams; +// production always runs on the constants below. +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'; + +// ── Hardcoded configuration ────────────────────────────────────────────────── +export const PORT = 4173; +export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verify +export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh +export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save +export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO +export const DOMAINS = [ + { code: 'uc', name: 'UNICAL' }, + { code: 'mtr', name: 'Metropolitan Hotel' }, + { code: 'ucth', name: 'UCTH' }, + { code: 'uri', name: 'UNICAL Radio International' }, + { code: 'yrka', name: 'YOUreka' }, +]; + +const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── helpers ────────────────────────────────────────────────────────────────── + +// Express does not parse cookies; this is all we need. +function cookie(req, name) { + const header = req.headers.cookie; + if (!header) return undefined; + for (const part of header.split(';')) { + const i = part.indexOf('='); + if (i < 0) continue; + if (part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()); + } + return undefined; +} + +function readProfile(raw) { + if (!raw) return { firstName: '', lastName: '', phone: '' }; + try { + const p = JSON.parse(raw); + return { + firstName: typeof p.firstName === 'string' ? p.firstName : '', + lastName: typeof p.lastName === 'string' ? p.lastName : '', + phone: typeof p.phone === 'string' ? p.phone : '', + }; + } catch { + return { firstName: '', lastName: '', phone: '' }; + } +} + +function errorPage(res, title, message) { + res + .status(502) + .type('html') + .send(` + + + + + ${title} · YOUreka Tools + + + +
+
+
${ICONS.alert}
+

${title}

+

${message}

+ Try again + Back to Tools +
+
+ +`); +} + +const ICONS = { + alert: + '', +}; + +// ── app factory ────────────────────────────────────────────────────────────── +export function createApp(overrides = {}) { + const cfg = { + portalUrl: PORTAL_URL, + pbUrl: PB_URL, + a2loftBase: A2LOFT_BASE, + agentUrl: AGENT_URL, + domains: DOMAINS, + ttlMs: DOMAIN_TTL_MS, + fetch: fetch, + now: Date.now, + ...overrides, + }; + const domainCodes = new Set(cfg.domains.map((d) => d.code)); + const domainCache = new Map(); // phone → { codes, 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()); + + const setSessionCookie = (req, res, value) => + res.cookie('session', value, { + path: '/', + httpOnly: true, + secure: req.secure, // https in production; plain http dev on localhost still works + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_MS, + }); + + const setProfileCookie = (req, res, profile) => + res.cookie('user_profile', JSON.stringify(profile), { + path: '/', + httpOnly: true, + secure: req.secure, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE_MS, + }); + + // ── session middleware: recover / refresh the YOUreka Auth session ──────── + app.use(async (req, res, next) => { + req.session = null; + const raw = cookie(req, 'session'); + if (raw) { + try { + const yrka = await loadYrkaSession(raw, { pbUrl: cfg.pbUrl }); + if (!yrka) { + res.clearCookie('session', { path: '/' }); + res.clearCookie('user_profile', { path: '/' }); + } else { + if (yrka.refreshed && yrka.cookie) setSessionCookie(req, res, yrka.cookie); + req.session = { + token: yrka.pb.authStore.token, + ...readProfile(cookie(req, 'user_profile')), + }; + } + } catch { + req.session = null; + } + } + next(); + }); + + const requirePage = (req, res, next) => { + if (!req.session) return res.redirect('/auth/login'); + next(); + }; + const requireApi = (req, res, next) => { + if (!req.session) return res.status(401).json({ ok: false, message: 'Authentication required.' }); + next(); + }; + + // ── static assets & landing page ────────────────────────────────────────── + app.use(express.static(path.join(__dirname, 'public'))); + + // ── auth: YOUreka portal web-auth flow ──────────────────────────────────── + app.get('/auth/login', async (req, res) => { + const returnUrl = `${req.protocol}://${req.get('host')}/auth/callback`; + try { + const r = await cfg.fetch(`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ returnUrl }), + }); + 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.'); + } + res.redirect(data.loginUrl); + } catch { + errorPage(res, '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.'); + 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.'); + } + if (data.status === 'Initialized' || data.status === 'TimedOut') { + return errorPage( + res, + 'Login failed', + data.status === 'TimedOut' + ? 'Your login session timed out. Please start again.' + : 'Verification incomplete. Please complete the login flow.' + ); + } + const sessionValue = extractPbCookieValue(data.cookie || ''); + if (!sessionValue) { + return errorPage(res, 'Login failed', 'The portal did not return a usable session. Please try again.'); + } + setSessionCookie(req, res, sessionValue); + setProfileCookie(req, res, { + firstName: data.firstName ?? '', + lastName: data.lastName ?? '', + phone: data.phone ?? '', + }); + res.redirect('/fingerprint'); + } catch { + errorPage(res, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); + } + }); + + app.get('/auth/logout', (req, res) => { + res.clearCookie('session', { path: '/' }); + res.clearCookie('user_profile', { path: '/' }); + res.redirect('/'); + }); + + // ── session context + permitted domains ─────────────────────────────────── + const domainContext = async (req, { force = false } = {}) => + permittedDomains({ + domains: cfg.domains, + phone: stripPhonePrefix(req.session.phone), + portalUrl: cfg.portalUrl, + fetchImpl: cfg.fetch, + cache: domainCache, + ttlMs: cfg.ttlMs, + now: cfg.now, + force, + }); + + const domainPayload = (codes, checkedAt) => ({ + domains: cfg.domains.filter((d) => codes.includes(d.code)), + domainsCheckedAt: checkedAt, + }); + + app.get('/auth/session', requireApi, async (req, res, next) => { + try { + const { codes, checkedAt } = await domainContext(req); + res.json({ + ok: true, + token: req.session.token, + phone: req.session.phone, + firstName: req.session.firstName, + lastName: req.session.lastName, + agentUrl: cfg.agentUrl, + domainCacheTtlMs: cfg.ttlMs, + ...domainPayload(codes, checkedAt), + }); + } catch (e) { + next(e); + } + }); + + app.post('/auth/domains/refresh', requireApi, async (req, res, next) => { + try { + const { codes, checkedAt } = await domainContext(req, { force: true }); + res.json({ ok: true, ...domainPayload(codes, checkedAt) }); + } catch (e) { + if (e?.code === CACHE_FRESH) { + return res + .status(409) + .json({ ok: false, message: 'Permissions were checked recently. The refresh option unlocks when the cache expires.' }); + } + next(e); + } + }); + + // ── wizard page (session required) ──────────────────────────────────────── + app.get('/fingerprint', requirePage, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html')); + }); + + // ── a2loft proxies: same-origin, phone injected from the session ────────── + const a2loftUrl = (domain, reference, otp, phone) => { + const q = new URLSearchParams({ phone, otp }); + return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent( + reference + )}?${q.toString()}`; + }; + const a2loftSaveUrl = (domain, reference, otp, phone) => { + const q = new URLSearchParams({ phone, otp }); + return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent( + reference + )}?${q.toString()}`; + }; + + const validateProxy = (req, res) => { + const { domain, reference } = req.params; + if (!domainCodes.has(domain)) { + res.status(400).json({ ok: false, message: 'Unknown domain.' }); + return false; + } + if (!reference) { + res.status(400).json({ ok: false, message: 'Missing staff reference.' }); + return false; + } + if (!req.query.otp) { + res.status(400).json({ ok: false, message: 'OTP is required.' }); + return false; + } + if (!req.session.phone) { + res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' }); + return false; + } + return true; + }; + + app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => { + if (!validateProxy(req, res)) return; + try { + const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone)); + res.status(r.status).type('json').send(await r.text()); + } catch { + res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); + } + }); + + app.post('/api/:domain/staff/:reference/fingerprints', requireApi, async (req, res) => { + if (!validateProxy(req, res)) return; + if (!Array.isArray(req.body?.fingerprints) || req.body.fingerprints.length === 0) { + return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' }); + } + try { + const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body) + }); + res.status(r.status).type('json').send(await r.text()); + } catch { + res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); + } + }); + + return app; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + createApp().listen(PORT, () => { + console.log(`YOUreka Tools listening on http://localhost:${PORT}`); + }); +} diff --git a/tools/test/helpers.js b/tools/test/helpers.js new file mode 100644 index 0000000..68cf4e8 --- /dev/null +++ b/tools/test/helpers.js @@ -0,0 +1,72 @@ +// Shared test helpers: unsigned JWTs, real PocketBase cookie envelopes, and +// throwaway http servers for fake upstreams. +import http from 'node:http'; +import PocketBase from 'pocketbase'; + +export function b64url(obj) { + return Buffer.from(JSON.stringify(obj)).toString('base64url'); +} + +/** Structurally valid (unsigned) JWT with a controllable exp. */ +export function makeJwt({ expiresInSec = 3600 } = {}) { + const payload = { + exp: Math.floor(Date.now() / 1000) + expiresInSec, + id: 'u1', + collectionId: 'c1', + collectionName: 'users', + }; + return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`; +} + +export const TEST_RECORD = { + id: 'u1', + collectionId: 'c1', + collectionName: 'users', + phone: '+2348068287222', +}; + +/** Build the real PocketBase cookie envelope (value only) via the actual SDK. */ +export function makePbCookie(token, record = TEST_RECORD) { + const pb = new PocketBase('http://127.0.0.1:9'); // never contacted for this + pb.authStore.save(token, record); + const setCookie = pb.authStore.exportToCookie({}, 'pb_auth'); + const m = /pb_auth=([^;]+)/.exec(setCookie); + if (!m) throw new Error('could not build pb cookie envelope'); + return m[1]; +} + +export function startServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +export const urlOf = (server) => `http://127.0.0.1:${server.address().port}`; + +export function stopServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +/** Read the `name=value` pair out of a Set-Cookie response header list. */ +export function cookieValue(res, name) { + for (const c of res.headers.getSetCookie()) { + const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(c); + if (m) return m[1]; + } + return undefined; +} + +/** Scriptable fetch double that records every call. */ +export function fakeFetch(script) { + const calls = []; + const fn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + const out = await script({ url, body, init }); + calls.push({ url, body }); + if (out instanceof Error) throw out; + return { status: out.status ?? 200, json: async () => out.body, text: async () => JSON.stringify(out.body ?? {}) }; + }; + fn.calls = calls; + return fn; +} diff --git a/tools/test/permit.test.js b/tools/test/permit.test.js new file mode 100644 index 0000000..243a677 --- /dev/null +++ b/tools/test/permit.test.js @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + permittedDomains, + checkDomain, + stripPhonePrefix, + CACHE_FRESH, +} from '../permit.js'; +import { fakeFetch } from './helpers.js'; + +const CATALOG = [ + { code: 'uc', name: 'UNICAL' }, + { code: 'mtr', name: 'Metropolitan Hotel' }, + { code: 'ucth', name: 'UCTH' }, +]; + +const PORTAL = 'https://portal.example'; + +test('stripPhonePrefix removes a single leading +', () => { + assert.equal(stripPhonePrefix('+2348068287222'), '2348068287222'); + assert.equal(stripPhonePrefix('2348068287222'), '2348068287222'); + assert.equal(stripPhonePrefix(undefined), ''); +}); + +test('checkDomain verifies payload shape and allows on first object', async () => { + const fetchImpl = fakeFetch(({ body }) => ({ body: { ok: true, allowed: true } })); + const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc'); + assert.equal(allowed, true); + assert.equal(fetchImpl.calls.length, 1); + assert.equal(fetchImpl.calls[0].url, `${PORTAL}/api/openfga/verify`); + assert.deepEqual(fetchImpl.calls[0].body, { + subsystem: 'idid', + user: 'user:2348068287222', + relation: 'capture_biometric', + object: 'domain:uc', + }); +}); + +test('checkDomain falls back to domain:idid and allows', async () => { + const fetchImpl = fakeFetch(({ body }) => ({ + body: { ok: true, allowed: body.object === 'domain:idid' }, + })); + const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc'); + assert.equal(allowed, true); + assert.deepEqual( + fetchImpl.calls.map((c) => c.body.object), + ['domain:uc', 'domain:idid'] + ); +}); + +test('checkDomain denies after both objects deny', async () => { + const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: false } })); + const allowed = await checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc'); + assert.equal(allowed, false); + assert.equal(fetchImpl.calls.length, 2); +}); + +test('checkDomain throws (fail closed) when neither object answers', async () => { + const fetchImpl = fakeFetch(() => ({ status: 503, body: {} })); + await assert.rejects(() => checkDomain(fetchImpl, PORTAL, '2348068287222', 'uc')); +}); + +test('permittedDomains keeps only allowed domains and fails closed per domain', async () => { + const fetchImpl = fakeFetch(({ body }) => { + if (body.object === 'domain:uc') return { body: { ok: true, allowed: true } }; + if (body.object === 'domain:mtr') return { body: { ok: true, allowed: false } }; + return { status: 500, body: {} }; // ucth: errors on both objects → excluded + }); + const cache = new Map(); + const { codes, checkedAt } = await permittedDomains({ + domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => 1000, + }); + assert.deepEqual(codes, ['uc']); + assert.equal(checkedAt, 1000); +}); + +test('permittedDomains serves the cache within the TTL without portal calls', async () => { + const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } })); + const cache = new Map(); + let t = 1000; + const now = () => t; + const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now }; + const first = await permittedDomains(opts); + const callsAfterFirst = fetchImpl.calls.length; + t += 60 * 1000; // 1 minute later — still fresh + const second = await permittedDomains(opts); + assert.equal(fetchImpl.calls.length, callsAfterFirst, 'no portal calls while cached'); + assert.deepEqual(second, first); +}); + +test('permittedDomains re-checks after the TTL expires', async () => { + const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } })); + const cache = new Map(); + let t = 1000; + const now = () => t; + const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now }; + await permittedDomains(opts); + const callsAfterFirst = fetchImpl.calls.length; + t += 5 * 60 * 1000 + 1; + const { checkedAt } = await permittedDomains(opts); + assert.ok(fetchImpl.calls.length > callsAfterFirst, 'portal re-consulted after expiry'); + assert.equal(checkedAt, t); +}); + +test('forced refresh refuses while the cache is fresh', async () => { + const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } })); + const cache = new Map(); + let t = 1000; + const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => t }; + await permittedDomains(opts); + const callsAfterFirst = fetchImpl.calls.length; + t += 1000; + await assert.rejects( + () => permittedDomains({ ...opts, force: true }), + (e) => e.code === CACHE_FRESH + ); + assert.equal(fetchImpl.calls.length, callsAfterFirst, 'refused refresh makes no portal calls'); +}); + +test('forced refresh after expiry re-checks and replaces the cache', async () => { + const fetchImpl = fakeFetch(({ body }) => ({ + body: { ok: true, allowed: body.object === 'domain:uc' }, + })); + const cache = new Map(); + let t = 1000; + const opts = { domains: CATALOG, phone: '234', portalUrl: PORTAL, fetchImpl, cache, now: () => t }; + await permittedDomains(opts); // everything allowed + t += 5 * 60 * 1000 + 1; + const forced = await permittedDomains({ ...opts, force: true }); + assert.deepEqual(forced.codes, ['uc']); + // cache was replaced: a plain read now serves the forced result + const plain = await permittedDomains(opts); + assert.deepEqual(plain, forced); +}); + +test('cache is per phone', async () => { + const fetchImpl = fakeFetch(() => ({ body: { ok: true, allowed: true } })); + const cache = new Map(); + const opts = { domains: CATALOG, portalUrl: PORTAL, fetchImpl, cache, now: () => 1000 }; + await permittedDomains({ ...opts, phone: 'a' }); + const callsAfterA = fetchImpl.calls.length; + await permittedDomains({ ...opts, phone: 'b' }); + assert.ok(fetchImpl.calls.length > callsAfterA, 'second phone performs its own checks'); +}); diff --git a/tools/test/server.test.js b/tools/test/server.test.js new file mode 100644 index 0000000..113d38d --- /dev/null +++ b/tools/test/server.test.js @@ -0,0 +1,265 @@ +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 YOUreka portal: web-auth session + openfga verify. +async function startFakePortal({ cookieStatus = 'Activated', allowed = true } = {}) { + return 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') { + const body = JSON.parse(raw || '{}'); + if (!body.returnUrl) return json(200, { ok: false, message: 'returnUrl is required' }); + return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login?return=1' }); + } + if (req.method === 'GET' && req.url.startsWith('/api/web/auth/s1/cookie')) { + return json(200, { + ok: true, + sessionId: 's1', + status: cookieStatus, + cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })), + phone: '+2348068287222', + firstName: 'Ada', + lastName: 'Obi', + }); + } + if (req.method === 'POST' && req.url === '/api/openfga/verify') { + return json(200, { ok: true, allowed }); + } + json(404, { ok: false }); + }); + }); +} + +// Fake PocketBase: auth-refresh always succeeds with a fresh token. +async function startFakePb() { + return startServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ token: makeJwt({ expiresInSec: 3600 }), record: { id: 'u1', collectionId: 'c1', collectionName: 'users', phone: '+2348068287222' } })); + }); +} + +// Fake a2loft: records requests, echoes a staff profile. +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 staff = { + ok: true, + data: { + name: 'Ada Obi', + reference: 'UC/0231', + domainReference: 'dom-1', + domainRankDescription: 'Director', + fingerPrintCaptureCount: 0, + }, + }; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(req.url.includes('/fingerprints') ? { ok: true, message: 'saved' } : staff)); + }); + }); + return { server, calls, url: null, stop: () => stopServer(server) }; +} + +async function bootApp(appOverrides = {}, portalOpts) { + const portal = await startFakePortal(portalOpts); + const pb = await startFakePb(); + const a2loft = await startFakeA2loft(); + a2loft.url = urlOf(a2loft.server); + const app = createApp({ + portalUrl: urlOf(portal), + pbUrl: urlOf(pb), + a2loftBase: a2loft.url, + ...appOverrides, + }); + 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 stopServer(portal); + await stopServer(pb); + await a2loft.stop(); + }; + return { base, a2loft, stop }; +} + +const manual = { redirect: 'manual' }; + +async function login(base) { + // exercises /auth/login → portal, then /auth/callback → cookies + const loginRes = await fetch(`${base}/auth/login`, manual); + assert.equal(loginRes.status, 302); + const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual); + assert.equal(callbackRes.status, 302); + assert.equal(callbackRes.headers.get('location'), '/fingerprint'); + const session = cookieValue(callbackRes, 'session'); + const profile = cookieValue(callbackRes, 'user_profile'); + assert.ok(session, 'session cookie set'); + assert.ok(profile, 'profile cookie set'); + assert.match(profile, /2348068287222/); + return `session=${session}; user_profile=${profile}`; +} + +test('landing page links both tools', async () => { + const { base, stop } = await bootApp(); + try { + 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/); + } finally { + await stop(); + } +}); + +test('anonymous /fingerprint redirects to login; session APIs 401', async () => { + const { base, stop } = await bootApp(); + try { + const page = await fetch(`${base}/fingerprint`, manual); + assert.equal(page.status, 302); + assert.equal(page.headers.get('location'), '/auth/login'); + const api = await fetch(`${base}/auth/session`); + assert.equal(api.status, 401); + } finally { + await stop(); + } +}); + +test('auth callback rejects unredeemable sessions without setting cookies', async () => { + 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(cookieValue(res, 'session'), undefined); + const html = await res.text(); + assert.match(html, /timed out/i); + } finally { + await stop(); + } +}); + +test('session endpoint returns token, profile, agent URL and permitted domains', async () => { + const { base, stop } = await bootApp(); + try { + const cookies = await login(base); + const res = await fetch(`${base}/auth/session`, { headers: { cookie: cookies } }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.match(body.token, /\./); // JWT-ish + assert.equal(body.phone, '+2348068287222'); + assert.equal(body.firstName, 'Ada'); + assert.equal(body.agentUrl, 'http://127.0.0.1:9000'); + assert.equal(body.domains.length, 5, 'all catalog domains allowed by the fake portal'); + assert.equal(body.domains[0].code, 'uc'); + assert.ok(body.domainsCheckedAt > 0); + assert.equal(body.domainCacheTtlMs, 5 * 60 * 1000); + } finally { + await stop(); + } +}); + +test('domain refresh refuses while the cache is fresh, succeeds after the TTL', async () => { + const { base, stop } = await bootApp({ ttlMs: 30 }); + try { + const cookies = await login(base); + const first = await fetch(`${base}/auth/session`, { headers: { cookie: cookies } }); + assert.equal(first.status, 200); // populates the permission cache + const fresh = await fetch(`${base}/auth/domains/refresh`, { method: 'POST', headers: { cookie: cookies } }); + assert.equal(fresh.status, 409); + const body = await fresh.json(); + assert.equal(body.ok, false); + await new Promise((r) => setTimeout(r, 40)); + const stale = await fetch(`${base}/auth/domains/refresh`, { method: 'POST', headers: { cookie: cookies } }); + assert.equal(stale.status, 200); + const body2 = await stale.json(); + assert.equal(body2.ok, true); + assert.ok(body2.domainsCheckedAt > 0); + } finally { + await stop(); + } +}); + +test('staff lookup proxy injects the session phone and forwards a2loft responses', async () => { + const { base, a2loft, stop } = await bootApp(); + try { + const cookies = await login(base); + const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, { + method: 'POST', + headers: { cookie: cookies }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.data.name, 'Ada Obi'); + assert.equal(a2loft.calls.length, 1); + const call = a2loft.calls[0]; + assert.equal(call.method, 'GET'); + const u = new URL(call.url, 'http://x'); + assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231'); + assert.equal(u.searchParams.get('phone'), '+2348068287222'); + assert.equal(u.searchParams.get('otp'), '4455'); + } finally { + await stop(); + } +}); + +test('fingerprint save proxy forwards the body unchanged', async () => { + const { base, a2loft, stop } = await bootApp(); + try { + const cookies = await login(base); + const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] }; + const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true, message: 'saved' }); + const call = a2loft.calls[0]; + assert.equal(call.method, 'POST'); + assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`); + assert.deepEqual(call.body, payload); + } finally { + await stop(); + } +}); + +test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => { + const { base, a2loft, stop } = await bootApp(); + try { + const cookies = await login(base); + const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' }); + assert.equal(anon.status, 401); + const noOtp = await fetch(`${base}/api/uc/staff/REF`, { + method: 'POST', headers: { cookie: cookies }, + }); + assert.equal(noOtp.status, 400); + const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, { + method: 'POST', headers: { cookie: cookies }, + }); + assert.equal(badDomain.status, 400); + assert.equal(a2loft.calls.length, 0, 'a2loft never contacted for rejected calls'); + } finally { + await stop(); + } +}); diff --git a/tools/test/yrka-auth.test.js b/tools/test/yrka-auth.test.js new file mode 100644 index 0000000..b5dd016 --- /dev/null +++ b/tools/test/yrka-auth.test.js @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import PocketBase from 'pocketbase'; +import { loadYrkaSession, extractPbCookieValue, tokenExpiresAtMs } from '../yrka-auth.js'; +import { makeJwt, makePbCookie, startServer, stopServer, urlOf, TEST_RECORD } from './helpers.js'; + +test('extractPbCookieValue handles prefixed and bare values', () => { + assert.equal(extractPbCookieValue('pb_auth=abc123; Path=/; HttpOnly'), 'abc123'); + assert.equal(extractPbCookieValue('bare-value'), 'bare-value'); +}); + +test('tokenExpiresAtMs reads exp from a JWT payload', () => { + const expSec = Math.floor(Date.now() / 1000) + 60; + const token = makeJwt({ expiresInSec: 60 }); + assert.equal(tokenExpiresAtMs(token), expSec * 1000); + assert.equal(tokenExpiresAtMs('garbage'), 0); + assert.equal(tokenExpiresAtMs(''), 0); +}); + +test('loadYrkaSession returns null without a cookie', async () => { + assert.equal(await loadYrkaSession(''), null); + assert.equal(await loadYrkaSession(undefined), null); +}); + +test('loadYrkaSession returns null on malformed cookie values', async () => { + let constructed = false; + const pb = await loadYrkaSession('not-a-valid-envelope', { + pbUrl: 'http://127.0.0.1:9', + createPb: (url) => { + constructed = true; + return new PocketBase(url); + }, + }); + assert.equal(pb, null); + assert.equal(constructed, true, 'a PocketBase client was constructed before parsing failed'); +}); + +// The remaining tests exercise the real SDK against a fake PocketBase server, +// so the cookie envelope and auth-refresh handling match production exactly. + +async function withFakePb(refreshStatus, fn) { + const server = await startServer((req, res) => { + if (req.method === 'POST' && req.url.startsWith('/api/collections/users/auth-refresh')) { + res.writeHead(refreshStatus, { 'content-type': 'application/json' }); + if (refreshStatus === 200) { + res.end(JSON.stringify({ token: makeJwt({ expiresInSec: 3600 }), record: TEST_RECORD })); + } else { + res.end(JSON.stringify({ code: 401, message: 'The request is not authenticated.' })); + } + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end('{}'); + }); + try { + await fn(urlOf(server)); + } finally { + await stopServer(server); + } +} + +test('loadYrkaSession accepts a valid, not-nearly-expired token without refreshing', async () => { + await withFakePb(200, async (pbUrl) => { + const cookie = makePbCookie(makeJwt({ expiresInSec: 3600 })); + const yrka = await loadYrkaSession(cookie, { pbUrl }); + assert.ok(yrka); + assert.equal(yrka.refreshed, false); + assert.ok(yrka.pb.authStore.token); + assert.equal(yrka.pb.authStore.record.id, TEST_RECORD.id); + assert.equal(yrka.cookie, undefined); + }); +}); + +test('loadYrkaSession refreshes proactively when expiry is within the buffer', async () => { + await withFakePb(200, async (pbUrl) => { + const oldToken = makeJwt({ expiresInSec: 60 }); // inside the 5-minute buffer + const cookie = makePbCookie(oldToken); + const yrka = await loadYrkaSession(cookie, { pbUrl }); + assert.ok(yrka, 'session recovered'); + assert.equal(yrka.refreshed, true); + assert.notEqual(yrka.pb.authStore.token, oldToken); + assert.ok(yrka.cookie, 'refreshed cookie value is returned for persistence'); + }); +}); + +test('loadYrkaSession returns null when the token is expired and refresh fails', async () => { + await withFakePb(401, async (pbUrl) => { + const cookie = makePbCookie(makeJwt({ expiresInSec: -3600 })); + const yrka = await loadYrkaSession(cookie, { pbUrl }); + assert.equal(yrka, null); + }); +}); diff --git a/tools/yrka-auth.js b/tools/yrka-auth.js new file mode 100644 index 0000000..894cd8c --- /dev/null +++ b/tools/yrka-auth.js @@ -0,0 +1,101 @@ +// YOUreka Auth cookie session → PocketBase client. Ported from +// one-school-chat (src/lib/server/yrka-auth.ts): the portal returns a +// PocketBase session cookie from `GET /api/web/auth/{sessionId}/cookie`; we +// store its encoded value under our own `session` cookie and reload it into a +// PocketBase SDK client (pb.itcass.net), refreshing when invalid or near +// expiry. `createPb` is injectable for tests; production always uses the +// hardcoded pbUrl default. +import PocketBase from 'pocketbase'; + +export const PB_URL = 'https://pb.itcass.net'; + +/** Refresh proactively when the token expires within this window. */ +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +/** The auth cookie name used by the YRKA-auth PocketBase instance. */ +const PB_COOKIE_KEY = 'pb_auth'; + +/** Extract the bare `pb_auth` value from a Set-Cookie-style string. */ +export function extractPbCookieValue(raw) { + const match = new RegExp(`${PB_COOKIE_KEY}=([^;]+)`).exec(raw); + if (match) return match[1]; + // Already a bare value (no attributes / no name prefix). + return raw.split(';')[0]; +} + +/** Decode a JWT's `exp` (seconds) → ms, or 0 if unreadable. */ +export function tokenExpiresAtMs(token) { + if (!token) return 0; + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); + return typeof payload.exp === 'number' ? payload.exp * 1000 : 0; + } catch { + return 0; + } +} + +function rebuildCookieHeader(encodedValue) { + return `${PB_COOKIE_KEY}=${encodedValue}`; +} + +function recordCollectionName(pb) { + return pb.authStore.record?.collectionName ?? 'users'; +} + +function exportCookieValue(pb) { + return extractPbCookieValue(pb.authStore.exportToCookie({}, PB_COOKIE_KEY)); +} + +async function tryRefresh(pb) { + try { + await pb.collection(recordCollectionName(pb)).authRefresh(); + return true; + } catch { + return false; + } +} + +/** + * Load a YRKA-auth PocketBase session from the persisted cookie value, + * refreshing the token when it is expired or about to expire. Returns + * { pb, refreshed, cookie? } or null when the session cannot be recovered + * (caller treats the operator as anonymous). + */ +export async function loadYrkaSession( + encodedCookieValue, + { pbUrl = PB_URL, createPb = (url) => new PocketBase(url) } = {} +) { + if (!encodedCookieValue) return null; + + // Normalize: tolerate stale values that still carry a `pb_auth=` prefix. + const normalized = extractPbCookieValue(encodedCookieValue); + + const pb = createPb(pbUrl); + pb.autoCancellation(false); + + try { + pb.authStore.loadFromCookie(rebuildCookieHeader(normalized)); + } catch { + return null; + } + + if (!pb.authStore.token) return null; + + // Invalid / expired → attempt one refresh (the stored token may redeem). + if (!pb.authStore.isValid) { + if (await tryRefresh(pb)) { + return { pb, refreshed: true, cookie: exportCookieValue(pb) }; + } + return null; + } + + // Valid but nearing expiry → refresh proactively and persist the new token. + const expiresAt = tokenExpiresAtMs(pb.authStore.token); + if (expiresAt > 0 && Date.now() + REFRESH_BUFFER_MS >= expiresAt) { + if (await tryRefresh(pb)) { + return { pb, refreshed: true, cookie: exportCookieValue(pb) }; + } + } + + return { pb, refreshed: false }; +}