Compare commits

..
19 Commits
Author SHA1 Message Date
adebisi-fa 528eaafb18 docs(openspec): archive cropper-token-auth and sync specs
The cropper upload POSTs with the session token and no OTP query auth, and the
panel shows the profile's domain and picture target. Field pass was verified
earlier (upload 0b90075b, saved with no OTP). Merged the image-cropper delta
into the main spec and archived the change.
2026-10-06 19:29:45 +01:00
adebisi-fa 8a13fb527e docs(openspec): archive fingerprint-token-auth after the production pass
The wizard's a2loft calls now authenticate with the session token and carry no
OTP. Field pass in production: the lookup and both fingerprint saves succeeded
with no OTP, so task 3.2 is done. Merged the fingerprint-wizard and tools-app
deltas into the main specs and archived the change.
2026-10-06 19:19:29 +01:00
adebisi-fa 8d42faf707 docs(openspec): archive fpagent-installer-multiarch and sync specs
Bundle selection is now architecture-aware, not just codename-aware, and the
no-compatible-bundle failure names the host architecture. Merged into the
fpagent-installer spec and archived the change.
2026-10-06 19:19:23 +01:00
adebisi-fa e432585f04 docs(fpagent): document 32-bit bundles and the default service
The installer section still showed --enable-service as required and named no
architecture. Note that x86_64 and i386 are both published, that the codename
bundle is preferred, and that the service and self-update timer are on by
default unless --no-service or --no-updates is passed.
2026-10-06 19:19:16 +01:00
adebisi-fa fa33c6e179 feat(fpagent): pick the installer bundle for the host architecture
The hosted installer only ever chose the x86_64 bundle, so a 32-bit field
laptop running the public one-liner failed with "no compatible bundle" even
though fingerprint-agent v1.5.0 publishes fpagent-<version>-linux-i386.tar.gz.

Resolve the host architecture from uname -m (x86_64 or i386, else fail before
any download), then prefer -ubuntu-<codename>-<arch>.tar.gz and fall back to
-linux-<arch>.tar.gz. The version regex now derives the git hash for every
supported architecture, so a 32-bit install records and reports it too.

The help text matches the bundle installer: the systemd service and the
self-update timer are on by default, with --no-service, --no-updates and
--update-base documented.
2026-10-06 19:19:07 +01:00
adebisi-fa 14f6fd6a8e feat(fpagent): authenticate the wizard's a2loft calls with the session token
The fingerprint wizard's staff-lookup and fingerprint-save proxies forwarded the page's OTP with the session phone. a2loft resolves that phone against the domain being addressed, so an operator who holds only capture_biometric was rejected unless they were a domain super-user. The cropper already saves with the session token.

- tools/server.js: both a2loft proxies send Authorization: Bearer <session token>, no phone/otp; validateProxy drops the OTP and phone checks.
- tools/public/assets/fp: both OTP fields, otpLookup/otpSave state, request params and resets removed; canLookup/canSave no longer gate on OTP.
- Tests assert the Bearer header, no query auth, no OTP field in the served page, and both gates without a credential (66/66 pass).
- README and Help page updated.

Requires the a2loft fingerprint-token-auth change deployed first. OpenSpec change: fingerprint-token-auth.
2026-10-02 20:41:43 +01:00
adebisi-fa 548e33390c feat(fpagent): show scanner availability and gate capture
Consume fpagent's fprint-device-status {available, device, captureAgentId}
in the /fingerprint wizard. Availability is tracked tri-state so an agent
that does not report it leaves capture working as before; the header tag
shows offline / no-scanner / ready, the capture panel warns, and capture is
disabled until a supported reader is attached.

Adds a vm-sandbox test for the wizard logic and the OpenSpec change
fpagent-device-availability, archived with the fingerprint-wizard spec.
2026-10-02 15:12:16 +01:00
adebisi-fa 35d895574f docs(openspec): archive fpagent-install-upgrade and sync specs
Documents release pinning (--tag), the pinned-tag metadata route,
installed-version tracking with upgrade reporting, and restart of a
managed running service on upgrade; syncs the four requirements into the
fpagent-installer main spec.
2026-10-02 11:41:50 +01:00
adebisi-fa ff62770bcc docs(fpagent): document --tag and the upgrade path 2026-10-02 11:26:35 +01:00
adebisi-fa 2ad4aa63c6 feat(fpagent): pin releases, report upgrades, restart the service
The installer gains --tag to install a specific release (gateway route
/fpagent/releases/tags/:tag), derives the git hash from the asset name,
and hands it to the bundle installer as VERSION so upgrades are reported
and the installed version is recorded. Service restart on upgrade lives
in the bundle installer, which owns the systemd unit.
2026-10-02 11:26:35 +01:00
adebisi-fa 0698a9c9a9 fix(fpagent): sweep scratch left by a SIGKILLed installer run
The EXIT trap cannot run on SIGKILL, so a killed run could leave its
fpagent-install.* temp dir behind. Sweep those before any network work,
scoped to our prefix and this user and only dirs older than a day so a
concurrent install is never touched.
2026-10-02 10:49:07 +01:00
adebisi-fa e3392c7750 docs(openspec): archive fpagent-release-installer and sync specs 2026-10-02 09:53:35 +01:00
adebisi-fa 46027acec1 chore(env): provision GITEA_RELEASE_INSTALL_KEY 2026-10-02 09:53:35 +01:00
adebisi-fa 6d3909c334 docs(fpagent): document the one-line installer and its key 2026-10-02 09:53:35 +01:00
adebisi-fa b3b75ffac0 feat(fpagent): public installer and private-release proxy
Serve /fpagent/install.sh (also curl|bash) and proxy the private
fingerprint-agent releases through /fpagent/releases/* using
GITEA_RELEASE_INSTALL_KEY, so a field laptop needs no forge credentials.
The installer picks the offline bundle for the host codename when present,
verifies the published sha256, then hands off to the bundle's own installer.
2026-10-02 09:53:35 +01:00
adebisi-fa 1ddf61b59f feat(ui): icon-only header actions and side-nav separators
- Help and Sign in/out become icon-only Carbon cds-header-global-action
  items (real links, accessible name in a visually-hidden span); the
  header nav keeps just Home + the tools.
- Side nav gains a separator so Home, the tools, and Help/Sign In/Out
  form three groups.
- Drop the now-unused header-nav-divider rule.
2026-10-01 14:53:10 +01:00
adebisi-fa 4d006e361f docs: rewrite README for the current project status
Restructure around the four pages, the nav, auth/sessions, permissions,
the a2loft proxy, configuration, deployment (incl. asset cache-busting)
and tests; fix the duplicate Docker heading and the stale test count.
2026-10-01 14:39:57 +01:00
adebisi-fa a218835a1c feat(ui): auth-gate the landing, add Help page and Home nav
- Landing shows the tool tiles only when signed in and a sign-in card
  otherwise; the login callback now returns to /.
- New public /help page: intro plus a per-tool Carbon accordion.
- Nav restructured to Home, {services}, divider, Help, Sign In/Out in
  both the header and the side nav; the header right keeps only the
  identity chip. Side-nav logo enlarged, border removed, spacing
  equalised. Sign-out modal no longer flashes before it is defined.
- Per-deploy asset versioning (?v=) with immutable asset caching and
  no-store HTML; help step lists show their numbers again.
- Tests updated and extended (48 total).
2026-10-01 14:39:50 +01:00
adebisi-fa 134edb6ebd feat(ui): add Carbon icons to all tool buttons
Cropper: next, search, reset, trash-can, magic-wand, checkmark, misuse,
crop, undo, send, subtract.
Fingerprint Agent: search, renew, fingerprint-recognition, close, save.
2026-10-01 14:39:42 +01:00
51 changed files with 2981 additions and 276 deletions
+1
View File
@@ -18,3 +18,4 @@ API_BASE="encrypted:BIJm9rXCjB90I6zZFK57kTTjx2KY0+V42NF8zk38YVcSNTu4PB5Dxdz6iOEz
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
GITEA_RELEASE_INSTALL_KEY="encrypted:BNgr8Q5ZURg8EhuMNk7gdZy7DvzjC98tGRciLsQQFpAWnZ5eEGCM/S8DgJTPpQR1OA+L4LUUbW8QhBwsrUwjzqG3/Z8Tnqwma4bIwtXr1s9RERfimZGJ+1XEieY6b7hU3icl2aUBScdOYGFSfx4/Cj94Uz0onzeiMfzS9ZdGy6TUrVzXWCJsS9g="
+9 -3
View File
@@ -1,6 +1,6 @@
# Image Cropper service credentials — the ONLY environment variables this app
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
# Without these the rest of the app works and /cropper answers 503.
# Image Cropper service credentials. Everything else (URLs, port, domain
# catalog) is hardcoded in source. Without these the rest of the app works and
# /cropper answers 503.
#
# 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:
@@ -16,3 +16,9 @@ AZURE_STORAGE_CONNECTION_STRING=
NODERED_HTTP_USERNAME=
NODERED_HTTP_PASSWORD=
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=
+1
View File
@@ -18,3 +18,4 @@ API_BASE="encrypted:BFVQaDK474EqpNvSw6iiP8KYjRqyXOMQLL/m5lbQHI30QCrNoMfsxmC7W2mH
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
GITEA_RELEASE_INSTALL_KEY="encrypted:BOYECnGcqfZlj8pRvTldqB/UF6HnBlMCfOymdMd3eRvGFo+HwPRViboFteB/tdMAF9uxL5q80OrgB9qbC6LaDTa/ociZg2kVR+R00YB/ZEV4epamIZ1ZzXWrg8J9D4zTGkJJ5iRiqyE4qGoPxDhn3OhTrs5RfBl/7pJa6Wi9wTU+1xhT3oBJr7E="
+181 -74
View File
@@ -1,80 +1,129 @@
# YOUreka Tools — Fingerprint Service
# YOUreka Tools
A small Express app that hosts the YOUreka tools landing page and the
**Fingerprint Agent** enrollment wizard. Fingerprint capture is performed by
the local [`fpagent`](../fingerprint-agent) binary (Socket.IO on
`127.0.0.1:9000`); this app provides the login (YOUreka Auth), the permitted
domain selection, and the a2loft integration that stores the enrolled
fingerprints.
The web front-end for YOUreka ID field operations. A small Express app that
hosts an authentication-aware landing page, an **Image Cropper**, a
**Fingerprint Agent** enrollment wizard, and a public **Help** page.
Fingerprint captures run through the local [`fpagent`](../fingerprint-agent)
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
```
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)
├── 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/
│ ├── 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/
│ ├── style.css
│ └── fp/ # /fingerprint enrollment wizard (Vue 2 via CDN)
└── test/ # node --test suite (fake portal / PocketBase / a2loft)
│ ├── style.css # shared Carbon-styled layout + g100 theme
│ ├── help.html # /help docs page
│ ├── 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
Prerequisites: Node.js 22+, `fpagent` installed and running on this laptop
(see the fingerprint-agent repository for installation), the U.are.U 4500
scanner attached.
Prerequisites: Node.js 22+. For the Fingerprint Agent you also need `fpagent`
installed and running on this laptop (see the fingerprint-agent repository) with
the U.are.U 4500 scanner attached.
```sh
cd tools
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 -allowed-origins http://localhost:4173`
## How enrollment works
## How it works
1. **Sign in** — YOUreka Auth (portal web-auth redirect). The PocketBase
session is kept in an httpOnly cookie and refreshed server-side.
2. **Pick a domain** — the hardcoded catalog (UNICAL, Metropolitan Hotel, UCTH,
UNICAL Radio International, YOUreka) is filtered against the same portal
OpenFGA endpoint fpagent uses (`capture_biometric` per domain). Results are
cached for 5 minutes; the **Refresh domains** control unlocks once the
cache expires. fpagent re-authorizes every capture regardless.
3. **Locate the staff profile** — reference + OTP, validated by
`apis.a2loft.net` through a same-origin proxy (the operator's phone comes
from the session).
4. **Capture** — right thumb and right index finger, one capture at a time,
with per-scan progress and cancel; the PocketBase token, domain and
reference travel with every `fprint-enroll` to fpagent.
5. **Save** — both templates are posted to a2loft (with a fresh OTP).
### Authentication and sessions
## 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
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the domain
catalog. Change them there; there are no environment variables or config
files.
### Permissions
- **Fingerprint captures** require `capture_biometric` per domain. The hardcoded
catalog (UNICAL, Metropolitan Hotel, UCTH, UNICAL Radio International,
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 `/cropper` page (Smart Photo Editor) lets operators review, crop and save
identity photos. It reuses the YOUreka session for identity, requires the
`crop_image` permission on `domain:idid` for every proxied action (verified
against the portal's OpenFGA endpoint — live on save, 5-minute cached on
reads), and keeps the a2loft OTP requirement only on the save step.
The `/cropper` page (Smart Photo Editor) lets operators work through the a2loft
`id-images` queue: **Load next queued** or **Load by upload ID**, review the
record, crop the photo, optionally **Clean background**, then accept or reject
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
live and uploads through the queue.
Its service credentials are the sole environment variables this app uses.
They live in dotenvx-encrypted files at the repository root — `.env` for
development, `.env.production` for deployment:
Its service credentials live in dotenvx-encrypted files at the repository root
— `.env` for development, `.env.production` for deployment. The fpagent
installer key is read from the same files (see
[Installing the agent](#installing-the-agent)):
```sh
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
@@ -83,16 +132,81 @@ NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
CARVEKIT_API_KEY=…
```
Without them the rest of the app works and `/cropper` routes answer `503`.
The private decryption keys sit in `.env.keys` (gitignored, never commit it).
Without them the rest of the app works and `/cropper` answers `503`. The private
decryption keys sit in `.env.keys` (gitignored, never commit it).
```sh
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
```
The Docker image contains the encrypted `.env.production` and runs through
`dotenvx`; only the private key is supplied at runtime (never baked in):
## The Fingerprint Agent
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
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
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`
(`trust proxy` is enabled), so session cookies get the `Secure` flag
automatically.
Field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500): login →
permitted-domain filtering → staff lookup → two scanner captures with cancel
path → save → reset, all verified end to end via Playwright.
## Tests
```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.
@@ -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`).
@@ -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.
@@ -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-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.
@@ -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.
@@ -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`.
+62 -19
View File
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
### Requirement: Two-finger enrollment flow
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
saving both fingerprints through the a2loft proxy with an OTP. 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.
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
with an OTP
- **THEN** both fingerprints are saved through the a2loft proxy and the
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
cleared while the permitted domain list persists
### Requirement: OTP validated on every a2loft call
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
Submission SHALL be blocked while the OTP is missing.
### 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: Lookup with OTP
- **WHEN** the operator submits a reference with an OTP
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
displays the returned staff details
#### 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: 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
- **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
- **WHEN** the OTP field is empty for a lookup or save
- **THEN** the corresponding submit control is disabled
#### Scenario: No OTP field
- **WHEN** the wizard page is served
- **THEN** it offers no OTP input for lookup or save
+255
View File
@@ -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
+18 -9
View File
@@ -60,24 +60,33 @@ only for permitted operators.
### 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 require an OTP from the
operator, forwarding the session phone as the validating identity together
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
SHALL not reach a2loft.
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 with an OTP
- **WHEN** a permitted operator saves
- **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
- **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
#### 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 (phone prefilled), the picture's metadata carries the
domain, and the only credential the operator supplies is the save-time OTP.
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
+28 -14
View File
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
- **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at
`/cropper` and a link to `/fingerprint`
### Requirement: YOUreka Auth login establishes a cookie session
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,
@@ -100,17 +101,21 @@ portal.
### 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
attach the operator's phone from the server-side session and forward the
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
the a2loft request and response shapes. The proxies SHALL require a
session; the OTP SHALL NOT be accepted without one.
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 with
an OTP
- **THEN** the app calls a2loft's identity endpoint with the operator's
phone from the session and the supplied OTP, and returns a2loft's
response unchanged
- **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
@@ -119,11 +124,14 @@ session; the OTP SHALL NOT be accepted without one.
### 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`, and agent `http://127.0.0.1:9000`. There SHALL be
no runtime configuration via environment variables or config files, with one
exception: 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.
`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
@@ -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
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
The tools pages SHALL be built on the Carbon Design System: Carbon web
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
+36 -8
View File
@@ -51,11 +51,18 @@
</div>
<div class="row-gap">
<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-else>Loading&hellip;</span>
</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="ghost" v-if="message && message.message" v-on:click="reset">Clear</cds-button>
<cds-button kind="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">
<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>
</section>
@@ -98,7 +105,10 @@
<div class="croppa-pad mt-1">
<img :src="cleanedImage" alt="cleaned preview">
</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>
</div>
@@ -113,6 +123,7 @@
</select>
</div>
<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-else>Cleaning&hellip;</span>
</cds-button>
@@ -121,17 +132,27 @@
<hr class="mt-2">
<div class="row-gap" v-if="!reviewed">
<cds-button v-on:click="reviewed = true; accepted = true">Accept picture</cds-button>
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">Reject picture</cds-button>
<cds-button v-on:click="reviewed = true; accepted = true">
<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 v-if="reviewed && accepted" class="mt-2">
<div class="row-gap" style="align-items:flex-end;">
<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-else>Saving&hellip;</span>
</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>
@@ -154,11 +175,18 @@
</div>
<div class="row-gap mt-2">
<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-else>Sending&hellip;</span>
</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-on:click="reviewed = false">Change review</cds-button>
<cds-button kind="ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">
<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>
+64 -17
View File
@@ -20,11 +20,11 @@ Vue.config.ignoredElements = [/^cds-/];
session: null,
tick: Date.now(),
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: '',
reference: '',
otpLookup: '',
otpSave: '',
staff: null,
busy: { lookup: false, save: false },
refreshing: false,
@@ -47,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
this.fingers['right-index-finger'].status === 'done';
},
canLookup: function () {
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
return !!(this.domain && this.reference) && !this.busy.lookup;
},
canSave: function () {
return this.bothCaptured && !!this.otpSave && !this.busy.save;
return this.bothCaptured && !this.busy.save;
},
refreshUnlocksAt: function () {
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 '';
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 () {
@@ -139,18 +160,38 @@ Vue.config.ignoredElements = [/^cds-/];
renderAgentTag: function () {
var el = document.getElementById('agent-status');
if (!el) return;
el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
el.setAttribute('type', this.agent.connected ? 'green' : 'red');
el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
if (!this.agent.connected) {
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 () {
var self = this;
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('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('disconnect', offline);
this.socket.on('connect_error', offline);
this.socket.on('agent-hello', function (data) {
self.agent.version = (data && data.version) || '';
@@ -158,6 +199,12 @@ Vue.config.ignoredElements = [/^cds-/];
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) {
if (!self.capturing) return;
self.fingers[self.capturing].stage = (data && data.stage) || 0;
@@ -213,6 +260,11 @@ Vue.config.ignoredElements = [/^cds-/];
capture: function (finger) {
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
// can restore it instead of destroying it.
var old = this.fingers[finger];
@@ -242,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.canLookup) return;
this.busy.lookup = true;
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
null,
{ params: { otp: this.otpLookup } }
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
).then(function (r) {
if (!r.data.ok) {
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
@@ -268,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
});
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
{ fingerprints: fingerprints },
{ params: { otp: this.otpSave } }
{ fingerprints: fingerprints }
).then(function (r) {
if (r.data.ok) {
self.showNotif('success', 'Enrollment saved',
@@ -289,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
newEnrollment: function () {
this.staff = null;
this.reference = '';
this.otpLookup = '';
this.otpSave = '';
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
this.capturing = null;
this.cancelling = false;
+15 -11
View File
@@ -54,16 +54,14 @@
<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">
</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">
<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-else>Checking&hellip;</span>
</cds-button>
<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">
<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-else>Refreshing&hellip;</span>
</cds-button>
@@ -82,6 +80,10 @@
<span class="hint">Two fingers are required &middot; place the finger on the scanner each scan</span>
</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">
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
@@ -105,13 +107,18 @@
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
<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' }}
</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
</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>
@@ -125,11 +132,8 @@
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
</div>
<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">
<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-else>Saving&hellip;</span>
</cds-button>
+72
View File
@@ -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 &mdash; 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? &rarr; 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 &ldquo;No access&rdquo;, 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 &middot; Help</span>
<span>Need more detail? Ask your YOUreka ID administrator.</span>
</div>
</footer>
</body>
</html>
+16 -7
View File
@@ -26,11 +26,13 @@ cds-progress-bar:not(:defined) {
/* Carbon styles the shadow anchor inside tiles, not the host — give the host
a real box so grid columns and click targets work correctly. */
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) ────────────────────────────────── */
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
.brand-inner { display: inline-flex; align-items: center; gap: 0.5rem; }
.brand-logo { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 4px; padding: 2px; }
.brand-logo { display: inline-flex; align-items: center; justify-content: center; }
.brand-logo img { display: block; }
.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; }
@@ -61,13 +63,10 @@ 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 .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 */
.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 img { border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 6px; padding: 2px; display: block; }
.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 { display: block; }
/* ── layout ─────────────────────────────────────────────────────────────── */
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
@@ -114,6 +113,14 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
.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; }
/* ── 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) ──────────────────────────────── */
label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; }
.form-control {
@@ -131,9 +138,10 @@ textarea.form-control { height: auto; padding: 0.6rem 0.75rem; }
.btn {
border-radius: 0; font-weight: 400; font-size: 14px;
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;
}
.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: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); }
@@ -196,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-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.info { background: var(--cds-button-primary, #0f62fe); }
.message-card .btn { margin-top: 0.6rem; min-width: 200px; }
+157
View File
@@ -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[@]}"
+2 -28
View File
@@ -22,36 +22,10 @@
<main class="container">
<div class="hero">
<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 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>
<!--landing-view-->
</main>
<footer class="foot">
+224 -48
View File
@@ -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 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 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 = [
{ code: 'uc', name: 'UNICAL' },
{ 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 __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 ──────────────────────────────────────────────────────────────────
// Express does not parse cookies; this is all we need.
@@ -66,7 +83,7 @@ function errorPage(res, status, title, message, actions = {}) {
res
.status(status)
.type('html')
.send(`<!doctype html>
.send(versionAssets(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
@@ -90,7 +107,7 @@ function errorPage(res, status, title, message, actions = {}) {
</div>
</main>
</body>
</html>`);
</html>`));
}
const ICONS = {
@@ -122,10 +139,15 @@ const appHeader = (session, active) => {
const sideLink = (href, label, key, icon) =>
`<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 right = session
? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>
<a class="linklike" href="/auth/logout" data-signout>Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
// Help and the auth action are icon-only global header actions.
const headerAction = (href, label, svg, extra = '') =>
`<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>`;
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 `
<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>
@@ -133,25 +155,32 @@ const appHeader = (session, active) => {
<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-nav>
${toolLink('/cropper', 'Image Cropper', 'cropper')}
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
${toolLink('/', 'Home', 'home')}
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}` : ''}
</cds-header-nav>
${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-side-nav aria-label="Tools" id="side-nav">
<div class="side-nav-brand">
<img src="/assets/youreka-logo.png" alt="YOUreka" width="40" height="40">
<span>YOUreka Tools</span>
<img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
</div>
<cds-side-nav-items>
${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
<cds-side-nav-divider></cds-side-nav-divider>
<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.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>
<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools">
${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>
@@ -159,8 +188,8 @@ const appHeader = (session, active) => {
</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>Cancel</cds-modal-footer-button>
<cds-modal-footer-button kind="danger" id="signout-confirm">Sign out</cds-modal-footer-button>
<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>
@@ -183,7 +212,7 @@ const appHeader = (session, active) => {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
</script>
</script>` : ''}
`;
};
@@ -192,8 +221,53 @@ 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 ──────────────────────────────────────────────────────────────
export function createApp(overrides = {}) {
const cfg = {
@@ -203,6 +277,10 @@ export function createApp(overrides = {}) {
agentUrl: AGENT_URL,
domains: DOMAINS,
ttlMs: DOMAIN_TTL_MS,
giteaUrl: GITEA_URL,
fpagentRepo: FPAGENT_REPO,
giteaToken: GITEA_RELEASE_KEY,
giteaFetch: fetch,
fetch: fetch,
now: Date.now,
...overrides,
@@ -237,7 +315,102 @@ export function createApp(overrides = {}) {
});
// ── 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 ────────
app.use(async (req, res, next) => {
@@ -324,7 +497,7 @@ export function createApp(overrides = {}) {
lastName: data.lastName ?? '',
phone: data.phone ?? '',
});
res.redirect('/fingerprint');
res.redirect('/');
} catch {
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
}
@@ -387,9 +560,11 @@ export function createApp(overrides = {}) {
.set('Vary', 'Cookie')
.type('html')
.send(
html
.replace('<!--app-header-->', appHeader(session, active))
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
versionAssets(
html
.replace('<!--app-header-->', appHeader(session, active))
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
)
);
});
};
@@ -403,10 +578,20 @@ export function createApp(overrides = {}) {
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.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) => {
try {
const { codes, checkedAt } = await domainContext(req);
@@ -519,19 +704,11 @@ export function createApp(overrides = {}) {
);
}
// ── a2loft proxies: same-origin, phone injected from the session ──────────
const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
const a2loftSaveUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
// ── a2loft proxies: same-origin, bearer token from the session ────────────
const a2loftUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
const a2loftSaveUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`;
const validateProxy = (req, res) => {
const { domain, reference } = req.params;
@@ -543,21 +720,15 @@ export function createApp(overrides = {}) {
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
return false;
}
if (!req.query.otp) {
res.status(400).json({ ok: false, message: 'OTP is required.' });
return false;
}
if (!req.session.phone) {
res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' });
return false;
}
return true;
};
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
if (!validateProxy(req, res)) return;
try {
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
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());
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
@@ -570,8 +741,13 @@ export function createApp(overrides = {}) {
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
}
try {
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
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());
} catch {
+153
View File
@@ -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);
});
+371
View File
@@ -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 });
}
});
+104 -38
View File
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null });
const staff = {
ok: true,
data: {
@@ -112,7 +112,7 @@ async function login(base) {
assert.equal(loginRes.status, 302);
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
assert.equal(callbackRes.status, 302);
assert.equal(callbackRes.headers.get('location'), '/fingerprint');
assert.equal(callbackRes.headers.get('location'), '/');
const session = cookieValue(callbackRes, 'session');
const profile = cookieValue(callbackRes, 'user_profile');
assert.ok(session, 'session cookie set');
@@ -121,14 +121,19 @@ async function login(base) {
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();
try {
const res = await fetch(`${base}/`);
const html = await res.text();
assert.equal(res.status, 200);
assert.match(html, /href="\/cropper"/);
assert.match(html, /href="\/fingerprint"/);
const anon = await (await fetch(`${base}/`)).text();
// no tools until authenticated — a call to action instead
assert.match(anon, /href="\/auth\/login">Sign in with YOUreka</);
assert.ok(!anon.includes('href="/cropper"'), 'no cropper tile when signed out');
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 {
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();
try {
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',
headers: { cookie: cookies },
});
@@ -219,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
assert.equal(call.method, 'GET');
const u = new URL(call.url, 'http://x');
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
assert.equal(u.searchParams.get('phone'), '+2348068287222');
assert.equal(u.searchParams.get('otp'), '4455');
assert.equal(u.search, '', 'no phone/otp query parameters');
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
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();
try {
const cookies = await login(base);
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
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' });
const call = a2loft.calls[0];
assert.equal(call.method, 'POST');
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
assert.equal(call.url, '/identity/uc/fingerprints/UC0231');
assert.deepEqual(call.body, payload);
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
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();
try {
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);
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
assert.equal(noOtp.status, 400);
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
const badDomain = await fetch(`${base}/api/nope/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
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, /2348068287222/);
assert.match(html, /"domains"/, 'permitted domains injected too');
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
} finally {
await stop();
}
@@ -348,30 +351,93 @@ test('landing header is server-rendered for the session', async () => {
const { base, stop } = await bootApp();
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/);
// side nav items carry Carbon icons in the title-icon slot
assert.match(anon, /cds-side-nav-link href="\/cropper"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/fingerprint"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/);
// 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/);
// favicon is the Youreka logo; sign-out is confirmed via a Carbon modal
// 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/);
assert.match(anon, /data-signout/);
assert.match(anon, /cds-modal id="signout-modal"/);
assert.match(anon, /id="signout-confirm"/);
const cookies = await login(base);
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
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 {
await stop();
}