Compare commits
19
Commits
3a5edbfe75
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528eaafb18 | ||
|
|
8a13fb527e | ||
|
|
8d42faf707 | ||
|
|
e432585f04 | ||
|
|
fa33c6e179 | ||
|
|
14f6fd6a8e | ||
|
|
548e33390c | ||
|
|
35d895574f | ||
|
|
ff62770bcc | ||
|
|
2ad4aa63c6 | ||
|
|
0698a9c9a9 | ||
|
|
e3392c7750 | ||
|
|
46027acec1 | ||
|
|
6d3909c334 | ||
|
|
b3b75ffac0 | ||
|
|
1ddf61b59f | ||
|
|
4d006e361f | ||
|
|
a218835a1c | ||
|
|
134edb6ebd |
@@ -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
@@ -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=
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Spec Delta: fingerprint-wizard
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Scanner availability is shown and gates capture
|
||||
The wizard SHALL consume fpagent's `fprint-device-status`
|
||||
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
||||
being offline (socket not connected), connected with no supported reader
|
||||
attached (`available` false), and ready (`available` true). Until a status is
|
||||
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
|
||||
capture while no supported reader is attached, SHALL re-enable capture when a
|
||||
reader becomes available, and SHALL clear availability when the agent
|
||||
disconnects. Availability means "a supported reader is attached"; it does not
|
||||
guarantee a capture will succeed.
|
||||
|
||||
#### Scenario: Reader attached on connect
|
||||
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||
`available: true`
|
||||
- **THEN** the wizard shows the agent ready and leaves capture enabled
|
||||
|
||||
#### Scenario: No reader attached on connect
|
||||
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||
`available: false`
|
||||
- **THEN** the wizard shows that the scanner is not connected, explains captures
|
||||
are disabled, and disables the capture controls
|
||||
|
||||
#### Scenario: Unplug blocks capture
|
||||
- **WHEN** fpagent reports `available: false` while the operator is capturing or
|
||||
between captures
|
||||
- **THEN** the wizard disables the capture controls and a capture attempt is
|
||||
refused
|
||||
|
||||
#### Scenario: Replug re-enables capture
|
||||
- **WHEN** fpagent reports `available: true` after the reader is reconnected
|
||||
- **THEN** the wizard re-enables the capture controls
|
||||
|
||||
#### Scenario: Status absent or old agent
|
||||
- **WHEN** the connected agent never reports `fprint-device-status`
|
||||
- **THEN** availability stays unknown and the wizard does not block capture
|
||||
|
||||
#### Scenario: Disconnect clears availability
|
||||
- **WHEN** the agent socket disconnects
|
||||
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||
available
|
||||
@@ -0,0 +1,40 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Consume the availability event
|
||||
|
||||
- [x] 1.1 In `tools/public/assets/fp/app.js`, add `agent.available` (default
|
||||
`null`) and `agent.device` (default `''`); handle `fprint-device-status` to set
|
||||
both and re-render the tag. Verify the handler is present.
|
||||
- [x] 1.2 Clear `available` back to `null` and `device` to `''` on `disconnect`
|
||||
and `connect_error`.
|
||||
|
||||
## 2. Show availability
|
||||
|
||||
- [x] 2.1 Extend `renderAgentTag` to the four states (offline, scanner not
|
||||
connected, agent ready, agent connected/unknown) with the device name in the
|
||||
tag `title`.
|
||||
- [x] 2.2 Add a computed notice (title/subtitle) for agent-offline and
|
||||
no-scanner, and render it as a warning `cds-inline-notification` in the
|
||||
capture panel.
|
||||
- [x] 2.3 Disable the capture and recapture buttons while
|
||||
`agent.available === false`, and make `capture()` refuse an attempt in the
|
||||
same state.
|
||||
|
||||
## 3. Docs
|
||||
|
||||
- [x] 3.1 Add a paragraph to `README.md` under "The Fingerprint Agent".
|
||||
- [x] 3.2 Add a line to `tools/public/assets/help.html`.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 `cd tools && npm test` passes (65 tests, 6 new in
|
||||
`test/fp-wizard.test.js`).
|
||||
- [x] 4.2 `openspec validate 2026-10-02-fpagent-device-availability --strict`
|
||||
passes.
|
||||
- [x] 4.3 `test/fp-wizard.test.js` loads `app.js` in a vm sandbox and asserts the
|
||||
availability states, tag labels, capture refusal, and disconnect reset.
|
||||
- [x] 4.4 End-to-end browser pass against a running agent (tag transitions and
|
||||
disabled controls) — done with Playwright driving the real page against a
|
||||
stubbed agent socket; screenshots in the harness run.
|
||||
- [x] 4.5 Archive the change (`openspec archive`), which syncs the
|
||||
`fingerprint-wizard` spec.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-02
|
||||
@@ -0,0 +1,64 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md — Why. The `fpagent-installer` capability (change
|
||||
`fpagent-release-installer`) installs the newest release and hands off to the
|
||||
bundle's `deploy/install.sh`, which replaces the binary but only warns when a
|
||||
daemon is running. The bundle name already embeds the git hash
|
||||
(`fpagent-<hash>-<platform>.tar.gz`) and `bundle.sh` already builds the binary
|
||||
with that hash as its embedded `Version`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** install a pinned release; make a rerun a real upgrade that reports
|
||||
what changed and takes effect; keep both install paths (hosted and direct
|
||||
bundle) consistent.
|
||||
|
||||
**Non-Goals:** a separate version service or a new `--version` CLI flag; signing
|
||||
or provenance beyond sha256; a downgrade-unsafe rollback.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **The version is the asset-name git hash.** It is already the binary's
|
||||
embedded `Version` and needs no new endpoint or flag. The hosted installer
|
||||
parses it, and also writes it as `VERSION` in the extracted bundle so the
|
||||
bundle installer has it even for bundles built before this change.
|
||||
|
||||
2. **The bundle installer owns the upgrade reporting and the restart.** It
|
||||
knows `INSTALL_PATH` and the service; the hosted installer only fetches,
|
||||
verifies and hands off. `bundle.sh` ships `VERSION` so direct bundle installs
|
||||
behave the same. The stamp lives at `${INSTALL_PATH}.version`, so a custom
|
||||
`--install-path` keeps its own record, and uninstall removes it.
|
||||
|
||||
3. **Restart only on a managed, already-running service.** `enable --now` starts
|
||||
a stopped unit; for an upgrade the installer checks `is-active` before
|
||||
enabling and restarts only if it was active, avoiding a redundant restart on
|
||||
a fresh install.
|
||||
|
||||
4. **`--tag` reuses the release handler.** A second same-origin metadata route
|
||||
(`/fpagent/releases/tags/:tag`) shares the latest route's handler, so the
|
||||
filtering and non-disclosure rules are identical; the installer switches only
|
||||
the metadata URL, then downloads by the resolved tag.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Bundles built before this change carry no VERSION and do not restart] → the
|
||||
hosted installer writes VERSION from the asset name (fixes reporting), but the
|
||||
restart is in the bundle and appears only after a new `fingerprint-agent`
|
||||
release. Until then upgrades still need a manual restart.
|
||||
- [Upgrade without `--enable-service` leaves a running daemon on the old binary]
|
||||
→ the installer warns; the documented install command uses `--enable-service`.
|
||||
- [A pinned tag whose assets differ] → the tag's own metadata drives asset
|
||||
selection, the same as latest.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Release a new `fingerprint-agent` bundle (ships VERSION and restarts on
|
||||
upgrade), then deploy the app. The hosted installer is backward compatible: with
|
||||
an older bundle it reports the version but the bundle's older installer ignores
|
||||
the stamp. Rollback = revert the app; `--tag` can pin the previous release.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Proposal: fpagent-install-upgrade
|
||||
|
||||
## Why
|
||||
|
||||
The installer always took the newest release and left any running `fpagent`
|
||||
daemon on the old binary, so there was no way to hold a known-good version and
|
||||
an "upgrade" did not take effect until a reboot. Operators need a controlled
|
||||
version and a rerun that actually upgrades.
|
||||
|
||||
## What Changes
|
||||
|
||||
- The installer accepts `--tag TAG` to install a specific release instead of
|
||||
`latest`, and the app proxies that tag's metadata through a same-origin route.
|
||||
- The installed version is the git hash carried in the bundle's asset name. The
|
||||
installer passes it to the bundle installer, which records it beside the
|
||||
binary and reports `upgrading <old> -> <new>` on the next run.
|
||||
- When the installer manages the systemd service and the service was already
|
||||
running, an upgrade restarts it so the new binary takes effect. The version
|
||||
stamp is removed on uninstall.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `fpagent-installer`: adds release pinning, a pinned-tag metadata endpoint,
|
||||
installed-version tracking with upgrade reporting, and restart of a managed
|
||||
service on upgrade.
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/server.js`: `/fpagent/releases/tags/:tag` route (shares the release
|
||||
metadata handler with `/fpagent/releases/latest`).
|
||||
- `tools/public/fpagent/install.sh`: `--tag`, version parse from the asset name,
|
||||
and handoff of the version to the bundle installer.
|
||||
- `fingerprint-agent` `deploy/install.sh` and `deploy/bundle.sh`: read/stamp the
|
||||
version and restart an already-running service; `bundle.sh` ships `VERSION`.
|
||||
- Depends on the `fingerprint-agent` bundle naming, which already embeds the
|
||||
git hash (`fpagent-<hash>-<platform>.tar.gz`).
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pinned release selection
|
||||
|
||||
The installer SHALL accept a release tag and install that release instead of the
|
||||
latest. Without a tag it SHALL install the latest release.
|
||||
|
||||
#### Scenario: A pinned release is installed
|
||||
|
||||
- **WHEN** the installer is invoked with a release tag
|
||||
- **THEN** it fetches that tag's metadata and installs its bundle
|
||||
|
||||
#### Scenario: Default is the latest release
|
||||
|
||||
- **WHEN** the installer is invoked without a release tag
|
||||
- **THEN** it installs the latest release
|
||||
|
||||
#### Scenario: Unknown pinned release
|
||||
|
||||
- **WHEN** the pinned tag does not exist
|
||||
- **THEN** the installer exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Pinned release metadata endpoint
|
||||
|
||||
The app SHALL expose a same-origin endpoint that returns a specific release's
|
||||
tag and asset names as same-origin URLs, with the same authentication, filtering
|
||||
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
|
||||
tag that is not a valid release tag without contacting the forge.
|
||||
|
||||
#### Scenario: Tagged release is returned
|
||||
|
||||
- **WHEN** a client requests the metadata for an existing tag
|
||||
- **THEN** the response carries that release's tag and its assets as same-origin
|
||||
URLs
|
||||
|
||||
#### Scenario: Invalid tag is rejected
|
||||
|
||||
- **WHEN** a client requests metadata for a tag that is not a valid release tag
|
||||
- **THEN** the app rejects the request without contacting the forge
|
||||
|
||||
### Requirement: Installed version is tracked and upgrades are reported
|
||||
|
||||
The installer SHALL treat the git hash in the bundle's asset name as the version.
|
||||
It SHALL hand that version to the bundle installer, which SHALL record it beside
|
||||
the installed binary and SHALL report an upgrade with the previous and new
|
||||
versions on the next run. Uninstalling SHALL remove the recorded version.
|
||||
|
||||
#### Scenario: Version is derived from the asset name
|
||||
|
||||
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Upgrade is reported
|
||||
|
||||
- **WHEN** the recorded version differs from the version being installed
|
||||
- **THEN** the bundle installer reports the change from the old to the new
|
||||
version
|
||||
|
||||
#### Scenario: First install records the version
|
||||
|
||||
- **WHEN** no version has been recorded yet
|
||||
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||
|
||||
#### Scenario: Uninstall removes the recorded version
|
||||
|
||||
- **WHEN** the installer is asked to uninstall
|
||||
- **THEN** the recorded version is removed
|
||||
|
||||
### Requirement: A managed running service is restarted on upgrade
|
||||
|
||||
When the installer is asked to manage the systemd service and a service is
|
||||
already running, the installer SHALL restart it after installing the new binary
|
||||
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
|
||||
restart, and the installer SHALL NOT restart the service when service management
|
||||
is not requested.
|
||||
|
||||
#### Scenario: Upgrade restarts the running service
|
||||
|
||||
- **WHEN** the running service is managed and a new binary is installed
|
||||
- **THEN** the service is restarted on the new binary
|
||||
|
||||
#### Scenario: Fresh install starts the service
|
||||
|
||||
- **WHEN** the service is not running and service management is requested
|
||||
- **THEN** the service is started and no separate restart is performed
|
||||
|
||||
#### Scenario: Service management not requested
|
||||
|
||||
- **WHEN** service management is not requested
|
||||
- **THEN** the installer does not restart the service
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tasks
|
||||
|
||||
- [x] 1. App: add `/fpagent/releases/tags/:tag`, sharing the release-metadata
|
||||
handler with `/fpagent/releases/latest`; reject invalid tags without contacting
|
||||
the forge.
|
||||
- [x] 2. Hosted installer: parse `--tag`, fetch the pinned tag's metadata,
|
||||
derive the git hash from the asset name, and write it as `VERSION` into the
|
||||
extracted bundle before handoff.
|
||||
- [x] 3. Bundle installer (`fingerprint-agent`): read `VERSION` beside the script
|
||||
or the binary, report `upgrading <old> -> <new>` from `${INSTALL_PATH}.version`,
|
||||
stamp the new version, remove it on uninstall.
|
||||
- [x] 4. Bundle installer: restart an already-running `fpagent.service` after
|
||||
`enable --now` (via `is-active`, so a fresh install does not restart).
|
||||
- [x] 5. `bundle.sh`: ship `VERSION` in the bundle.
|
||||
- [x] 6. Tests: tag-route metadata and invalid-tag rejection; `--tag` makes the
|
||||
installer request the tag endpoint, not latest. Verified 59 tests pass.
|
||||
- [x] 7. Verified end to end: a real patched bundle served through a fake gateway
|
||||
→ hosted installer → bundle installer reported `upgrading fpagent deadbeef ->
|
||||
cafebabe`, version `cafebabe`, with options forwarded.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-02
|
||||
@@ -0,0 +1,85 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md — Why. The `fingerprint-agent` repository and its releases are
|
||||
private: an unauthenticated request to the forge's releases API answers `404`.
|
||||
Releases already publish, per build, a generic bundle
|
||||
(`fpagent-<version>-linux-x86_64.tar.gz`), per-Ubuntu offline bundles
|
||||
(`fpagent-<version>-ubuntu-<codename>-x86_64.tar.gz`) with the libfprint `.deb`
|
||||
closure vendored, and a `.sha256` beside each. Every bundle already contains
|
||||
the fpagent binary, the udev rule, and its own `install.sh` that handles
|
||||
libfprint, udev, `plugdev` and the systemd service. The tools app is an Express
|
||||
server with a hardcoded-configuration policy whose only sanctioned environment
|
||||
variables were the Image Cropper credentials.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- A field laptop installs or updates fpagent with one public command.
|
||||
- The forge credential never reaches the field laptop.
|
||||
- Reuse the bundle's installer rather than reimplementing hardware setup.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Replacing the bundle installer, or the forge's own release tooling.
|
||||
- Authenticating the installer: it is intentionally public.
|
||||
- Supporting architectures other than x86_64 (the bundle installer already
|
||||
requires it).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Proxy the release through the app; do not embed the key in the script.**
|
||||
The credential lives in `GITEA_RELEASE_INSTALL_KEY` and the app fetches the
|
||||
private release server-side. The alternative — rendering the key into the
|
||||
served script so the client could call the forge directly — publishes a
|
||||
repository credential to every installer and any `curl`; rejected.
|
||||
|
||||
2. **Two thin routes, no proxy framework.** `/fpagent/releases/latest` returns
|
||||
a trimmed `{tag, assets:[{name,url}]}` with same-origin URLs, and
|
||||
`/fpagent/releases/download/:tag/:name` streams one asset. Both are
|
||||
registered before the session middleware so they stay public and do no
|
||||
per-request PocketBase work. A single combined "give me the right tarball"
|
||||
route was considered but needs the server to guess the client's OS; the
|
||||
client knows its own codename, so selection happens in the script.
|
||||
|
||||
3. **The installer delegates to the bundle's own `install.sh`.** All hardware
|
||||
and dependency logic already exists and is versioned with the binary;
|
||||
reimplementing it in the hosted script would duplicate it and drift. The
|
||||
hosted script only fetches, verifies, extracts, and forwards flags.
|
||||
|
||||
4. **Bundle selection prefers the codename offline bundle.** When the host's
|
||||
`/etc/os-release` `VERSION_CODENAME` matches a published offline bundle, use
|
||||
it (no network needed for the dependency closure); otherwise fall back to
|
||||
the generic online bundle, whose installer apt-gets libfprint.
|
||||
|
||||
5. **Checksum verification, not signature verification.** The release publishes
|
||||
sha256 only, so the installer verifies sha256 over TLS from the same origin
|
||||
it trusts for the script itself. GPG would require release signing that does
|
||||
not exist yet; deferred.
|
||||
|
||||
6. **Asset names are validated server-side** against a conservative pattern,
|
||||
so a crafted tag or name cannot walk the forge path or reach another host.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A compromised tools app serves a malicious installer] → the app is already
|
||||
trusted to serve the tools pages and holds the operator session; the
|
||||
installer is not a new trust boundary. TLS to `tools.yrka.app` is the same
|
||||
trust as the app.
|
||||
- [The forge changes its bundle naming] → selection falls back to the generic
|
||||
bundle, and a release with no matching asset fails loudly rather than
|
||||
installing something wrong.
|
||||
- [The app buffers an asset in memory] → bundles are tens of MB; acceptable,
|
||||
and requests are rare. Streaming can replace it if that changes.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Add `GITEA_RELEASE_INSTALL_KEY` to `.env`/`.env.production` (already supplied)
|
||||
and deploy the app; the installer and proxy are new routes and change no
|
||||
existing behavior. Rollback = remove the routes; nothing else depends on them.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Proposal: fpagent-release-installer
|
||||
|
||||
## Why
|
||||
|
||||
Field laptops need `fpagent` installed and kept current, but the
|
||||
`fingerprint-agent` repository is private: its releases require a forge token
|
||||
that operators do not have. Today an operator must obtain a bundle out of band
|
||||
and run its installer by hand. A public one-line installer hosted by the tools
|
||||
app removes that step while keeping the forge credential server-side.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a public installer script served at `/fpagent/install.sh`, intended to be
|
||||
piped into `bash`. It fetches the latest release, selects the bundle matching
|
||||
the host (the Ubuntu-codename offline bundle when present, else the generic
|
||||
online bundle), verifies the published sha256, extracts it, and runs the
|
||||
bundle's own installer, forwarding flags such as `--enable-service`,
|
||||
`--check` and `--uninstall`.
|
||||
- Add same-origin proxy routes `/fpagent/releases/latest` and
|
||||
`/fpagent/releases/download/:tag/:name` that read the private release from the
|
||||
forge using the `GITEA_RELEASE_INSTALL_KEY` environment variable and stream
|
||||
assets to the client. The token and the forge host are never exposed.
|
||||
- Extend the app's sanctioned environment configuration to include the installer
|
||||
key, alongside the existing Image Cropper credentials.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `fpagent-installer`: the public installer surface — the download/verify/handoff
|
||||
script and the token-guarded same-origin release proxy that backs it.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `tools-app`: the hardcoded-configuration requirement now sanctions a second
|
||||
environment variable (the fpagent installer key) in addition to the Image
|
||||
Cropper credentials.
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/server.js`: `GITEA_URL` / `FPAGENT_REPO` constants, the installer key
|
||||
read, and two public routes registered before the session middleware.
|
||||
- `tools/public/fpagent/install.sh`: new installer script.
|
||||
- `tools/test/fpagent-install.test.js`: proxy and script tests.
|
||||
- `.env` / `.env.production` hold the key; `.env.example` and `README.md`
|
||||
document the install flow.
|
||||
- Depends on the private `fingerprint-agent` releases and their bundle naming
|
||||
(`fpagent-<version>-{linux-x86_64|ubuntu-<codename>-x86_64}.tar.gz`) plus the
|
||||
matching `.sha256` assets.
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# fpagent-installer Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets a field laptop install the latest private `fpagent` release with a single
|
||||
public command, without ever handling the forge credential — the tools app
|
||||
proxies the private release and serves the installer.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public installer endpoint
|
||||
|
||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||
reachable without authentication. When run, the script SHALL fetch the latest
|
||||
release metadata, choose a bundle for the host, verify the bundle's published
|
||||
sha256 checksum, extract it, and hand off to the extracted bundle's own
|
||||
installer. The script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Installer is fetched anonymously
|
||||
|
||||
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||
- **THEN** the app returns the installer script and does not redirect to login
|
||||
|
||||
#### Scenario: Host-appropriate bundle is chosen
|
||||
|
||||
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||
codename and a generic bundle
|
||||
- **THEN** the installer uses the codename bundle
|
||||
|
||||
#### Scenario: Generic bundle is the fallback
|
||||
|
||||
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||
codename
|
||||
- **THEN** the installer uses the generic bundle
|
||||
|
||||
#### Scenario: Checksum is verified before extraction
|
||||
|
||||
- **WHEN** the installer downloads a bundle and its published checksum
|
||||
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||
when the checksum does not match
|
||||
|
||||
#### Scenario: Options are forwarded to the bundled installer
|
||||
|
||||
- **WHEN** the installer is invoked with options such as `--enable-service` or
|
||||
`--check`
|
||||
- **THEN** it passes those options through to the extracted bundle's installer
|
||||
|
||||
#### Scenario: No compatible bundle
|
||||
|
||||
- **WHEN** the latest release contains no bundle for the host architecture
|
||||
- **THEN** the installer exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Release metadata endpoint
|
||||
|
||||
The app SHALL expose a same-origin endpoint that returns the latest release's
|
||||
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
|
||||
authenticate to the private forge using the configured release key, SHALL omit
|
||||
asset names that are not valid asset names, and SHALL NOT reveal the release
|
||||
key or the forge host to clients.
|
||||
|
||||
#### Scenario: Latest release is returned
|
||||
|
||||
- **WHEN** a client requests the latest-release endpoint and the forge returns
|
||||
a release
|
||||
- **THEN** the response carries the release tag and each asset's name and a
|
||||
same-origin URL under `/fpagent/releases/download/`
|
||||
|
||||
#### Scenario: Unsafe asset names are omitted
|
||||
|
||||
- **WHEN** the forge release lists an asset whose name is not a valid asset
|
||||
name
|
||||
- **THEN** that asset does not appear in the response
|
||||
|
||||
#### Scenario: Release key is not exposed
|
||||
|
||||
- **WHEN** any client fetches the latest-release endpoint
|
||||
- **THEN** the response body contains neither the release key nor the forge host
|
||||
|
||||
#### Scenario: Installer not configured
|
||||
|
||||
- **WHEN** the release key is not configured
|
||||
- **THEN** the endpoint answers that the installer is not configured
|
||||
|
||||
#### Scenario: Forge unavailable
|
||||
|
||||
- **WHEN** the forge cannot be reached or returns an error for the latest
|
||||
release
|
||||
- **THEN** the endpoint answers with a gateway error
|
||||
|
||||
### Requirement: Release asset download proxy
|
||||
|
||||
The app SHALL stream a requested release asset to the client through a
|
||||
same-origin route, authenticating the upstream request with the release key so
|
||||
the client sends no credentials. It SHALL reject asset names that are not valid
|
||||
asset names and SHALL report a missing asset as not found.
|
||||
|
||||
#### Scenario: Asset is streamed without client credentials
|
||||
|
||||
- **WHEN** a client requests a release asset by tag and name without any
|
||||
credential
|
||||
- **THEN** the app fetches it from the forge with the release key and streams
|
||||
its bytes to the client
|
||||
|
||||
#### Scenario: Invalid asset name is rejected
|
||||
|
||||
- **WHEN** a client requests an asset whose tag or name is not a valid asset
|
||||
name
|
||||
- **THEN** the app rejects the request without contacting the forge
|
||||
|
||||
#### Scenario: Missing asset
|
||||
|
||||
- **WHEN** the forge reports the requested asset as not found
|
||||
- **THEN** the app answers with not found
|
||||
|
||||
#### Scenario: Installer not configured
|
||||
|
||||
- **WHEN** the release key is not configured
|
||||
- **THEN** the endpoint answers that the installer is not configured
|
||||
|
||||
### Requirement: Installer credential stays server-side
|
||||
|
||||
The app SHALL read the private-forge release key from the
|
||||
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
|
||||
the installer script, in any release metadata response, or in any streamed
|
||||
asset.
|
||||
|
||||
#### Scenario: Served installer carries no credential
|
||||
|
||||
- **WHEN** a client fetches `/fpagent/install.sh`
|
||||
- **THEN** the response contains no release key
|
||||
|
||||
#### Scenario: Credential supplied by the environment
|
||||
|
||||
- **WHEN** the release key is supplied in the environment
|
||||
- **THEN** the installer and proxy endpoints use it without any change to the
|
||||
hardcoded configuration
|
||||
@@ -0,0 +1,34 @@
|
||||
# tools-app Specification
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Configuration is hardcoded
|
||||
|
||||
The app SHALL hardcode all configuration in source: port `4173`, portal
|
||||
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
||||
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
|
||||
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
|
||||
configuration via environment variables or config files, with two exceptions:
|
||||
the Image Cropper's service credentials (Minio keys, Azure storage connection
|
||||
string, nodered credentials, cleanup-service key) SHALL come from environment
|
||||
variables and SHALL NOT be stored in source; and the Fingerprint Agent
|
||||
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
|
||||
environment and SHALL NOT be stored in source.
|
||||
|
||||
#### Scenario: No configuration required to run
|
||||
|
||||
- **WHEN** the app is started with no environment variables or config files
|
||||
- **THEN** it serves on port 4173 with the hardcoded external endpoints
|
||||
|
||||
#### Scenario: Cropper credentials from the environment
|
||||
|
||||
- **WHEN** the cropper credentials are supplied as environment variables
|
||||
- **THEN** the cropper routes use them without any change to the hardcoded
|
||||
URL/port configuration
|
||||
|
||||
#### Scenario: Installer key from the environment
|
||||
|
||||
- **WHEN** the fpagent installer release key is supplied as an environment
|
||||
variable
|
||||
- **THEN** the installer and release-proxy routes use it without any change to
|
||||
the hardcoded URL/port configuration
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tasks
|
||||
|
||||
- [x] 1. Server: `GITEA_URL`/`FPAGENT_REPO` constants, the installer key read
|
||||
from `GITEA_RELEASE_INSTALL_KEY`, and two public proxy routes registered
|
||||
before the session middleware: `GET /fpagent/releases/latest` (trimmed
|
||||
`{tag, assets:[{name,url}]}`, same-origin URLs, unsafe names dropped) and
|
||||
`GET /fpagent/releases/download/:tag/:name` (validated names, streamed with
|
||||
the key, 404/502 mapping, 503 when unconfigured).
|
||||
- [x] 2. Installer script `tools/public/fpagent/install.sh`: fetch metadata,
|
||||
prefer the host-codename offline bundle over the generic one, download and
|
||||
verify sha256, extract, and forward all options to the bundle's `install.sh`.
|
||||
Served uncached at `/fpagent/install.sh`.
|
||||
- [x] 3. Tests (`tools/test/fpagent-install.test.js`): latest proxy shape and
|
||||
token non-disclosure, asset streaming with upstream auth, invalid-name
|
||||
rejection, missing-asset 404, unconfigured 503, public script serving, and
|
||||
`bash -n` on the script.
|
||||
- [x] 4. Docs/config: `GITEA_RELEASE_INSTALL_KEY` in `.env.example`, installer
|
||||
and configuration notes in `README.md`.
|
||||
- [x] 5. Verified: 55 tests pass (48 existing + 7 new); routes exercised live
|
||||
against the forge (latest release `v1.0.0`, proxied checksum matches the
|
||||
publisher's, invalid name refused); the installer run end to end against a
|
||||
fake release base — codename bundle chosen, checksum pass and fail, options
|
||||
forwarded to the extracted bundle installer.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-02
|
||||
@@ -0,0 +1,49 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md for motivation. The wizard already holds the PocketBase token
|
||||
in the page (`window.__SESSION__.token`) for fpagent enrollment, and the
|
||||
cropper's save already sends `Authorization: Bearer ${req.session.token}` to
|
||||
a2loft through its own proxy (`tools/cropper.js`). The two remaining OTP calls
|
||||
are the wizard's staff-lookup and fingerprint-save proxies in `tools/server.js`;
|
||||
their handlers receive the a2loft URL from `a2loftUrl`/`a2loftSaveUrl`, which
|
||||
currently append `phone`+`otp`, and `validateProxy` rejects a missing `otp`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** both wizard a2loft calls use the session token; no OTP in the
|
||||
wizard; proxy validation keeps rejecting unauthenticated calls; deployable
|
||||
after the a2loft change with a clean rollback.
|
||||
|
||||
**Non-Goals:** fpagent capture (already carries the token); the cropper; a2loft's
|
||||
own OTP compatibility; the legacy singular fingerprint endpoint.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Bearer from the server session**, never the browser: the proxy attaches
|
||||
`Authorization: Bearer ${req.session.token}`, mirroring `tools/cropper.js`.
|
||||
The page's token copy stays for fpagent only.
|
||||
2. **Drop the OTP check in `validateProxy`**, along with the now-unnecessary
|
||||
phone check; keep the hardcoded-domain and reference checks. `requireApi`
|
||||
already guarantees a session.
|
||||
3. **Remove the OTP inputs and state** rather than hiding them;
|
||||
`canLookup`/`canSave` no longer require an OTP.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [a2loft not yet dual-mode] → deploy the a2loft change first; this proxy change
|
||||
depends on it.
|
||||
- [Operators outside a domain's uploader list can now enroll] → intended;
|
||||
a2loft authorizes `capture_biometric` with the `domain:idid` fallback.
|
||||
- [Rollback] → revert the proxy and re-add the OTP fields; a2loft stays
|
||||
dual-mode.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Deploy a2loft dual-mode → deploy this app → field pass: look up a reference and
|
||||
save both fingerprints with no OTP; a bogus-token call fails cleanly.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Proposal: fingerprint-token-auth
|
||||
|
||||
## Why
|
||||
|
||||
The Fingerprint Agent wizard's two a2loft calls — staff lookup and fingerprint
|
||||
save — prove the operator with a phone number and an OTP forwarded through
|
||||
same-origin proxies. a2loft resolves that phone against the domain being
|
||||
addressed, so an operator who holds the OpenFGA `capture_biometric` relation
|
||||
but is not a super-user of that domain's uploader list is rejected. The cropper
|
||||
already saves with the session's PocketBase token; the wizard's a2loft calls
|
||||
should use the same token flow.
|
||||
|
||||
## What Changes
|
||||
|
||||
- The `/api/:domain/staff/:reference` and
|
||||
`/api/:domain/staff/:reference/fingerprints` proxies send the session's
|
||||
PocketBase token as an `Authorization: Bearer` header instead of forwarding
|
||||
`phone`+`otp` query parameters.
|
||||
- The OTP validation is removed from the proxies; the session token is the
|
||||
credential.
|
||||
- The wizard's lookup and save steps drop their OTP fields and OTP state.
|
||||
- The Fingerprint Agent and tools-app specs, the README, and the Help page are
|
||||
updated.
|
||||
- Requires the a2loft `fingerprint-token-auth` change deployed first.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `fingerprint-wizard`: the two-finger enrollment flow and the per-call OTP
|
||||
requirement become token-authenticated.
|
||||
- `tools-app`: the a2loft proxies requirement now injects the session token
|
||||
instead of forwarding `phone`+`otp`.
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/server.js`: the two a2loft proxy handlers and `validateProxy`.
|
||||
- `tools/public/assets/fp/index.html`, `tools/public/assets/fp/app.js`: the OTP
|
||||
fields, state and request parameters removed.
|
||||
- `tools/test/server.test.js`: proxy assertions.
|
||||
- Documentation: README and the Help page.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Spec Delta: fingerprint-wizard
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Two-finger enrollment flow
|
||||
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||
the staff details, capturing the right thumb and right index finger, and
|
||||
saving both fingerprints through the a2loft proxy authenticated with the
|
||||
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||
require both fingers before save, and SHALL reset domain-independent state
|
||||
(reference, staff details, captured artifacts) when starting a new
|
||||
enrollment.
|
||||
|
||||
#### Scenario: Complete enrollment
|
||||
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||
wizard reports success
|
||||
|
||||
#### Scenario: Save requires both fingers
|
||||
- **WHEN** the operator attempts to save with only one finger captured
|
||||
- **THEN** the wizard rejects the save and explains both fingers are
|
||||
required
|
||||
|
||||
#### Scenario: New enrollment resets state
|
||||
- **WHEN** the operator starts a new enrollment after a save
|
||||
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||
cleared while the permitted domain list persists
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: OTP validated on every a2loft call
|
||||
**Reason**: a2loft's fingerprint staff-lookup and save endpoints now accept the
|
||||
session's PocketBase token, so the operator proves identity with the session
|
||||
instead of a per-call OTP. The OTP scoped operators to the domain's uploader
|
||||
list and rejected capture agents who hold only `capture_biometric`.
|
||||
**Migration**: the wizard sends the session token through the same-origin
|
||||
proxy and collects no OTP; a2loft keeps OTP mode for other clients.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Token authenticated on every a2loft call
|
||||
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||
save — with the signed-in operator's PocketBase session token, sent by the
|
||||
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||
NOT collect an OTP.
|
||||
|
||||
#### Scenario: Lookup uses the session token
|
||||
- **WHEN** the operator submits a reference
|
||||
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||
session token
|
||||
|
||||
#### Scenario: Save uses the session token
|
||||
- **WHEN** the operator saves the captured fingerprints
|
||||
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
|
||||
the session token
|
||||
|
||||
#### Scenario: No OTP field
|
||||
- **WHEN** the wizard page is served
|
||||
- **THEN** it offers no OTP input for lookup or save
|
||||
@@ -0,0 +1,26 @@
|
||||
# Spec Delta: tools-app
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: a2loft proxies inject operator identity
|
||||
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||
response shapes. The proxies SHALL require a session; a request without one
|
||||
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||
require an OTP.
|
||||
|
||||
#### Scenario: Staff lookup via proxy
|
||||
- **WHEN** the page requests staff details for a domain and reference
|
||||
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||
bearer credential and returns a2loft's response unchanged
|
||||
|
||||
#### Scenario: Fingerprint save via proxy
|
||||
- **WHEN** the page saves fingerprints for a domain and reference
|
||||
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||
token as a bearer credential
|
||||
|
||||
#### Scenario: Unauthenticated proxy call rejected
|
||||
- **WHEN** a proxy route is called without a valid session
|
||||
- **THEN** the app rejects the call without contacting a2loft
|
||||
@@ -0,0 +1,30 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Proxies
|
||||
|
||||
- [x] 1.1 `tools/server.js`: `a2loftUrl`/`a2loftSaveUrl` drop `phone`/`otp`;
|
||||
both handlers send `Authorization: Bearer ${req.session.token}`;
|
||||
`validateProxy` drops the OTP/phone checks and keeps domain + reference.
|
||||
Verified: the proxy tests assert the Bearer header and no query auth.
|
||||
- [x] 1.2 Update the proxy tests: assert no `?otp`, assert the Bearer header,
|
||||
and drop the no-OTP rejection case while the auth/domain rejection remains
|
||||
(no a2loft call). Verified.
|
||||
|
||||
## 2. Wizard UI
|
||||
|
||||
- [x] 2.1 `tools/public/assets/fp/index.html`: remove the lookup and save OTP
|
||||
inputs.
|
||||
- [x] 2.2 `tools/public/assets/fp/app.js`: remove `otpLookup`/`otpSave`, the
|
||||
`otp` request params, and the OTP resets; tighten `canLookup`/`canSave`.
|
||||
Verified: the served page has no OTP field (server test) and both gates pass
|
||||
without a credential (wizard test).
|
||||
- [x] 2.3 Update the `fingerprint-wizard` and `tools-app` spec deltas, README
|
||||
steps 1/3, the a2loft proxy paragraph, and the Help page.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
|
||||
(66/66 pass.)
|
||||
- [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
||||
reference and save both fingerprints with no OTP. Verified in production:
|
||||
the lookup and both fingerprint saves succeeded with no OTP.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-06
|
||||
@@ -0,0 +1,72 @@
|
||||
# Design: fpagent-installer-multiarch
|
||||
|
||||
## Context
|
||||
|
||||
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
|
||||
laptop and proxies the private release through `tools.yrka.app`. It resolves
|
||||
the release's asset names with a small `python3` snippet and hardcodes the
|
||||
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
|
||||
|
||||
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
|
||||
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
|
||||
|
||||
The bundle's own installer already refuses a binary whose ELF class does not
|
||||
match the host, so the hosted installer only has to pick the right asset.
|
||||
|
||||
## Goals / Non-goals
|
||||
|
||||
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
|
||||
that architecture, and record its version.
|
||||
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
|
||||
and asset filtering/naming is unchanged.
|
||||
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
|
||||
x86_64-only; i386 hosts fall back to the generic online bundle whose
|
||||
installer apt-get's libfprint.
|
||||
- Non-goal: adding a production environment knob for the architecture. The
|
||||
host's own `uname -m` is the source of truth.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Architecture from `uname -m`, resolved once
|
||||
|
||||
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
|
||||
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
|
||||
so the hosted and bundled installers agree on what is supported.
|
||||
|
||||
### Selection order: codename bundle, then generic, both arch-scoped
|
||||
|
||||
With `codename` from `/etc/os-release`:
|
||||
|
||||
1. `-ubuntu-<codename>-<arch>.tar.gz`
|
||||
2. `-linux-<arch>.tar.gz`
|
||||
|
||||
The codename attempt is skipped when the host has no codename. Because offline
|
||||
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
|
||||
to step 2. The suffix match keeps one architecture's bundle from ever matching
|
||||
another's (`-linux-i386` does not end with `-linux-x86_64`).
|
||||
|
||||
### Version regex derived from the architecture
|
||||
|
||||
The version is the git hash embedded in the asset name. The existing regex only
|
||||
listed the x86_64 endings, so it produced an empty version for an i386 bundle
|
||||
and nothing was recorded. The regex must accept the same set of endings the
|
||||
selection can produce: `linux-x86_64`, `linux-i386` and
|
||||
`ubuntu-<codename>-x86_64`.
|
||||
|
||||
### Tests stub `uname`
|
||||
|
||||
The installer takes the architecture from the host, so a test cannot assert the
|
||||
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
|
||||
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
|
||||
production-only override. Assertions are made on the gateway requests the script
|
||||
makes (which asset it asks for), the way the existing `--tag` test does.
|
||||
|
||||
## Risks / trade-offs
|
||||
|
||||
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
|
||||
choice for the bundled installer, so the hosted one follows it rather than
|
||||
trying a second heuristic. `ponytail:` if such hosts appear, add a
|
||||
`getconf LONG_BIT` fallback in both installers at once.
|
||||
- The generated `install.sh --help` text duplicates the bundle installer's
|
||||
flags list. It can drift again; keeping it short and pointing at the bundle
|
||||
installer's own help limits the cost.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proposal: fpagent-installer-multiarch
|
||||
|
||||
## Why
|
||||
|
||||
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
|
||||
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
|
||||
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
|
||||
"no compatible bundle" and installs nothing, even though a matching release
|
||||
asset exists.
|
||||
|
||||
## What Changes
|
||||
|
||||
- The installer detects the host architecture (`uname -m` → `x86_64` or
|
||||
`i386`) and selects the matching bundle: the distro-codename bundle when one
|
||||
exists for that architecture, else the generic `-linux-<arch>` bundle.
|
||||
- An unsupported architecture fails with a message that names the host machine,
|
||||
and installs nothing.
|
||||
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
|
||||
32-bit install records and reports its version the same way x86_64 does.
|
||||
- The installer's help text matches the bundle installer: the systemd service
|
||||
and the self-update timer are on by default, so `--no-service`,
|
||||
`--no-updates` and `--update-base` are documented instead of
|
||||
`--enable-service` being described as required to get a service.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
|
||||
distro-codename aware), the no-compatible-bundle failure names the host
|
||||
architecture, and the version is derived for every published architecture.
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
|
||||
version regex, help text.
|
||||
- `tools/test/fpagent-install.test.js`: architecture selection and version
|
||||
parsing tests.
|
||||
- `README.md`: the installer section (flags and architectures).
|
||||
- Depends on the `fingerprint-agent` bundle naming
|
||||
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
|
||||
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
|
||||
whatever assets the release lists.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public installer endpoint
|
||||
|
||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||
reachable without authentication. When run, the script SHALL determine the
|
||||
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||
bundle built for the host's distro codename when one exists for that
|
||||
architecture, and otherwise use the generic bundle for that architecture. The
|
||||
script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Installer is fetched anonymously
|
||||
|
||||
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||
- **THEN** the app returns the installer script and does not redirect to login
|
||||
|
||||
#### Scenario: Host-appropriate bundle is chosen
|
||||
|
||||
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||
codename and a generic bundle
|
||||
- **THEN** the installer uses the codename bundle
|
||||
|
||||
#### Scenario: Generic bundle is the fallback
|
||||
|
||||
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||
codename
|
||||
- **THEN** the installer uses the generic bundle
|
||||
|
||||
#### Scenario: Bundle matches the host architecture
|
||||
|
||||
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||
bundle for another architecture
|
||||
- **THEN** the installer chooses the bundle for the host architecture
|
||||
- **AND** it never chooses a bundle for another architecture
|
||||
|
||||
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||
|
||||
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||
generic 32-bit bundle
|
||||
- **THEN** the installer uses that generic 32-bit bundle
|
||||
|
||||
#### Scenario: Checksum is verified before extraction
|
||||
|
||||
- **WHEN** the installer downloads a bundle and its published checksum
|
||||
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||
when the checksum does not match
|
||||
|
||||
#### Scenario: Options are forwarded to the bundled installer
|
||||
|
||||
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||
`--check`
|
||||
- **THEN** it passes those options through to the extracted bundle's installer
|
||||
|
||||
#### Scenario: No compatible bundle
|
||||
|
||||
- **WHEN** the latest release contains no bundle for the host architecture
|
||||
- **THEN** the installer exits with a failure message that names the host
|
||||
architecture and installs nothing
|
||||
|
||||
#### Scenario: Unsupported architecture
|
||||
|
||||
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||
x86_64 nor i386
|
||||
- **THEN** it exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Installed version is tracked and upgrades are reported
|
||||
|
||||
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||
version, for every architecture it can install. It SHALL hand that version to
|
||||
the bundle installer, which SHALL record it beside the installed binary and
|
||||
SHALL report an upgrade with the previous and new versions on the next run.
|
||||
Uninstalling SHALL remove the recorded version.
|
||||
|
||||
#### Scenario: Version is derived from the asset name
|
||||
|
||||
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Version is derived for a 32-bit bundle
|
||||
|
||||
- **WHEN** the installer selects a generic bundle named
|
||||
`fpagent-<hash>-linux-i386.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Upgrade is reported
|
||||
|
||||
- **WHEN** the recorded version differs from the version being installed
|
||||
- **THEN** the bundle installer reports the change from the old to the new
|
||||
version
|
||||
|
||||
#### Scenario: First install records the version
|
||||
|
||||
- **WHEN** no version has been recorded yet
|
||||
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||
|
||||
#### Scenario: Uninstall removes the recorded version
|
||||
|
||||
- **WHEN** the installer is asked to uninstall
|
||||
- **THEN** the recorded version is removed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Tasks
|
||||
|
||||
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
|
||||
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
|
||||
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
|
||||
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
|
||||
dies without fetching.
|
||||
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
|
||||
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
|
||||
host architecture. Verify: with a release offering only
|
||||
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
|
||||
with only `-linux-x86_64`, the same host fails and installs nothing.
|
||||
- [x] 3. Hosted installer: derive the version from the asset name for every
|
||||
supported architecture (`linux-x86_64`, `linux-i386`,
|
||||
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
|
||||
not an empty string.
|
||||
- [x] 4. Hosted installer: correct the help text — service and self-update are
|
||||
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
|
||||
the note that options are forwarded to the bundle installer.
|
||||
Verify: `install.sh --help` mentions the three flags and no longer implies
|
||||
`--enable-service` is required.
|
||||
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
|
||||
and assert i386 selection, cross-architecture rejection, and i386 version
|
||||
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
|
||||
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
|
||||
and the on-by-default service/self-update. Verify: reading the section, no
|
||||
flag claims contradict `install.sh --help`.
|
||||
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
|
||||
|
||||
### Requirement: Two-finger enrollment flow
|
||||
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
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# fpagent-installer Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Lets a field laptop install the latest private `fpagent` release with a single
|
||||
public command, without ever handling the forge credential — the tools app
|
||||
proxies the private release and serves the installer.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Public installer endpoint
|
||||
|
||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||
reachable without authentication. When run, the script SHALL determine the
|
||||
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||
bundle built for the host's distro codename when one exists for that
|
||||
architecture, and otherwise use the generic bundle for that architecture. The
|
||||
script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Installer is fetched anonymously
|
||||
|
||||
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||
- **THEN** the app returns the installer script and does not redirect to login
|
||||
|
||||
#### Scenario: Host-appropriate bundle is chosen
|
||||
|
||||
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||
codename and a generic bundle
|
||||
- **THEN** the installer uses the codename bundle
|
||||
|
||||
#### Scenario: Generic bundle is the fallback
|
||||
|
||||
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||
codename
|
||||
- **THEN** the installer uses the generic bundle
|
||||
|
||||
#### Scenario: Bundle matches the host architecture
|
||||
|
||||
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||
bundle for another architecture
|
||||
- **THEN** the installer chooses the bundle for the host architecture
|
||||
- **AND** it never chooses a bundle for another architecture
|
||||
|
||||
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||
|
||||
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||
generic 32-bit bundle
|
||||
- **THEN** the installer uses that generic 32-bit bundle
|
||||
|
||||
#### Scenario: Checksum is verified before extraction
|
||||
|
||||
- **WHEN** the installer downloads a bundle and its published checksum
|
||||
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||
when the checksum does not match
|
||||
|
||||
#### Scenario: Options are forwarded to the bundled installer
|
||||
|
||||
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||
`--check`
|
||||
- **THEN** it passes those options through to the extracted bundle's installer
|
||||
|
||||
#### Scenario: No compatible bundle
|
||||
|
||||
- **WHEN** the latest release contains no bundle for the host architecture
|
||||
- **THEN** the installer exits with a failure message that names the host
|
||||
architecture and installs nothing
|
||||
|
||||
#### Scenario: Unsupported architecture
|
||||
|
||||
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||
x86_64 nor i386
|
||||
- **THEN** it exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Release metadata endpoint
|
||||
|
||||
The app SHALL expose a same-origin endpoint that returns the latest release's
|
||||
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
|
||||
authenticate to the private forge using the configured release key, SHALL omit
|
||||
asset names that are not valid asset names, and SHALL NOT reveal the release
|
||||
key or the forge host to clients.
|
||||
|
||||
#### Scenario: Latest release is returned
|
||||
|
||||
- **WHEN** a client requests the latest-release endpoint and the forge returns
|
||||
a release
|
||||
- **THEN** the response carries the release tag and each asset's name and a
|
||||
same-origin URL under `/fpagent/releases/download/`
|
||||
|
||||
#### Scenario: Unsafe asset names are omitted
|
||||
|
||||
- **WHEN** the forge release lists an asset whose name is not a valid asset
|
||||
name
|
||||
- **THEN** that asset does not appear in the response
|
||||
|
||||
#### Scenario: Release key is not exposed
|
||||
|
||||
- **WHEN** any client fetches the latest-release endpoint
|
||||
- **THEN** the response body contains neither the release key nor the forge host
|
||||
|
||||
#### Scenario: Installer not configured
|
||||
|
||||
- **WHEN** the release key is not configured
|
||||
- **THEN** the endpoint answers that the installer is not configured
|
||||
|
||||
#### Scenario: Forge unavailable
|
||||
|
||||
- **WHEN** the forge cannot be reached or returns an error for the latest
|
||||
release
|
||||
- **THEN** the endpoint answers with a gateway error
|
||||
|
||||
### Requirement: Release asset download proxy
|
||||
|
||||
The app SHALL stream a requested release asset to the client through a
|
||||
same-origin route, authenticating the upstream request with the release key so
|
||||
the client sends no credentials. It SHALL reject asset names that are not valid
|
||||
asset names and SHALL report a missing asset as not found.
|
||||
|
||||
#### Scenario: Asset is streamed without client credentials
|
||||
|
||||
- **WHEN** a client requests a release asset by tag and name without any
|
||||
credential
|
||||
- **THEN** the app fetches it from the forge with the release key and streams
|
||||
its bytes to the client
|
||||
|
||||
#### Scenario: Invalid asset name is rejected
|
||||
|
||||
- **WHEN** a client requests an asset whose tag or name is not a valid asset
|
||||
name
|
||||
- **THEN** the app rejects the request without contacting the forge
|
||||
|
||||
#### Scenario: Missing asset
|
||||
|
||||
- **WHEN** the forge reports the requested asset as not found
|
||||
- **THEN** the app answers with not found
|
||||
|
||||
#### Scenario: Installer not configured
|
||||
|
||||
- **WHEN** the release key is not configured
|
||||
- **THEN** the endpoint answers that the installer is not configured
|
||||
|
||||
### Requirement: Installer credential stays server-side
|
||||
|
||||
The app SHALL read the private-forge release key from the
|
||||
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
|
||||
the installer script, in any release metadata response, or in any streamed
|
||||
asset.
|
||||
|
||||
#### Scenario: Served installer carries no credential
|
||||
|
||||
- **WHEN** a client fetches `/fpagent/install.sh`
|
||||
- **THEN** the response contains no release key
|
||||
|
||||
#### Scenario: Credential supplied by the environment
|
||||
|
||||
- **WHEN** the release key is supplied in the environment
|
||||
- **THEN** the installer and proxy endpoints use it without any change to the
|
||||
hardcoded configuration
|
||||
|
||||
### Requirement: Pinned release selection
|
||||
|
||||
The installer SHALL accept a release tag and install that release instead of the
|
||||
latest. Without a tag it SHALL install the latest release.
|
||||
|
||||
#### Scenario: A pinned release is installed
|
||||
|
||||
- **WHEN** the installer is invoked with a release tag
|
||||
- **THEN** it fetches that tag's metadata and installs its bundle
|
||||
|
||||
#### Scenario: Default is the latest release
|
||||
|
||||
- **WHEN** the installer is invoked without a release tag
|
||||
- **THEN** it installs the latest release
|
||||
|
||||
#### Scenario: Unknown pinned release
|
||||
|
||||
- **WHEN** the pinned tag does not exist
|
||||
- **THEN** the installer exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Pinned release metadata endpoint
|
||||
|
||||
The app SHALL expose a same-origin endpoint that returns a specific release's
|
||||
tag and asset names as same-origin URLs, with the same authentication, filtering
|
||||
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
|
||||
tag that is not a valid release tag without contacting the forge.
|
||||
|
||||
#### Scenario: Tagged release is returned
|
||||
|
||||
- **WHEN** a client requests the metadata for an existing tag
|
||||
- **THEN** the response carries that release's tag and its assets as same-origin
|
||||
URLs
|
||||
|
||||
#### Scenario: Invalid tag is rejected
|
||||
|
||||
- **WHEN** a client requests metadata for a tag that is not a valid release tag
|
||||
- **THEN** the app rejects the request without contacting the forge
|
||||
|
||||
### Requirement: Installed version is tracked and upgrades are reported
|
||||
|
||||
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||
version, for every architecture it can install. It SHALL hand that version to
|
||||
the bundle installer, which SHALL record it beside the installed binary and
|
||||
SHALL report an upgrade with the previous and new versions on the next run.
|
||||
Uninstalling SHALL remove the recorded version.
|
||||
|
||||
#### Scenario: Version is derived from the asset name
|
||||
|
||||
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Version is derived for a 32-bit bundle
|
||||
|
||||
- **WHEN** the installer selects a generic bundle named
|
||||
`fpagent-<hash>-linux-i386.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Upgrade is reported
|
||||
|
||||
- **WHEN** the recorded version differs from the version being installed
|
||||
- **THEN** the bundle installer reports the change from the old to the new
|
||||
version
|
||||
|
||||
#### Scenario: First install records the version
|
||||
|
||||
- **WHEN** no version has been recorded yet
|
||||
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||
|
||||
#### Scenario: Uninstall removes the recorded version
|
||||
|
||||
- **WHEN** the installer is asked to uninstall
|
||||
- **THEN** the recorded version is removed
|
||||
|
||||
### Requirement: A managed running service is restarted on upgrade
|
||||
|
||||
When the installer is asked to manage the systemd service and a service is
|
||||
already running, the installer SHALL restart it after installing the new binary
|
||||
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
|
||||
restart, and the installer SHALL NOT restart the service when service management
|
||||
is not requested.
|
||||
|
||||
#### Scenario: Upgrade restarts the running service
|
||||
|
||||
- **WHEN** the running service is managed and a new binary is installed
|
||||
- **THEN** the service is restarted on the new binary
|
||||
|
||||
#### Scenario: Fresh install starts the service
|
||||
|
||||
- **WHEN** the service is not running and service management is requested
|
||||
- **THEN** the service is started and no separate restart is performed
|
||||
|
||||
#### Scenario: Service management not requested
|
||||
|
||||
- **WHEN** service management is not requested
|
||||
- **THEN** the installer does not restart the service
|
||||
@@ -60,24 +60,33 @@ only for permitted operators.
|
||||
|
||||
### Requirement: Saving requires a live gate and an OTP
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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…</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…</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…</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…</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…</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…</span>
|
||||
</cds-button>
|
||||
@@ -82,6 +80,10 @@
|
||||
<span class="hint">Two fingers are required · 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…</span>
|
||||
</cds-button>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Help · YOUreka Tools</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
|
||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
|
||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
|
||||
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/accordion.min.js"></script>
|
||||
</head>
|
||||
<body class="app cds--g100">
|
||||
<!--app-header-->
|
||||
|
||||
<main class="container">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Help</h1>
|
||||
<p class="sub">How to use the YOUreka Tools</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="help-intro">
|
||||
YOUreka Tools is a set of field utilities for YOUreka ID domains. Sign in
|
||||
with your YOUreka account to use the Image Cropper and the Fingerprint
|
||||
Agent. Everything runs on this machine — nothing is uploaded except
|
||||
the artifacts you explicitly save. Permissions are per domain, so an
|
||||
administrator must grant you access to the domains you work on.
|
||||
</p>
|
||||
|
||||
<cds-accordion>
|
||||
<cds-accordion-item title="Image Cropper">
|
||||
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
|
||||
<ol class="help-steps">
|
||||
<li>Open <strong>Image Cropper</strong> from the landing page or the menu.</li>
|
||||
<li>Load a picture with <strong>Load next queued</strong>, or paste a queue item with <strong>Load by upload ID</strong>.</li>
|
||||
<li>Check the details (phone, domain, agent) and crop the photo in the editor.</li>
|
||||
<li>If the background needs cleaning, choose <strong>Requires cleaning? → Yes</strong>, then click <strong>Clean background</strong>.</li>
|
||||
<li>To keep it, click <strong>Accept picture</strong>, then <strong>Crop and upload picture</strong>. To reject it, click <strong>Reject picture</strong>, tick the reasons (and fill in <em>Others</em> if asked), then <strong>Send review</strong>.</li>
|
||||
</ol>
|
||||
<p class="help-note">The cropper requires the <em>crop_image</em> permission. If you see “No access”, ask a domain administrator to grant it.</p>
|
||||
</cds-accordion-item>
|
||||
|
||||
<cds-accordion-item title="Fingerprint Agent">
|
||||
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
||||
<ol class="help-steps">
|
||||
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
|
||||
<li>Choose the domain, enter the staff reference, then look up the profile.</li>
|
||||
<li>Confirm the profile is the right person.</li>
|
||||
<li>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
|
||||
<li>Review the captured prints and save.</li>
|
||||
</ol>
|
||||
<p class="help-note">The scanner runs locally through the <em>fpagent</em> service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes. The wizard shows whether a supported scanner is connected and disables capture until one is.</p>
|
||||
</cds-accordion-item>
|
||||
</cds-accordion>
|
||||
|
||||
<p class="help-note">
|
||||
Your session lasts 30 days. Use <strong>Sign out</strong> in the menu to end it early.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<div class="container">
|
||||
<span>YOUreka Tools · Help</span>
|
||||
<span>Need more detail? Ask your YOUreka ID administrator.</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# install.sh — download and install the latest YOUreka Fingerprint Agent
|
||||
# (fpagent) release on this machine.
|
||||
#
|
||||
# Hosted at https://tools.yrka.app/fpagent/install.sh and meant to be piped
|
||||
# straight into bash on a field laptop:
|
||||
#
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
|
||||
#
|
||||
# It downloads the bundle, verifies its sha256, then hands off to the bundle's
|
||||
# own installer, which handles libfprint, the udev rule, plugdev and the
|
||||
# systemd service. Every flag is forwarded to that installer, e.g.:
|
||||
#
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --service-user alice
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check
|
||||
#
|
||||
# The private release is reached through the tools app, so no credentials are
|
||||
# needed here.
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${FPAGENT_BASE_URL:-https://tools.yrka.app/fpagent}"
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf '[FAIL] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
|
||||
|
||||
Downloads the fpagent release, verifies it, and runs its installer.
|
||||
|
||||
Options:
|
||||
--tag TAG release tag to install (default: latest)
|
||||
|
||||
Options below are forwarded to the bundled installer:
|
||||
--check preflight and report only; change nothing
|
||||
--offline never touch the network; fail if dependencies are missing
|
||||
--no-service do not install the systemd service (also disables updates)
|
||||
--no-updates do not install the self-update timer
|
||||
--update-base URL release base for self-update
|
||||
(default: https://tools.yrka.app/fpagent)
|
||||
--service-user NAME user for the service (default: the invoking user)
|
||||
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
|
||||
--uninstall remove the binary, the udev rule and the service
|
||||
-h, --help the installer's help
|
||||
|
||||
The systemd service and the self-update timer are installed and enabled by
|
||||
default; --no-service and --no-updates turn them off.
|
||||
|
||||
Environment:
|
||||
FPAGENT_BASE_URL override the download base (default: ${BASE})
|
||||
EOF
|
||||
}
|
||||
|
||||
RELEASE_TAG="latest"
|
||||
FORWARD=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--tag)
|
||||
RELEASE_TAG="${2:?--tag needs a release tag}"
|
||||
shift
|
||||
;;
|
||||
--tag=*)
|
||||
RELEASE_TAG="${1#--tag=}"
|
||||
;;
|
||||
*)
|
||||
FORWARD+=("$1")
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
for cmd in curl tar sha256sum python3 find; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || die "$cmd is required but not installed"
|
||||
done
|
||||
|
||||
tmpbase="${TMPDIR:-/tmp}"
|
||||
# Sweep scratch left by an earlier run that was SIGKILLed: the EXIT trap cannot
|
||||
# run then (that is the one signal it cannot catch). Scoped to our own prefix,
|
||||
# this user, and dirs older than a day, so a concurrent install is never touched.
|
||||
# Runs before any network work so cleanup is not skipped when the fetch fails.
|
||||
find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
|
||||
-name 'fpagent-install.*' -mtime +0 -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Offline bundles ship the libfprint .deb closure for a specific Ubuntu
|
||||
# release; prefer the one matching this machine, else fall back to the generic
|
||||
# online bundle (whose installer apt-get's the dependency).
|
||||
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
||||
|
||||
# Releases are named for the target architecture; only these two are published.
|
||||
# Mirrors the bundled installer's own preflight so both reject the same hosts.
|
||||
host_machine="$(uname -m)"
|
||||
case "$host_machine" in
|
||||
x86_64 | amd64) arch="x86_64" ;;
|
||||
i386 | i486 | i586 | i686) arch="i386" ;;
|
||||
*) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;;
|
||||
esac
|
||||
|
||||
if [ "$RELEASE_TAG" = "latest" ]; then
|
||||
meta_url="${BASE}/releases/latest"
|
||||
else
|
||||
meta_url="${BASE}/releases/tags/${RELEASE_TAG}"
|
||||
fi
|
||||
|
||||
say "Fetching the Fingerprint Agent release (${RELEASE_TAG})..."
|
||||
meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from ${BASE}"
|
||||
|
||||
readout="$(python3 -c '
|
||||
import json, re, sys
|
||||
codename, arch = sys.argv[1], sys.argv[2]
|
||||
release = json.loads(sys.argv[3])
|
||||
assets = [a["name"] for a in release.get("assets", [])]
|
||||
|
||||
def pick(suffix):
|
||||
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "")
|
||||
|
||||
name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
|
||||
if not name:
|
||||
name = pick("-linux-" + arch + ".tar.gz")
|
||||
# The git hash is baked into the asset name (bundle.sh --version) and into
|
||||
# the binary itself; it is the version we record and later compare against.
|
||||
m = re.match(r"^fpagent-(.+)-(?:linux|ubuntu-[a-z0-9]+)-" + re.escape(arch) + r"\.tar\.gz$", name)
|
||||
version = m.group(1) if m else ""
|
||||
print(release.get("tag", "") + "\t" + name + "\t" + version)
|
||||
' "$codename" "$arch" "$meta")" || die "could not parse the release metadata"
|
||||
|
||||
IFS=$'\t' read -r tag asset version <<<"$readout"
|
||||
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)"
|
||||
|
||||
say " release ${tag}"
|
||||
say " bundle ${asset}"
|
||||
[ -n "$version" ] && say " version ${version}"
|
||||
|
||||
tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
get() { curl -fsSL "$1" -o "$2" || die "download failed: $1"; }
|
||||
|
||||
say "Downloading and verifying..."
|
||||
get "${BASE}/releases/download/${tag}/${asset}" "${tmp}/${asset}"
|
||||
get "${BASE}/releases/download/${tag}/${asset}.sha256" "${tmp}/${asset}.sha256"
|
||||
(cd "$tmp" && sha256sum -c "${asset}.sha256") || die "checksum verification failed for ${asset}"
|
||||
|
||||
tar xzf "${tmp}/${asset}" -C "$tmp" || die "could not extract ${asset}"
|
||||
[ -x "${tmp}/install.sh" ] || die "the bundle does not contain install.sh"
|
||||
|
||||
# Hand the version to the bundle installer so it can record it and report an
|
||||
# upgrade. Bundles built by bundle.sh already carry VERSION; this also covers
|
||||
# older bundles and other packagers, using the hash from the asset name.
|
||||
[ -n "$version" ] && printf '%s\n' "$version" >"${tmp}/VERSION"
|
||||
|
||||
say "Running the installer (pass --enable-service to start at boot)..."
|
||||
"${tmp}/install.sh" "${FORWARD[@]}"
|
||||
+2
-28
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// The /fingerprint wizard's scanner-availability logic. app.js is a browser
|
||||
// IIFE, so it is loaded in a vm sandbox with stubbed browser globals and its
|
||||
// Vue options are exercised directly (no Vue runtime needed).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = fs.readFileSync(new URL('../public/assets/fp/app.js', import.meta.url), 'utf8');
|
||||
|
||||
function loadApp() {
|
||||
let options = null;
|
||||
let socket = null;
|
||||
const els = {};
|
||||
const document = {
|
||||
getElementById(id) {
|
||||
return (els[id] ||= {
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
});
|
||||
},
|
||||
};
|
||||
const sandbox = {
|
||||
window: {
|
||||
__SESSION__: { agentUrl: 'http://127.0.0.1:9000', domains: [] },
|
||||
location: { href: '' },
|
||||
},
|
||||
document,
|
||||
Vue: class Vue { static config = {}; constructor(o) { options = o; } },
|
||||
io() {
|
||||
socket = { handlers: {}, on(e, cb) { this.handlers[e] = cb; return this; } };
|
||||
return socket;
|
||||
},
|
||||
setInterval: () => 0,
|
||||
setTimeout: () => 0,
|
||||
clearTimeout: () => {},
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(SRC, sandbox);
|
||||
return {
|
||||
get options() { return options; },
|
||||
get socket() { return socket; },
|
||||
getElement: (id) => document.getElementById(id),
|
||||
};
|
||||
}
|
||||
|
||||
function makeVm(app) {
|
||||
const o = app.options;
|
||||
const inst = Object.assign({}, o.data);
|
||||
for (const [k, fn] of Object.entries(o.methods)) inst[k] = fn.bind(inst);
|
||||
for (const [k, fn] of Object.entries(o.computed)) {
|
||||
Object.defineProperty(inst, k, { get: fn.bind(inst), configurable: true });
|
||||
}
|
||||
inst.$forceUpdate = () => {};
|
||||
inst.loadSession();
|
||||
inst.connectAgent();
|
||||
return inst;
|
||||
}
|
||||
|
||||
test('disconnected agent blocks capture and warns', () => {
|
||||
const vmInst = makeVm(loadApp());
|
||||
assert.equal(vmInst.captureReady, false);
|
||||
assert.equal(vmInst.scannerNotice.title, 'Capture agent offline');
|
||||
});
|
||||
|
||||
test('available:false blocks capture and warns; true re-enables', () => {
|
||||
const app = loadApp();
|
||||
const vmInst = makeVm(app);
|
||||
const h = app.socket.handlers;
|
||||
h.connect();
|
||||
h['fprint-device-status']({ available: false, device: '' });
|
||||
assert.equal(vmInst.captureReady, false);
|
||||
assert.equal(vmInst.scannerNotice.title, 'Scanner not connected');
|
||||
|
||||
h['fprint-device-status']({ available: true, device: 'Digital Persona U.are.U' });
|
||||
assert.equal(vmInst.captureReady, true);
|
||||
assert.equal(vmInst.scannerNotice, null);
|
||||
assert.equal(vmInst.agent.device, 'Digital Persona U.are.U');
|
||||
});
|
||||
|
||||
test('unknown availability (old agent) does not block capture', () => {
|
||||
const app = loadApp();
|
||||
const vmInst = makeVm(app);
|
||||
app.socket.handlers.connect();
|
||||
assert.equal(vmInst.agent.available, null);
|
||||
assert.equal(vmInst.captureReady, true);
|
||||
assert.equal(vmInst.scannerNotice, null);
|
||||
});
|
||||
|
||||
test('status tag reflects offline, connected, no-scanner and ready', () => {
|
||||
const app = loadApp();
|
||||
const vmInst = makeVm(app);
|
||||
const h = app.socket.handlers;
|
||||
const el = app.getElement('agent-status');
|
||||
|
||||
vmInst.renderAgentTag();
|
||||
assert.equal(el.textContent, 'Agent offline');
|
||||
assert.equal(el.getAttribute('type'), 'red');
|
||||
|
||||
h.connect();
|
||||
assert.equal(el.textContent, 'Agent connected');
|
||||
|
||||
h['agent-hello']({ version: 'abc123', captureAgentId: 'aid' });
|
||||
assert.equal(el.getAttribute('title'), 'fpagent abc123');
|
||||
|
||||
h['fprint-device-status']({ available: false });
|
||||
assert.equal(el.textContent, 'Scanner not connected');
|
||||
assert.equal(el.getAttribute('type'), 'red');
|
||||
|
||||
h['fprint-device-status']({ available: true, device: 'U.are.U 4500' });
|
||||
assert.equal(el.textContent, 'Agent ready');
|
||||
assert.equal(el.getAttribute('type'), 'green');
|
||||
assert.match(el.getAttribute('title'), /abc123/);
|
||||
assert.match(el.getAttribute('title'), /U\.are\.U 4500/);
|
||||
});
|
||||
|
||||
test('capture is refused when no reader is attached', () => {
|
||||
const app = loadApp();
|
||||
const vmInst = makeVm(app);
|
||||
const h = app.socket.handlers;
|
||||
h.connect();
|
||||
h['fprint-device-status']({ available: false });
|
||||
vmInst.domain = 'uc';
|
||||
vmInst.reference = 'UC/1';
|
||||
vmInst.capture('right-thumb');
|
||||
assert.equal(vmInst.capturing, null);
|
||||
assert.equal(vmInst.notif.show, true);
|
||||
});
|
||||
|
||||
test('lookup and save need no OTP credential', () => {
|
||||
const vmInst = makeVm(loadApp());
|
||||
vmInst.domain = 'uc';
|
||||
vmInst.reference = 'UC/1';
|
||||
assert.equal(vmInst.canLookup, true);
|
||||
vmInst.fingers['right-thumb'].status = 'done';
|
||||
vmInst.fingers['right-index-finger'].status = 'done';
|
||||
assert.equal(vmInst.canSave, true);
|
||||
});
|
||||
|
||||
test('disconnect clears availability', () => {
|
||||
const app = loadApp();
|
||||
const vmInst = makeVm(app);
|
||||
const h = app.socket.handlers;
|
||||
h.connect();
|
||||
h['fprint-device-status']({ available: true, device: 'U.are.U' });
|
||||
h.disconnect();
|
||||
assert.equal(vmInst.agent.connected, false);
|
||||
assert.equal(vmInst.agent.available, null);
|
||||
assert.equal(vmInst.agent.device, '');
|
||||
assert.equal(vmInst.captureReady, false);
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
// The /fpagent installer surface: a public script, and a proxy that reads the
|
||||
// private Gitea release with GITEA_RELEASE_INSTALL_KEY so the operator's
|
||||
// laptop never needs credentials.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { createApp, FPAGENT_REPO } from '../server.js';
|
||||
import { startServer, stopServer, urlOf } from './helpers.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'public',
|
||||
'fpagent',
|
||||
'install.sh'
|
||||
);
|
||||
|
||||
const TOKEN = 'release-install-secret';
|
||||
const TAG = 'v9.9.9';
|
||||
const ARCHIVE = 'fpagent-deadbee-linux-x86_64.tar.gz';
|
||||
const UBUNTU_ARCHIVE = 'fpagent-deadbee-ubuntu-jammy-x86_64.tar.gz';
|
||||
const RELEASE = {
|
||||
tag_name: TAG,
|
||||
assets: [
|
||||
{ name: ARCHIVE },
|
||||
{ name: `${ARCHIVE}.sha256` },
|
||||
{ name: UBUNTU_ARCHIVE },
|
||||
{ name: 'not a safe name' },
|
||||
{ name: '../escape' },
|
||||
],
|
||||
};
|
||||
|
||||
// A directory holding a fake `uname` that reports the given machine, so the
|
||||
// installer's architecture-sensitive picker runs on any CI host. PATH is
|
||||
// prepended with it; everything else resolves as usual.
|
||||
function fakeUname(machine) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
|
||||
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Fake Gitea: refuses without the token, serves the release and its assets.
|
||||
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
const server = await startServer((req, res) => {
|
||||
calls.push({ url: req.url, auth: req.headers.authorization });
|
||||
if (req.headers.authorization !== `token ${TOKEN}`) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.url === `/api/v1/repos/${FPAGENT_REPO}/releases/latest`) {
|
||||
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(RELEASE));
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) {
|
||||
res.writeHead(releaseStatus, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(RELEASE));
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) {
|
||||
res.writeHead(downloadStatus, { 'content-type': 'application/gzip' });
|
||||
res.end(Buffer.from('bundle-bytes'));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
return { server, calls };
|
||||
}
|
||||
|
||||
async function bootFpagentApp(giteaUrl, overrides = {}) {
|
||||
const app = createApp({
|
||||
giteaUrl,
|
||||
fpagentRepo: FPAGENT_REPO,
|
||||
giteaToken: TOKEN,
|
||||
cropperClients: null,
|
||||
...overrides,
|
||||
});
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
return { server, base: urlOf(server) };
|
||||
}
|
||||
|
||||
test('latest release is proxied as tools-relative URLs, without the token', async () => {
|
||||
const gitea = await startFakeGitea();
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/latest`);
|
||||
assert.equal(r.status, 200);
|
||||
const body = await r.json();
|
||||
assert.equal(body.tag, TAG);
|
||||
// Unsafe asset names are dropped; the rest point back at this app.
|
||||
assert.deepEqual(
|
||||
body.assets.map((a) => a.name),
|
||||
[ARCHIVE, `${ARCHIVE}.sha256`, UBUNTU_ARCHIVE]
|
||||
);
|
||||
for (const a of body.assets) {
|
||||
assert.equal(a.url, `/fpagent/releases/download/${TAG}/${a.name}`);
|
||||
}
|
||||
assert.ok(!JSON.stringify(body).includes(TOKEN));
|
||||
assert.equal(gitea.calls[0].auth, `token ${TOKEN}`); // server authenticated upstream
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinned tag is proxied from the forge tag endpoint', async () => {
|
||||
const gitea = await startFakeGitea();
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/tags/v1.0.0`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal((await r.json()).tag, TAG);
|
||||
assert.ok(gitea.calls[0].url.endsWith('/releases/tags/v1.0.0'));
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsafe release tag is rejected before reaching the forge', async () => {
|
||||
const gitea = await startFakeGitea();
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/tags/-bad`);
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(gitea.calls.length, 0);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.sh --tag asks for that tag, not latest', async () => {
|
||||
const calls = [];
|
||||
const gateway = await startServer((req, res) => {
|
||||
calls.push(req.url);
|
||||
if (req.url === '/releases/tags/v7.7.7') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
tag: 'v7.7.7',
|
||||
assets: [{ name: 'fpagent-cafebabe-linux-x86_64.tar.gz' }],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-tag-test-'));
|
||||
try {
|
||||
let failed = false;
|
||||
try {
|
||||
// Async so the in-process gateway can answer while the child runs.
|
||||
await execFileAsync('bash', [SCRIPT, '--tag', 'v7.7.7', '--check'], {
|
||||
env: { ...process.env, TMPDIR: tmp, FPAGENT_BASE_URL: urlOf(gateway) },
|
||||
});
|
||||
} catch {
|
||||
failed = true; // the asset download 404s; the metadata request is what matters
|
||||
}
|
||||
assert.equal(failed, true);
|
||||
assert.ok(calls.includes('/releases/tags/v7.7.7'), 'should request the pinned tag');
|
||||
assert.ok(!calls.includes('/releases/latest'), 'should not fall back to latest');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
await stopServer(gateway);
|
||||
}
|
||||
});
|
||||
|
||||
test('bundles stream through this app; the client sends no credentials', async () => {
|
||||
const gitea = await startFakeGitea();
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(Buffer.from(await r.arrayBuffer()).toString(), 'bundle-bytes');
|
||||
const assetCall = gitea.calls.find((c) => c.url.includes('/releases/download/'));
|
||||
assert.equal(assetCall.auth, `token ${TOKEN}`);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('unsafe asset names are rejected before reaching the forge', async () => {
|
||||
const gitea = await startFakeGitea();
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/-bad`);
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(gitea.calls.length, 0);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing bundle surfaces as 404', async () => {
|
||||
const gitea = await startFakeGitea({ downloadStatus: 404 });
|
||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`);
|
||||
assert.equal(r.status, 404);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
await stopServer(gitea.server);
|
||||
}
|
||||
});
|
||||
|
||||
test('without GITEA_RELEASE_INSTALL_KEY the installer reports 503', async () => {
|
||||
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/releases/latest`);
|
||||
assert.equal(r.status, 503);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.sh is served publicly at /fpagent/install.sh', async () => {
|
||||
const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' });
|
||||
try {
|
||||
const r = await fetch(`${base}/fpagent/install.sh`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(await r.text(), /FPAGENT_BASE_URL/);
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.sh picks the bundle for the host architecture', async () => {
|
||||
const calls = [];
|
||||
const gateway = await startServer((req, res) => {
|
||||
calls.push(req.url);
|
||||
if (req.url === '/releases/latest') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
tag: 'v9.9.9',
|
||||
assets: [
|
||||
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz' },
|
||||
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' },
|
||||
{ name: 'fpagent-cafebabe-linux-i386.tar.gz' },
|
||||
{ name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' },
|
||||
],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
const unameDir = fakeUname('i686');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||
try {
|
||||
let out = '';
|
||||
try {
|
||||
// Async so the in-process gateway can answer while the child runs.
|
||||
const r = await execFileAsync('bash', [SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${unameDir}:${process.env.PATH}`,
|
||||
TMPDIR: tmp,
|
||||
FPAGENT_BASE_URL: urlOf(gateway),
|
||||
},
|
||||
});
|
||||
out = r.stdout + r.stderr;
|
||||
} catch (e) {
|
||||
out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s
|
||||
}
|
||||
assert.match(out, /cafebabe/, 'the i386 version should come from the asset name');
|
||||
assert.ok(
|
||||
calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')),
|
||||
'should request the i386 bundle'
|
||||
);
|
||||
assert.ok(
|
||||
!calls.some((u) => u.includes('x86_64')),
|
||||
'should never request another architecture\u2019s bundle'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||
await stopServer(gateway);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.sh refuses an unsupported architecture before fetching', async () => {
|
||||
const calls = [];
|
||||
const gateway = await startServer((req, res) => {
|
||||
calls.push(req.url);
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
const unameDir = fakeUname('aarch64');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
|
||||
try {
|
||||
let out = '';
|
||||
let failed = false;
|
||||
try {
|
||||
const r = await execFileAsync('bash', [SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${unameDir}:${process.env.PATH}`,
|
||||
TMPDIR: tmp,
|
||||
FPAGENT_BASE_URL: urlOf(gateway),
|
||||
},
|
||||
});
|
||||
out = r.stdout + r.stderr;
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
out = `${e.stdout ?? ''}${e.stderr ?? ''}`;
|
||||
}
|
||||
assert.equal(failed, true, 'the installer should reject an unsupported host');
|
||||
assert.match(out, /unsupported architecture: aarch64/);
|
||||
assert.equal(calls.length, 0, 'should fail before touching the network');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
fs.rmSync(unameDir, { recursive: true, force: true });
|
||||
await stopServer(gateway);
|
||||
}
|
||||
});
|
||||
|
||||
test('install.sh is valid bash', () => {
|
||||
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
test('install.sh sweeps only its own stale scratch dirs', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-sweep-test-'));
|
||||
const stale = path.join(base, 'fpagent-install.oldoldold');
|
||||
const fresh = path.join(base, 'fpagent-install.freshfresh');
|
||||
const unrelated = path.join(base, 'someone-else.oldold');
|
||||
for (const dir of [stale, fresh, unrelated]) fs.mkdirSync(dir);
|
||||
const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
fs.utimesSync(stale, old, old);
|
||||
fs.utimesSync(unrelated, old, old);
|
||||
|
||||
try {
|
||||
// The base URL is unreachable, so the run fails after the sweep: enough to
|
||||
// exercise the cleanup without downloading anything.
|
||||
let failed = false;
|
||||
try {
|
||||
execFileSync('bash', [SCRIPT], {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, TMPDIR: base, FPAGENT_BASE_URL: 'http://127.0.0.1:9' },
|
||||
});
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
assert.equal(failed, true, 'the installer should fail on the unreachable base');
|
||||
|
||||
assert.equal(fs.existsSync(stale), false, 'stale fpagent scratch should be removed');
|
||||
assert.equal(fs.existsSync(fresh), true, 'fresh (possibly concurrent) scratch should survive');
|
||||
assert.equal(fs.existsSync(unrelated), true, 'another prefix should never be touched');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+104
-38
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user