Compare commits
29
Commits
340d8da19d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528eaafb18 | ||
|
|
8a13fb527e | ||
|
|
8d42faf707 | ||
|
|
e432585f04 | ||
|
|
fa33c6e179 | ||
|
|
14f6fd6a8e | ||
|
|
548e33390c | ||
|
|
35d895574f | ||
|
|
ff62770bcc | ||
|
|
2ad4aa63c6 | ||
|
|
0698a9c9a9 | ||
|
|
e3392c7750 | ||
|
|
46027acec1 | ||
|
|
6d3909c334 | ||
|
|
b3b75ffac0 | ||
|
|
1ddf61b59f | ||
|
|
4d006e361f | ||
|
|
a218835a1c | ||
|
|
134edb6ebd | ||
|
|
3a5edbfe75 | ||
|
|
796094f61b | ||
|
|
88d71ffec0 | ||
|
|
645d3d2bc9 | ||
|
|
001290a789 | ||
|
|
1f8fdb47b6 | ||
|
|
95200b6abf | ||
|
|
cb5b151988 | ||
|
|
2e4a51d018 | ||
|
|
17c407883d |
@@ -18,3 +18,4 @@ API_BASE="encrypted:BIJm9rXCjB90I6zZFK57kTTjx2KY0+V42NF8zk38YVcSNTu4PB5Dxdz6iOEz
|
|||||||
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BNgr8Q5ZURg8EhuMNk7gdZy7DvzjC98tGRciLsQQFpAWnZ5eEGCM/S8DgJTPpQR1OA+L4LUUbW8QhBwsrUwjzqG3/Z8Tnqwma4bIwtXr1s9RERfimZGJ+1XEieY6b7hU3icl2aUBScdOYGFSfx4/Cj94Uz0onzeiMfzS9ZdGy6TUrVzXWCJsS9g="
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
# Image Cropper service credentials — the ONLY environment variables this app
|
# Image Cropper service credentials. Everything else (URLs, port, domain
|
||||||
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
|
# catalog) is hardcoded in source. Without these the rest of the app works and
|
||||||
# Without these the rest of the app works and /cropper answers 503.
|
# /cropper answers 503.
|
||||||
#
|
#
|
||||||
# This repository uses dotenvx-encrypted env files (.env, .env.production);
|
# This repository uses dotenvx-encrypted env files (.env, .env.production);
|
||||||
# the private keys live in .env.keys (gitignored). To set up or change a value:
|
# the private keys live in .env.keys (gitignored). To set up or change a value:
|
||||||
@@ -16,3 +16,9 @@ AZURE_STORAGE_CONNECTION_STRING=
|
|||||||
NODERED_HTTP_USERNAME=
|
NODERED_HTTP_USERNAME=
|
||||||
NODERED_HTTP_PASSWORD=
|
NODERED_HTTP_PASSWORD=
|
||||||
CARVEKIT_API_KEY=
|
CARVEKIT_API_KEY=
|
||||||
|
|
||||||
|
# Token the server uses to read the private fpagent releases from the forge
|
||||||
|
# (https://sources.yrka.app). It stays server-side: /fpagent/releases/* proxies
|
||||||
|
# the bundles for the public installer at /fpagent/install.sh. Without it the
|
||||||
|
# installer answers 503.
|
||||||
|
GITEA_RELEASE_INSTALL_KEY=
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ API_BASE="encrypted:BFVQaDK474EqpNvSw6iiP8KYjRqyXOMQLL/m5lbQHI30QCrNoMfsxmC7W2mH
|
|||||||
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BOYECnGcqfZlj8pRvTldqB/UF6HnBlMCfOymdMd3eRvGFo+HwPRViboFteB/tdMAF9uxL5q80OrgB9qbC6LaDTa/ociZg2kVR+R00YB/ZEV4epamIZ1ZzXWrg8J9D4zTGkJJ5iRiqyE4qGoPxDhn3OhTrs5RfBl/7pJa6Wi9wTU+1xhT3oBJr7E="
|
||||||
|
|||||||
@@ -1,80 +1,129 @@
|
|||||||
# YOUreka Tools — Fingerprint Service
|
# YOUreka Tools
|
||||||
|
|
||||||
A small Express app that hosts the YOUreka tools landing page and the
|
The web front-end for YOUreka ID field operations. A small Express app that
|
||||||
**Fingerprint Agent** enrollment wizard. Fingerprint capture is performed by
|
hosts an authentication-aware landing page, an **Image Cropper**, a
|
||||||
the local [`fpagent`](../fingerprint-agent) binary (Socket.IO on
|
**Fingerprint Agent** enrollment wizard, and a public **Help** page.
|
||||||
`127.0.0.1:9000`); this app provides the login (YOUreka Auth), the permitted
|
|
||||||
domain selection, and the a2loft integration that stores the enrolled
|
Fingerprint captures run through the local [`fpagent`](../fingerprint-agent)
|
||||||
fingerprints.
|
binary (Socket.IO on `127.0.0.1:9000`); this app handles login (YOUreka Auth),
|
||||||
|
per-domain permissions, and the a2loft integration that reads and stores records.
|
||||||
|
It is stateless — the operator's PocketBase session lives in an httpOnly cookie,
|
||||||
|
so the container can run anywhere while fpagent stays on the field laptop.
|
||||||
|
|
||||||
|
## Tools at a glance
|
||||||
|
|
||||||
|
| Page | Path | Access | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Landing | `/` | public | Links the tools when signed in; shows a sign-in call to action when not |
|
||||||
|
| Fingerprint Agent | `/fingerprint` | signed in | Enroll right-thumb and right-index fingerprints via the attached scanner |
|
||||||
|
| Image Cropper | `/cropper` | signed in + `crop_image` | Review, crop, clean and save identity photos from the a2loft queue |
|
||||||
|
| Help | `/help` | public | Intro plus a per-tool how-to (Carbon accordion) |
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Every page shares one Carbon ui-shell header and mobile side nav, rendered
|
||||||
|
server-side:
|
||||||
|
|
||||||
|
- **Home** → `/`
|
||||||
|
- **Image Cropper**, **Fingerprint Agent** → shown only when signed in
|
||||||
|
- a divider, then **Help**
|
||||||
|
- **Sign in** (anonymous) or **Sign out** (signed in; confirmed via a modal)
|
||||||
|
|
||||||
|
The landing page hides the two service tiles until you sign in, and the sign-in
|
||||||
|
callback returns you to `/`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
tools/
|
tools/
|
||||||
├── server.js # Express app: routes, session middleware, proxies
|
├── server.js # Express app: routes, session middleware, caching, proxies
|
||||||
├── yrka-auth.js # YOUreka Auth cookie session → PocketBase (authRefresh)
|
├── yrka-auth.js # YOUreka Auth cookie session → PocketBase (authRefresh)
|
||||||
├── permit.js # OpenFGA domain permission checks + 5-minute cache
|
├── permit.js # OpenFGA permission checks + 5-minute cache
|
||||||
|
├── cropper.js # /cropper router (queue, review, save) — injectable clients
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html # landing page (Image Cropper + Fingerprint Agent)
|
│ ├── index.html # landing page shell (tiles injected per session)
|
||||||
|
│ ├── fpagent/
|
||||||
|
│ │ └── install.sh # public /fpagent/install.sh (proxies private releases)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── style.css
|
│ ├── style.css # shared Carbon-styled layout + g100 theme
|
||||||
│ └── fp/ # /fingerprint enrollment wizard (Vue 2 via CDN)
|
│ ├── help.html # /help docs page
|
||||||
└── test/ # node --test suite (fake portal / PocketBase / a2loft)
|
│ ├── fp/ # /fingerprint wizard (Vue 2 via CDN)
|
||||||
|
│ ├── cropper/ # /cropper page (Vue 2 + vue-croppa via CDN)
|
||||||
|
│ └── youreka-logo.png, youreka-favicon.png
|
||||||
|
└── test/ # node --test suite (fake portal / PocketBase / a2loft), 48 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`openspec/` at the repository root holds the design specs and the in-flight
|
||||||
|
change proposals (see [Design docs](#design-docs)).
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
Prerequisites: Node.js 22+, `fpagent` installed and running on this laptop
|
Prerequisites: Node.js 22+. For the Fingerprint Agent you also need `fpagent`
|
||||||
(see the fingerprint-agent repository for installation), the U.are.U 4500
|
installed and running on this laptop (see the fingerprint-agent repository) with
|
||||||
scanner attached.
|
the U.are.U 4500 scanner attached.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd tools
|
cd tools
|
||||||
npm install
|
npm install
|
||||||
npm start # → http://localhost:4173
|
npm start # → http://localhost:4173
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:4173 and sign in with your YOUreka account.
|
Open http://localhost:4173 and sign in with your YOUreka account. Started this
|
||||||
|
way (no cropper credentials) everything works except `/cropper`, which answers
|
||||||
|
`503`.
|
||||||
|
|
||||||
|
To run locally **with** the cropper, supply the credentials via dotenvx from the
|
||||||
|
repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotenvx run -f .env -- node tools/server.js
|
||||||
|
```
|
||||||
|
|
||||||
> fpagent must allow the tools origin. On a dev machine run it with:
|
> fpagent must allow the tools origin. On a dev machine run it with:
|
||||||
> `fpagent -allowed-origins http://localhost:4173`
|
> `fpagent -allowed-origins http://localhost:4173`
|
||||||
|
|
||||||
## How enrollment works
|
## How it works
|
||||||
|
|
||||||
1. **Sign in** — YOUreka Auth (portal web-auth redirect). The PocketBase
|
### Authentication and sessions
|
||||||
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
|
Sign-in is the YOUreka portal web-auth flow: the app opens a portal auth session
|
||||||
|
with a local callback URL, redirects the operator to the portal, and on callback
|
||||||
|
redeems the session id for a PocketBase cookie. That value is stored under the
|
||||||
|
app's own httpOnly `session` cookie (30-day max age) alongside the operator's
|
||||||
|
profile. On every request the session is recovered server-side and refreshed
|
||||||
|
when the token is invalid or expires within 5 minutes; an unrecoverable session
|
||||||
|
is treated as anonymous and the stale cookie cleared.
|
||||||
|
|
||||||
Everything is hardcoded in `tools/server.js` by design — port `4173`, portal
|
### Permissions
|
||||||
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
|
||||||
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the domain
|
- **Fingerprint captures** require `capture_biometric` per domain. The hardcoded
|
||||||
catalog. Change them there; there are no environment variables or config
|
catalog (UNICAL, Metropolitan Hotel, UCTH, UNICAL Radio International,
|
||||||
files.
|
YOUreka) is filtered against the portal's OpenFGA endpoint, cached for 5
|
||||||
|
minutes; the **Refresh domains** control unlocks once the cache expires.
|
||||||
|
fpagent re-authorizes every capture regardless.
|
||||||
|
- **Image Cropper** actions require `crop_image`. Reads may use the 5-minute
|
||||||
|
cached verdict; the save always verifies live.
|
||||||
|
|
||||||
|
### a2loft proxy
|
||||||
|
|
||||||
|
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
|
||||||
|
under same-origin routes. The proxy authenticates the operator with the
|
||||||
|
signed-in session's PocketBase token, sent to a2loft as a `Bearer` credential;
|
||||||
|
the operator never sends credentials to a third party directly. The cropper's
|
||||||
|
save call uses the same token flow after a live permission check.
|
||||||
|
|
||||||
## The Image Cropper
|
## The Image Cropper
|
||||||
|
|
||||||
The `/cropper` page (Smart Photo Editor) lets operators review, crop and save
|
The `/cropper` page (Smart Photo Editor) lets operators work through the a2loft
|
||||||
identity photos. It reuses the YOUreka session for identity, requires the
|
`id-images` queue: **Load next queued** or **Load by upload ID**, review the
|
||||||
`crop_image` permission on `domain:idid` for every proxied action (verified
|
record, crop the photo, optionally **Clean background**, then accept or reject
|
||||||
against the portal's OpenFGA endpoint — live on save, 5-minute cached on
|
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
|
||||||
reads), and keeps the a2loft OTP requirement only on the save step.
|
live and uploads through the queue.
|
||||||
|
|
||||||
Its service credentials are the sole environment variables this app uses.
|
Its service credentials live in dotenvx-encrypted files at the repository root
|
||||||
They live in dotenvx-encrypted files at the repository root — `.env` for
|
— `.env` for development, `.env.production` for deployment. The fpagent
|
||||||
development, `.env.production` for deployment:
|
installer key is read from the same files (see
|
||||||
|
[Installing the agent](#installing-the-agent)):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
||||||
@@ -83,16 +132,81 @@ NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
|
|||||||
CARVEKIT_API_KEY=…
|
CARVEKIT_API_KEY=…
|
||||||
```
|
```
|
||||||
|
|
||||||
Without them the rest of the app works and `/cropper` routes answer `503`.
|
Without them the rest of the app works and `/cropper` answers `503`. The private
|
||||||
The private decryption keys sit in `.env.keys` (gitignored, never commit it).
|
decryption keys sit in `.env.keys` (gitignored, never commit it).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dotenvx run -f .env -- node tools/server.js # local, from the repo root
|
dotenvx run -f .env -- node tools/server.js # local, from the repo root
|
||||||
dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
|
dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker image contains the encrypted `.env.production` and runs through
|
## The Fingerprint Agent
|
||||||
`dotenvx`; only the private key is supplied at runtime (never baked in):
|
|
||||||
|
The `/fingerprint` wizard walks the operator through:
|
||||||
|
|
||||||
|
1. **Locate profile** — pick a permitted domain, enter the staff reference, and
|
||||||
|
look up the record through the a2loft proxy.
|
||||||
|
2. **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.
|
||||||
|
3. **Save** — both templates are posted to a2loft authenticated with the
|
||||||
|
session token.
|
||||||
|
|
||||||
|
The wizard tracks fpagent's scanner availability: the header tag and the capture
|
||||||
|
panel show when the agent is offline or no reader is attached, and capture is
|
||||||
|
disabled until a supported reader is detected. Availability means the reader is
|
||||||
|
attached, not that a capture will succeed; enrolling still fails if the scanner
|
||||||
|
is claimed or a scan does not complete.
|
||||||
|
|
||||||
|
### Installing the agent
|
||||||
|
|
||||||
|
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
|
||||||
|
fetches the newest bundle for the host architecture — x86_64 or 32-bit i386,
|
||||||
|
preferring the bundle built for the machine's Ubuntu codename — from the private
|
||||||
|
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
|
||||||
|
forge, verifies its sha256, then runs the bundle's own installer, which handles
|
||||||
|
libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
|
||||||
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only
|
||||||
|
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Rerunning it is the upgrade path: it installs the newest release (or `--tag`
|
||||||
|
for a pinned one), the bundle installer reports the version change, and it
|
||||||
|
restarts a running service so the new binary takes effect. The version is the
|
||||||
|
git hash carried in the asset name, stamped next to the binary
|
||||||
|
(`/usr/local/bin/fpagent.version`) and compared on the next run. The systemd
|
||||||
|
service and the self-update timer are on by default; pass `--no-service` or
|
||||||
|
`--no-updates` to leave them off.
|
||||||
|
|
||||||
|
The forge is private, so the operator's laptop needs no credentials: the app
|
||||||
|
reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through
|
||||||
|
`/fpagent/releases/*`. Without that key the installer answers `503`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Everything except the credentials 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`, forge
|
||||||
|
`https://sources.yrka.app` and the domain catalog. Change them there. The only
|
||||||
|
environment variables are the cropper credentials and the fpagent installer key
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` above.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
**Cache busting.** Each process start generates a deploy id
|
||||||
|
(`DEPLOY_VERSION`) that is appended to every `/assets/...` URL in served pages
|
||||||
|
(`?v=…`), so browsers fetch fresh CSS/JS/images after a deploy. Versioned assets
|
||||||
|
are cached hard (`max-age=1y, immutable`); the `.html` templates and all page
|
||||||
|
routes are served `no-store`.
|
||||||
|
|
||||||
|
**Stateless container.** The container only serves the web front-end; fpagent
|
||||||
|
keeps running on the operator's laptop (the wizard reaches it at
|
||||||
|
`http://127.0.0.1:9000` from the browser, wherever the container lives). The
|
||||||
|
image bakes in the encrypted `.env.production` and runs through `dotenvx`; only
|
||||||
|
the private key is supplied at runtime (never baked in):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t yreka-tools .
|
docker build -t yreka-tools .
|
||||||
@@ -106,32 +220,25 @@ undecryptable credentials. Note that `--env` values are visible to
|
|||||||
`docker inspect`; on a shared host prefer an env file with restrictive
|
`docker inspect`; on a shared host prefer an env file with restrictive
|
||||||
permissions (`--env-file deploy.keys`) or your platform's secret store.
|
permissions (`--env-file deploy.keys`) or your platform's secret store.
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd tools
|
|
||||||
npm test # node --test — 35 tests, no network or hardware required
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
The app is stateless, so the container only serves the web front-end; fpagent
|
|
||||||
keeps running on the operator's laptop (the wizard connects to
|
|
||||||
`http://127.0.0.1:9000` from the browser, wherever this container lives).
|
|
||||||
|
|
||||||
```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`
|
Behind a TLS-terminating proxy the app honours `X-Forwarded-Proto`
|
||||||
(`trust proxy` is enabled), so session cookies get the `Secure` flag
|
(`trust proxy` is enabled), so session cookies get the `Secure` flag
|
||||||
automatically.
|
automatically.
|
||||||
|
|
||||||
Field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500): login →
|
## Tests
|
||||||
permitted-domain filtering → staff lookup → two scanner captures with cancel
|
|
||||||
path → save → reset, all verified end to end via Playwright.
|
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd tools
|
||||||
|
npm test # node --test — 48 tests, no network or hardware required
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite covers the auth round-trip, session refresh, permission caching, the
|
||||||
|
cropper router and token auth, and the a2loft proxies against fakes. A full
|
||||||
|
field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500) — login →
|
||||||
|
domain filtering → staff lookup → two captures with cancel path → save → reset —
|
||||||
|
has been verified end to end via Playwright.
|
||||||
|
|
||||||
|
## Design docs
|
||||||
|
|
||||||
|
`openspec/` holds the source-of-truth specs under `openspec/specs/`
|
||||||
|
(`tools-app`, `fingerprint-wizard`, `image-cropper`) and change proposals under
|
||||||
|
`openspec/changes/`. See `openspec/config.yaml` for the workflow.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-02
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See `proposal.md` — Why. fpagent pushes `fprint-device-status`
|
||||||
|
`{available, device, captureAgentId}` to each connection on connect and to all
|
||||||
|
connections when availability changes (2 s watcher; probes skipped while an
|
||||||
|
enrollment runs). `available` means "a supported reader is attached", not that a
|
||||||
|
capture will succeed. The wizard currently learns only `agent-hello` and the
|
||||||
|
enrollment events, and its header tag reflects only the socket connection.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** surface scanner availability in the wizard; stop a capture from
|
||||||
|
starting when no reader is attached; keep working against agents that do not
|
||||||
|
emit the event.
|
||||||
|
|
||||||
|
**Non-Goals:** changing the agent or the event contract; blocking capture when
|
||||||
|
the agent is merely unreachable (pre-existing behavior); a proactive device
|
||||||
|
picker or wiring guidance.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **The client is a pure listener.** The agent already emits on connect and on
|
||||||
|
change, so the wizard adds one handler and no polling or request event.
|
||||||
|
|
||||||
|
2. **Availability is tri-state, not boolean.** `agent.available` starts `null`
|
||||||
|
(unknown) and becomes `true`/`false` on the first status. An agent that never
|
||||||
|
emits the event leaves it `null`, so captures are not blocked — the feature
|
||||||
|
degrades to today's behavior instead of deadlocking older field laptops.
|
||||||
|
|
||||||
|
3. **The status tag has four labels.** Offline (socket down), "Scanner not
|
||||||
|
connected" (`available: false`), "Agent ready" (`available: true`), and
|
||||||
|
"Agent connected" (unknown). The `title` carries the version and the device
|
||||||
|
name when known.
|
||||||
|
|
||||||
|
4. **Capture is gated at both layers.** The buttons are disabled when
|
||||||
|
`available === false`, and `capture()` returns early on the same condition so
|
||||||
|
a race (availability changing between click and handler) cannot start an
|
||||||
|
enrollment. The agent remains authoritative and still rejects a capture with
|
||||||
|
no reader.
|
||||||
|
|
||||||
|
5. **The gating logic gets a small vm-sandbox test.** `app.js` is a browser
|
||||||
|
IIFE, so `test/fp-wizard.test.js` loads it in a `node:vm` sandbox with
|
||||||
|
stubbed `Vue`/`io`/`document` and exercises the availability state, the tag
|
||||||
|
labels, and the capture refusal directly. No new dependency and no browser
|
||||||
|
runtime; the remaining end-to-end pass against a real agent is manual, since
|
||||||
|
`/fingerprint` requires a session.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Old agent never emits the event] → availability stays `null`, captures are
|
||||||
|
allowed, behavior matches today. The status tag shows "Agent connected".
|
||||||
|
- [Brief wrong tag between socket connect and first status] → the agent emits
|
||||||
|
the status in the same connection handler as `agent-hello`, so the window is
|
||||||
|
negligible; the `null` state shows "Agent connected" rather than a false
|
||||||
|
"Scanner not connected".
|
||||||
|
- [`available` read as "ready to capture"] → the agent's contract says it means
|
||||||
|
"attached"; enrollment remains authoritative and a capture can still fail.
|
||||||
|
- [Watcher is skipped during a capture] → availability is not updated mid-scan,
|
||||||
|
which is correct: the reader is in use by that capture.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Ship the wizard; it is backward compatible with agents that lack the event.
|
||||||
|
No agent change is required to deploy the wizard, and the full benefit appears
|
||||||
|
once the field laptops run the agent that emits `fprint-device-status`. Rollback
|
||||||
|
= revert the wizard.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Proposal: fpagent-device-availability
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
fpagent now reports whether a supported fingerprint reader is attached, as
|
||||||
|
`fprint-device-status {available, device, captureAgentId}`, sent to each portal
|
||||||
|
client on connect and whenever availability changes. The wizard does not consume
|
||||||
|
it: the header tag reflects only the socket connection, so a connected agent
|
||||||
|
with no scanner still reads "Agent connected" and capture is allowed, failing
|
||||||
|
only when the enrollment reaches the reader. Operators need to see that the
|
||||||
|
scanner is missing before they try.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The wizard consumes `fprint-device-status` and tracks availability and the
|
||||||
|
device name alongside the connected state.
|
||||||
|
- The header status tag distinguishes agent offline, connected with no scanner,
|
||||||
|
and ready.
|
||||||
|
- While no supported reader is attached, the capture panel shows a warning and
|
||||||
|
the capture/recapture controls are disabled; the capture handler refuses a
|
||||||
|
racing attempt.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `fingerprint-wizard`: shows scanner availability from `fprint-device-status`
|
||||||
|
and gates capture on a supported reader being attached.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/public/assets/fp/app.js`: handle the event, a multi-state status tag,
|
||||||
|
and a gated `capture()`.
|
||||||
|
- `tools/public/assets/fp/index.html`: disabled controls and an inline warning
|
||||||
|
when no reader is attached.
|
||||||
|
- `README.md`, `tools/public/assets/help.html`: document the behavior.
|
||||||
|
- Depends on `fingerprint-agent`'s `fprint-device-status` event (change
|
||||||
|
`scanner-hotplug-recovery`). Agents that do not emit it are tolerated:
|
||||||
|
availability stays unknown and captures are not blocked.
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Spec Delta: fingerprint-wizard
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Scanner availability is shown and gates capture
|
||||||
|
The wizard SHALL consume fpagent's `fprint-device-status`
|
||||||
|
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
||||||
|
being offline (socket not connected), connected with no supported reader
|
||||||
|
attached (`available` false), and ready (`available` true). Until a status is
|
||||||
|
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
|
||||||
|
capture while no supported reader is attached, SHALL re-enable capture when a
|
||||||
|
reader becomes available, and SHALL clear availability when the agent
|
||||||
|
disconnects. Availability means "a supported reader is attached"; it does not
|
||||||
|
guarantee a capture will succeed.
|
||||||
|
|
||||||
|
#### Scenario: Reader attached on connect
|
||||||
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
|
`available: true`
|
||||||
|
- **THEN** the wizard shows the agent ready and leaves capture enabled
|
||||||
|
|
||||||
|
#### Scenario: No reader attached on connect
|
||||||
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
|
`available: false`
|
||||||
|
- **THEN** the wizard shows that the scanner is not connected, explains captures
|
||||||
|
are disabled, and disables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Unplug blocks capture
|
||||||
|
- **WHEN** fpagent reports `available: false` while the operator is capturing or
|
||||||
|
between captures
|
||||||
|
- **THEN** the wizard disables the capture controls and a capture attempt is
|
||||||
|
refused
|
||||||
|
|
||||||
|
#### Scenario: Replug re-enables capture
|
||||||
|
- **WHEN** fpagent reports `available: true` after the reader is reconnected
|
||||||
|
- **THEN** the wizard re-enables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Status absent or old agent
|
||||||
|
- **WHEN** the connected agent never reports `fprint-device-status`
|
||||||
|
- **THEN** availability stays unknown and the wizard does not block capture
|
||||||
|
|
||||||
|
#### Scenario: Disconnect clears availability
|
||||||
|
- **WHEN** the agent socket disconnects
|
||||||
|
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||||
|
available
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Consume the availability event
|
||||||
|
|
||||||
|
- [x] 1.1 In `tools/public/assets/fp/app.js`, add `agent.available` (default
|
||||||
|
`null`) and `agent.device` (default `''`); handle `fprint-device-status` to set
|
||||||
|
both and re-render the tag. Verify the handler is present.
|
||||||
|
- [x] 1.2 Clear `available` back to `null` and `device` to `''` on `disconnect`
|
||||||
|
and `connect_error`.
|
||||||
|
|
||||||
|
## 2. Show availability
|
||||||
|
|
||||||
|
- [x] 2.1 Extend `renderAgentTag` to the four states (offline, scanner not
|
||||||
|
connected, agent ready, agent connected/unknown) with the device name in the
|
||||||
|
tag `title`.
|
||||||
|
- [x] 2.2 Add a computed notice (title/subtitle) for agent-offline and
|
||||||
|
no-scanner, and render it as a warning `cds-inline-notification` in the
|
||||||
|
capture panel.
|
||||||
|
- [x] 2.3 Disable the capture and recapture buttons while
|
||||||
|
`agent.available === false`, and make `capture()` refuse an attempt in the
|
||||||
|
same state.
|
||||||
|
|
||||||
|
## 3. Docs
|
||||||
|
|
||||||
|
- [x] 3.1 Add a paragraph to `README.md` under "The Fingerprint Agent".
|
||||||
|
- [x] 3.2 Add a line to `tools/public/assets/help.html`.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [x] 4.1 `cd tools && npm test` passes (65 tests, 6 new in
|
||||||
|
`test/fp-wizard.test.js`).
|
||||||
|
- [x] 4.2 `openspec validate 2026-10-02-fpagent-device-availability --strict`
|
||||||
|
passes.
|
||||||
|
- [x] 4.3 `test/fp-wizard.test.js` loads `app.js` in a vm sandbox and asserts the
|
||||||
|
availability states, tag labels, capture refusal, and disconnect reset.
|
||||||
|
- [x] 4.4 End-to-end browser pass against a running agent (tag transitions and
|
||||||
|
disabled controls) — done with Playwright driving the real page against a
|
||||||
|
stubbed agent socket; screenshots in the harness run.
|
||||||
|
- [x] 4.5 Archive the change (`openspec archive`), which syncs the
|
||||||
|
`fingerprint-wizard` spec.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-02
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md — Why. The `fpagent-installer` capability (change
|
||||||
|
`fpagent-release-installer`) installs the newest release and hands off to the
|
||||||
|
bundle's `deploy/install.sh`, which replaces the binary but only warns when a
|
||||||
|
daemon is running. The bundle name already embeds the git hash
|
||||||
|
(`fpagent-<hash>-<platform>.tar.gz`) and `bundle.sh` already builds the binary
|
||||||
|
with that hash as its embedded `Version`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** install a pinned release; make a rerun a real upgrade that reports
|
||||||
|
what changed and takes effect; keep both install paths (hosted and direct
|
||||||
|
bundle) consistent.
|
||||||
|
|
||||||
|
**Non-Goals:** a separate version service or a new `--version` CLI flag; signing
|
||||||
|
or provenance beyond sha256; a downgrade-unsafe rollback.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **The version is the asset-name git hash.** It is already the binary's
|
||||||
|
embedded `Version` and needs no new endpoint or flag. The hosted installer
|
||||||
|
parses it, and also writes it as `VERSION` in the extracted bundle so the
|
||||||
|
bundle installer has it even for bundles built before this change.
|
||||||
|
|
||||||
|
2. **The bundle installer owns the upgrade reporting and the restart.** It
|
||||||
|
knows `INSTALL_PATH` and the service; the hosted installer only fetches,
|
||||||
|
verifies and hands off. `bundle.sh` ships `VERSION` so direct bundle installs
|
||||||
|
behave the same. The stamp lives at `${INSTALL_PATH}.version`, so a custom
|
||||||
|
`--install-path` keeps its own record, and uninstall removes it.
|
||||||
|
|
||||||
|
3. **Restart only on a managed, already-running service.** `enable --now` starts
|
||||||
|
a stopped unit; for an upgrade the installer checks `is-active` before
|
||||||
|
enabling and restarts only if it was active, avoiding a redundant restart on
|
||||||
|
a fresh install.
|
||||||
|
|
||||||
|
4. **`--tag` reuses the release handler.** A second same-origin metadata route
|
||||||
|
(`/fpagent/releases/tags/:tag`) shares the latest route's handler, so the
|
||||||
|
filtering and non-disclosure rules are identical; the installer switches only
|
||||||
|
the metadata URL, then downloads by the resolved tag.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Bundles built before this change carry no VERSION and do not restart] → the
|
||||||
|
hosted installer writes VERSION from the asset name (fixes reporting), but the
|
||||||
|
restart is in the bundle and appears only after a new `fingerprint-agent`
|
||||||
|
release. Until then upgrades still need a manual restart.
|
||||||
|
- [Upgrade without `--enable-service` leaves a running daemon on the old binary]
|
||||||
|
→ the installer warns; the documented install command uses `--enable-service`.
|
||||||
|
- [A pinned tag whose assets differ] → the tag's own metadata drives asset
|
||||||
|
selection, the same as latest.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Release a new `fingerprint-agent` bundle (ships VERSION and restarts on
|
||||||
|
upgrade), then deploy the app. The hosted installer is backward compatible: with
|
||||||
|
an older bundle it reports the version but the bundle's older installer ignores
|
||||||
|
the stamp. Rollback = revert the app; `--tag` can pin the previous release.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Proposal: fpagent-install-upgrade
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The installer always took the newest release and left any running `fpagent`
|
||||||
|
daemon on the old binary, so there was no way to hold a known-good version and
|
||||||
|
an "upgrade" did not take effect until a reboot. Operators need a controlled
|
||||||
|
version and a rerun that actually upgrades.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The installer accepts `--tag TAG` to install a specific release instead of
|
||||||
|
`latest`, and the app proxies that tag's metadata through a same-origin route.
|
||||||
|
- The installed version is the git hash carried in the bundle's asset name. The
|
||||||
|
installer passes it to the bundle installer, which records it beside the
|
||||||
|
binary and reports `upgrading <old> -> <new>` on the next run.
|
||||||
|
- When the installer manages the systemd service and the service was already
|
||||||
|
running, an upgrade restarts it so the new binary takes effect. The version
|
||||||
|
stamp is removed on uninstall.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `fpagent-installer`: adds release pinning, a pinned-tag metadata endpoint,
|
||||||
|
installed-version tracking with upgrade reporting, and restart of a managed
|
||||||
|
service on upgrade.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/server.js`: `/fpagent/releases/tags/:tag` route (shares the release
|
||||||
|
metadata handler with `/fpagent/releases/latest`).
|
||||||
|
- `tools/public/fpagent/install.sh`: `--tag`, version parse from the asset name,
|
||||||
|
and handoff of the version to the bundle installer.
|
||||||
|
- `fingerprint-agent` `deploy/install.sh` and `deploy/bundle.sh`: read/stamp the
|
||||||
|
version and restart an already-running service; `bundle.sh` ships `VERSION`.
|
||||||
|
- Depends on the `fingerprint-agent` bundle naming, which already embeds the
|
||||||
|
git hash (`fpagent-<hash>-<platform>.tar.gz`).
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Pinned release selection
|
||||||
|
|
||||||
|
The installer SHALL accept a release tag and install that release instead of the
|
||||||
|
latest. Without a tag it SHALL install the latest release.
|
||||||
|
|
||||||
|
#### Scenario: A pinned release is installed
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with a release tag
|
||||||
|
- **THEN** it fetches that tag's metadata and installs its bundle
|
||||||
|
|
||||||
|
#### Scenario: Default is the latest release
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked without a release tag
|
||||||
|
- **THEN** it installs the latest release
|
||||||
|
|
||||||
|
#### Scenario: Unknown pinned release
|
||||||
|
|
||||||
|
- **WHEN** the pinned tag does not exist
|
||||||
|
- **THEN** the installer exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Pinned release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns a specific release's
|
||||||
|
tag and asset names as same-origin URLs, with the same authentication, filtering
|
||||||
|
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
|
||||||
|
tag that is not a valid release tag without contacting the forge.
|
||||||
|
|
||||||
|
#### Scenario: Tagged release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the metadata for an existing tag
|
||||||
|
- **THEN** the response carries that release's tag and its assets as same-origin
|
||||||
|
URLs
|
||||||
|
|
||||||
|
#### Scenario: Invalid tag is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests metadata for a tag that is not a valid release tag
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
|
The installer SHALL treat the git hash in the bundle's asset name as the version.
|
||||||
|
It SHALL hand that version to the bundle installer, which SHALL record it beside
|
||||||
|
the installed binary and SHALL report an upgrade with the previous and new
|
||||||
|
versions on the next run. Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
- **THEN** the bundle installer reports the change from the old to the new
|
||||||
|
version
|
||||||
|
|
||||||
|
#### Scenario: First install records the version
|
||||||
|
|
||||||
|
- **WHEN** no version has been recorded yet
|
||||||
|
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||||
|
|
||||||
|
#### Scenario: Uninstall removes the recorded version
|
||||||
|
|
||||||
|
- **WHEN** the installer is asked to uninstall
|
||||||
|
- **THEN** the recorded version is removed
|
||||||
|
|
||||||
|
### Requirement: A managed running service is restarted on upgrade
|
||||||
|
|
||||||
|
When the installer is asked to manage the systemd service and a service is
|
||||||
|
already running, the installer SHALL restart it after installing the new binary
|
||||||
|
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
|
||||||
|
restart, and the installer SHALL NOT restart the service when service management
|
||||||
|
is not requested.
|
||||||
|
|
||||||
|
#### Scenario: Upgrade restarts the running service
|
||||||
|
|
||||||
|
- **WHEN** the running service is managed and a new binary is installed
|
||||||
|
- **THEN** the service is restarted on the new binary
|
||||||
|
|
||||||
|
#### Scenario: Fresh install starts the service
|
||||||
|
|
||||||
|
- **WHEN** the service is not running and service management is requested
|
||||||
|
- **THEN** the service is started and no separate restart is performed
|
||||||
|
|
||||||
|
#### Scenario: Service management not requested
|
||||||
|
|
||||||
|
- **WHEN** service management is not requested
|
||||||
|
- **THEN** the installer does not restart the service
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. App: add `/fpagent/releases/tags/:tag`, sharing the release-metadata
|
||||||
|
handler with `/fpagent/releases/latest`; reject invalid tags without contacting
|
||||||
|
the forge.
|
||||||
|
- [x] 2. Hosted installer: parse `--tag`, fetch the pinned tag's metadata,
|
||||||
|
derive the git hash from the asset name, and write it as `VERSION` into the
|
||||||
|
extracted bundle before handoff.
|
||||||
|
- [x] 3. Bundle installer (`fingerprint-agent`): read `VERSION` beside the script
|
||||||
|
or the binary, report `upgrading <old> -> <new>` from `${INSTALL_PATH}.version`,
|
||||||
|
stamp the new version, remove it on uninstall.
|
||||||
|
- [x] 4. Bundle installer: restart an already-running `fpagent.service` after
|
||||||
|
`enable --now` (via `is-active`, so a fresh install does not restart).
|
||||||
|
- [x] 5. `bundle.sh`: ship `VERSION` in the bundle.
|
||||||
|
- [x] 6. Tests: tag-route metadata and invalid-tag rejection; `--tag` makes the
|
||||||
|
installer request the tag endpoint, not latest. Verified 59 tests pass.
|
||||||
|
- [x] 7. Verified end to end: a real patched bundle served through a fake gateway
|
||||||
|
→ hosted installer → bundle installer reported `upgrading fpagent deadbeef ->
|
||||||
|
cafebabe`, version `cafebabe`, with options forwarded.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-02
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md — Why. The `fingerprint-agent` repository and its releases are
|
||||||
|
private: an unauthenticated request to the forge's releases API answers `404`.
|
||||||
|
Releases already publish, per build, a generic bundle
|
||||||
|
(`fpagent-<version>-linux-x86_64.tar.gz`), per-Ubuntu offline bundles
|
||||||
|
(`fpagent-<version>-ubuntu-<codename>-x86_64.tar.gz`) with the libfprint `.deb`
|
||||||
|
closure vendored, and a `.sha256` beside each. Every bundle already contains
|
||||||
|
the fpagent binary, the udev rule, and its own `install.sh` that handles
|
||||||
|
libfprint, udev, `plugdev` and the systemd service. The tools app is an Express
|
||||||
|
server with a hardcoded-configuration policy whose only sanctioned environment
|
||||||
|
variables were the Image Cropper credentials.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- A field laptop installs or updates fpagent with one public command.
|
||||||
|
- The forge credential never reaches the field laptop.
|
||||||
|
- Reuse the bundle's installer rather than reimplementing hardware setup.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Replacing the bundle installer, or the forge's own release tooling.
|
||||||
|
- Authenticating the installer: it is intentionally public.
|
||||||
|
- Supporting architectures other than x86_64 (the bundle installer already
|
||||||
|
requires it).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Proxy the release through the app; do not embed the key in the script.**
|
||||||
|
The credential lives in `GITEA_RELEASE_INSTALL_KEY` and the app fetches the
|
||||||
|
private release server-side. The alternative — rendering the key into the
|
||||||
|
served script so the client could call the forge directly — publishes a
|
||||||
|
repository credential to every installer and any `curl`; rejected.
|
||||||
|
|
||||||
|
2. **Two thin routes, no proxy framework.** `/fpagent/releases/latest` returns
|
||||||
|
a trimmed `{tag, assets:[{name,url}]}` with same-origin URLs, and
|
||||||
|
`/fpagent/releases/download/:tag/:name` streams one asset. Both are
|
||||||
|
registered before the session middleware so they stay public and do no
|
||||||
|
per-request PocketBase work. A single combined "give me the right tarball"
|
||||||
|
route was considered but needs the server to guess the client's OS; the
|
||||||
|
client knows its own codename, so selection happens in the script.
|
||||||
|
|
||||||
|
3. **The installer delegates to the bundle's own `install.sh`.** All hardware
|
||||||
|
and dependency logic already exists and is versioned with the binary;
|
||||||
|
reimplementing it in the hosted script would duplicate it and drift. The
|
||||||
|
hosted script only fetches, verifies, extracts, and forwards flags.
|
||||||
|
|
||||||
|
4. **Bundle selection prefers the codename offline bundle.** When the host's
|
||||||
|
`/etc/os-release` `VERSION_CODENAME` matches a published offline bundle, use
|
||||||
|
it (no network needed for the dependency closure); otherwise fall back to
|
||||||
|
the generic online bundle, whose installer apt-gets libfprint.
|
||||||
|
|
||||||
|
5. **Checksum verification, not signature verification.** The release publishes
|
||||||
|
sha256 only, so the installer verifies sha256 over TLS from the same origin
|
||||||
|
it trusts for the script itself. GPG would require release signing that does
|
||||||
|
not exist yet; deferred.
|
||||||
|
|
||||||
|
6. **Asset names are validated server-side** against a conservative pattern,
|
||||||
|
so a crafted tag or name cannot walk the forge path or reach another host.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [A compromised tools app serves a malicious installer] → the app is already
|
||||||
|
trusted to serve the tools pages and holds the operator session; the
|
||||||
|
installer is not a new trust boundary. TLS to `tools.yrka.app` is the same
|
||||||
|
trust as the app.
|
||||||
|
- [The forge changes its bundle naming] → selection falls back to the generic
|
||||||
|
bundle, and a release with no matching asset fails loudly rather than
|
||||||
|
installing something wrong.
|
||||||
|
- [The app buffers an asset in memory] → bundles are tens of MB; acceptable,
|
||||||
|
and requests are rare. Streaming can replace it if that changes.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Add `GITEA_RELEASE_INSTALL_KEY` to `.env`/`.env.production` (already supplied)
|
||||||
|
and deploy the app; the installer and proxy are new routes and change no
|
||||||
|
existing behavior. Rollback = remove the routes; nothing else depends on them.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Proposal: fpagent-release-installer
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Field laptops need `fpagent` installed and kept current, but the
|
||||||
|
`fingerprint-agent` repository is private: its releases require a forge token
|
||||||
|
that operators do not have. Today an operator must obtain a bundle out of band
|
||||||
|
and run its installer by hand. A public one-line installer hosted by the tools
|
||||||
|
app removes that step while keeping the forge credential server-side.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a public installer script served at `/fpagent/install.sh`, intended to be
|
||||||
|
piped into `bash`. It fetches the latest release, selects the bundle matching
|
||||||
|
the host (the Ubuntu-codename offline bundle when present, else the generic
|
||||||
|
online bundle), verifies the published sha256, extracts it, and runs the
|
||||||
|
bundle's own installer, forwarding flags such as `--enable-service`,
|
||||||
|
`--check` and `--uninstall`.
|
||||||
|
- Add same-origin proxy routes `/fpagent/releases/latest` and
|
||||||
|
`/fpagent/releases/download/:tag/:name` that read the private release from the
|
||||||
|
forge using the `GITEA_RELEASE_INSTALL_KEY` environment variable and stream
|
||||||
|
assets to the client. The token and the forge host are never exposed.
|
||||||
|
- Extend the app's sanctioned environment configuration to include the installer
|
||||||
|
key, alongside the existing Image Cropper credentials.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `fpagent-installer`: the public installer surface — the download/verify/handoff
|
||||||
|
script and the token-guarded same-origin release proxy that backs it.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `tools-app`: the hardcoded-configuration requirement now sanctions a second
|
||||||
|
environment variable (the fpagent installer key) in addition to the Image
|
||||||
|
Cropper credentials.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/server.js`: `GITEA_URL` / `FPAGENT_REPO` constants, the installer key
|
||||||
|
read, and two public routes registered before the session middleware.
|
||||||
|
- `tools/public/fpagent/install.sh`: new installer script.
|
||||||
|
- `tools/test/fpagent-install.test.js`: proxy and script tests.
|
||||||
|
- `.env` / `.env.production` hold the key; `.env.example` and `README.md`
|
||||||
|
document the install flow.
|
||||||
|
- Depends on the private `fingerprint-agent` releases and their bundle naming
|
||||||
|
(`fpagent-<version>-{linux-x86_64|ubuntu-<codename>-x86_64}.tar.gz`) plus the
|
||||||
|
matching `.sha256` assets.
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
# fpagent-installer Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Lets a field laptop install the latest private `fpagent` release with a single
|
||||||
|
public command, without ever handling the forge credential — the tools app
|
||||||
|
proxies the private release and serves the installer.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
|
reachable without authentication. When run, the script SHALL fetch the latest
|
||||||
|
release metadata, choose a bundle for the host, verify the bundle's published
|
||||||
|
sha256 checksum, extract it, and hand off to the extracted bundle's own
|
||||||
|
installer. The script SHALL be served uncached.
|
||||||
|
|
||||||
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
|
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||||
|
- **THEN** the app returns the installer script and does not redirect to login
|
||||||
|
|
||||||
|
#### Scenario: Host-appropriate bundle is chosen
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||||
|
codename and a generic bundle
|
||||||
|
- **THEN** the installer uses the codename bundle
|
||||||
|
|
||||||
|
#### Scenario: Generic bundle is the fallback
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||||
|
codename
|
||||||
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
|
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||||
|
when the checksum does not match
|
||||||
|
|
||||||
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with options such as `--enable-service` or
|
||||||
|
`--check`
|
||||||
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
|
- **THEN** the installer exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns the latest release's
|
||||||
|
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
|
||||||
|
authenticate to the private forge using the configured release key, SHALL omit
|
||||||
|
asset names that are not valid asset names, and SHALL NOT reveal the release
|
||||||
|
key or the forge host to clients.
|
||||||
|
|
||||||
|
#### Scenario: Latest release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the latest-release endpoint and the forge returns
|
||||||
|
a release
|
||||||
|
- **THEN** the response carries the release tag and each asset's name and a
|
||||||
|
same-origin URL under `/fpagent/releases/download/`
|
||||||
|
|
||||||
|
#### Scenario: Unsafe asset names are omitted
|
||||||
|
|
||||||
|
- **WHEN** the forge release lists an asset whose name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** that asset does not appear in the response
|
||||||
|
|
||||||
|
#### Scenario: Release key is not exposed
|
||||||
|
|
||||||
|
- **WHEN** any client fetches the latest-release endpoint
|
||||||
|
- **THEN** the response body contains neither the release key nor the forge host
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
#### Scenario: Forge unavailable
|
||||||
|
|
||||||
|
- **WHEN** the forge cannot be reached or returns an error for the latest
|
||||||
|
release
|
||||||
|
- **THEN** the endpoint answers with a gateway error
|
||||||
|
|
||||||
|
### Requirement: Release asset download proxy
|
||||||
|
|
||||||
|
The app SHALL stream a requested release asset to the client through a
|
||||||
|
same-origin route, authenticating the upstream request with the release key so
|
||||||
|
the client sends no credentials. It SHALL reject asset names that are not valid
|
||||||
|
asset names and SHALL report a missing asset as not found.
|
||||||
|
|
||||||
|
#### Scenario: Asset is streamed without client credentials
|
||||||
|
|
||||||
|
- **WHEN** a client requests a release asset by tag and name without any
|
||||||
|
credential
|
||||||
|
- **THEN** the app fetches it from the forge with the release key and streams
|
||||||
|
its bytes to the client
|
||||||
|
|
||||||
|
#### Scenario: Invalid asset name is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests an asset whose tag or name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
#### Scenario: Missing asset
|
||||||
|
|
||||||
|
- **WHEN** the forge reports the requested asset as not found
|
||||||
|
- **THEN** the app answers with not found
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
### Requirement: Installer credential stays server-side
|
||||||
|
|
||||||
|
The app SHALL read the private-forge release key from the
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
|
||||||
|
the installer script, in any release metadata response, or in any streamed
|
||||||
|
asset.
|
||||||
|
|
||||||
|
#### Scenario: Served installer carries no credential
|
||||||
|
|
||||||
|
- **WHEN** a client fetches `/fpagent/install.sh`
|
||||||
|
- **THEN** the response contains no release key
|
||||||
|
|
||||||
|
#### Scenario: Credential supplied by the environment
|
||||||
|
|
||||||
|
- **WHEN** the release key is supplied in the environment
|
||||||
|
- **THEN** the installer and proxy endpoints use it without any change to the
|
||||||
|
hardcoded configuration
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# tools-app Specification
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Configuration is hardcoded
|
||||||
|
|
||||||
|
The app SHALL hardcode all configuration in source: 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 fingerprint
|
||||||
|
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
|
||||||
|
configuration via environment variables or config files, with two exceptions:
|
||||||
|
the Image Cropper's service credentials (Minio keys, Azure storage connection
|
||||||
|
string, nodered credentials, cleanup-service key) SHALL come from environment
|
||||||
|
variables and SHALL NOT be stored in source; and the Fingerprint Agent
|
||||||
|
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
|
||||||
|
environment and SHALL NOT be stored in source.
|
||||||
|
|
||||||
|
#### Scenario: No configuration required to run
|
||||||
|
|
||||||
|
- **WHEN** the app is started with no environment variables or config files
|
||||||
|
- **THEN** it serves on port 4173 with the hardcoded external endpoints
|
||||||
|
|
||||||
|
#### Scenario: Cropper credentials from the environment
|
||||||
|
|
||||||
|
- **WHEN** the cropper credentials are supplied as environment variables
|
||||||
|
- **THEN** the cropper routes use them without any change to the hardcoded
|
||||||
|
URL/port configuration
|
||||||
|
|
||||||
|
#### Scenario: Installer key from the environment
|
||||||
|
|
||||||
|
- **WHEN** the fpagent installer release key is supplied as an environment
|
||||||
|
variable
|
||||||
|
- **THEN** the installer and release-proxy routes use it without any change to
|
||||||
|
the hardcoded URL/port configuration
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. Server: `GITEA_URL`/`FPAGENT_REPO` constants, the installer key read
|
||||||
|
from `GITEA_RELEASE_INSTALL_KEY`, and two public proxy routes registered
|
||||||
|
before the session middleware: `GET /fpagent/releases/latest` (trimmed
|
||||||
|
`{tag, assets:[{name,url}]}`, same-origin URLs, unsafe names dropped) and
|
||||||
|
`GET /fpagent/releases/download/:tag/:name` (validated names, streamed with
|
||||||
|
the key, 404/502 mapping, 503 when unconfigured).
|
||||||
|
- [x] 2. Installer script `tools/public/fpagent/install.sh`: fetch metadata,
|
||||||
|
prefer the host-codename offline bundle over the generic one, download and
|
||||||
|
verify sha256, extract, and forward all options to the bundle's `install.sh`.
|
||||||
|
Served uncached at `/fpagent/install.sh`.
|
||||||
|
- [x] 3. Tests (`tools/test/fpagent-install.test.js`): latest proxy shape and
|
||||||
|
token non-disclosure, asset streaming with upstream auth, invalid-name
|
||||||
|
rejection, missing-asset 404, unconfigured 503, public script serving, and
|
||||||
|
`bash -n` on the script.
|
||||||
|
- [x] 4. Docs/config: `GITEA_RELEASE_INSTALL_KEY` in `.env.example`, installer
|
||||||
|
and configuration notes in `README.md`.
|
||||||
|
- [x] 5. Verified: 55 tests pass (48 existing + 7 new); routes exercised live
|
||||||
|
against the forge (latest release `v1.0.0`, proxied checksum matches the
|
||||||
|
publisher's, invalid name refused); the installer run end to end against a
|
||||||
|
fake release base — codename bundle chosen, checksum pass and fail, options
|
||||||
|
forwarded to the extracted bundle installer.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-01
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md. The a2loft `cropper-token-auth` change adds token mode to the
|
||||||
|
profile-picture endpoints: `Authorization: Bearer <pb token>`, identity from
|
||||||
|
the token's phone, authorized with OpenFGA `crop_image` on `domain:<code>` then
|
||||||
|
`domain:idid`. This change consumes it.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** the cropper saves with the session token and no OTP; the save's
|
||||||
|
domain is a2loft's own record of the picture; the panel is truthful about the
|
||||||
|
profile's domain.
|
||||||
|
|
||||||
|
**Non-Goals:** the fingerprint wizard's OTP flow; the cropper's lookup (there
|
||||||
|
is none — pictures come from the queue); removing OTP support in a2loft.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Token sent from the server**, never exposed to the browser beyond the
|
||||||
|
page's existing injected session: the proxy attaches
|
||||||
|
`Authorization: Bearer ${req.session.token}`.
|
||||||
|
2. **Domain from a2loft, not the client**: `dims/picture/{uploadId}` is the
|
||||||
|
record of the picture; its `domainCode` is the profile's identity domain and
|
||||||
|
its `domain` is the picture target (`core`). Sent as `domainCode`, displayed
|
||||||
|
as both. Falls back to the queue item's label when a2loft has no record
|
||||||
|
(custom ids), guarded by a path-safety pattern.
|
||||||
|
3. **The `core → uc` alias is deleted**: `core` is a2loft's own pseudo-domain
|
||||||
|
(special-cased in `getUserByPhoneAsync`/`updatePicture`), so rewriting it —
|
||||||
|
as the old bot did for its own calls — changed attribution and the
|
||||||
|
notification. Authorization no longer depends on the domain, so no alias is
|
||||||
|
needed.
|
||||||
|
4. **Queue loads are enriched** with `domainCode` for display (one extra
|
||||||
|
read-only a2loft call per picture).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [a2loft record unavailable for an upload] → falls back to the queue label
|
||||||
|
under the path-safety check; the save still carries the token for authz.
|
||||||
|
- [Operators outside the domain's super-users] → irrelevant now: the OpenFGA
|
||||||
|
check with the `domain:idid` fallback authorizes them.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Deploy a2loft first (dual-mode, no client change), then this change; the
|
||||||
|
cropper saves without an OTP. Rollback = revert the proxy and re-add the field.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Proposal: cropper-token-auth
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The cropper's save proves the operator with a phone number and an OTP. a2loft
|
||||||
|
resolves that phone against the domain being saved to, so an operator without
|
||||||
|
super-user rights in that domain is rejected with a blanket `Invalid OTP
|
||||||
|
specified.` — even though they are authorized to crop images for the domain.
|
||||||
|
The a2loft endpoints now accept a PocketBase token (see the a2loft change
|
||||||
|
`cropper-token-auth`), which authorizes with OpenFGA `crop_image` and takes the
|
||||||
|
actor's identity from the token.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The cropper's save posts the **session token** to a2loft instead of `phone` +
|
||||||
|
`otp`; the OTP input is removed from the UI.
|
||||||
|
- The save's domain comes from **a2loft's own picture record**
|
||||||
|
(`dims/picture/{uploadId}` → `domainCode`), falling back to the queue item's
|
||||||
|
label, and the panel shows both (the profile's domain and the picture
|
||||||
|
target).
|
||||||
|
- The `core → uc` alias is deleted: `core` is a2loft's own pseudo-domain for a
|
||||||
|
profile picture, not an alias for `uc`.
|
||||||
|
- The fingerprint wizard's staff lookup and fingerprint save keep their OTP;
|
||||||
|
only the cropper's calls change.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `image-cropper`: the save is token-authenticated (no OTP), the domain comes
|
||||||
|
from a2loft's record, and the OTP requirement is removed.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/cropper.js`: save handler (Bearer token, no `?otp=`), domain
|
||||||
|
resolution via dims with the label as fallback, path-safety check replacing
|
||||||
|
the catalog check, queue loads enriched with the profile's domain.
|
||||||
|
- `tools/public/assets/cropper/`: the OTP field and all `otp` state removed;
|
||||||
|
the panel shows the profile's domain and the picture target.
|
||||||
|
- Requires the a2loft `cropper-token-auth` change to be deployed first.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Spec Delta: image-cropper
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Saving requires a live gate and an OTP
|
||||||
|
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||||
|
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||||
|
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||||
|
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||||
|
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||||
|
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||||
|
is used in the request. A denied save SHALL not reach a2loft.
|
||||||
|
|
||||||
|
#### Scenario: Successful save
|
||||||
|
- **WHEN** a permitted operator saves
|
||||||
|
- **THEN** the route verifies the live gate and forwards the picture to
|
||||||
|
a2loft with the session token as a bearer credential and the profile's
|
||||||
|
resolved domain
|
||||||
|
|
||||||
|
#### Scenario: Denied save does not reach a2loft
|
||||||
|
- **WHEN** the live crop check denies, or the request carries no message body
|
||||||
|
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||||
|
|
||||||
|
#### Scenario: Unsafe domain values are refused
|
||||||
|
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||||
|
domain
|
||||||
|
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||||
|
|
||||||
|
### Requirement: Unified login — no separate artist login
|
||||||
|
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||||
|
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||||
|
token; the picture's own metadata carries the domain. The cropper collects no
|
||||||
|
other credential — there is no save-time OTP.
|
||||||
|
|
||||||
|
#### Scenario: Phone prefilled from the session
|
||||||
|
- **WHEN** a signed-in operator opens the cropper
|
||||||
|
- **THEN** their phone is already known to the page without any login form
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. Save handler: `Authorization: Bearer <session token>`, no `?phone/otp`;
|
||||||
|
domain resolved from `dims/picture/{uploadId}` (`domainCode`) with the queue
|
||||||
|
label as fallback, guarded by a path-safety pattern; `requireOtp` and the
|
||||||
|
`core → uc` alias deleted.
|
||||||
|
- [x] 2. Queue loads enriched with the profile's `domainCode` for display;
|
||||||
|
load-by-id passes it through.
|
||||||
|
- [x] 3. UI: OTP field, state and call parameters removed; panel shows the
|
||||||
|
profile's domain and the picture target.
|
||||||
|
- [x] 4. Tests: save carries the bearer token and no query auth; the resolved
|
||||||
|
domain wins over the body; unsafe values are refused; no OTP field in the
|
||||||
|
served page; queue load exposes `domainCode` (46 tests pass).
|
||||||
|
- [x] 5. Deploy a2loft and this app, then a field pass: load a queued picture →
|
||||||
|
save with no OTP → confirm the picture lands attributed to the token's phone.
|
||||||
|
Verified: loaded upload 0b90075b-… (panel showed "Domain ucth · picture target
|
||||||
|
core"), no OTP field, save POSTed with the session token and no query auth,
|
||||||
|
answered "Saved — Picture saved for +2348068287222" and reset the panel.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-02
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md for motivation. The wizard already holds the PocketBase token
|
||||||
|
in the page (`window.__SESSION__.token`) for fpagent enrollment, and the
|
||||||
|
cropper's save already sends `Authorization: Bearer ${req.session.token}` to
|
||||||
|
a2loft through its own proxy (`tools/cropper.js`). The two remaining OTP calls
|
||||||
|
are the wizard's staff-lookup and fingerprint-save proxies in `tools/server.js`;
|
||||||
|
their handlers receive the a2loft URL from `a2loftUrl`/`a2loftSaveUrl`, which
|
||||||
|
currently append `phone`+`otp`, and `validateProxy` rejects a missing `otp`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** both wizard a2loft calls use the session token; no OTP in the
|
||||||
|
wizard; proxy validation keeps rejecting unauthenticated calls; deployable
|
||||||
|
after the a2loft change with a clean rollback.
|
||||||
|
|
||||||
|
**Non-Goals:** fpagent capture (already carries the token); the cropper; a2loft's
|
||||||
|
own OTP compatibility; the legacy singular fingerprint endpoint.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Bearer from the server session**, never the browser: the proxy attaches
|
||||||
|
`Authorization: Bearer ${req.session.token}`, mirroring `tools/cropper.js`.
|
||||||
|
The page's token copy stays for fpagent only.
|
||||||
|
2. **Drop the OTP check in `validateProxy`**, along with the now-unnecessary
|
||||||
|
phone check; keep the hardcoded-domain and reference checks. `requireApi`
|
||||||
|
already guarantees a session.
|
||||||
|
3. **Remove the OTP inputs and state** rather than hiding them;
|
||||||
|
`canLookup`/`canSave` no longer require an OTP.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [a2loft not yet dual-mode] → deploy the a2loft change first; this proxy change
|
||||||
|
depends on it.
|
||||||
|
- [Operators outside a domain's uploader list can now enroll] → intended;
|
||||||
|
a2loft authorizes `capture_biometric` with the `domain:idid` fallback.
|
||||||
|
- [Rollback] → revert the proxy and re-add the OTP fields; a2loft stays
|
||||||
|
dual-mode.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Deploy a2loft dual-mode → deploy this app → field pass: look up a reference and
|
||||||
|
save both fingerprints with no OTP; a bogus-token call fails cleanly.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Proposal: fingerprint-token-auth
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The Fingerprint Agent wizard's two a2loft calls — staff lookup and fingerprint
|
||||||
|
save — prove the operator with a phone number and an OTP forwarded through
|
||||||
|
same-origin proxies. a2loft resolves that phone against the domain being
|
||||||
|
addressed, so an operator who holds the OpenFGA `capture_biometric` relation
|
||||||
|
but is not a super-user of that domain's uploader list is rejected. The cropper
|
||||||
|
already saves with the session's PocketBase token; the wizard's a2loft calls
|
||||||
|
should use the same token flow.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The `/api/:domain/staff/:reference` and
|
||||||
|
`/api/:domain/staff/:reference/fingerprints` proxies send the session's
|
||||||
|
PocketBase token as an `Authorization: Bearer` header instead of forwarding
|
||||||
|
`phone`+`otp` query parameters.
|
||||||
|
- The OTP validation is removed from the proxies; the session token is the
|
||||||
|
credential.
|
||||||
|
- The wizard's lookup and save steps drop their OTP fields and OTP state.
|
||||||
|
- The Fingerprint Agent and tools-app specs, the README, and the Help page are
|
||||||
|
updated.
|
||||||
|
- Requires the a2loft `fingerprint-token-auth` change deployed first.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `fingerprint-wizard`: the two-finger enrollment flow and the per-call OTP
|
||||||
|
requirement become token-authenticated.
|
||||||
|
- `tools-app`: the a2loft proxies requirement now injects the session token
|
||||||
|
instead of forwarding `phone`+`otp`.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/server.js`: the two a2loft proxy handlers and `validateProxy`.
|
||||||
|
- `tools/public/assets/fp/index.html`, `tools/public/assets/fp/app.js`: the OTP
|
||||||
|
fields, state and request parameters removed.
|
||||||
|
- `tools/test/server.test.js`: proxy assertions.
|
||||||
|
- Documentation: README and the Help page.
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# Spec Delta: fingerprint-wizard
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Two-finger enrollment flow
|
||||||
|
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||||
|
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||||
|
the staff details, capturing the right thumb and right index finger, and
|
||||||
|
saving both fingerprints through the a2loft proxy authenticated with the
|
||||||
|
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||||
|
require both fingers before save, and SHALL reset domain-independent state
|
||||||
|
(reference, staff details, captured artifacts) when starting a new
|
||||||
|
enrollment.
|
||||||
|
|
||||||
|
#### Scenario: Complete enrollment
|
||||||
|
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||||
|
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||||
|
wizard reports success
|
||||||
|
|
||||||
|
#### Scenario: Save requires both fingers
|
||||||
|
- **WHEN** the operator attempts to save with only one finger captured
|
||||||
|
- **THEN** the wizard rejects the save and explains both fingers are
|
||||||
|
required
|
||||||
|
|
||||||
|
#### Scenario: New enrollment resets state
|
||||||
|
- **WHEN** the operator starts a new enrollment after a save
|
||||||
|
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||||
|
cleared while the permitted domain list persists
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: OTP validated on every a2loft call
|
||||||
|
**Reason**: a2loft's fingerprint staff-lookup and save endpoints now accept the
|
||||||
|
session's PocketBase token, so the operator proves identity with the session
|
||||||
|
instead of a per-call OTP. The OTP scoped operators to the domain's uploader
|
||||||
|
list and rejected capture agents who hold only `capture_biometric`.
|
||||||
|
**Migration**: the wizard sends the session token through the same-origin
|
||||||
|
proxy and collects no OTP; a2loft keeps OTP mode for other clients.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Token authenticated on every a2loft call
|
||||||
|
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||||
|
save — with the signed-in operator's PocketBase session token, sent by the
|
||||||
|
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||||
|
NOT collect an OTP.
|
||||||
|
|
||||||
|
#### Scenario: Lookup uses the session token
|
||||||
|
- **WHEN** the operator submits a reference
|
||||||
|
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||||
|
session token
|
||||||
|
|
||||||
|
#### Scenario: Save uses the session token
|
||||||
|
- **WHEN** the operator saves the captured fingerprints
|
||||||
|
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
|
||||||
|
the session token
|
||||||
|
|
||||||
|
#### Scenario: No OTP field
|
||||||
|
- **WHEN** the wizard page is served
|
||||||
|
- **THEN** it offers no OTP input for lookup or save
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Spec Delta: tools-app
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: a2loft proxies inject operator identity
|
||||||
|
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||||
|
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||||
|
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||||
|
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||||
|
response shapes. The proxies SHALL require a session; a request without one
|
||||||
|
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||||
|
require an OTP.
|
||||||
|
|
||||||
|
#### Scenario: Staff lookup via proxy
|
||||||
|
- **WHEN** the page requests staff details for a domain and reference
|
||||||
|
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||||
|
bearer credential and returns a2loft's response unchanged
|
||||||
|
|
||||||
|
#### Scenario: Fingerprint save via proxy
|
||||||
|
- **WHEN** the page saves fingerprints for a domain and reference
|
||||||
|
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||||
|
token as a bearer credential
|
||||||
|
|
||||||
|
#### Scenario: Unauthenticated proxy call rejected
|
||||||
|
- **WHEN** a proxy route is called without a valid session
|
||||||
|
- **THEN** the app rejects the call without contacting a2loft
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Proxies
|
||||||
|
|
||||||
|
- [x] 1.1 `tools/server.js`: `a2loftUrl`/`a2loftSaveUrl` drop `phone`/`otp`;
|
||||||
|
both handlers send `Authorization: Bearer ${req.session.token}`;
|
||||||
|
`validateProxy` drops the OTP/phone checks and keeps domain + reference.
|
||||||
|
Verified: the proxy tests assert the Bearer header and no query auth.
|
||||||
|
- [x] 1.2 Update the proxy tests: assert no `?otp`, assert the Bearer header,
|
||||||
|
and drop the no-OTP rejection case while the auth/domain rejection remains
|
||||||
|
(no a2loft call). Verified.
|
||||||
|
|
||||||
|
## 2. Wizard UI
|
||||||
|
|
||||||
|
- [x] 2.1 `tools/public/assets/fp/index.html`: remove the lookup and save OTP
|
||||||
|
inputs.
|
||||||
|
- [x] 2.2 `tools/public/assets/fp/app.js`: remove `otpLookup`/`otpSave`, the
|
||||||
|
`otp` request params, and the OTP resets; tighten `canLookup`/`canSave`.
|
||||||
|
Verified: the served page has no OTP field (server test) and both gates pass
|
||||||
|
without a credential (wizard test).
|
||||||
|
- [x] 2.3 Update the `fingerprint-wizard` and `tools-app` spec deltas, README
|
||||||
|
steps 1/3, the a2loft proxy paragraph, and the Help page.
|
||||||
|
|
||||||
|
## 3. Verify
|
||||||
|
|
||||||
|
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
|
||||||
|
(66/66 pass.)
|
||||||
|
- [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
||||||
|
reference and save both fingerprints with no OTP. Verified in production:
|
||||||
|
the lookup and both fingerprint saves succeeded with no OTP.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-06
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Design: fpagent-installer-multiarch
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
|
||||||
|
laptop and proxies the private release through `tools.yrka.app`. It resolves
|
||||||
|
the release's asset names with a small `python3` snippet and hardcodes the
|
||||||
|
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
|
||||||
|
|
||||||
|
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
|
||||||
|
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
|
||||||
|
|
||||||
|
The bundle's own installer already refuses a binary whose ELF class does not
|
||||||
|
match the host, so the hosted installer only has to pick the right asset.
|
||||||
|
|
||||||
|
## Goals / Non-goals
|
||||||
|
|
||||||
|
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
|
||||||
|
that architecture, and record its version.
|
||||||
|
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
|
||||||
|
and asset filtering/naming is unchanged.
|
||||||
|
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
|
||||||
|
x86_64-only; i386 hosts fall back to the generic online bundle whose
|
||||||
|
installer apt-get's libfprint.
|
||||||
|
- Non-goal: adding a production environment knob for the architecture. The
|
||||||
|
host's own `uname -m` is the source of truth.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Architecture from `uname -m`, resolved once
|
||||||
|
|
||||||
|
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
|
||||||
|
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
|
||||||
|
so the hosted and bundled installers agree on what is supported.
|
||||||
|
|
||||||
|
### Selection order: codename bundle, then generic, both arch-scoped
|
||||||
|
|
||||||
|
With `codename` from `/etc/os-release`:
|
||||||
|
|
||||||
|
1. `-ubuntu-<codename>-<arch>.tar.gz`
|
||||||
|
2. `-linux-<arch>.tar.gz`
|
||||||
|
|
||||||
|
The codename attempt is skipped when the host has no codename. Because offline
|
||||||
|
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
|
||||||
|
to step 2. The suffix match keeps one architecture's bundle from ever matching
|
||||||
|
another's (`-linux-i386` does not end with `-linux-x86_64`).
|
||||||
|
|
||||||
|
### Version regex derived from the architecture
|
||||||
|
|
||||||
|
The version is the git hash embedded in the asset name. The existing regex only
|
||||||
|
listed the x86_64 endings, so it produced an empty version for an i386 bundle
|
||||||
|
and nothing was recorded. The regex must accept the same set of endings the
|
||||||
|
selection can produce: `linux-x86_64`, `linux-i386` and
|
||||||
|
`ubuntu-<codename>-x86_64`.
|
||||||
|
|
||||||
|
### Tests stub `uname`
|
||||||
|
|
||||||
|
The installer takes the architecture from the host, so a test cannot assert the
|
||||||
|
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
|
||||||
|
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
|
||||||
|
production-only override. Assertions are made on the gateway requests the script
|
||||||
|
makes (which asset it asks for), the way the existing `--tag` test does.
|
||||||
|
|
||||||
|
## Risks / trade-offs
|
||||||
|
|
||||||
|
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
|
||||||
|
choice for the bundled installer, so the hosted one follows it rather than
|
||||||
|
trying a second heuristic. `ponytail:` if such hosts appear, add a
|
||||||
|
`getconf LONG_BIT` fallback in both installers at once.
|
||||||
|
- The generated `install.sh --help` text duplicates the bundle installer's
|
||||||
|
flags list. It can drift again; keeping it short and pointing at the bundle
|
||||||
|
installer's own help limits the cost.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Proposal: fpagent-installer-multiarch
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
|
||||||
|
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
|
||||||
|
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
|
||||||
|
"no compatible bundle" and installs nothing, even though a matching release
|
||||||
|
asset exists.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- The installer detects the host architecture (`uname -m` → `x86_64` or
|
||||||
|
`i386`) and selects the matching bundle: the distro-codename bundle when one
|
||||||
|
exists for that architecture, else the generic `-linux-<arch>` bundle.
|
||||||
|
- An unsupported architecture fails with a message that names the host machine,
|
||||||
|
and installs nothing.
|
||||||
|
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
|
||||||
|
32-bit install records and reports its version the same way x86_64 does.
|
||||||
|
- The installer's help text matches the bundle installer: the systemd service
|
||||||
|
and the self-update timer are on by default, so `--no-service`,
|
||||||
|
`--no-updates` and `--update-base` are documented instead of
|
||||||
|
`--enable-service` being described as required to get a service.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
|
||||||
|
distro-codename aware), the no-compatible-bundle failure names the host
|
||||||
|
architecture, and the version is derived for every published architecture.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
|
||||||
|
version regex, help text.
|
||||||
|
- `tools/test/fpagent-install.test.js`: architecture selection and version
|
||||||
|
parsing tests.
|
||||||
|
- `README.md`: the installer section (flags and architectures).
|
||||||
|
- Depends on the `fingerprint-agent` bundle naming
|
||||||
|
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
|
||||||
|
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
|
||||||
|
whatever assets the release lists.
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
|
reachable without authentication. When run, the script SHALL determine the
|
||||||
|
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||||
|
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||||
|
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||||
|
bundle built for the host's distro codename when one exists for that
|
||||||
|
architecture, and otherwise use the generic bundle for that architecture. The
|
||||||
|
script SHALL be served uncached.
|
||||||
|
|
||||||
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
|
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||||
|
- **THEN** the app returns the installer script and does not redirect to login
|
||||||
|
|
||||||
|
#### Scenario: Host-appropriate bundle is chosen
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||||
|
codename and a generic bundle
|
||||||
|
- **THEN** the installer uses the codename bundle
|
||||||
|
|
||||||
|
#### Scenario: Generic bundle is the fallback
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||||
|
codename
|
||||||
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Bundle matches the host architecture
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||||
|
bundle for another architecture
|
||||||
|
- **THEN** the installer chooses the bundle for the host architecture
|
||||||
|
- **AND** it never chooses a bundle for another architecture
|
||||||
|
|
||||||
|
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||||
|
generic 32-bit bundle
|
||||||
|
- **THEN** the installer uses that generic 32-bit bundle
|
||||||
|
|
||||||
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
|
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||||
|
when the checksum does not match
|
||||||
|
|
||||||
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||||
|
`--check`
|
||||||
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
|
- **THEN** the installer exits with a failure message that names the host
|
||||||
|
architecture and installs nothing
|
||||||
|
|
||||||
|
#### Scenario: Unsupported architecture
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||||
|
x86_64 nor i386
|
||||||
|
- **THEN** it exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
|
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||||
|
version, for every architecture it can install. It SHALL hand that version to
|
||||||
|
the bundle installer, which SHALL record it beside the installed binary and
|
||||||
|
SHALL report an upgrade with the previous and new versions on the next run.
|
||||||
|
Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Version is derived for a 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a generic bundle named
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
- **THEN** the bundle installer reports the change from the old to the new
|
||||||
|
version
|
||||||
|
|
||||||
|
#### Scenario: First install records the version
|
||||||
|
|
||||||
|
- **WHEN** no version has been recorded yet
|
||||||
|
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||||
|
|
||||||
|
#### Scenario: Uninstall removes the recorded version
|
||||||
|
|
||||||
|
- **WHEN** the installer is asked to uninstall
|
||||||
|
- **THEN** the recorded version is removed
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
|
||||||
|
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
|
||||||
|
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
|
||||||
|
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
|
||||||
|
dies without fetching.
|
||||||
|
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
|
||||||
|
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
|
||||||
|
host architecture. Verify: with a release offering only
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
|
||||||
|
with only `-linux-x86_64`, the same host fails and installs nothing.
|
||||||
|
- [x] 3. Hosted installer: derive the version from the asset name for every
|
||||||
|
supported architecture (`linux-x86_64`, `linux-i386`,
|
||||||
|
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
|
||||||
|
not an empty string.
|
||||||
|
- [x] 4. Hosted installer: correct the help text — service and self-update are
|
||||||
|
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
|
||||||
|
the note that options are forwarded to the bundle installer.
|
||||||
|
Verify: `install.sh --help` mentions the three flags and no longer implies
|
||||||
|
`--enable-service` is required.
|
||||||
|
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
|
||||||
|
and assert i386 selection, cross-architecture rejection, and i386 version
|
||||||
|
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
|
||||||
|
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
|
||||||
|
and the on-by-default service/self-update. Verify: reading the section, no
|
||||||
|
flag claims contradict `install.sh --help`.
|
||||||
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
|
|||||||
|
|
||||||
### Requirement: Two-finger enrollment flow
|
### Requirement: Two-finger enrollment flow
|
||||||
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||||
up staff by reference (OTP-validated through the a2loft proxy), displaying
|
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||||
the staff details, capturing the right thumb and right index finger, and
|
the staff details, capturing the right thumb and right index finger, and
|
||||||
saving both fingerprints through the a2loft proxy with an OTP. The wizard
|
saving both fingerprints through the a2loft proxy authenticated with the
|
||||||
SHALL offer per-finger recapture until save, SHALL require both fingers
|
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||||
before save, and SHALL reset domain-independent state (reference, staff
|
require both fingers before save, and SHALL reset domain-independent state
|
||||||
details, captured artifacts) when starting a new enrollment.
|
(reference, staff details, captured artifacts) when starting a new
|
||||||
|
enrollment.
|
||||||
|
|
||||||
#### Scenario: Complete enrollment
|
#### Scenario: Complete enrollment
|
||||||
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||||
with an OTP
|
|
||||||
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||||
wizard reports success
|
wizard reports success
|
||||||
|
|
||||||
@@ -137,20 +137,63 @@ details, captured artifacts) when starting a new enrollment.
|
|||||||
- **THEN** the reference, staff details, and captured finger artifacts are
|
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||||
cleared while the permitted domain list persists
|
cleared while the permitted domain list persists
|
||||||
|
|
||||||
### Requirement: OTP validated on every a2loft call
|
### Requirement: Scanner availability is shown and gates capture
|
||||||
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
|
The wizard SHALL consume fpagent's `fprint-device-status`
|
||||||
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
|
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
||||||
Submission SHALL be blocked while the OTP is missing.
|
being offline (socket not connected), connected with no supported reader
|
||||||
|
attached (`available` false), and ready (`available` true). Until a status is
|
||||||
|
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
|
||||||
|
capture while no supported reader is attached, SHALL re-enable capture when a
|
||||||
|
reader becomes available, and SHALL clear availability when the agent
|
||||||
|
disconnects. Availability means "a supported reader is attached"; it does not
|
||||||
|
guarantee a capture will succeed.
|
||||||
|
|
||||||
#### Scenario: Lookup with OTP
|
#### Scenario: Reader attached on connect
|
||||||
- **WHEN** the operator submits a reference with an OTP
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
|
`available: true`
|
||||||
displays the returned staff details
|
- **THEN** the wizard shows the agent ready and leaves capture enabled
|
||||||
|
|
||||||
#### Scenario: Save requires a fresh OTP
|
#### Scenario: No reader attached on connect
|
||||||
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
|
`available: false`
|
||||||
|
- **THEN** the wizard shows that the scanner is not connected, explains captures
|
||||||
|
are disabled, and disables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Unplug blocks capture
|
||||||
|
- **WHEN** fpagent reports `available: false` while the operator is capturing or
|
||||||
|
between captures
|
||||||
|
- **THEN** the wizard disables the capture controls and a capture attempt is
|
||||||
|
refused
|
||||||
|
|
||||||
|
#### Scenario: Replug re-enables capture
|
||||||
|
- **WHEN** fpagent reports `available: true` after the reader is reconnected
|
||||||
|
- **THEN** the wizard re-enables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Status absent or old agent
|
||||||
|
- **WHEN** the connected agent never reports `fprint-device-status`
|
||||||
|
- **THEN** availability stays unknown and the wizard does not block capture
|
||||||
|
|
||||||
|
#### Scenario: Disconnect clears availability
|
||||||
|
- **WHEN** the agent socket disconnects
|
||||||
|
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||||
|
available
|
||||||
|
|
||||||
|
### Requirement: Token authenticated on every a2loft call
|
||||||
|
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||||
|
save — with the signed-in operator's PocketBase session token, sent by the
|
||||||
|
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||||
|
NOT collect an OTP.
|
||||||
|
|
||||||
|
#### Scenario: Lookup uses the session token
|
||||||
|
- **WHEN** the operator submits a reference
|
||||||
|
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||||
|
session token
|
||||||
|
|
||||||
|
#### Scenario: Save uses the session token
|
||||||
- **WHEN** the operator saves the captured fingerprints
|
- **WHEN** the operator saves the captured fingerprints
|
||||||
- **THEN** the wizard sends the OTP entered for the save call
|
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
|
||||||
|
the session token
|
||||||
|
|
||||||
#### Scenario: Missing OTP blocks submission
|
#### Scenario: No OTP field
|
||||||
- **WHEN** the OTP field is empty for a lookup or save
|
- **WHEN** the wizard page is served
|
||||||
- **THEN** the corresponding submit control is disabled
|
- **THEN** it offers no OTP input for lookup or save
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# fpagent-installer Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Lets a field laptop install the latest private `fpagent` release with a single
|
||||||
|
public command, without ever handling the forge credential — the tools app
|
||||||
|
proxies the private release and serves the installer.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
|
reachable without authentication. When run, the script SHALL determine the
|
||||||
|
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||||
|
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||||
|
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||||
|
bundle built for the host's distro codename when one exists for that
|
||||||
|
architecture, and otherwise use the generic bundle for that architecture. The
|
||||||
|
script SHALL be served uncached.
|
||||||
|
|
||||||
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
|
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||||
|
- **THEN** the app returns the installer script and does not redirect to login
|
||||||
|
|
||||||
|
#### Scenario: Host-appropriate bundle is chosen
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||||
|
codename and a generic bundle
|
||||||
|
- **THEN** the installer uses the codename bundle
|
||||||
|
|
||||||
|
#### Scenario: Generic bundle is the fallback
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||||
|
codename
|
||||||
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Bundle matches the host architecture
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||||
|
bundle for another architecture
|
||||||
|
- **THEN** the installer chooses the bundle for the host architecture
|
||||||
|
- **AND** it never chooses a bundle for another architecture
|
||||||
|
|
||||||
|
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||||
|
generic 32-bit bundle
|
||||||
|
- **THEN** the installer uses that generic 32-bit bundle
|
||||||
|
|
||||||
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
|
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||||
|
when the checksum does not match
|
||||||
|
|
||||||
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||||
|
`--check`
|
||||||
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
|
- **THEN** the installer exits with a failure message that names the host
|
||||||
|
architecture and installs nothing
|
||||||
|
|
||||||
|
#### Scenario: Unsupported architecture
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||||
|
x86_64 nor i386
|
||||||
|
- **THEN** it exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns the latest release's
|
||||||
|
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
|
||||||
|
authenticate to the private forge using the configured release key, SHALL omit
|
||||||
|
asset names that are not valid asset names, and SHALL NOT reveal the release
|
||||||
|
key or the forge host to clients.
|
||||||
|
|
||||||
|
#### Scenario: Latest release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the latest-release endpoint and the forge returns
|
||||||
|
a release
|
||||||
|
- **THEN** the response carries the release tag and each asset's name and a
|
||||||
|
same-origin URL under `/fpagent/releases/download/`
|
||||||
|
|
||||||
|
#### Scenario: Unsafe asset names are omitted
|
||||||
|
|
||||||
|
- **WHEN** the forge release lists an asset whose name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** that asset does not appear in the response
|
||||||
|
|
||||||
|
#### Scenario: Release key is not exposed
|
||||||
|
|
||||||
|
- **WHEN** any client fetches the latest-release endpoint
|
||||||
|
- **THEN** the response body contains neither the release key nor the forge host
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
#### Scenario: Forge unavailable
|
||||||
|
|
||||||
|
- **WHEN** the forge cannot be reached or returns an error for the latest
|
||||||
|
release
|
||||||
|
- **THEN** the endpoint answers with a gateway error
|
||||||
|
|
||||||
|
### Requirement: Release asset download proxy
|
||||||
|
|
||||||
|
The app SHALL stream a requested release asset to the client through a
|
||||||
|
same-origin route, authenticating the upstream request with the release key so
|
||||||
|
the client sends no credentials. It SHALL reject asset names that are not valid
|
||||||
|
asset names and SHALL report a missing asset as not found.
|
||||||
|
|
||||||
|
#### Scenario: Asset is streamed without client credentials
|
||||||
|
|
||||||
|
- **WHEN** a client requests a release asset by tag and name without any
|
||||||
|
credential
|
||||||
|
- **THEN** the app fetches it from the forge with the release key and streams
|
||||||
|
its bytes to the client
|
||||||
|
|
||||||
|
#### Scenario: Invalid asset name is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests an asset whose tag or name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
#### Scenario: Missing asset
|
||||||
|
|
||||||
|
- **WHEN** the forge reports the requested asset as not found
|
||||||
|
- **THEN** the app answers with not found
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
### Requirement: Installer credential stays server-side
|
||||||
|
|
||||||
|
The app SHALL read the private-forge release key from the
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
|
||||||
|
the installer script, in any release metadata response, or in any streamed
|
||||||
|
asset.
|
||||||
|
|
||||||
|
#### Scenario: Served installer carries no credential
|
||||||
|
|
||||||
|
- **WHEN** a client fetches `/fpagent/install.sh`
|
||||||
|
- **THEN** the response contains no release key
|
||||||
|
|
||||||
|
#### Scenario: Credential supplied by the environment
|
||||||
|
|
||||||
|
- **WHEN** the release key is supplied in the environment
|
||||||
|
- **THEN** the installer and proxy endpoints use it without any change to the
|
||||||
|
hardcoded configuration
|
||||||
|
|
||||||
|
### Requirement: Pinned release selection
|
||||||
|
|
||||||
|
The installer SHALL accept a release tag and install that release instead of the
|
||||||
|
latest. Without a tag it SHALL install the latest release.
|
||||||
|
|
||||||
|
#### Scenario: A pinned release is installed
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with a release tag
|
||||||
|
- **THEN** it fetches that tag's metadata and installs its bundle
|
||||||
|
|
||||||
|
#### Scenario: Default is the latest release
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked without a release tag
|
||||||
|
- **THEN** it installs the latest release
|
||||||
|
|
||||||
|
#### Scenario: Unknown pinned release
|
||||||
|
|
||||||
|
- **WHEN** the pinned tag does not exist
|
||||||
|
- **THEN** the installer exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Pinned release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns a specific release's
|
||||||
|
tag and asset names as same-origin URLs, with the same authentication, filtering
|
||||||
|
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
|
||||||
|
tag that is not a valid release tag without contacting the forge.
|
||||||
|
|
||||||
|
#### Scenario: Tagged release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the metadata for an existing tag
|
||||||
|
- **THEN** the response carries that release's tag and its assets as same-origin
|
||||||
|
URLs
|
||||||
|
|
||||||
|
#### Scenario: Invalid tag is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests metadata for a tag that is not a valid release tag
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
|
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||||
|
version, for every architecture it can install. It SHALL hand that version to
|
||||||
|
the bundle installer, which SHALL record it beside the installed binary and
|
||||||
|
SHALL report an upgrade with the previous and new versions on the next run.
|
||||||
|
Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Version is derived for a 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a generic bundle named
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
- **THEN** the bundle installer reports the change from the old to the new
|
||||||
|
version
|
||||||
|
|
||||||
|
#### Scenario: First install records the version
|
||||||
|
|
||||||
|
- **WHEN** no version has been recorded yet
|
||||||
|
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||||
|
|
||||||
|
#### Scenario: Uninstall removes the recorded version
|
||||||
|
|
||||||
|
- **WHEN** the installer is asked to uninstall
|
||||||
|
- **THEN** the recorded version is removed
|
||||||
|
|
||||||
|
### Requirement: A managed running service is restarted on upgrade
|
||||||
|
|
||||||
|
When the installer is asked to manage the systemd service and a service is
|
||||||
|
already running, the installer SHALL restart it after installing the new binary
|
||||||
|
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
|
||||||
|
restart, and the installer SHALL NOT restart the service when service management
|
||||||
|
is not requested.
|
||||||
|
|
||||||
|
#### Scenario: Upgrade restarts the running service
|
||||||
|
|
||||||
|
- **WHEN** the running service is managed and a new binary is installed
|
||||||
|
- **THEN** the service is restarted on the new binary
|
||||||
|
|
||||||
|
#### Scenario: Fresh install starts the service
|
||||||
|
|
||||||
|
- **WHEN** the service is not running and service management is requested
|
||||||
|
- **THEN** the service is started and no separate restart is performed
|
||||||
|
|
||||||
|
#### Scenario: Service management not requested
|
||||||
|
|
||||||
|
- **WHEN** service management is not requested
|
||||||
|
- **THEN** the installer does not restart the service
|
||||||
@@ -60,24 +60,33 @@ only for permitted operators.
|
|||||||
|
|
||||||
### Requirement: Saving requires a live gate and an OTP
|
### Requirement: Saving requires a live gate and an OTP
|
||||||
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||||
permission live (bypassing the cache) and SHALL require an OTP from the
|
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||||
operator, forwarding the session phone as the validating identity together
|
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||||
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
|
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||||
SHALL not reach a2loft.
|
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||||
|
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||||
|
is used in the request. A denied save SHALL not reach a2loft.
|
||||||
|
|
||||||
#### Scenario: Successful save
|
#### Scenario: Successful save
|
||||||
- **WHEN** a permitted operator saves with an OTP
|
- **WHEN** a permitted operator saves
|
||||||
- **THEN** the route verifies the live gate and forwards the picture to
|
- **THEN** the route verifies the live gate and forwards the picture to
|
||||||
a2loft with the session phone, OTP, and picture domain
|
a2loft with the session token as a bearer credential and the profile's
|
||||||
|
resolved domain
|
||||||
|
|
||||||
#### Scenario: Denied save does not reach a2loft
|
#### Scenario: Denied save does not reach a2loft
|
||||||
- **WHEN** the live crop check denies, or the OTP is missing
|
- **WHEN** the live crop check denies, or the request carries no message body
|
||||||
- **THEN** the route answers 403/400 and makes no a2loft call
|
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||||
|
|
||||||
|
#### Scenario: Unsafe domain values are refused
|
||||||
|
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||||
|
domain
|
||||||
|
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||||
|
|
||||||
### Requirement: Unified login — no separate artist login
|
### Requirement: Unified login — no separate artist login
|
||||||
The cropper SHALL NOT implement its own login. The operator's identity comes
|
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||||
from the YOUreka session (phone prefilled), the picture's metadata carries the
|
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||||
domain, and the only credential the operator supplies is the save-time OTP.
|
token; the picture's own metadata carries the domain. The cropper collects no
|
||||||
|
other credential — there is no save-time OTP.
|
||||||
|
|
||||||
#### Scenario: Phone prefilled from the session
|
#### Scenario: Phone prefilled from the session
|
||||||
- **WHEN** a signed-in operator opens the cropper
|
- **WHEN** a signed-in operator opens the cropper
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
|
|||||||
- **WHEN** a user opens the app root URL
|
- **WHEN** a user opens the app root URL
|
||||||
- **THEN** the page shows a link to the Image Cropper pointing at
|
- **THEN** the page shows a link to the Image Cropper pointing at
|
||||||
`/cropper` and a link to `/fingerprint`
|
`/cropper` and a link to `/fingerprint`
|
||||||
|
|
||||||
### Requirement: YOUreka Auth login establishes a cookie session
|
### Requirement: YOUreka Auth login establishes a cookie session
|
||||||
The app SHALL authenticate operators through the YOUreka portal web-auth
|
The app SHALL authenticate operators through the YOUreka portal web-auth
|
||||||
flow: create a web auth session at the portal with a local callback URL,
|
flow: create a web auth session at the portal with a local callback URL,
|
||||||
@@ -100,17 +101,21 @@ portal.
|
|||||||
### Requirement: a2loft proxies inject operator identity
|
### Requirement: a2loft proxies inject operator identity
|
||||||
The app SHALL proxy staff lookup and fingerprint-save calls to
|
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||||
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||||
attach the operator's phone from the server-side session and forward the
|
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||||
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
|
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||||
the a2loft request and response shapes. The proxies SHALL require a
|
response shapes. The proxies SHALL require a session; a request without one
|
||||||
session; the OTP SHALL NOT be accepted without one.
|
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||||
|
require an OTP.
|
||||||
|
|
||||||
#### Scenario: Staff lookup via proxy
|
#### Scenario: Staff lookup via proxy
|
||||||
- **WHEN** the page requests staff details for a domain and reference with
|
- **WHEN** the page requests staff details for a domain and reference
|
||||||
an OTP
|
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||||
- **THEN** the app calls a2loft's identity endpoint with the operator's
|
bearer credential and returns a2loft's response unchanged
|
||||||
phone from the session and the supplied OTP, and returns a2loft's
|
|
||||||
response unchanged
|
#### Scenario: Fingerprint save via proxy
|
||||||
|
- **WHEN** the page saves fingerprints for a domain and reference
|
||||||
|
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||||
|
token as a bearer credential
|
||||||
|
|
||||||
#### Scenario: Unauthenticated proxy call rejected
|
#### Scenario: Unauthenticated proxy call rejected
|
||||||
- **WHEN** a proxy route is called without a valid session
|
- **WHEN** a proxy route is called without a valid session
|
||||||
@@ -119,11 +124,14 @@ session; the OTP SHALL NOT be accepted without one.
|
|||||||
### Requirement: Configuration is hardcoded
|
### Requirement: Configuration is hardcoded
|
||||||
The app SHALL hardcode all configuration in source: port `4173`, portal
|
The app SHALL hardcode all configuration in source: port `4173`, portal
|
||||||
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
||||||
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL be
|
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
|
||||||
no runtime configuration via environment variables or config files, with one
|
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
|
||||||
exception: the Image Cropper's service credentials (Minio keys, Azure storage
|
configuration via environment variables or config files, with two exceptions:
|
||||||
connection string, nodered credentials, cleanup-service key) SHALL come from
|
the Image Cropper's service credentials (Minio keys, Azure storage connection
|
||||||
environment variables and SHALL NOT be stored in source.
|
string, nodered credentials, cleanup-service key) SHALL come from environment
|
||||||
|
variables and SHALL NOT be stored in source; and the Fingerprint Agent
|
||||||
|
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
|
||||||
|
environment and SHALL NOT be stored in source.
|
||||||
|
|
||||||
#### Scenario: No configuration required to run
|
#### Scenario: No configuration required to run
|
||||||
- **WHEN** the app is started with no environment variables or config files
|
- **WHEN** the app is started with no environment variables or config files
|
||||||
@@ -134,6 +142,12 @@ environment variables and SHALL NOT be stored in source.
|
|||||||
- **THEN** the cropper routes use them without any change to the hardcoded
|
- **THEN** the cropper routes use them without any change to the hardcoded
|
||||||
URL/port configuration
|
URL/port configuration
|
||||||
|
|
||||||
|
#### Scenario: Installer key from the environment
|
||||||
|
- **WHEN** the fpagent installer release key is supplied as an environment
|
||||||
|
variable
|
||||||
|
- **THEN** the installer and release-proxy routes use it without any change to
|
||||||
|
the hardcoded URL/port configuration
|
||||||
|
|
||||||
### Requirement: Carbon Design System UI
|
### Requirement: Carbon Design System UI
|
||||||
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
||||||
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
||||||
|
|||||||
+48
-15
@@ -12,6 +12,12 @@ import { stripPhonePrefix, cropPermission } from './permit.js';
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
const QUEUE_NAME = 'id-images';
|
const QUEUE_NAME = 'id-images';
|
||||||
|
/**
|
||||||
|
* Domains are forwarded as the picture record states them. The queue's label
|
||||||
|
* (e.g. `core` from the Telegram intake) and the profile's identity domain
|
||||||
|
* (`domainCode`, e.g. `ucth`) are both legitimate values there: a2loft
|
||||||
|
* authorizes the actor with OpenFGA, not with the domain's uploader list.
|
||||||
|
*/
|
||||||
const BUCKET = 'files';
|
const BUCKET = 'files';
|
||||||
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
||||||
|
|
||||||
@@ -20,6 +26,16 @@ const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
|||||||
* credential is missing — the router then answers 503 and nothing else in the
|
* credential is missing — the router then answers 503 and nothing else in the
|
||||||
* app is affected. URLs are hardcoded here; only credentials come from env.
|
* app is affected. URLs are hardcoded here; only credentials come from env.
|
||||||
*/
|
*/
|
||||||
|
/** Credentials the cropper needs (used for the startup warning). */
|
||||||
|
export const CROPPER_CREDENTIALS = [
|
||||||
|
'MINIO_ACCESS_KEY',
|
||||||
|
'MINIO_SECRET_KEY',
|
||||||
|
'AZURE_STORAGE_CONNECTION_STRING',
|
||||||
|
'NODERED_HTTP_USERNAME',
|
||||||
|
'NODERED_HTTP_PASSWORD',
|
||||||
|
'CARVEKIT_API_KEY',
|
||||||
|
];
|
||||||
|
|
||||||
export function buildCropperClients(env = process.env) {
|
export function buildCropperClients(env = process.env) {
|
||||||
const {
|
const {
|
||||||
MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY,
|
||||||
@@ -116,6 +132,26 @@ export function createCropperRouter(cfg) {
|
|||||||
|
|
||||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||||
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile's identity domain as a2loft records it (`domainCode`), falling
|
||||||
|
* back to the queue item's own label. Used for the save and for display.
|
||||||
|
*/
|
||||||
|
const resolvePictureDomain = async (uploadId, fallback) => {
|
||||||
|
if (!uploadId) return fallback;
|
||||||
|
try {
|
||||||
|
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(uploadId)}`, {
|
||||||
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const body = await r.json().catch(() => null);
|
||||||
|
return (body && body.ok && body.data && body.data.domainCode) || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Path-safety only: a2loft decides which codes exist or are authorized. */
|
||||||
|
const SAFE_DOMAIN = /^[A-Za-z0-9_-]+$/;
|
||||||
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
||||||
|
|
||||||
const getMessage = () =>
|
const getMessage = () =>
|
||||||
@@ -131,14 +167,6 @@ export function createCropperRouter(cfg) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const requireOtp = (req, res) => {
|
|
||||||
if (!req.query.otp) {
|
|
||||||
res.status(400).json({ ok: false, message: 'OTP is required.' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── pictures ────────────────────────────────────────────────────────────
|
// ── pictures ────────────────────────────────────────────────────────────
|
||||||
router.get('/picture/next', async (req, res) => {
|
router.get('/picture/next', async (req, res) => {
|
||||||
let message;
|
let message;
|
||||||
@@ -156,6 +184,7 @@ export function createCropperRouter(cfg) {
|
|||||||
} catch {
|
} catch {
|
||||||
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
||||||
}
|
}
|
||||||
|
data.domainCode = await resolvePictureDomain(data.upload_id, null);
|
||||||
const { messageId, popReceipt, dequeueCount } = message;
|
const { messageId, popReceipt, dequeueCount } = message;
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -171,14 +200,14 @@ export function createCropperRouter(cfg) {
|
|||||||
const body = await r.json().catch(() => null);
|
const body = await r.json().catch(() => null);
|
||||||
const data = body && body.data;
|
const data = body && body.data;
|
||||||
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
||||||
const { upload_id, file_name, caption, domain, raw_file_id } = data;
|
const { upload_id, file_name, caption, domain, raw_file_id, domainCode } = data;
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
message_id: 'custom',
|
message_id: 'custom',
|
||||||
pop_receipt: 'custom',
|
pop_receipt: 'custom',
|
||||||
dequeue_count: 0,
|
dequeue_count: 0,
|
||||||
message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false },
|
message: { upload_id, file_name, caption, domain, domainCode, raw_file_id, from_queue: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -309,7 +338,6 @@ export function createCropperRouter(cfg) {
|
|||||||
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
||||||
router.post('/profile/picture', async (req, res) => {
|
router.post('/profile/picture', async (req, res) => {
|
||||||
if (!requireMessageBody(req, res)) return;
|
if (!requireMessageBody(req, res)) return;
|
||||||
if (!requireOtp(req, res)) return;
|
|
||||||
let live;
|
let live;
|
||||||
try {
|
try {
|
||||||
live = await cropPermission({
|
live = await cropPermission({
|
||||||
@@ -338,17 +366,22 @@ export function createCropperRouter(cfg) {
|
|||||||
message: { upload_id, domain, agentDeviceHash } = {},
|
message: { upload_id, domain, agentDeviceHash } = {},
|
||||||
},
|
},
|
||||||
} = req.body;
|
} = req.body;
|
||||||
if (!domainCodes.has(domain)) {
|
// The profile's own domain comes from a2loft's record, not the client.
|
||||||
|
const urlDomain = await resolvePictureDomain(upload_id, domain);
|
||||||
|
if (!SAFE_DOMAIN.test(urlDomain || '')) {
|
||||||
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
||||||
}
|
}
|
||||||
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
|
|
||||||
try {
|
try {
|
||||||
const r = await fetchImpl(
|
const r = await fetchImpl(
|
||||||
`${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`,
|
`${base}/${encodeURIComponent(urlDomain)}/profile/picture`,
|
||||||
{
|
{
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
// the session token proves the operator; a2loft authorizes with OpenFGA
|
||||||
|
authorization: `Bearer ${req.session.token}`,
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pictureDataUrl,
|
pictureDataUrl,
|
||||||
phone,
|
phone,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
|
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
|
||||||
/* global Vue, axios, Croppa, customElements */
|
/* global Vue, axios, Croppa, customElements */
|
||||||
/* window.__SESSION__ is injected server-side by /cropper */
|
/* window.__SESSION__ is injected server-side by /cropper */
|
||||||
|
Vue.use(Croppa); // registers the <croppa> component — without this the crop UI never renders
|
||||||
Vue.config.ignoredElements = [/^cds-/];
|
Vue.config.ignoredElements = [/^cds-/];
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -15,16 +16,13 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
data: {
|
data: {
|
||||||
loading: true,
|
loading: true,
|
||||||
session: null,
|
session: null,
|
||||||
message: {
|
message: null, // set once a picture is loaded; null hides the review panel
|
||||||
message: { caption: null, file_url: null, domain: '' },
|
|
||||||
},
|
|
||||||
agent: null,
|
agent: null,
|
||||||
process: null,
|
process: null,
|
||||||
reviewed: false,
|
reviewed: false,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
cleanedImage: null,
|
cleanedImage: null,
|
||||||
requiresCleaning: '',
|
requiresCleaning: '',
|
||||||
otp: '',
|
|
||||||
reviewOptions: [
|
reviewOptions: [
|
||||||
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
|
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
|
||||||
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
|
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
|
||||||
@@ -60,7 +58,21 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
|
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
|
||||||
},
|
},
|
||||||
canSave: function () {
|
canSave: function () {
|
||||||
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
|
// the session token authenticates the save; no OTP is involved
|
||||||
|
return !this.busy.save && this.requiresCleaning !== '';
|
||||||
|
},
|
||||||
|
otherOptionSelected: function () {
|
||||||
|
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
|
||||||
|
},
|
||||||
|
croppaInstance: function () {
|
||||||
|
// the v-model object is not always the live instance; the ref is
|
||||||
|
return this.$refs.croppa || this.myCroppa;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
otherOptionSelected: function (selected) {
|
||||||
|
if (!selected) this.otherReviewOption = ''; // hidden field must not contribute
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -83,6 +95,12 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
/** axios hides the server's reason in response.data — prefer it. */
|
||||||
|
errMessage: function (error, fallback) {
|
||||||
|
return (error && error.response && error.response.data && error.response.data.message) ||
|
||||||
|
(error && error.message) || fallback;
|
||||||
|
},
|
||||||
|
|
||||||
toast: function (kind, title, caption) {
|
toast: function (kind, title, caption) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var id = ++toastSeq;
|
var id = ++toastSeq;
|
||||||
@@ -97,7 +115,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
},
|
},
|
||||||
|
|
||||||
reset: function () {
|
reset: function () {
|
||||||
this.message = { message: { caption: null, file_url: null, domain: '' } };
|
this.message = null;
|
||||||
this.agent = null;
|
this.agent = null;
|
||||||
this.process = null;
|
this.process = null;
|
||||||
this.reviewed = false;
|
this.reviewed = false;
|
||||||
@@ -106,7 +124,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.selectedReviewOptions = [];
|
this.selectedReviewOptions = [];
|
||||||
this.otherReviewOption = '';
|
this.otherReviewOption = '';
|
||||||
this.requiresCleaning = '';
|
this.requiresCleaning = '';
|
||||||
this.otp = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadPicture: async function (mode) {
|
loadPicture: async function (mode) {
|
||||||
@@ -145,8 +162,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
);
|
);
|
||||||
this.agent = review.data.ok ? review.data.data : null;
|
this.agent = review.data.ok ? review.data.data : null;
|
||||||
}
|
}
|
||||||
} catch ({ message }) {
|
} catch (error) {
|
||||||
this.toast('error', 'Operation Failed', message);
|
this.toast('error', 'Operation Failed', this.errMessage(error, 'Could not load the picture.'));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.load = false;
|
this.busy.load = false;
|
||||||
}
|
}
|
||||||
@@ -156,7 +173,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
var self = this;
|
var self = this;
|
||||||
this.busy.clean = true;
|
this.busy.clean = true;
|
||||||
try {
|
try {
|
||||||
var dataUrl = this.myCroppa.generateDataUrl('image/jpeg');
|
var dataUrl = this.croppaInstance.generateDataUrl('image/jpeg');
|
||||||
var response = await fetch('/cropper/api/removebg', {
|
var response = await fetch('/cropper/api/removebg', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -174,8 +191,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
var fr = new FileReader();
|
var fr = new FileReader();
|
||||||
fr.onload = function () { self.cleanedImage = fr.result; };
|
fr.onload = function () { self.cleanedImage = fr.result; };
|
||||||
fr.readAsDataURL(blob);
|
fr.readAsDataURL(blob);
|
||||||
} catch ({ message }) {
|
} catch (error) {
|
||||||
this.toast('error', 'Image Cleanup Failed', message);
|
this.toast('error', 'Image Cleanup Failed', this.errMessage(error, 'Cleanup failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.clean = false;
|
this.busy.clean = false;
|
||||||
}
|
}
|
||||||
@@ -220,8 +237,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.');
|
this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.');
|
||||||
}
|
}
|
||||||
this.reset();
|
this.reset();
|
||||||
} catch ({ message }) {
|
} catch (error) {
|
||||||
this.toast('error', 'Operation Failed', message);
|
this.toast('error', 'Operation Failed', this.errMessage(error, 'Review failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.review = false;
|
this.busy.review = false;
|
||||||
}
|
}
|
||||||
@@ -239,8 +256,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
}
|
}
|
||||||
this.toast('success', 'Removed!', data.message || '');
|
this.toast('success', 'Removed!', data.message || '');
|
||||||
this.reset();
|
this.reset();
|
||||||
} catch ({ message }) {
|
} catch (error) {
|
||||||
this.toast('error', 'Not Removed', message);
|
this.toast('error', 'Not Removed', this.errMessage(error, 'Removal failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.review = false;
|
this.busy.review = false;
|
||||||
}
|
}
|
||||||
@@ -250,9 +267,9 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.busy.save = true;
|
this.busy.save = true;
|
||||||
try {
|
try {
|
||||||
var { data } = await axios.post(
|
var { data } = await axios.post(
|
||||||
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
|
'/cropper/profile/picture',
|
||||||
{
|
{
|
||||||
pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'),
|
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
|
||||||
phone: this.message.message.caption,
|
phone: this.message.message.caption,
|
||||||
message: this.message,
|
message: this.message,
|
||||||
requiresCleaning: this.requiresCleaning !== 'No',
|
requiresCleaning: this.requiresCleaning !== 'No',
|
||||||
@@ -264,8 +281,8 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
}
|
}
|
||||||
this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption);
|
this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption);
|
||||||
this.reset();
|
this.reset();
|
||||||
} catch ({ message }) {
|
} catch (error) {
|
||||||
this.toast('error', 'Not Saved', message);
|
this.toast('error', 'Not Saved', this.errMessage(error, 'Save failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.save = false;
|
this.busy.save = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
|
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.js"></script>
|
||||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.css">
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/button.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/button.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
.review-options .form-check { margin-bottom: 0.35rem; }
|
.review-options .form-check { margin-bottom: 0.35rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app cds--g100">
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!--app-header-->
|
<!--app-header-->
|
||||||
|
|
||||||
@@ -36,10 +37,9 @@
|
|||||||
|
|
||||||
<!-- notifications -->
|
<!-- notifications -->
|
||||||
<div class="toast-viewport" aria-live="polite">
|
<div class="toast-viewport" aria-live="polite">
|
||||||
<cds-toast>
|
<cds-inline-notification v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
|
||||||
<cds-toast-item v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
|
v-bind:title="t.title" v-bind:subtitle="t.caption" low-contrast
|
||||||
v-bind:title="t.title" v-bind:caption="t.caption" timeout="6000"></cds-toast-item>
|
v-on:cds-notification-closed="removeToast(t.id)"></cds-inline-notification>
|
||||||
</cds-toast>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1 · load -->
|
<!-- 1 · load -->
|
||||||
@@ -51,11 +51,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row-gap">
|
<div class="row-gap">
|
||||||
<cds-button v-bind:disabled="busy.load" v-on:click="loadPicture('next')">
|
<cds-button v-bind:disabled="busy.load" v-on:click="loadPicture('next')">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M16 8L14.57 9.393 20.15 15 8 15 8 17 20.15 17 14.57 22.573 16 24 24 16 16 8z"/><path d="M16,30A14,14,0,1,1,30,16,14.0158,14.0158,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12.0137,12.0137,0,0,0,16,4Z"/></svg>
|
||||||
<span v-if="!busy.load">Load next queued</span>
|
<span v-if="!busy.load">Load next queued</span>
|
||||||
<span v-else>Loading…</span>
|
<span v-else>Loading…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">Load by upload ID</cds-button>
|
<cds-button kind="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">
|
||||||
<cds-button kind="ghost" v-if="message && message.message" v-on:click="reset">Clear</cds-button>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
|
||||||
|
Load by upload ID
|
||||||
|
</cds-button>
|
||||||
|
<cds-button kind="ghost" v-if="message && message.message" v-on:click="reset">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M18,28A12,12,0,1,0,6,16v6.2L2.4,18.6,1,20l6,6,6-6-1.4-1.4L8,22.2V16H8A10,10,0,1,1,18,26Z"/></svg>
|
||||||
|
Clear
|
||||||
|
</cds-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -69,7 +76,15 @@
|
|||||||
|
|
||||||
<dl class="details">
|
<dl class="details">
|
||||||
<div><dt>Phone</dt><dd>{{ message.message.caption || '—' }}</dd></div>
|
<div><dt>Phone</dt><dd>{{ message.message.caption || '—' }}</dd></div>
|
||||||
<div><dt>Domain</dt><dd>{{ message.message.domain }}</dd></div>
|
<div>
|
||||||
|
<dt>Domain</dt>
|
||||||
|
<dd>
|
||||||
|
{{ message.message.domainCode || message.message.domain }}
|
||||||
|
<span v-if="message.message.domainCode && message.message.domainCode !== message.message.domain" class="faint">
|
||||||
|
· picture target {{ message.message.domain }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
|
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
|
||||||
<div v-if="message.message.process">
|
<div v-if="message.message.process">
|
||||||
<dt>Process</dt>
|
<dt>Process</dt>
|
||||||
@@ -81,7 +96,7 @@
|
|||||||
<div class="editor-media">
|
<div class="editor-media">
|
||||||
<label class="cds--label">Original — crop it</label>
|
<label class="cds--label">Original — crop it</label>
|
||||||
<div class="croppa-pad mt-1">
|
<div class="croppa-pad mt-1">
|
||||||
<croppa v-model="myCroppa" :show-remove-button="false" :width="250" :height="267.44" :quality="2">
|
<croppa ref="croppa" v-model="myCroppa" :show-remove-button="false" :width="250" :height="267.44" :quality="2">
|
||||||
<img slot="initial" crossOrigin="anonymous" :src="message.message.file_url" style="width: 300px">
|
<img slot="initial" crossOrigin="anonymous" :src="message.message.file_url" style="width: 300px">
|
||||||
</croppa>
|
</croppa>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +105,10 @@
|
|||||||
<div class="croppa-pad mt-1">
|
<div class="croppa-pad mt-1">
|
||||||
<img :src="cleanedImage" alt="cleaned preview">
|
<img :src="cleanedImage" alt="cleaned preview">
|
||||||
</div>
|
</div>
|
||||||
<cds-button kind="ghost" class="mt-1" v-on:click="cleanedImage = null">Remove cleaned image</cds-button>
|
<cds-button kind="ghost" class="mt-1" v-on:click="cleanedImage = null">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 12H14V24H12zM18 12H20V24H18z"/><path d="M4 6V8H6V28a2 2 0 002 2H24a2 2 0 002-2V8h2V6zM8 28V8H24V28zM12 2H20V4H12z"/></svg>
|
||||||
|
Remove cleaned image
|
||||||
|
</cds-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +123,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<cds-button v-if="requiresCleaning === 'Yes'" v-bind:disabled="busy.clean" v-on:click="cleanImage">
|
<cds-button v-if="requiresCleaning === 'Yes'" v-bind:disabled="busy.clean" v-on:click="cleanImage">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29.4141,24,12,6.5859a2.0476,2.0476,0,0,0-2.8281,0l-2.586,2.586a2.0021,2.0021,0,0,0,0,2.8281L23.999,29.4141a2.0024,2.0024,0,0,0,2.8281,0l2.587-2.5865a1.9993,1.9993,0,0,0,0-2.8281ZM8,10.5859,10.5859,8l5,5-2.5866,2.5869-5-5ZM25.4131,28l-11-10.999L17,14.4141l11,11Z"/><path d="M2.586 14.586H5.414V17.414H2.586z" transform="rotate(-45 4 16)"/><path d="M14.586 2.586H17.414V5.414H14.586z" transform="rotate(-45 16 4)"/><path d="M2.586 2.586H5.414V5.414H2.586z" transform="rotate(-45 4 4)"/></svg>
|
||||||
<span v-if="!busy.clean">Clean background</span>
|
<span v-if="!busy.clean">Clean background</span>
|
||||||
<span v-else>Cleaning…</span>
|
<span v-else>Cleaning…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
@@ -113,43 +132,61 @@
|
|||||||
<hr class="mt-2">
|
<hr class="mt-2">
|
||||||
|
|
||||||
<div class="row-gap" v-if="!reviewed">
|
<div class="row-gap" v-if="!reviewed">
|
||||||
<cds-button v-on:click="reviewed = true; accepted = true">Accept picture</cds-button>
|
<cds-button v-on:click="reviewed = true; accepted = true">
|
||||||
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">Reject picture</cds-button>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M13 24L4 15 5.414 13.586 13 21.171 26.586 7.586 28 9 13 24z"/></svg>
|
||||||
|
Accept picture
|
||||||
|
</cds-button>
|
||||||
|
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M16,2C8.2,2,2,8.2,2,16s6.2,14,14,14s14-6.2,14-14S23.8,2,16,2z M16,28C9.4,28,4,22.6,4,16S9.4,4,16,4s12,5.4,12,12S22.6,28,16,28z"/><path d="M21.4 23L16 17.6 10.6 23 9 21.4 14.4 16 9 10.6 10.6 9 16 14.4 21.4 9 23 10.6 17.6 16 23 21.4z"/></svg>
|
||||||
|
Reject picture
|
||||||
|
</cds-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="reviewed && accepted" class="mt-2">
|
<div v-if="reviewed && accepted" class="mt-2">
|
||||||
<div class="row-gap" style="align-items:flex-end;">
|
<div class="row-gap" style="align-items:flex-end;">
|
||||||
<div style="min-width:150px; max-width:200px;">
|
|
||||||
<label class="cds--label" for="otpSave">OTP</label>
|
|
||||||
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges">
|
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>
|
||||||
<span v-if="!busy.save">Crop and upload picture</span>
|
<span v-if="!busy.save">Crop and upload picture</span>
|
||||||
<span v-else>Saving…</span>
|
<span v-else>Saving…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="ghost" v-on:click="reviewed = false">Change review</cds-button>
|
<cds-button kind="ghost" v-on:click="reviewed = false">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M20,10H7.8149l3.5874-3.5859L10,5,4,11,10,17l1.4023-1.4146L7.8179,12H20a6,6,0,0,1,0,12H12v2h8a8,8,0,0,0,0-16Z"/></svg>
|
||||||
|
Change review
|
||||||
|
</cds-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="reviewed && !accepted" class="mt-2">
|
<div v-if="reviewed && !accepted" class="mt-2">
|
||||||
<label class="cds--label">Please state why this picture is not good enough.</label>
|
<label class="cds--label">Please state why this picture is not good enough.</label>
|
||||||
<div class="review-options mt-1">
|
<div class="review-options mt-1" v-for="item in reviewOptions" v-bind:key="item.index">
|
||||||
<div v-for="item in reviewOptions" v-bind:key="item.index" style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.35rem;">
|
<div class="cds--form-item cds--checkbox-wrapper">
|
||||||
<input type="checkbox" class="cds--checkbox" v-bind:id="'reason-' + item.index" v-bind:value="item.name" v-model="selectedReviewOptions">
|
<input class="cds--checkbox" type="checkbox" v-bind:id="'reason-' + item.index"
|
||||||
<label v-bind:for="'reason-' + item.index" style="margin:0;" v-on:click.prevent>{{ item.name }}</label>
|
v-bind:value="item.name" v-model="selectedReviewOptions">
|
||||||
|
<label class="cds--checkbox-label" v-bind:for="'reason-' + item.index">
|
||||||
|
<span class="cds--checkbox-label-text">{{ item.name }}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1" style="max-width:480px;">
|
<div class="mt-1" style="max-width:480px;" v-if="otherOptionSelected">
|
||||||
<label class="cds--label" for="otherReviewOption">Others (please specify).</label>
|
<label class="cds--label" for="otherReviewOption">Others (please specify).</label>
|
||||||
<textarea id="otherReviewOption" rows="3" class="cds--text-area" v-model="otherReviewOption"></textarea>
|
<div class="cds--text-area__wrapper">
|
||||||
|
<textarea id="otherReviewOption" rows="3" class="cds--text-area" v-model="otherReviewOption"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-gap mt-2">
|
<div class="row-gap mt-2">
|
||||||
<cds-button kind="danger" v-bind:disabled="!canReview" v-on:click="reviewPicture()">
|
<cds-button kind="danger" v-bind:disabled="!canReview" v-on:click="reviewPicture()">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.45,15.11l-22-11a1,1,0,0,0-1.08.12,1,1,0,0,0-.33,1L7,16,4,26.74A1,1,0,0,0,5,28a1,1,0,0,0,.45-.11l22-11a1,1,0,0,0,0-1.78Zm-20.9,10L8.76,17H18V15H8.76L6.55,6.89,24.76,16Z"/></svg>
|
||||||
<span v-if="!busy.review">Send review</span>
|
<span v-if="!busy.review">Send review</span>
|
||||||
<span v-else>Sending…</span>
|
<span v-else>Sending…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">Dequeue only</cds-button>
|
<cds-button kind="ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">
|
||||||
<cds-button kind="ghost" v-on:click="reviewed = false">Change review</cds-button>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M8 15H24V17H8z"/></svg>
|
||||||
|
Dequeue only
|
||||||
|
</cds-button>
|
||||||
|
<cds-button kind="ghost" v-on:click="reviewed = false">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M20,10H7.8149l3.5874-3.5859L10,5,4,11,10,17l1.4023-1.4146L7.8179,12H20a6,6,0,0,1,0,12H12v2h8a8,8,0,0,0,0-16Z"/></svg>
|
||||||
|
Change review
|
||||||
|
</cds-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
session: null,
|
session: null,
|
||||||
tick: Date.now(),
|
tick: Date.now(),
|
||||||
socket: null,
|
socket: null,
|
||||||
agent: { connected: false, version: '', captureAgentId: '' },
|
// available is tri-state: null = not yet reported (old agent), true/false
|
||||||
|
// = the last fprint-device-status. Unknown is not blocked.
|
||||||
|
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
|
||||||
domain: '',
|
domain: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
otpLookup: '',
|
|
||||||
otpSave: '',
|
|
||||||
staff: null,
|
staff: null,
|
||||||
busy: { lookup: false, save: false },
|
busy: { lookup: false, save: false },
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
@@ -47,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.fingers['right-index-finger'].status === 'done';
|
this.fingers['right-index-finger'].status === 'done';
|
||||||
},
|
},
|
||||||
canLookup: function () {
|
canLookup: function () {
|
||||||
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
|
return !!(this.domain && this.reference) && !this.busy.lookup;
|
||||||
},
|
},
|
||||||
canSave: function () {
|
canSave: function () {
|
||||||
return this.bothCaptured && !!this.otpSave && !this.busy.save;
|
return this.bothCaptured && !this.busy.save;
|
||||||
},
|
},
|
||||||
refreshUnlocksAt: function () {
|
refreshUnlocksAt: function () {
|
||||||
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
||||||
@@ -68,6 +68,27 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
if (!this.session || !this.session.domainsCheckedAt) return '';
|
if (!this.session || !this.session.domainsCheckedAt) return '';
|
||||||
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
|
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
|
||||||
},
|
},
|
||||||
|
// Warning shown in the capture panel when the agent cannot capture.
|
||||||
|
scannerNotice: function () {
|
||||||
|
if (!this.agent.connected) {
|
||||||
|
return {
|
||||||
|
title: 'Capture agent offline',
|
||||||
|
subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.agent.available === false) {
|
||||||
|
return {
|
||||||
|
title: 'Scanner not connected',
|
||||||
|
subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// Capture is allowed when the agent is connected and no status has said
|
||||||
|
// the scanner is missing. Unknown availability (old agent) stays allowed.
|
||||||
|
captureReady: function () {
|
||||||
|
return this.agent.connected && this.agent.available !== false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created: function () {
|
created: function () {
|
||||||
@@ -139,18 +160,38 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
renderAgentTag: function () {
|
renderAgentTag: function () {
|
||||||
var el = document.getElementById('agent-status');
|
var el = document.getElementById('agent-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
|
var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
|
||||||
el.setAttribute('type', this.agent.connected ? 'green' : 'red');
|
if (!this.agent.connected) {
|
||||||
el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
|
label = 'Agent offline'; type = 'red';
|
||||||
|
} else if (this.agent.available === false) {
|
||||||
|
label = 'Scanner not connected'; type = 'red';
|
||||||
|
} else if (this.agent.available === true) {
|
||||||
|
label = 'Agent ready'; type = 'green';
|
||||||
|
} else {
|
||||||
|
// Connected but no status yet (or an agent that does not report it).
|
||||||
|
label = 'Agent connected'; type = 'green';
|
||||||
|
}
|
||||||
|
if (this.agent.available === true && this.agent.device) {
|
||||||
|
title += ' · ' + this.agent.device;
|
||||||
|
}
|
||||||
|
el.textContent = label;
|
||||||
|
el.setAttribute('type', type);
|
||||||
|
el.setAttribute('title', title);
|
||||||
},
|
},
|
||||||
|
|
||||||
connectAgent: function () {
|
connectAgent: function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
|
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
|
||||||
|
|
||||||
|
var offline = function () {
|
||||||
|
self.agent.connected = false;
|
||||||
|
self.agent.available = null;
|
||||||
|
self.agent.device = '';
|
||||||
|
self.renderAgentTag();
|
||||||
|
};
|
||||||
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
|
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
|
||||||
this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
|
this.socket.on('disconnect', offline);
|
||||||
this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
|
this.socket.on('connect_error', offline);
|
||||||
|
|
||||||
this.socket.on('agent-hello', function (data) {
|
this.socket.on('agent-hello', function (data) {
|
||||||
self.agent.version = (data && data.version) || '';
|
self.agent.version = (data && data.version) || '';
|
||||||
@@ -158,6 +199,12 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
self.renderAgentTag();
|
self.renderAgentTag();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on('fprint-device-status', function (data) {
|
||||||
|
self.agent.available = !!(data && data.available);
|
||||||
|
self.agent.device = (data && data.device) || '';
|
||||||
|
self.renderAgentTag();
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on('fprint-enroll-status', function (data) {
|
this.socket.on('fprint-enroll-status', function (data) {
|
||||||
if (!self.capturing) return;
|
if (!self.capturing) return;
|
||||||
self.fingers[self.capturing].stage = (data && data.stage) || 0;
|
self.fingers[self.capturing].stage = (data && data.stage) || 0;
|
||||||
@@ -213,6 +260,11 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
|
|
||||||
capture: function (finger) {
|
capture: function (finger) {
|
||||||
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
|
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
|
||||||
|
if (!this.captureReady) {
|
||||||
|
var notice = this.scannerNotice;
|
||||||
|
if (notice) this.showNotif('warning', notice.title, notice.subtitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Preserve a previous good capture so a cancelled/failed recapture
|
// Preserve a previous good capture so a cancelled/failed recapture
|
||||||
// can restore it instead of destroying it.
|
// can restore it instead of destroying it.
|
||||||
var old = this.fingers[finger];
|
var old = this.fingers[finger];
|
||||||
@@ -242,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
if (!this.canLookup) return;
|
if (!this.canLookup) return;
|
||||||
this.busy.lookup = true;
|
this.busy.lookup = true;
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
|
||||||
null,
|
|
||||||
{ params: { otp: this.otpLookup } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (!r.data.ok) {
|
if (!r.data.ok) {
|
||||||
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
||||||
@@ -268,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
});
|
});
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
||||||
{ fingerprints: fingerprints },
|
{ fingerprints: fingerprints }
|
||||||
{ params: { otp: this.otpSave } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (r.data.ok) {
|
if (r.data.ok) {
|
||||||
self.showNotif('success', 'Enrollment saved',
|
self.showNotif('success', 'Enrollment saved',
|
||||||
@@ -289,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
newEnrollment: function () {
|
newEnrollment: function () {
|
||||||
this.staff = null;
|
this.staff = null;
|
||||||
this.reference = '';
|
this.reference = '';
|
||||||
this.otpLookup = '';
|
|
||||||
this.otpSave = '';
|
|
||||||
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
||||||
this.capturing = null;
|
this.capturing = null;
|
||||||
this.cancelling = false;
|
this.cancelling = false;
|
||||||
|
|||||||
@@ -7,15 +7,16 @@
|
|||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
|
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||||
<script src="https://cdn.socket.io/4.4.0/socket.io.min.js" integrity="sha384-1fOn6VtTq3PWwfsOrk45LnYcGosJwzMHv+Xh/Jx5303FVOXzEnw0EpLv30mtjmlj" crossorigin="anonymous"></script>
|
<script src="https://cdn.socket.io/4.4.0/socket.io.min.js" integrity="sha384-1fOn6VtTq3PWwfsOrk45LnYcGosJwzMHv+Xh/Jx5303FVOXzEnw0EpLv30mtjmlj" crossorigin="anonymous"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/button.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/button.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/progress-bar.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/progress-bar.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app cds--g100">
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!--app-header-->
|
<!--app-header-->
|
||||||
|
|
||||||
@@ -53,16 +54,14 @@
|
|||||||
<label class="cds--label" for="reference">Staff reference</label>
|
<label class="cds--label" for="reference">Staff reference</label>
|
||||||
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
|
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
|
||||||
</div>
|
</div>
|
||||||
<div style="min-width:130px; max-width:170px;">
|
|
||||||
<label class="cds--label" for="otpLookup">OTP</label>
|
|
||||||
<input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
|
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
|
||||||
<span v-if="!busy.lookup">Get details</span>
|
<span v-if="!busy.lookup">Get details</span>
|
||||||
<span v-else>Checking…</span>
|
<span v-else>Checking…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="ghost" v-bind:disabled="!refreshEnabled" v-on:click="refreshDomains"
|
<cds-button kind="ghost" v-bind:disabled="!refreshEnabled" v-on:click="refreshDomains"
|
||||||
v-bind:title="refreshEnabled ? 'Re-check which domains you may capture for' : 'Permissions were checked recently — refresh unlocks ' + refreshEtaLabel">
|
v-bind:title="refreshEnabled ? 'Re-check which domains you may capture for' : 'Permissions were checked recently — refresh unlocks ' + refreshEtaLabel">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
|
||||||
<span v-if="!refreshing">Refresh domains</span>
|
<span v-if="!refreshing">Refresh domains</span>
|
||||||
<span v-else>Refreshing…</span>
|
<span v-else>Refreshing…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
@@ -81,6 +80,10 @@
|
|||||||
<span class="hint">Two fingers are required · place the finger on the scanner each scan</span>
|
<span class="hint">Two fingers are required · place the finger on the scanner each scan</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<cds-inline-notification v-if="scannerNotice" kind="warning" hide-close-button class="mt-1"
|
||||||
|
v-bind:title="scannerNotice.title" v-bind:subtitle="scannerNotice.subtitle">
|
||||||
|
</cds-inline-notification>
|
||||||
|
|
||||||
<dl class="details">
|
<dl class="details">
|
||||||
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
|
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
|
||||||
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
|
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
|
||||||
@@ -104,13 +107,18 @@
|
|||||||
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
|
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
|
||||||
|
|
||||||
<div class="row-gap mt-1">
|
<div class="row-gap mt-1">
|
||||||
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
|
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>
|
||||||
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
|
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
|
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
|
||||||
Recapture
|
Recapture
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="danger" v-if="capturing === finger" v-on:click="cancelCapture">Cancel</cds-button>
|
<cds-button kind="danger" v-if="capturing === finger" v-on:click="cancelCapture">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M17.4141 16L24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16z"/></svg>
|
||||||
|
Cancel
|
||||||
|
</cds-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,11 +132,8 @@
|
|||||||
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
|
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-gap" style="align-items:flex-end;">
|
<div class="row-gap" style="align-items:flex-end;">
|
||||||
<div style="min-width:150px; max-width:200px;">
|
|
||||||
<label class="cds--label" for="otpSave">OTP</label>
|
|
||||||
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canSave" v-on:click="save">
|
<cds-button v-bind:disabled="!canSave" v-on:click="save">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
|
||||||
<span v-if="!busy.save">Save enrollment</span>
|
<span v-if="!busy.save">Save enrollment</span>
|
||||||
<span v-else>Saving…</span>
|
<span v-else>Saving…</span>
|
||||||
</cds-button>
|
</cds-button>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Help · YOUreka Tools</title>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/accordion.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="app cds--g100">
|
||||||
|
<!--app-header-->
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Help</h1>
|
||||||
|
<p class="sub">How to use the YOUreka Tools</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="help-intro">
|
||||||
|
YOUreka Tools is a set of field utilities for YOUreka ID domains. Sign in
|
||||||
|
with your YOUreka account to use the Image Cropper and the Fingerprint
|
||||||
|
Agent. Everything runs on this machine — nothing is uploaded except
|
||||||
|
the artifacts you explicitly save. Permissions are per domain, so an
|
||||||
|
administrator must grant you access to the domains you work on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<cds-accordion>
|
||||||
|
<cds-accordion-item title="Image Cropper">
|
||||||
|
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
|
||||||
|
<ol class="help-steps">
|
||||||
|
<li>Open <strong>Image Cropper</strong> from the landing page or the menu.</li>
|
||||||
|
<li>Load a picture with <strong>Load next queued</strong>, or paste a queue item with <strong>Load by upload ID</strong>.</li>
|
||||||
|
<li>Check the details (phone, domain, agent) and crop the photo in the editor.</li>
|
||||||
|
<li>If the background needs cleaning, choose <strong>Requires cleaning? → Yes</strong>, then click <strong>Clean background</strong>.</li>
|
||||||
|
<li>To keep it, click <strong>Accept picture</strong>, then <strong>Crop and upload picture</strong>. To reject it, click <strong>Reject picture</strong>, tick the reasons (and fill in <em>Others</em> if asked), then <strong>Send review</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="help-note">The cropper requires the <em>crop_image</em> permission. If you see “No access”, ask a domain administrator to grant it.</p>
|
||||||
|
</cds-accordion-item>
|
||||||
|
|
||||||
|
<cds-accordion-item title="Fingerprint Agent">
|
||||||
|
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
||||||
|
<ol class="help-steps">
|
||||||
|
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
|
||||||
|
<li>Choose the domain, enter the staff reference, then look up the profile.</li>
|
||||||
|
<li>Confirm the profile is the right person.</li>
|
||||||
|
<li>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
|
||||||
|
<li>Review the captured prints and save.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="help-note">The scanner runs locally through the <em>fpagent</em> service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes. The wizard shows whether a supported scanner is connected and disables capture until one is.</p>
|
||||||
|
</cds-accordion-item>
|
||||||
|
</cds-accordion>
|
||||||
|
|
||||||
|
<p class="help-note">
|
||||||
|
Your session lasts 30 days. Use <strong>Sign out</strong> in the menu to end it early.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<div class="container">
|
||||||
|
<span>YOUreka Tools · Help</span>
|
||||||
|
<span>Need more detail? Ask your YOUreka ID administrator.</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
[v-cloak] { display: none; }
|
[v-cloak] { display: none; }
|
||||||
a { text-decoration: none; }
|
a { text-decoration: none; }
|
||||||
|
|
||||||
|
html { background: var(--cds-background, #161616); }
|
||||||
body.app {
|
body.app {
|
||||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background: var(--cds-background, #f4f4f4);
|
background: var(--cds-background, #f4f4f4);
|
||||||
@@ -19,18 +20,34 @@ body.app {
|
|||||||
/* prevent FOUC on web components */
|
/* prevent FOUC on web components */
|
||||||
cds-button:not(:defined), cds-tag:not(:defined), cds-tile:not(:defined),
|
cds-button:not(:defined), cds-tag:not(:defined), cds-tile:not(:defined),
|
||||||
cds-clickable-tile:not(:defined), cds-inline-notification:not(:defined),
|
cds-clickable-tile:not(:defined), cds-inline-notification:not(:defined),
|
||||||
cds-toast:not(:defined), cds-toast-item:not(:defined), cds-progress-bar:not(:defined) {
|
cds-progress-bar:not(:defined) {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
/* Carbon styles the shadow anchor inside tiles, not the host — give the host
|
/* Carbon styles the shadow anchor inside tiles, not the host — give the host
|
||||||
a real box so grid columns and click targets work correctly. */
|
a real box so grid columns and click targets work correctly. */
|
||||||
cds-tile, cds-clickable-tile { display: block; }
|
cds-tile, cds-clickable-tile { display: block; }
|
||||||
|
/* an undefined modal renders its header/body/footer inline — hide it until defined */
|
||||||
|
cds-modal:not(:defined) { display: none; }
|
||||||
|
|
||||||
/* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */
|
/* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */
|
||||||
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
|
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
|
||||||
.brand-logo { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 4px; padding: 2px; margin-right: 0.5rem; }
|
.brand-inner { display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.brand-logo { display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.brand-logo img { display: block; }
|
.brand-logo img { display: block; }
|
||||||
.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; }
|
.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; }
|
||||||
|
|
||||||
|
/* the current tool is marked on the hosts (aria-current sits outside the shadow,
|
||||||
|
so Carbon's own [aria-current=page] rule never matches) */
|
||||||
|
cds-header-nav-item[aria-current="page"] {
|
||||||
|
border-bottom: 2px solid var(--cds-border-interactive, #0f62fe);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
cds-header-nav-item[aria-current="page"]::part(link) { color: var(--cds-text-primary, #161616); }
|
||||||
|
cds-side-nav-link[aria-current="page"] {
|
||||||
|
border-left: 3px solid var(--cds-border-interactive, #0f62fe);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
cds-side-nav-link[aria-current="page"]::part(link) { color: var(--cds-text-primary, #161616); }
|
||||||
.header-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; padding-right: 1rem; }
|
.header-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; padding-right: 1rem; }
|
||||||
.header-agent { margin-left: 1rem; }
|
.header-agent { margin-left: 1rem; }
|
||||||
/* mobile: account actions live in the side nav, not the header */
|
/* mobile: account actions live in the side nav, not the header */
|
||||||
@@ -46,20 +63,17 @@ cds-side-nav[expanded] { pointer-events: auto; }
|
|||||||
}
|
}
|
||||||
.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--cds-text-primary, #161616); font-size: 13px; }
|
.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--cds-text-primary, #161616); font-size: 13px; }
|
||||||
.user-chip .avatar { width: 26px; height: 26px; border-radius: 50%; background: #0f62fe; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
|
.user-chip .avatar { width: 26px; height: 26px; border-radius: 50%; background: #0f62fe; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
|
||||||
.linklike { background: none; border: none; padding: 0; color: var(--cds-link-primary, #0f62fe); font-weight: 400; cursor: pointer; font-size: 13px; font-family: inherit; }
|
|
||||||
.linklike:hover { color: var(--cds-link-primary-hover, #0043ce); text-decoration: underline; }
|
|
||||||
.linklike[disabled] { color: var(--cds-text-disabled, #c6c6c6); cursor: default; }
|
|
||||||
|
|
||||||
/* logo above the mobile side-nav items */
|
/* logo above the mobile side-nav items */
|
||||||
.side-nav-brand { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; color: var(--cds-text-primary, #161616); font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--cds-border-subtle, #e0e0e0); }
|
.side-nav-brand { display: flex; align-items: center; gap: 0.75rem; padding: 2rem 1rem 1rem; color: var(--cds-text-primary, #161616); font-weight: 600; font-size: 14px; }
|
||||||
.side-nav-brand img { border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 6px; padding: 2px; display: block; }
|
.side-nav-brand img { display: block; }
|
||||||
|
|
||||||
/* ── layout ─────────────────────────────────────────────────────────────── */
|
/* ── layout ─────────────────────────────────────────────────────────────── */
|
||||||
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
|
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
|
||||||
main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||||
.narrow { max-width: 560px; }
|
.narrow { max-width: 560px; }
|
||||||
.center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; }
|
.center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; }
|
||||||
.page-head { margin: 1.5rem auto 1.5rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
.page-head { margin: 2.75rem auto 1.5rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||||
.page-head h1 { font-size: 1.5rem; font-weight: 600; margin: 0; letter-spacing: 0; }
|
.page-head h1 { font-size: 1.5rem; font-weight: 600; margin: 0; letter-spacing: 0; }
|
||||||
.page-head .sub { color: var(--cds-text-secondary, #525252); margin: 0.25rem 0 0; font-size: 14px; }
|
.page-head .sub { color: var(--cds-text-secondary, #525252); margin: 0.25rem 0 0; font-size: 14px; }
|
||||||
|
|
||||||
@@ -73,7 +87,7 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
|||||||
.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
.step-chip {
|
.step-chip {
|
||||||
width: 22px; height: 22px; border-radius: 2px; flex: 0 0 auto;
|
width: 22px; height: 22px; border-radius: 2px; flex: 0 0 auto;
|
||||||
background: var(--cds-layer-selected-inverse, #161616); color: #f4f4f4;
|
background: var(--cds-button-primary, #0f62fe); color: #fff;
|
||||||
font-size: 12px; font-weight: 600;
|
font-size: 12px; font-weight: 600;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -93,12 +107,20 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
|||||||
.tool-icon {
|
.tool-icon {
|
||||||
width: 40px; height: 40px; border-radius: 4px; margin: 0;
|
width: 40px; height: 40px; border-radius: 4px; margin: 0;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
background: var(--cds-layer-selected-inverse, #161616); color: #fff;
|
background: var(--cds-button-primary, #0f62fe); color: #fff;
|
||||||
}
|
}
|
||||||
.tool-icon svg { width: 20px; height: 20px; }
|
.tool-icon svg { width: 20px; height: 20px; }
|
||||||
.tool-card .go { margin-top: auto; color: var(--cds-link, #0f62fe); font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 0.3rem; }
|
.tool-card .go { margin-top: auto; color: var(--cds-link, #0f62fe); font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
.tool-card .go svg { width: 14px; height: 14px; }
|
.tool-card .go svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── help page ──────────────────────────────────────────────────────────── */
|
||||||
|
cds-accordion { display: block; max-width: 720px; }
|
||||||
|
.help-intro { max-width: 720px; color: var(--cds-text-secondary, #525252); margin: 0 0 1.5rem; font-size: 14px; }
|
||||||
|
.help-steps { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
|
||||||
|
.help-steps li { margin-bottom: 0.35rem; }
|
||||||
|
.help-steps li::marker { color: var(--cds-text-secondary, #525252); }
|
||||||
|
.help-note { color: var(--cds-text-secondary, #525252); font-size: 13px; max-width: 720px; }
|
||||||
|
|
||||||
/* ── forms (Carbon-styled native controls) ──────────────────────────────── */
|
/* ── forms (Carbon-styled native controls) ──────────────────────────────── */
|
||||||
label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; }
|
label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; }
|
||||||
.form-control {
|
.form-control {
|
||||||
@@ -116,9 +138,10 @@ textarea.form-control { height: auto; padding: 0.6rem 0.75rem; }
|
|||||||
.btn {
|
.btn {
|
||||||
border-radius: 0; font-weight: 400; font-size: 14px;
|
border-radius: 0; font-weight: 400; font-size: 14px;
|
||||||
padding: 0.65rem 0.95rem; border: 1px solid transparent;
|
padding: 0.65rem 0.95rem; border: 1px solid transparent;
|
||||||
text-decoration: none; display: inline-block; cursor: pointer;
|
text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; cursor: pointer;
|
||||||
font-family: inherit; line-height: 1.2; text-align: center;
|
font-family: inherit; line-height: 1.2; text-align: center;
|
||||||
}
|
}
|
||||||
|
.btn svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
.btn-primary { background: var(--cds-button-primary, #0f62fe); border-color: var(--cds-button-primary, #0f62fe); color: #fff; }
|
.btn-primary { background: var(--cds-button-primary, #0f62fe); border-color: var(--cds-button-primary, #0f62fe); color: #fff; }
|
||||||
.btn-primary:hover, .btn-primary:focus { background: #0353e9; border-color: #0353e9; color: #fff; }
|
.btn-primary:hover, .btn-primary:focus { background: #0353e9; border-color: #0353e9; color: #fff; }
|
||||||
.btn-ghost { background: transparent; border-color: var(--cds-border-strong, #8d8d8d); color: var(--cds-text-primary, #161616); }
|
.btn-ghost { background: transparent; border-color: var(--cds-border-strong, #8d8d8d); color: var(--cds-text-primary, #161616); }
|
||||||
@@ -168,7 +191,8 @@ cds-button::part(button) { font-size: 14px; }
|
|||||||
.review-options .form-check { margin-bottom: 0.35rem; }
|
.review-options .form-check { margin-bottom: 0.35rem; }
|
||||||
|
|
||||||
/* ── notifications viewport (cropper toasts) ────────────────────────────── */
|
/* ── notifications viewport (cropper toasts) ────────────────────────────── */
|
||||||
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(360px, calc(100vw - 2rem)); }
|
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(380px, calc(100vw - 2rem)); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.toast-viewport cds-inline-notification:not(:defined) { visibility: hidden; }
|
||||||
|
|
||||||
/* ── footer ─────────────────────────────────────────────────────────────── */
|
/* ── footer ─────────────────────────────────────────────────────────────── */
|
||||||
footer.foot { flex-shrink: 0; padding: 1.1rem 0 1.3rem; color: var(--cds-text-helper, #6f6f6f); font-size: 12px; border-top: 1px solid var(--cds-border-subtle, #c6c6c6); }
|
footer.foot { flex-shrink: 0; padding: 1.1rem 0 1.3rem; color: var(--cds-text-helper, #6f6f6f); font-size: 12px; border-top: 1px solid var(--cds-border-subtle, #c6c6c6); }
|
||||||
@@ -180,4 +204,5 @@ footer.foot a { color: var(--cds-text-secondary, #525252); }
|
|||||||
.message-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0; }
|
.message-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0; }
|
||||||
.message-icon { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--cds-support-error, #da1e28); color: #fff; margin-bottom: 0.4rem; }
|
.message-icon { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--cds-support-error, #da1e28); color: #fff; margin-bottom: 0.4rem; }
|
||||||
.message-icon.error svg { width: 24px; height: 24px; }
|
.message-icon.error svg { width: 24px; height: 24px; }
|
||||||
|
.message-icon.info { background: var(--cds-button-primary, #0f62fe); }
|
||||||
.message-card .btn { margin-top: 0.6rem; min-width: 200px; }
|
.message-card .btn { margin-top: 0.6rem; min-width: 200px; }
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Executable
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — download and install the latest YOUreka Fingerprint Agent
|
||||||
|
# (fpagent) release on this machine.
|
||||||
|
#
|
||||||
|
# Hosted at https://tools.yrka.app/fpagent/install.sh and meant to be piped
|
||||||
|
# straight into bash on a field laptop:
|
||||||
|
#
|
||||||
|
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
|
||||||
|
#
|
||||||
|
# It downloads the bundle, verifies its sha256, then hands off to the bundle's
|
||||||
|
# own installer, which handles libfprint, the udev rule, plugdev and the
|
||||||
|
# systemd service. Every flag is forwarded to that installer, e.g.:
|
||||||
|
#
|
||||||
|
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --service-user alice
|
||||||
|
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check
|
||||||
|
#
|
||||||
|
# The private release is reached through the tools app, so no credentials are
|
||||||
|
# needed here.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${FPAGENT_BASE_URL:-https://tools.yrka.app/fpagent}"
|
||||||
|
|
||||||
|
say() { printf '%s\n' "$*"; }
|
||||||
|
die() { printf '[FAIL] %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
|
||||||
|
|
||||||
|
Downloads the fpagent release, verifies it, and runs its installer.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--tag TAG release tag to install (default: latest)
|
||||||
|
|
||||||
|
Options below are forwarded to the bundled installer:
|
||||||
|
--check preflight and report only; change nothing
|
||||||
|
--offline never touch the network; fail if dependencies are missing
|
||||||
|
--no-service do not install the systemd service (also disables updates)
|
||||||
|
--no-updates do not install the self-update timer
|
||||||
|
--update-base URL release base for self-update
|
||||||
|
(default: https://tools.yrka.app/fpagent)
|
||||||
|
--service-user NAME user for the service (default: the invoking user)
|
||||||
|
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
|
||||||
|
--uninstall remove the binary, the udev rule and the service
|
||||||
|
-h, --help the installer's help
|
||||||
|
|
||||||
|
The systemd service and the self-update timer are installed and enabled by
|
||||||
|
default; --no-service and --no-updates turn them off.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
FPAGENT_BASE_URL override the download base (default: ${BASE})
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
RELEASE_TAG="latest"
|
||||||
|
FORWARD=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
RELEASE_TAG="${2:?--tag needs a release tag}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--tag=*)
|
||||||
|
RELEASE_TAG="${1#--tag=}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
FORWARD+=("$1")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
for cmd in curl tar sha256sum python3 find; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || die "$cmd is required but not installed"
|
||||||
|
done
|
||||||
|
|
||||||
|
tmpbase="${TMPDIR:-/tmp}"
|
||||||
|
# Sweep scratch left by an earlier run that was SIGKILLed: the EXIT trap cannot
|
||||||
|
# run then (that is the one signal it cannot catch). Scoped to our own prefix,
|
||||||
|
# this user, and dirs older than a day, so a concurrent install is never touched.
|
||||||
|
# Runs before any network work so cleanup is not skipped when the fetch fails.
|
||||||
|
find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
|
||||||
|
-name 'fpagent-install.*' -mtime +0 -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
# Offline bundles ship the libfprint .deb closure for a specific Ubuntu
|
||||||
|
# release; prefer the one matching this machine, else fall back to the generic
|
||||||
|
# online bundle (whose installer apt-get's the dependency).
|
||||||
|
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
||||||
|
|
||||||
|
# Releases are named for the target architecture; only these two are published.
|
||||||
|
# Mirrors the bundled installer's own preflight so both reject the same hosts.
|
||||||
|
host_machine="$(uname -m)"
|
||||||
|
case "$host_machine" in
|
||||||
|
x86_64 | amd64) arch="x86_64" ;;
|
||||||
|
i386 | i486 | i586 | i686) arch="i386" ;;
|
||||||
|
*) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$RELEASE_TAG" = "latest" ]; then
|
||||||
|
meta_url="${BASE}/releases/latest"
|
||||||
|
else
|
||||||
|
meta_url="${BASE}/releases/tags/${RELEASE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Fetching the Fingerprint Agent release (${RELEASE_TAG})..."
|
||||||
|
meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from ${BASE}"
|
||||||
|
|
||||||
|
readout="$(python3 -c '
|
||||||
|
import json, re, sys
|
||||||
|
codename, arch = sys.argv[1], sys.argv[2]
|
||||||
|
release = json.loads(sys.argv[3])
|
||||||
|
assets = [a["name"] for a in release.get("assets", [])]
|
||||||
|
|
||||||
|
def pick(suffix):
|
||||||
|
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "")
|
||||||
|
|
||||||
|
name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
|
||||||
|
if not name:
|
||||||
|
name = pick("-linux-" + arch + ".tar.gz")
|
||||||
|
# The git hash is baked into the asset name (bundle.sh --version) and into
|
||||||
|
# the binary itself; it is the version we record and later compare against.
|
||||||
|
m = re.match(r"^fpagent-(.+)-(?:linux|ubuntu-[a-z0-9]+)-" + re.escape(arch) + r"\.tar\.gz$", name)
|
||||||
|
version = m.group(1) if m else ""
|
||||||
|
print(release.get("tag", "") + "\t" + name + "\t" + version)
|
||||||
|
' "$codename" "$arch" "$meta")" || die "could not parse the release metadata"
|
||||||
|
|
||||||
|
IFS=$'\t' read -r tag asset version <<<"$readout"
|
||||||
|
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)"
|
||||||
|
|
||||||
|
say " release ${tag}"
|
||||||
|
say " bundle ${asset}"
|
||||||
|
[ -n "$version" ] && say " version ${version}"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
get() { curl -fsSL "$1" -o "$2" || die "download failed: $1"; }
|
||||||
|
|
||||||
|
say "Downloading and verifying..."
|
||||||
|
get "${BASE}/releases/download/${tag}/${asset}" "${tmp}/${asset}"
|
||||||
|
get "${BASE}/releases/download/${tag}/${asset}.sha256" "${tmp}/${asset}.sha256"
|
||||||
|
(cd "$tmp" && sha256sum -c "${asset}.sha256") || die "checksum verification failed for ${asset}"
|
||||||
|
|
||||||
|
tar xzf "${tmp}/${asset}" -C "$tmp" || die "could not extract ${asset}"
|
||||||
|
[ -x "${tmp}/install.sh" ] || die "the bundle does not contain install.sh"
|
||||||
|
|
||||||
|
# Hand the version to the bundle installer so it can record it and report an
|
||||||
|
# upgrade. Bundles built by bundle.sh already carry VERSION; this also covers
|
||||||
|
# older bundles and other packagers, using the hash from the asset name.
|
||||||
|
[ -n "$version" ] && printf '%s\n' "$version" >"${tmp}/VERSION"
|
||||||
|
|
||||||
|
say "Running the installer (pass --enable-service to start at boot)..."
|
||||||
|
"${tmp}/install.sh" "${FORWARD[@]}"
|
||||||
+5
-30
@@ -7,50 +7,25 @@
|
|||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
|
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tile.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tile.min.js"></script>
|
||||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
cds-tile:not(:defined), cds-clickable-tile:not(:defined) { visibility: hidden; }
|
cds-tile:not(:defined), cds-clickable-tile:not(:defined) { visibility: hidden; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app cds--g100">
|
||||||
<!--app-header-->
|
<!--app-header-->
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>YOUreka Tools</h1>
|
<h1>YOUreka Tools</h1>
|
||||||
<p>Field utilities for YOUreka ID Domains. Sign in with your YOUreka account to use the tools that require it.</p>
|
<p>Field utilities for YOUreka ID Domains.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-grid">
|
<!--landing-view-->
|
||||||
<cds-clickable-tile href="/cropper">
|
|
||||||
<div class="tool-card">
|
|
||||||
<span class="tool-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
|
|
||||||
</span>
|
|
||||||
<h3>Image Cropper</h3>
|
|
||||||
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
|
|
||||||
<span class="go">Open Image Cropper
|
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</cds-clickable-tile>
|
|
||||||
|
|
||||||
<cds-clickable-tile href="/fingerprint">
|
|
||||||
<div class="tool-card">
|
|
||||||
<span class="tool-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a8 8 0 0 0-8 8c0 3.5 1 5.5 2 7"/><path d="M16 21a10 10 0 0 0 4-13 8 8 0 0 0-15.7-1"/><path d="M8 21a8 8 0 0 1-1-11 5 5 0 0 1 9.7 1.4A6 6 0 0 1 12 17"/><path d="M12 21a4 4 0 0 0 4-4 4 4 0 0 0-1.2-2.9"/></svg>
|
|
||||||
</span>
|
|
||||||
<h3>Fingerprint Agent</h3>
|
|
||||||
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
|
||||||
<span class="go">Open Fingerprint Agent
|
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</cds-clickable-tile>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
|
|||||||
+283
-49
@@ -12,7 +12,7 @@ import fs from 'node:fs';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
|
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
|
||||||
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
|
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
|
||||||
import { createCropperRouter, buildCropperClients } from './cropper.js';
|
import { createCropperRouter, buildCropperClients, CROPPER_CREDENTIALS } from './cropper.js';
|
||||||
|
|
||||||
// ── Hardcoded configuration ──────────────────────────────────────────────────
|
// ── Hardcoded configuration ──────────────────────────────────────────────────
|
||||||
export const PORT = 4173;
|
export const PORT = 4173;
|
||||||
@@ -20,6 +20,11 @@ export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verif
|
|||||||
export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh
|
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 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 AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO
|
||||||
|
export const GITEA_URL = 'https://sources.yrka.app'; // fpagent release forge (private)
|
||||||
|
export const FPAGENT_REPO = 'YOUreka/fingerprint-agent';
|
||||||
|
// The Gitea token used to read the private fpagent releases. The operator's
|
||||||
|
// laptop never sees it: /fpagent/* proxies the bundles through this server.
|
||||||
|
const GITEA_RELEASE_KEY = process.env.GITEA_RELEASE_INSTALL_KEY || '';
|
||||||
export const DOMAINS = [
|
export const DOMAINS = [
|
||||||
{ code: 'uc', name: 'UNICAL' },
|
{ code: 'uc', name: 'UNICAL' },
|
||||||
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
||||||
@@ -31,6 +36,18 @@ export const DOMAINS = [
|
|||||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Bumped once per process start, so every deployment serves fresh asset URLs.
|
||||||
|
export const DEPLOY_VERSION = Date.now().toString(36);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the per-deploy version to every /assets URL in a served page, so the
|
||||||
|
* browser fetches fresh CSS/JS/images after a deploy instead of reusing cached
|
||||||
|
* ones. Idempotent: URLs that already carry `?v=` are left alone.
|
||||||
|
*/
|
||||||
|
function versionAssets(html) {
|
||||||
|
return html.replace(/(["'(])(\/assets\/[^"')\s?]+)/g, `$1$2?v=${DEPLOY_VERSION}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Express does not parse cookies; this is all we need.
|
// Express does not parse cookies; this is all we need.
|
||||||
@@ -66,15 +83,19 @@ function errorPage(res, status, title, message, actions = {}) {
|
|||||||
res
|
res
|
||||||
.status(status)
|
.status(status)
|
||||||
.type('html')
|
.type('html')
|
||||||
.send(`<!doctype html>
|
.send(versionAssets(`<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${esc(title)} · YOUreka Tools</title>
|
<title>${esc(title)} · YOUreka Tools</title>
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||||
|
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app cds--g100">
|
||||||
${appHeader(null, null)}
|
${appHeader(null, null)}
|
||||||
<main class="container narrow center-v">
|
<main class="container narrow center-v">
|
||||||
<div class="card message-card">
|
<div class="card message-card">
|
||||||
@@ -86,7 +107,7 @@ function errorPage(res, status, title, message, actions = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -115,41 +136,138 @@ function esc(value) {
|
|||||||
const appHeader = (session, active) => {
|
const appHeader = (session, active) => {
|
||||||
const toolLink = (href, label, key) =>
|
const toolLink = (href, label, key) =>
|
||||||
`<cds-header-nav-item href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-header-nav-item>`;
|
`<cds-header-nav-item href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-header-nav-item>`;
|
||||||
const sideLink = (href, label, key) =>
|
const sideLink = (href, label, key, icon) =>
|
||||||
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-side-nav-link>`;
|
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
|
||||||
const name = session ? (session.firstName || session.phone || '') : '';
|
const name = session ? (session.firstName || session.phone || '') : '';
|
||||||
const right = session
|
// Help and the auth action are icon-only global header actions.
|
||||||
? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>
|
const headerAction = (href, label, svg, extra = '') =>
|
||||||
<a class="linklike" href="/auth/logout">Sign out</a>`
|
`<cds-header-global-action href="${href}"${extra}>${svg.replace('slot="title-icon"', 'slot="icon"')}<span class="cds--visually-hidden">${label}</span></cds-header-global-action>`;
|
||||||
: '<a class="linklike" href="/auth/login">Sign in</a>';
|
const headerAuth = session
|
||||||
|
? headerAction('/auth/logout', 'Sign out', SIDE_NAV_ICONS.logout, ' data-signout')
|
||||||
|
: headerAction('/auth/login', 'Sign in', SIDE_NAV_ICONS.login);
|
||||||
|
const sideAuth = session
|
||||||
|
? `<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.logout}Sign out</cds-side-nav-link>`
|
||||||
|
: `<cds-side-nav-link href="/auth/login">${SIDE_NAV_ICONS.login}Sign in</cds-side-nav-link>`;
|
||||||
return `
|
return `
|
||||||
<cds-header aria-label="YOUreka Tools">
|
<cds-header aria-label="YOUreka Tools">
|
||||||
<cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button>
|
<cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button>
|
||||||
<cds-header-name href="/">
|
<cds-header-name href="/">
|
||||||
<span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span>YOUreka<span class="brand-suffix"> Tools</span>
|
<span class="brand-inner"><span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span><span>YOUreka<span class="brand-suffix"> Tools</span></span></span>
|
||||||
</cds-header-name>
|
</cds-header-name>
|
||||||
<cds-header-nav>
|
<cds-header-nav>
|
||||||
${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
${toolLink('/', 'Home', 'home')}
|
||||||
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
|
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
||||||
|
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}` : ''}
|
||||||
</cds-header-nav>
|
</cds-header-nav>
|
||||||
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
|
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
|
||||||
<div class="header-right">${right}</div>
|
<div class="header-right">
|
||||||
|
${headerAction('/help', 'Help', SIDE_NAV_ICONS.help)}
|
||||||
|
${headerAuth}
|
||||||
|
${session ? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</cds-header>
|
</cds-header>
|
||||||
<cds-side-nav aria-label="Tools" id="side-nav">
|
<cds-side-nav aria-label="Tools" id="side-nav">
|
||||||
<div class="side-nav-brand">
|
<div class="side-nav-brand">
|
||||||
<img src="/assets/youreka-logo.png" alt="YOUreka" width="40" height="40">
|
<img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
|
||||||
<span>YOUreka Tools</span>
|
|
||||||
</div>
|
</div>
|
||||||
<cds-side-nav-items>
|
<cds-side-nav-items>
|
||||||
${sideLink('/cropper', 'Image Cropper', 'cropper')}
|
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
|
||||||
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
|
|
||||||
<cds-side-nav-divider></cds-side-nav-divider>
|
<cds-side-nav-divider></cds-side-nav-divider>
|
||||||
<cds-side-nav-link href="/auth/logout">Sign out</cds-side-nav-link>
|
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
|
||||||
|
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
|
||||||
|
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
|
||||||
|
${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)}
|
||||||
|
${sideAuth}
|
||||||
</cds-side-nav-items>
|
</cds-side-nav-items>
|
||||||
</cds-side-nav>
|
</cds-side-nav>
|
||||||
|
${session ? `<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools">
|
||||||
|
<cds-modal-header>
|
||||||
|
<cds-modal-close-button></cds-modal-close-button>
|
||||||
|
<cds-modal-label>Account</cds-modal-label>
|
||||||
|
<cds-modal-heading>Sign out of YOUreka Tools?</cds-modal-heading>
|
||||||
|
</cds-modal-header>
|
||||||
|
<cds-modal-body>You will need to authenticate again to use the tools.</cds-modal-body>
|
||||||
|
<cds-modal-footer>
|
||||||
|
<cds-modal-footer-button kind="secondary" data-modal-close><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M17.4141 16L24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16z"/></svg>Cancel</cds-modal-footer-button>
|
||||||
|
<cds-modal-footer-button kind="danger" id="signout-confirm"><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>Sign out</cds-modal-footer-button>
|
||||||
|
</cds-modal-footer>
|
||||||
|
</cds-modal>
|
||||||
|
<script>
|
||||||
|
// Confirm before signing out (Carbon modal)
|
||||||
|
(function () {
|
||||||
|
function wire() {
|
||||||
|
var modal = document.getElementById('signout-modal');
|
||||||
|
var confirm = document.getElementById('signout-confirm');
|
||||||
|
if (!modal || !confirm) return;
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var trigger = event.target && event.target.closest ? event.target.closest('[data-signout]') : null;
|
||||||
|
if (!trigger) return;
|
||||||
|
event.preventDefault();
|
||||||
|
modal.open = true;
|
||||||
|
});
|
||||||
|
confirm.addEventListener('click', function () {
|
||||||
|
window.location.href = '/auth/logout';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
|
||||||
|
else wire();
|
||||||
|
})();
|
||||||
|
</script>` : ''}
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Carbon icons for the mobile side nav (inlined so they inherit the theme). */
|
||||||
|
const SIDE_NAV_ICONS = {
|
||||||
|
crop: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>',
|
||||||
|
fingerprint: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>',
|
||||||
|
logout: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>',
|
||||||
|
login: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg>',
|
||||||
|
help: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,26A12,12,0,1,1,28,16,12,12,0,0,1,16,28Z"/><path d="M17,8H15.5A4.49,4.49,0,0,0,11,12.5V13h2v-.5A2.5,2.5,0,0,1,15.5,10H17a2.5,2.5,0,0,1,0,5H15v4.5h2V17a4.5,4.5,0,0,0,0-9Z"/></svg>',
|
||||||
|
home: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16.6123,2.2138a1.01,1.01,0,0,0-1.2427,0L1,13.4194l1.2427,1.5717L4,13.6209V26a2.0041,2.0041,0,0,0,2,2H26a2.0037,2.0037,0,0,0,2-2V13.63L29.7573,15,31,13.4282ZM18,26H14V18h4Zm2,0V18a2.0023,2.0023,0,0,0-2-2H14a2.002,2.002,0,0,0-2,2v8H6V12.0615l10-7.79,10,7.8005V26Z"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing-page body: the tool tiles when signed in, a sign-in call to action
|
||||||
|
* when not. Both are injected server-side into index.html's `<!--landing-view-->`
|
||||||
|
* marker so the two states never share markup or flash the wrong one.
|
||||||
|
*/
|
||||||
|
const LANDING_TOOLS = `
|
||||||
|
<div class="tool-grid">
|
||||||
|
<cds-clickable-tile href="/cropper">
|
||||||
|
<div class="tool-card">
|
||||||
|
<span class="tool-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
|
||||||
|
</span>
|
||||||
|
<h3>Image Cropper</h3>
|
||||||
|
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
|
||||||
|
<span class="go">Open Image Cropper
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</cds-clickable-tile>
|
||||||
|
|
||||||
|
<cds-clickable-tile href="/fingerprint">
|
||||||
|
<div class="tool-card">
|
||||||
|
<span class="tool-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a8 8 0 0 0-8 8c0 3.5 1 5.5 2 7"/><path d="M16 21a10 10 0 0 0 4-13 8 8 0 0 0-15.7-1"/><path d="M8 21a8 8 0 0 1-1-11 5 5 0 0 1 9.7 1.4A6 6 0 0 1 12 17"/><path d="M12 21a4 4 0 0 0 4-4 4 4 0 0 0-1.2-2.9"/></svg>
|
||||||
|
</span>
|
||||||
|
<h3>Fingerprint Agent</h3>
|
||||||
|
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
||||||
|
<span class="go">Open Fingerprint Agent
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</cds-clickable-tile>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const LANDING_SIGNIN = `
|
||||||
|
<div class="card message-card narrow">
|
||||||
|
<div class="message-icon info">${ICONS.lock}</div>
|
||||||
|
<h1>Sign in required</h1>
|
||||||
|
<p class="muted">Sign in with your YOUreka account to open the Image Cropper and Fingerprint Agent.</p>
|
||||||
|
<a class="btn btn-primary" href="/auth/login">Sign in with YOUreka<svg width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg></a>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
// ── app factory ──────────────────────────────────────────────────────────────
|
// ── app factory ──────────────────────────────────────────────────────────────
|
||||||
export function createApp(overrides = {}) {
|
export function createApp(overrides = {}) {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -159,6 +277,10 @@ export function createApp(overrides = {}) {
|
|||||||
agentUrl: AGENT_URL,
|
agentUrl: AGENT_URL,
|
||||||
domains: DOMAINS,
|
domains: DOMAINS,
|
||||||
ttlMs: DOMAIN_TTL_MS,
|
ttlMs: DOMAIN_TTL_MS,
|
||||||
|
giteaUrl: GITEA_URL,
|
||||||
|
fpagentRepo: FPAGENT_REPO,
|
||||||
|
giteaToken: GITEA_RELEASE_KEY,
|
||||||
|
giteaFetch: fetch,
|
||||||
fetch: fetch,
|
fetch: fetch,
|
||||||
now: Date.now,
|
now: Date.now,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -193,7 +315,102 @@ export function createApp(overrides = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── static assets (no session work per asset) ─────────────────────────────
|
// ── static assets (no session work per asset) ─────────────────────────────
|
||||||
app.use(express.static(path.join(__dirname, 'public'), { index: false })); // assets only; '/' is session-aware
|
// Versioned asset URLs are safe to cache hard; the .html templates that also
|
||||||
|
// live under /assets must never be cached (they are read server-side anyway).
|
||||||
|
app.use(
|
||||||
|
express.static(path.join(__dirname, 'public'), {
|
||||||
|
index: false,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
res.setHeader(
|
||||||
|
'Cache-Control',
|
||||||
|
filePath.endsWith('.html') || filePath.endsWith('.sh')
|
||||||
|
? 'no-store'
|
||||||
|
: 'public, max-age=31536000, immutable'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
); // assets only; '/' is session-aware
|
||||||
|
|
||||||
|
// ── fpagent installer: public proxy for the private release bundles ───────
|
||||||
|
// The install script (public/fpagent/install.sh) runs on a field laptop with
|
||||||
|
// no Gitea credentials, so this server reads the private release with the
|
||||||
|
// token and streams the bundle back. The token and forge host never leave
|
||||||
|
// here. Registered before the session middleware: the installer is public.
|
||||||
|
const giteaBase = () => cfg.giteaUrl.replace(/\/+$/, '');
|
||||||
|
const giteaHeaders = () => (cfg.giteaToken ? { authorization: `token ${cfg.giteaToken}` } : {});
|
||||||
|
const SAFE_ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
const installerNotConfigured = (res) =>
|
||||||
|
res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
message: 'The Fingerprint Agent installer is not configured on this deployment.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const releasePayload = async (res, giteaPath) => {
|
||||||
|
try {
|
||||||
|
const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() });
|
||||||
|
if (!r.ok) {
|
||||||
|
return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
const release = await r.json();
|
||||||
|
const tag = String(release?.tag_name || '');
|
||||||
|
const assets = (Array.isArray(release?.assets) ? release.assets : [])
|
||||||
|
.map((a) => String(a?.name || ''))
|
||||||
|
.filter((name) => SAFE_ASSET.test(name))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
url: `/fpagent/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
|
||||||
|
}));
|
||||||
|
if (!tag || assets.length === 0) {
|
||||||
|
return res
|
||||||
|
.status(502)
|
||||||
|
.json({ ok: false, message: 'The Fingerprint Agent release has no downloadable bundles.' });
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store').json({ ok: true, tag, assets });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/latest', (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/tags/:tag', (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
if (!SAFE_ASSET.test(req.params.tag)) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'Unknown release tag.' });
|
||||||
|
}
|
||||||
|
return releasePayload(
|
||||||
|
res,
|
||||||
|
`/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/download/:tag/:name', async (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
const { tag, name } = req.params;
|
||||||
|
if (!SAFE_ASSET.test(tag) || !SAFE_ASSET.test(name)) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'Unknown release asset.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await cfg.giteaFetch(
|
||||||
|
`${giteaBase()}/${cfg.fpagentRepo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
|
||||||
|
{ headers: giteaHeaders() }
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
return res
|
||||||
|
.status(r.status === 404 ? 404 : 502)
|
||||||
|
.json({ ok: false, message: 'Release asset not available.' });
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.set('Content-Type', r.headers.get('content-type') || 'application/octet-stream')
|
||||||
|
.set('Cache-Control', 'no-store')
|
||||||
|
.send(Buffer.from(await r.arrayBuffer()));
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
@@ -280,7 +497,7 @@ export function createApp(overrides = {}) {
|
|||||||
lastName: data.lastName ?? '',
|
lastName: data.lastName ?? '',
|
||||||
phone: data.phone ?? '',
|
phone: data.phone ?? '',
|
||||||
});
|
});
|
||||||
res.redirect('/fingerprint');
|
res.redirect('/');
|
||||||
} catch {
|
} catch {
|
||||||
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
|
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
|
||||||
}
|
}
|
||||||
@@ -343,9 +560,11 @@ export function createApp(overrides = {}) {
|
|||||||
.set('Vary', 'Cookie')
|
.set('Vary', 'Cookie')
|
||||||
.type('html')
|
.type('html')
|
||||||
.send(
|
.send(
|
||||||
html
|
versionAssets(
|
||||||
.replace('<!--app-header-->', appHeader(session, active))
|
html
|
||||||
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
|
.replace('<!--app-header-->', appHeader(session, active))
|
||||||
|
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -359,10 +578,20 @@ export function createApp(overrides = {}) {
|
|||||||
.set('Cache-Control', 'no-store')
|
.set('Cache-Control', 'no-store')
|
||||||
.set('Vary', 'Cookie')
|
.set('Vary', 'Cookie')
|
||||||
.type('html')
|
.type('html')
|
||||||
.send(html.replace('<!--app-header-->', appHeader(req.session, null)));
|
.send(
|
||||||
|
versionAssets(
|
||||||
|
html
|
||||||
|
.replace('<!--app-header-->', appHeader(req.session, 'home'))
|
||||||
|
.replace('<!--landing-view-->', req.session ? LANDING_TOOLS : LANDING_SIGNIN)
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/help', (req, res) => {
|
||||||
|
renderPage(res, 'help.html', req.session, 'help');
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/auth/session', requireApi, async (req, res, next) => {
|
app.get('/auth/session', requireApi, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { codes, checkedAt } = await domainContext(req);
|
const { codes, checkedAt } = await domainContext(req);
|
||||||
@@ -434,6 +663,14 @@ export function createApp(overrides = {}) {
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
|
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
|
||||||
|
if (!cropperClients) {
|
||||||
|
return errorPage(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
'Cropper unavailable',
|
||||||
|
'The image cropper is not configured on this deployment. Contact your administrator.'
|
||||||
|
);
|
||||||
|
}
|
||||||
renderPage(
|
renderPage(
|
||||||
res,
|
res,
|
||||||
'cropper/index.html',
|
'cropper/index.html',
|
||||||
@@ -442,6 +679,12 @@ export function createApp(overrides = {}) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
|
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
|
||||||
|
if (!cropperClients) {
|
||||||
|
const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]);
|
||||||
|
console.warn(
|
||||||
|
`[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503`
|
||||||
|
);
|
||||||
|
}
|
||||||
if (cropperClients) {
|
if (cropperClients) {
|
||||||
app.use(
|
app.use(
|
||||||
'/cropper',
|
'/cropper',
|
||||||
@@ -461,19 +704,11 @@ export function createApp(overrides = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── a2loft proxies: same-origin, phone injected from the session ──────────
|
// ── a2loft proxies: same-origin, bearer token from the session ────────────
|
||||||
const a2loftUrl = (domain, reference, otp, phone) => {
|
const a2loftUrl = (domain, reference) =>
|
||||||
const q = new URLSearchParams({ phone, otp });
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
|
||||||
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
|
const a2loftSaveUrl = (domain, reference) =>
|
||||||
reference
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${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 validateProxy = (req, res) => {
|
||||||
const { domain, reference } = req.params;
|
const { domain, reference } = req.params;
|
||||||
@@ -485,21 +720,15 @@ export function createApp(overrides = {}) {
|
|||||||
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
||||||
return false;
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
||||||
if (!validateProxy(req, res)) return;
|
if (!validateProxy(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
|
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference), {
|
||||||
|
headers: { authorization: `Bearer ${req.session.token}` },
|
||||||
|
});
|
||||||
res.status(r.status).type('json').send(await r.text());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
||||||
@@ -512,8 +741,13 @@ export function createApp(overrides = {}) {
|
|||||||
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
|
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
|
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${req.session.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
});
|
});
|
||||||
res.status(r.status).type('json').send(await r.text());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+63
-19
@@ -56,19 +56,27 @@ async function startFakeA2loft() {
|
|||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
|
calls.push({
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
authorization: req.headers.authorization,
|
||||||
|
body: raw ? JSON.parse(raw) : null,
|
||||||
|
});
|
||||||
const json = (body) => {
|
const json = (body) => {
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
};
|
};
|
||||||
if (req.url.startsWith('/identity/dims/picture/')) {
|
if (req.url.startsWith('/identity/dims/picture/')) {
|
||||||
|
// 'no-record' lets a test exercise the fallback to the queue's own label
|
||||||
|
if (req.url.includes('no-record')) return json({ ok: false, message: 'No upload.' });
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
upload_id: 'up-1',
|
upload_id: 'up-1',
|
||||||
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
||||||
caption: '08068287222',
|
caption: '08068287222',
|
||||||
domain: 'ucth',
|
domain: 'core',
|
||||||
|
domainCode: 'ucth',
|
||||||
raw_file_id: 'raw-1',
|
raw_file_id: 'raw-1',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -217,6 +225,10 @@ test('unconfigured deployment: cropper answers 503, other tools unaffected', asy
|
|||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
|
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
|
||||||
assert.equal(res.status, 503);
|
assert.equal(res.status, 503);
|
||||||
|
// the page says so too, rather than rendering actions that all fail
|
||||||
|
const page = await fetch(`${base}/cropper`, { headers: { cookie: cookies } });
|
||||||
|
assert.equal(page.status, 503);
|
||||||
|
assert.match(await page.text(), /not configured/i);
|
||||||
const landing = await fetch(`${base}/`);
|
const landing = await fetch(`${base}/`);
|
||||||
assert.equal(landing.status, 200);
|
assert.equal(landing.status, 200);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -233,6 +245,12 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
|
|||||||
const html = await page.text();
|
const html = await page.text();
|
||||||
assert.match(html, /Smart Photo Editor|Image Cropper/);
|
assert.match(html, /Smart Photo Editor|Image Cropper/);
|
||||||
assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side');
|
assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side');
|
||||||
|
assert.match(html, /cds-header-nav-item href="\/cropper" aria-current="page"/);
|
||||||
|
// notifications must use a component the bundle actually defines
|
||||||
|
assert.match(html, /cds-inline-notification/);
|
||||||
|
assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element');
|
||||||
|
assert.ok(!/otpSave|otpLookup/.test(html), 'the cropper has no OTP fields');
|
||||||
|
assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/);
|
||||||
} finally {
|
} finally {
|
||||||
await ok.stop();
|
await ok.stop();
|
||||||
}
|
}
|
||||||
@@ -327,7 +345,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -339,9 +357,11 @@ test('save verifies the gate live and proxies to a2loft with session identity',
|
|||||||
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
||||||
assert.ok(save, 'a2loft save contacted');
|
assert.ok(save, 'a2loft save contacted');
|
||||||
const u = new URL(save.url, 'http://x');
|
const u = new URL(save.url, 'http://x');
|
||||||
|
// the profile's own domain comes from a2loft's record ('ucth'), not the
|
||||||
|
// queue label the browser echoed ('ucth' here) — and no OTP is involved
|
||||||
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
|
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
|
||||||
assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
||||||
assert.equal(u.searchParams.get('otp'), '778899');
|
assert.match(save.authorization, /^Bearer \S+/, 'the session token proves the operator');
|
||||||
assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
|
assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
|
||||||
assert.equal(save.body.uploadId, 'up-1');
|
assert.equal(save.body.uploadId, 'up-1');
|
||||||
assert.ok(portal.state.verifyCalls > 0);
|
assert.ok(portal.state.verifyCalls > 0);
|
||||||
@@ -350,7 +370,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save refuses without an OTP and without the crop permission', async () => {
|
test('save requires the live crop permission (no OTP involved)', async () => {
|
||||||
const { base, a2loft, stop } = await boot({ cropAllowed: false });
|
const { base, a2loft, stop } = await boot({ cropAllowed: false });
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
@@ -363,14 +383,7 @@ test('save refuses without an OTP and without the crop permission', async () =>
|
|||||||
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
|
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const noOtp = await fetch(`${base}/cropper/profile/picture`, {
|
const denied = await fetch(`${base}/cropper/profile/picture`, {
|
||||||
method: 'POST',
|
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
// permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec
|
|
||||||
assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`);
|
|
||||||
const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -428,7 +441,7 @@ test('malformed request bodies are rejected, never hang the request', async () =
|
|||||||
const { base, a2loft, stop } = await boot();
|
const { base, a2loft, stop } = await boot();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture?otp=1']) {
|
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture']) {
|
||||||
const res = await fetch(`${base}${path}`, {
|
const res = await fetch(`${base}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
@@ -458,11 +471,11 @@ test('a malformed queue item does not hang the request', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save rejects an unknown domain without contacting a2loft', async () => {
|
test('save rejects an unsafe domain and contacts no one', async () => {
|
||||||
const { base, a2loft, stop } = await boot();
|
const { base, a2loft, stop } = await boot();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -471,12 +484,14 @@ test('save rejects an unknown domain without contacting a2loft', async () => {
|
|||||||
message: {
|
message: {
|
||||||
pop_receipt: 'custom',
|
pop_receipt: 'custom',
|
||||||
message_id: 'm-1',
|
message_id: 'm-1',
|
||||||
message: { upload_id: 'up-1', domain: '../../admin', agentDeviceHash: 'a' },
|
// a2loft has no record for this upload, so the queue's label is used
|
||||||
|
// and the path-safety check rejects it
|
||||||
|
message: { upload_id: 'no-record', domain: '../../admin', agentDeviceHash: 'a' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
assert.equal(a2loft.calls.length, 0, 'a2loft never contacted');
|
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'no save attempted');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
@@ -502,3 +517,32 @@ test('picture fetches reject object keys outside the id-domains prefix', async (
|
|||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the picture's own target is forwarded when a2loft has no record", async () => {
|
||||||
|
const { base, a2loft, stop } = await boot();
|
||||||
|
try {
|
||||||
|
const cookies = await login(base);
|
||||||
|
const res = await fetch(`${base}/cropper/profile/picture`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
||||||
|
phone: '08068287222',
|
||||||
|
requiresCleaning: false,
|
||||||
|
message: {
|
||||||
|
pop_receipt: 'custom',
|
||||||
|
message_id: 'm-1',
|
||||||
|
message: { upload_id: 'no-record', domain: 'core', agentDeviceHash: 'a' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
|
||||||
|
// 'core' is a2loft's own pseudo-domain for a profile picture — no rewriting
|
||||||
|
assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/core/profile/picture');
|
||||||
|
} finally {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// The /fingerprint wizard's scanner-availability logic. app.js is a browser
|
||||||
|
// IIFE, so it is loaded in a vm sandbox with stubbed browser globals and its
|
||||||
|
// Vue options are exercised directly (no Vue runtime needed).
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(new URL('../public/assets/fp/app.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function loadApp() {
|
||||||
|
let options = null;
|
||||||
|
let socket = null;
|
||||||
|
const els = {};
|
||||||
|
const document = {
|
||||||
|
getElementById(id) {
|
||||||
|
return (els[id] ||= {
|
||||||
|
textContent: '',
|
||||||
|
attrs: {},
|
||||||
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
|
getAttribute(k) { return this.attrs[k]; },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sandbox = {
|
||||||
|
window: {
|
||||||
|
__SESSION__: { agentUrl: 'http://127.0.0.1:9000', domains: [] },
|
||||||
|
location: { href: '' },
|
||||||
|
},
|
||||||
|
document,
|
||||||
|
Vue: class Vue { static config = {}; constructor(o) { options = o; } },
|
||||||
|
io() {
|
||||||
|
socket = { handlers: {}, on(e, cb) { this.handlers[e] = cb; return this; } };
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
setInterval: () => 0,
|
||||||
|
setTimeout: () => 0,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
console,
|
||||||
|
};
|
||||||
|
vm.runInNewContext(SRC, sandbox);
|
||||||
|
return {
|
||||||
|
get options() { return options; },
|
||||||
|
get socket() { return socket; },
|
||||||
|
getElement: (id) => document.getElementById(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVm(app) {
|
||||||
|
const o = app.options;
|
||||||
|
const inst = Object.assign({}, o.data);
|
||||||
|
for (const [k, fn] of Object.entries(o.methods)) inst[k] = fn.bind(inst);
|
||||||
|
for (const [k, fn] of Object.entries(o.computed)) {
|
||||||
|
Object.defineProperty(inst, k, { get: fn.bind(inst), configurable: true });
|
||||||
|
}
|
||||||
|
inst.$forceUpdate = () => {};
|
||||||
|
inst.loadSession();
|
||||||
|
inst.connectAgent();
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disconnected agent blocks capture and warns', () => {
|
||||||
|
const vmInst = makeVm(loadApp());
|
||||||
|
assert.equal(vmInst.captureReady, false);
|
||||||
|
assert.equal(vmInst.scannerNotice.title, 'Capture agent offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('available:false blocks capture and warns; true re-enables', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const vmInst = makeVm(app);
|
||||||
|
const h = app.socket.handlers;
|
||||||
|
h.connect();
|
||||||
|
h['fprint-device-status']({ available: false, device: '' });
|
||||||
|
assert.equal(vmInst.captureReady, false);
|
||||||
|
assert.equal(vmInst.scannerNotice.title, 'Scanner not connected');
|
||||||
|
|
||||||
|
h['fprint-device-status']({ available: true, device: 'Digital Persona U.are.U' });
|
||||||
|
assert.equal(vmInst.captureReady, true);
|
||||||
|
assert.equal(vmInst.scannerNotice, null);
|
||||||
|
assert.equal(vmInst.agent.device, 'Digital Persona U.are.U');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown availability (old agent) does not block capture', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const vmInst = makeVm(app);
|
||||||
|
app.socket.handlers.connect();
|
||||||
|
assert.equal(vmInst.agent.available, null);
|
||||||
|
assert.equal(vmInst.captureReady, true);
|
||||||
|
assert.equal(vmInst.scannerNotice, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status tag reflects offline, connected, no-scanner and ready', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const vmInst = makeVm(app);
|
||||||
|
const h = app.socket.handlers;
|
||||||
|
const el = app.getElement('agent-status');
|
||||||
|
|
||||||
|
vmInst.renderAgentTag();
|
||||||
|
assert.equal(el.textContent, 'Agent offline');
|
||||||
|
assert.equal(el.getAttribute('type'), 'red');
|
||||||
|
|
||||||
|
h.connect();
|
||||||
|
assert.equal(el.textContent, 'Agent connected');
|
||||||
|
|
||||||
|
h['agent-hello']({ version: 'abc123', captureAgentId: 'aid' });
|
||||||
|
assert.equal(el.getAttribute('title'), 'fpagent abc123');
|
||||||
|
|
||||||
|
h['fprint-device-status']({ available: false });
|
||||||
|
assert.equal(el.textContent, 'Scanner not connected');
|
||||||
|
assert.equal(el.getAttribute('type'), 'red');
|
||||||
|
|
||||||
|
h['fprint-device-status']({ available: true, device: 'U.are.U 4500' });
|
||||||
|
assert.equal(el.textContent, 'Agent ready');
|
||||||
|
assert.equal(el.getAttribute('type'), 'green');
|
||||||
|
assert.match(el.getAttribute('title'), /abc123/);
|
||||||
|
assert.match(el.getAttribute('title'), /U\.are\.U 4500/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture is refused when no reader is attached', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const vmInst = makeVm(app);
|
||||||
|
const h = app.socket.handlers;
|
||||||
|
h.connect();
|
||||||
|
h['fprint-device-status']({ available: false });
|
||||||
|
vmInst.domain = 'uc';
|
||||||
|
vmInst.reference = 'UC/1';
|
||||||
|
vmInst.capture('right-thumb');
|
||||||
|
assert.equal(vmInst.capturing, null);
|
||||||
|
assert.equal(vmInst.notif.show, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookup and save need no OTP credential', () => {
|
||||||
|
const vmInst = makeVm(loadApp());
|
||||||
|
vmInst.domain = 'uc';
|
||||||
|
vmInst.reference = 'UC/1';
|
||||||
|
assert.equal(vmInst.canLookup, true);
|
||||||
|
vmInst.fingers['right-thumb'].status = 'done';
|
||||||
|
vmInst.fingers['right-index-finger'].status = 'done';
|
||||||
|
assert.equal(vmInst.canSave, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect clears availability', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const vmInst = makeVm(app);
|
||||||
|
const h = app.socket.handlers;
|
||||||
|
h.connect();
|
||||||
|
h['fprint-device-status']({ available: true, device: 'U.are.U' });
|
||||||
|
h.disconnect();
|
||||||
|
assert.equal(vmInst.agent.connected, false);
|
||||||
|
assert.equal(vmInst.agent.available, null);
|
||||||
|
assert.equal(vmInst.agent.device, '');
|
||||||
|
assert.equal(vmInst.captureReady, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
// The /fpagent installer surface: a public script, and a proxy that reads the
|
||||||
|
// private Gitea release with GITEA_RELEASE_INSTALL_KEY so the operator's
|
||||||
|
// laptop never needs credentials.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFile, execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { createApp, FPAGENT_REPO } from '../server.js';
|
||||||
|
import { startServer, stopServer, urlOf } from './helpers.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const SCRIPT = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'..',
|
||||||
|
'public',
|
||||||
|
'fpagent',
|
||||||
|
'install.sh'
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOKEN = 'release-install-secret';
|
||||||
|
const TAG = 'v9.9.9';
|
||||||
|
const ARCHIVE = 'fpagent-deadbee-linux-x86_64.tar.gz';
|
||||||
|
const UBUNTU_ARCHIVE = 'fpagent-deadbee-ubuntu-jammy-x86_64.tar.gz';
|
||||||
|
const RELEASE = {
|
||||||
|
tag_name: TAG,
|
||||||
|
assets: [
|
||||||
|
{ name: ARCHIVE },
|
||||||
|
{ name: `${ARCHIVE}.sha256` },
|
||||||
|
{ name: UBUNTU_ARCHIVE },
|
||||||
|
{ name: 'not a safe name' },
|
||||||
|
{ name: '../escape' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// A directory holding a fake `uname` that reports the given machine, so the
|
||||||
|
// installer's architecture-sensitive picker runs on any CI host. PATH is
|
||||||
|
// prepended with it; everything else resolves as usual.
|
||||||
|
function fakeUname(machine) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
|
||||||
|
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake Gitea: refuses without the token, serves the release and its assets.
|
||||||
|
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const server = await startServer((req, res) => {
|
||||||
|
calls.push({ url: req.url, auth: req.headers.authorization });
|
||||||
|
if (req.headers.authorization !== `token ${TOKEN}`) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === `/api/v1/repos/${FPAGENT_REPO}/releases/latest`) {
|
||||||
|
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(RELEASE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) {
|
||||||
|
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(RELEASE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) {
|
||||||
|
res.writeHead(downloadStatus, { 'content-type': 'application/gzip' });
|
||||||
|
res.end(Buffer.from('bundle-bytes'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
return { server, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootFpagentApp(giteaUrl, overrides = {}) {
|
||||||
|
const app = createApp({
|
||||||
|
giteaUrl,
|
||||||
|
fpagentRepo: FPAGENT_REPO,
|
||||||
|
giteaToken: TOKEN,
|
||||||
|
cropperClients: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||||
|
});
|
||||||
|
return { server, base: urlOf(server) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('latest release is proxied as tools-relative URLs, without the token', async () => {
|
||||||
|
const gitea = await startFakeGitea();
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/latest`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.equal(body.tag, TAG);
|
||||||
|
// Unsafe asset names are dropped; the rest point back at this app.
|
||||||
|
assert.deepEqual(
|
||||||
|
body.assets.map((a) => a.name),
|
||||||
|
[ARCHIVE, `${ARCHIVE}.sha256`, UBUNTU_ARCHIVE]
|
||||||
|
);
|
||||||
|
for (const a of body.assets) {
|
||||||
|
assert.equal(a.url, `/fpagent/releases/download/${TAG}/${a.name}`);
|
||||||
|
}
|
||||||
|
assert.ok(!JSON.stringify(body).includes(TOKEN));
|
||||||
|
assert.equal(gitea.calls[0].auth, `token ${TOKEN}`); // server authenticated upstream
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pinned tag is proxied from the forge tag endpoint', async () => {
|
||||||
|
const gitea = await startFakeGitea();
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/tags/v1.0.0`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal((await r.json()).tag, TAG);
|
||||||
|
assert.ok(gitea.calls[0].url.endsWith('/releases/tags/v1.0.0'));
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unsafe release tag is rejected before reaching the forge', async () => {
|
||||||
|
const gitea = await startFakeGitea();
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/tags/-bad`);
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(gitea.calls.length, 0);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh --tag asks for that tag, not latest', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gateway = await startServer((req, res) => {
|
||||||
|
calls.push(req.url);
|
||||||
|
if (req.url === '/releases/tags/v7.7.7') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
tag: 'v7.7.7',
|
||||||
|
assets: [{ name: 'fpagent-cafebabe-linux-x86_64.tar.gz' }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-tag-test-'));
|
||||||
|
try {
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
// Async so the in-process gateway can answer while the child runs.
|
||||||
|
await execFileAsync('bash', [SCRIPT, '--tag', 'v7.7.7', '--check'], {
|
||||||
|
env: { ...process.env, TMPDIR: tmp, FPAGENT_BASE_URL: urlOf(gateway) },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
failed = true; // the asset download 404s; the metadata request is what matters
|
||||||
|
}
|
||||||
|
assert.equal(failed, true);
|
||||||
|
assert.ok(calls.includes('/releases/tags/v7.7.7'), 'should request the pinned tag');
|
||||||
|
assert.ok(!calls.includes('/releases/latest'), 'should not fall back to latest');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
await stopServer(gateway);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundles stream through this app; the client sends no credentials', async () => {
|
||||||
|
const gitea = await startFakeGitea();
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(Buffer.from(await r.arrayBuffer()).toString(), 'bundle-bytes');
|
||||||
|
const assetCall = gitea.calls.find((c) => c.url.includes('/releases/download/'));
|
||||||
|
assert.equal(assetCall.auth, `token ${TOKEN}`);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsafe asset names are rejected before reaching the forge', async () => {
|
||||||
|
const gitea = await startFakeGitea();
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/-bad`);
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(gitea.calls.length, 0);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing bundle surfaces as 404', async () => {
|
||||||
|
const gitea = await startFakeGitea({ downloadStatus: 404 });
|
||||||
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
await stopServer(gitea.server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without GITEA_RELEASE_INSTALL_KEY the installer reports 503', async () => {
|
||||||
|
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/releases/latest`);
|
||||||
|
assert.equal(r.status, 503);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh is served publicly at /fpagent/install.sh', async () => {
|
||||||
|
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/fpagent/install.sh`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(await r.text(), /FPAGENT_BASE_URL/);
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh picks the bundle for the host architecture', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gateway = await startServer((req, res) => {
|
||||||
|
calls.push(req.url);
|
||||||
|
if (req.url === '/releases/latest') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
tag: 'v9.9.9',
|
||||||
|
assets: [
|
||||||
|
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz' },
|
||||||
|
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' },
|
||||||
|
{ name: 'fpagent-cafebabe-linux-i386.tar.gz' },
|
||||||
|
{ name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const unameDir = fakeUname('i686');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||||
|
try {
|
||||||
|
let out = '';
|
||||||
|
try {
|
||||||
|
// Async so the in-process gateway can answer while the child runs.
|
||||||
|
const r = await execFileAsync('bash', [SCRIPT], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${unameDir}:${process.env.PATH}`,
|
||||||
|
TMPDIR: tmp,
|
||||||
|
FPAGENT_BASE_URL: urlOf(gateway),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
out = r.stdout + r.stderr;
|
||||||
|
} catch (e) {
|
||||||
|
out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s
|
||||||
|
}
|
||||||
|
assert.match(out, /cafebabe/, 'the i386 version should come from the asset name');
|
||||||
|
assert.ok(
|
||||||
|
calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')),
|
||||||
|
'should request the i386 bundle'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!calls.some((u) => u.includes('x86_64')),
|
||||||
|
'should never request another architecture\u2019s bundle'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||||
|
await stopServer(gateway);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh refuses an unsupported architecture before fetching', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gateway = await startServer((req, res) => {
|
||||||
|
calls.push(req.url);
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
const unameDir = fakeUname('aarch64');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||||
|
try {
|
||||||
|
let out = '';
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const r = await execFileAsync('bash', [SCRIPT], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${unameDir}:${process.env.PATH}`,
|
||||||
|
TMPDIR: tmp,
|
||||||
|
FPAGENT_BASE_URL: urlOf(gateway),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
out = r.stdout + r.stderr;
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
out = `${e.stdout ?? ''}${e.stderr ?? ''}`;
|
||||||
|
}
|
||||||
|
assert.equal(failed, true, 'the installer should reject an unsupported host');
|
||||||
|
assert.match(out, /unsupported architecture: aarch64/);
|
||||||
|
assert.equal(calls.length, 0, 'should fail before touching the network');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||||
|
await stopServer(gateway);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh is valid bash', () => {
|
||||||
|
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('install.sh sweeps only its own stale scratch dirs', () => {
|
||||||
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-sweep-test-'));
|
||||||
|
const stale = path.join(base, 'fpagent-install.oldoldold');
|
||||||
|
const fresh = path.join(base, 'fpagent-install.freshfresh');
|
||||||
|
const unrelated = path.join(base, 'someone-else.oldold');
|
||||||
|
for (const dir of [stale, fresh, unrelated]) fs.mkdirSync(dir);
|
||||||
|
const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(stale, old, old);
|
||||||
|
fs.utimesSync(unrelated, old, old);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The base URL is unreachable, so the run fails after the sweep: enough to
|
||||||
|
// exercise the cleanup without downloading anything.
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
execFileSync('bash', [SCRIPT], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, TMPDIR: base, FPAGENT_BASE_URL: 'http://127.0.0.1:9' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
assert.equal(failed, true, 'the installer should fail on the unreachable base');
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(stale), false, 'stale fpagent scratch should be removed');
|
||||||
|
assert.equal(fs.existsSync(fresh), true, 'fresh (possibly concurrent) scratch should survive');
|
||||||
|
assert.equal(fs.existsSync(unrelated), true, 'another prefix should never be touched');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
+143
-36
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
|
|||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
|
calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null });
|
||||||
const staff = {
|
const staff = {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -112,7 +112,7 @@ async function login(base) {
|
|||||||
assert.equal(loginRes.status, 302);
|
assert.equal(loginRes.status, 302);
|
||||||
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
|
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
|
||||||
assert.equal(callbackRes.status, 302);
|
assert.equal(callbackRes.status, 302);
|
||||||
assert.equal(callbackRes.headers.get('location'), '/fingerprint');
|
assert.equal(callbackRes.headers.get('location'), '/');
|
||||||
const session = cookieValue(callbackRes, 'session');
|
const session = cookieValue(callbackRes, 'session');
|
||||||
const profile = cookieValue(callbackRes, 'user_profile');
|
const profile = cookieValue(callbackRes, 'user_profile');
|
||||||
assert.ok(session, 'session cookie set');
|
assert.ok(session, 'session cookie set');
|
||||||
@@ -121,14 +121,19 @@ async function login(base) {
|
|||||||
return `session=${session}; user_profile=${profile}`;
|
return `session=${session}; user_profile=${profile}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('landing page links both tools', async () => {
|
test('signed-out landing prompts sign-in; signed-in landing links both tools', async () => {
|
||||||
const { base, stop } = await bootApp();
|
const { base, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/`);
|
const anon = await (await fetch(`${base}/`)).text();
|
||||||
const html = await res.text();
|
// no tools until authenticated — a call to action instead
|
||||||
assert.equal(res.status, 200);
|
assert.match(anon, /href="\/auth\/login">Sign in with YOUreka</);
|
||||||
assert.match(html, /href="\/cropper"/);
|
assert.ok(!anon.includes('href="/cropper"'), 'no cropper tile when signed out');
|
||||||
assert.match(html, /href="\/fingerprint"/);
|
assert.ok(!anon.includes('href="/fingerprint"'), 'no fingerprint tile when signed out');
|
||||||
|
|
||||||
|
const cookies = await login(base);
|
||||||
|
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
||||||
|
assert.match(authed, /href="\/cropper"/);
|
||||||
|
assert.match(authed, /href="\/fingerprint"/);
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
@@ -202,11 +207,11 @@ test('domain refresh refuses while the cache is fresh, succeeds after the TTL',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('staff lookup proxy injects the session phone and forwards a2loft responses', async () => {
|
test('staff lookup proxy forwards the session token as a bearer credential', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, {
|
const res = await fetch(`${base}/api/uc/staff/UC%2F0231`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies },
|
headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
@@ -219,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
|
|||||||
assert.equal(call.method, 'GET');
|
assert.equal(call.method, 'GET');
|
||||||
const u = new URL(call.url, 'http://x');
|
const u = new URL(call.url, 'http://x');
|
||||||
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
||||||
assert.equal(u.searchParams.get('phone'), '+2348068287222');
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
||||||
assert.equal(u.searchParams.get('otp'), '4455');
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fingerprint save proxy forwards the body unchanged', async () => {
|
test('fingerprint save proxy forwards the body and the token, not OTP', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
||||||
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
|
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -240,24 +245,21 @@ test('fingerprint save proxy forwards the body unchanged', async () => {
|
|||||||
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
||||||
const call = a2loft.calls[0];
|
const call = a2loft.calls[0];
|
||||||
assert.equal(call.method, 'POST');
|
assert.equal(call.method, 'POST');
|
||||||
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
|
assert.equal(call.url, '/identity/uc/fingerprints/UC0231');
|
||||||
assert.deepEqual(call.body, payload);
|
assert.deepEqual(call.body, payload);
|
||||||
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => {
|
test('proxy validation: auth and domain are enforced before contacting a2loft', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' });
|
const anon = await fetch(`${base}/api/uc/staff/REF`, { method: 'POST' });
|
||||||
assert.equal(anon.status, 401);
|
assert.equal(anon.status, 401);
|
||||||
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
|
const badDomain = await fetch(`${base}/api/nope/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 },
|
method: 'POST', headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
assert.equal(badDomain.status, 400);
|
assert.equal(badDomain.status, 400);
|
||||||
@@ -293,6 +295,7 @@ test('pages receive the session injected server-side (no client fetch, no-store)
|
|||||||
assert.match(html, /window\.__SESSION__ = \{/);
|
assert.match(html, /window\.__SESSION__ = \{/);
|
||||||
assert.match(html, /2348068287222/);
|
assert.match(html, /2348068287222/);
|
||||||
assert.match(html, /"domains"/, 'permitted domains injected too');
|
assert.match(html, /"domains"/, 'permitted domains injected too');
|
||||||
|
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
@@ -312,25 +315,129 @@ test('injected session values cannot break out of the script tag', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('landing header is server-rendered for the session', async () => {
|
test('the active tool is marked on both navigation surfaces', async () => {
|
||||||
const { base, stop } = await bootApp();
|
const { base, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const anon = await (await fetch(`${base}/`)).text();
|
|
||||||
assert.match(anon, /href="\/auth\/login">Sign in</);
|
|
||||||
assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced');
|
|
||||||
// tools live in the header nav and in the mobile side nav
|
|
||||||
assert.match(anon, /cds-header-nav-item href="\/cropper"/);
|
|
||||||
assert.match(anon, /cds-header-nav-item href="\/fingerprint"/);
|
|
||||||
assert.match(anon, /cds-side-nav-link href="\/cropper"/);
|
|
||||||
assert.match(anon, /cds-side-nav-divider/);
|
|
||||||
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/);
|
|
||||||
assert.match(anon, /youreka-logo\.png/);
|
|
||||||
assert.match(anon, /header-menu-button/);
|
|
||||||
|
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
|
const wizard = await (await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } })).text();
|
||||||
|
assert.match(wizard, /cds-header-nav-item href="\/fingerprint" aria-current="page"/);
|
||||||
|
assert.match(wizard, /cds-side-nav-link href="\/fingerprint" aria-current="page"/);
|
||||||
|
assert.ok(
|
||||||
|
!/cds-header-nav-item href="\/cropper" aria-current/.test(wizard),
|
||||||
|
'the other tool is not marked current'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all pages render on the g100 dark theme', async () => {
|
||||||
|
const { base, stop } = await bootApp();
|
||||||
|
try {
|
||||||
|
const cookies = await login(base);
|
||||||
|
for (const path of ['/', '/fingerprint']) {
|
||||||
|
const html = await (await fetch(`${base}${path}`, { headers: { cookie: cookies } })).text();
|
||||||
|
assert.match(html, /<body class="app cds--g100">/, `${path} uses g100`);
|
||||||
|
}
|
||||||
|
// error pages too
|
||||||
|
const errorHtml = await (await fetch(`${base}/auth/callback?sessionId=nope`, manual)).text();
|
||||||
|
assert.match(errorHtml, /<body class="app cds--g100">/);
|
||||||
|
} finally {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('landing header is server-rendered for the session', async () => {
|
||||||
|
const { base, stop } = await bootApp();
|
||||||
|
try {
|
||||||
|
const anon = await (await fetch(`${base}/`)).text();
|
||||||
|
assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced');
|
||||||
|
// signed out: Home only in the header nav; Help and Sign in are icon-only
|
||||||
|
// global header actions
|
||||||
|
assert.match(anon, /cds-header-nav-item href="\/" aria-current="page"/);
|
||||||
|
assert.ok(!anon.includes('cds-header-nav-item href="/cropper"'), 'no cropper in header nav when signed out');
|
||||||
|
assert.ok(!anon.includes('cds-header-nav-item href="/fingerprint"'), 'no fingerprint in header nav when signed out');
|
||||||
|
assert.match(anon, /cds-header-global-action href="\/help"><svg slot="icon"/);
|
||||||
|
assert.match(anon, /cds-header-global-action href="\/auth\/login"><svg slot="icon"/);
|
||||||
|
// icon-only: the accessible name is a visually-hidden span
|
||||||
|
assert.match(anon, /<span class="cds--visually-hidden">Help<\/span>/);
|
||||||
|
assert.match(anon, /<span class="cds--visually-hidden">Sign in<\/span>/);
|
||||||
|
// side nav groups Home | Help, Sign in with a single divider
|
||||||
|
assert.match(anon, /cds-side-nav-link href="\/help"/);
|
||||||
|
assert.equal((anon.match(/<cds-side-nav-divider>/g) || []).length, 1, 'one side nav divider when signed out');
|
||||||
|
// no identity or sign-out when signed out
|
||||||
|
assert.ok(!anon.includes('user-chip'), 'no user chip when signed out');
|
||||||
|
assert.ok(!anon.includes('data-signout'), 'no sign-out when signed out');
|
||||||
|
assert.ok(!anon.includes('signout-modal'), 'no sign-out modal when signed out');
|
||||||
|
assert.match(anon, /youreka-logo\.png/);
|
||||||
|
assert.match(anon, /header-menu-button/);
|
||||||
|
// the side nav brand is just the logo — no repeated "YOUreka Tools" text
|
||||||
|
assert.match(anon, /<div class="side-nav-brand">\s*<img[^>]*>\s*<\/div>/);
|
||||||
|
// favicon is the Youreka logo
|
||||||
|
assert.match(anon, /rel="icon"[^>]*youreka-favicon\.png/);
|
||||||
|
|
||||||
|
const cookies = await login(base);
|
||||||
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
||||||
assert.match(authed, /Ada/);
|
assert.match(authed, /Ada/);
|
||||||
assert.match(authed, /href="\/auth\/logout">Sign out</);
|
assert.match(authed, /user-chip/);
|
||||||
|
// signed in: services live in the header nav; Help and Sign out are icon-only
|
||||||
|
assert.match(authed, /cds-header-nav-item href="\/cropper"/);
|
||||||
|
assert.match(authed, /cds-header-nav-item href="\/fingerprint"/);
|
||||||
|
assert.ok(!authed.includes('cds-header-nav-item href="/help"'), 'Help is not a text header nav item');
|
||||||
|
assert.match(authed, /cds-header-global-action href="\/help"><svg slot="icon"/);
|
||||||
|
assert.match(authed, /cds-header-global-action href="\/auth\/logout" data-signout><svg slot="icon"/);
|
||||||
|
assert.match(authed, /<span class="cds--visually-hidden">Sign out<\/span>/);
|
||||||
|
// side nav groups Home | services | Help, Sign out with two dividers
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/cropper"/);
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/help"/);
|
||||||
|
assert.equal((authed.match(/<cds-side-nav-divider>/g) || []).length, 2, 'two side nav dividers when signed in');
|
||||||
|
// side nav items carry Carbon icons in the title-icon slot
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/cropper"[^>]*><svg slot="title-icon"/);
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/fingerprint"[^>]*><svg slot="title-icon"/);
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/help"[^>]*><svg slot="title-icon"/);
|
||||||
|
assert.match(authed, /cds-side-nav-link href="\/auth\/logout"[^>]*><svg slot="title-icon"/);
|
||||||
|
// sign-out is confirmed via a Carbon modal
|
||||||
|
assert.match(authed, /data-signout/);
|
||||||
|
assert.match(authed, /cds-modal id="signout-modal"/);
|
||||||
|
assert.match(authed, /id="signout-confirm"/);
|
||||||
|
} finally {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('help page is public and documents each service', async () => {
|
||||||
|
const { base, stop } = await bootApp();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/help`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.ok(!html.includes('<!--app-header-->'), 'header marker is replaced');
|
||||||
|
assert.match(html, /cds-accordion-item title="Image Cropper"/);
|
||||||
|
assert.match(html, /cds-accordion-item title="Fingerprint Agent"/);
|
||||||
|
assert.match(html, /accordion\.min\.js/);
|
||||||
|
assert.match(html, /cds-header-global-action href="\/help"/);
|
||||||
|
assert.match(html, /cds-side-nav-link href="\/help" aria-current="page"/);
|
||||||
|
} finally {
|
||||||
|
await stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset URLs are versioned per deployment; HTML is never cached', async () => {
|
||||||
|
const { base, stop } = await bootApp();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/`);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.match(html, /\/assets\/style\.css\?v=[a-z0-9]+/);
|
||||||
|
assert.match(html, /\/assets\/youreka-favicon\.png\?v=[a-z0-9]+/);
|
||||||
|
|
||||||
|
// versioned assets may be cached hard...
|
||||||
|
const css = await fetch(`${base}/assets/style.css?v=x`);
|
||||||
|
assert.equal(css.status, 200);
|
||||||
|
assert.match(css.headers.get('cache-control'), /immutable/);
|
||||||
|
// ...but the raw .html templates must not be
|
||||||
|
const tpl = await fetch(`${base}/assets/help.html`);
|
||||||
|
assert.equal(tpl.headers.get('cache-control'), 'no-store');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user