Compare commits
16
Commits
4d006e361f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528eaafb18 | ||
|
|
8a13fb527e | ||
|
|
8d42faf707 | ||
|
|
e432585f04 | ||
|
|
fa33c6e179 | ||
|
|
14f6fd6a8e | ||
|
|
548e33390c | ||
|
|
35d895574f | ||
|
|
ff62770bcc | ||
|
|
2ad4aa63c6 | ||
|
|
0698a9c9a9 | ||
|
|
e3392c7750 | ||
|
|
46027acec1 | ||
|
|
6d3909c334 | ||
|
|
b3b75ffac0 | ||
|
|
1ddf61b59f |
@@ -18,3 +18,4 @@ API_BASE="encrypted:BIJm9rXCjB90I6zZFK57kTTjx2KY0+V42NF8zk38YVcSNTu4PB5Dxdz6iOEz
|
|||||||
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BNgr8Q5ZURg8EhuMNk7gdZy7DvzjC98tGRciLsQQFpAWnZ5eEGCM/S8DgJTPpQR1OA+L4LUUbW8QhBwsrUwjzqG3/Z8Tnqwma4bIwtXr1s9RERfimZGJ+1XEieY6b7hU3icl2aUBScdOYGFSfx4/Cj94Uz0onzeiMfzS9ZdGy6TUrVzXWCJsS9g="
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
# Image Cropper service credentials — the ONLY environment variables this app
|
# Image Cropper service credentials. Everything else (URLs, port, domain
|
||||||
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
|
# catalog) is hardcoded in source. Without these the rest of the app works and
|
||||||
# Without these the rest of the app works and /cropper answers 503.
|
# /cropper answers 503.
|
||||||
#
|
#
|
||||||
# This repository uses dotenvx-encrypted env files (.env, .env.production);
|
# This repository uses dotenvx-encrypted env files (.env, .env.production);
|
||||||
# the private keys live in .env.keys (gitignored). To set up or change a value:
|
# the private keys live in .env.keys (gitignored). To set up or change a value:
|
||||||
@@ -16,3 +16,9 @@ AZURE_STORAGE_CONNECTION_STRING=
|
|||||||
NODERED_HTTP_USERNAME=
|
NODERED_HTTP_USERNAME=
|
||||||
NODERED_HTTP_PASSWORD=
|
NODERED_HTTP_PASSWORD=
|
||||||
CARVEKIT_API_KEY=
|
CARVEKIT_API_KEY=
|
||||||
|
|
||||||
|
# Token the server uses to read the private fpagent releases from the forge
|
||||||
|
# (https://sources.yrka.app). It stays server-side: /fpagent/releases/* proxies
|
||||||
|
# the bundles for the public installer at /fpagent/install.sh. Without it the
|
||||||
|
# installer answers 503.
|
||||||
|
GITEA_RELEASE_INSTALL_KEY=
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ API_BASE="encrypted:BFVQaDK474EqpNvSw6iiP8KYjRqyXOMQLL/m5lbQHI30QCrNoMfsxmC7W2mH
|
|||||||
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BOYECnGcqfZlj8pRvTldqB/UF6HnBlMCfOymdMd3eRvGFo+HwPRViboFteB/tdMAF9uxL5q80OrgB9qbC6LaDTa/ociZg2kVR+R00YB/ZEV4epamIZ1ZzXWrg8J9D4zTGkJJ5iRiqyE4qGoPxDhn3OhTrs5RfBl/7pJa6Wi9wTU+1xhT3oBJr7E="
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ tools/
|
|||||||
├── cropper.js # /cropper router (queue, review, save) — injectable clients
|
├── cropper.js # /cropper router (queue, review, save) — injectable clients
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html # landing page shell (tiles injected per session)
|
│ ├── index.html # landing page shell (tiles injected per session)
|
||||||
|
│ ├── fpagent/
|
||||||
|
│ │ └── install.sh # public /fpagent/install.sh (proxies private releases)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── style.css # shared Carbon-styled layout + g100 theme
|
│ ├── style.css # shared Carbon-styled layout + g100 theme
|
||||||
│ ├── help.html # /help docs page
|
│ ├── help.html # /help docs page
|
||||||
@@ -105,11 +107,10 @@ is treated as anonymous and the stale cookie cleared.
|
|||||||
### a2loft proxy
|
### a2loft proxy
|
||||||
|
|
||||||
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
|
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
|
||||||
under same-origin routes. The proxy attaches the operator's phone from the
|
under same-origin routes. The proxy authenticates the operator with the
|
||||||
server-side session and forwards the OTP supplied by the page; the operator
|
signed-in session's PocketBase token, sent to a2loft as a `Bearer` credential;
|
||||||
never sends credentials to a third party directly. The cropper's save call
|
the operator never sends credentials to a third party directly. The cropper's
|
||||||
instead authenticates with the session token (`Bearer`) after a live permission
|
save call uses the same token flow after a live permission check.
|
||||||
check.
|
|
||||||
|
|
||||||
## The Image Cropper
|
## The Image Cropper
|
||||||
|
|
||||||
@@ -119,9 +120,10 @@ record, crop the photo, optionally **Clean background**, then accept or reject
|
|||||||
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
|
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
|
||||||
live and uploads through the queue.
|
live and uploads through the queue.
|
||||||
|
|
||||||
Its service credentials are the **only** environment variables this app uses.
|
Its service credentials live in dotenvx-encrypted files at the repository root
|
||||||
They live in dotenvx-encrypted files at the repository root — `.env` for
|
— `.env` for development, `.env.production` for deployment. The fpagent
|
||||||
development, `.env.production` for deployment:
|
installer key is read from the same files (see
|
||||||
|
[Installing the agent](#installing-the-agent)):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
||||||
@@ -142,20 +144,55 @@ dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
|
|||||||
|
|
||||||
The `/fingerprint` wizard walks the operator through:
|
The `/fingerprint` wizard walks the operator through:
|
||||||
|
|
||||||
1. **Locate profile** — pick a permitted domain, enter the staff reference and
|
1. **Locate profile** — pick a permitted domain, enter the staff reference, and
|
||||||
OTP, and look up the record through the a2loft proxy.
|
look up the record through the a2loft proxy.
|
||||||
2. **Capture** — right thumb and right index finger, one capture at a time, with
|
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
|
per-scan progress and cancel. The PocketBase token, domain and reference
|
||||||
travel with every `fprint-enroll` to fpagent.
|
travel with every `fprint-enroll` to fpagent.
|
||||||
3. **Save** — both templates are posted to a2loft with a fresh OTP.
|
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
|
## Configuration
|
||||||
|
|
||||||
Everything except the cropper credentials is hardcoded in `tools/server.js` by
|
Everything except the credentials is hardcoded in `tools/server.js` by design —
|
||||||
design — port `4173`, portal `https://portal.yrka.app`, PocketBase
|
port `4173`, portal `https://portal.yrka.app`, PocketBase `https://pb.itcass.net`,
|
||||||
`https://pb.itcass.net`, a2loft `https://apis.a2loft.net`, agent
|
a2loft `https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, forge
|
||||||
`http://127.0.0.1:9000`, and the domain catalog. Change them there; there are no
|
`https://sources.yrka.app` and the domain catalog. Change them there. The only
|
||||||
other environment variables or config files.
|
environment variables are the cropper credentials and the fpagent installer key
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` above.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
### Requirement: Two-finger enrollment flow
|
||||||
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||||
up staff by reference (OTP-validated through the a2loft proxy), displaying
|
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||||
the staff details, capturing the right thumb and right index finger, and
|
the staff details, capturing the right thumb and right index finger, and
|
||||||
saving both fingerprints through the a2loft proxy with an OTP. The wizard
|
saving both fingerprints through the a2loft proxy authenticated with the
|
||||||
SHALL offer per-finger recapture until save, SHALL require both fingers
|
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||||
before save, and SHALL reset domain-independent state (reference, staff
|
require both fingers before save, and SHALL reset domain-independent state
|
||||||
details, captured artifacts) when starting a new enrollment.
|
(reference, staff details, captured artifacts) when starting a new
|
||||||
|
enrollment.
|
||||||
|
|
||||||
#### Scenario: Complete enrollment
|
#### Scenario: Complete enrollment
|
||||||
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||||
with an OTP
|
|
||||||
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||||
wizard reports success
|
wizard reports success
|
||||||
|
|
||||||
@@ -137,20 +137,63 @@ details, captured artifacts) when starting a new enrollment.
|
|||||||
- **THEN** the reference, staff details, and captured finger artifacts are
|
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||||
cleared while the permitted domain list persists
|
cleared while the permitted domain list persists
|
||||||
|
|
||||||
### Requirement: OTP validated on every a2loft call
|
### Requirement: Scanner availability is shown and gates capture
|
||||||
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
|
The wizard SHALL consume fpagent's `fprint-device-status`
|
||||||
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
|
`{available, device, captureAgentId}` event and SHALL distinguish the agent
|
||||||
Submission SHALL be blocked while the OTP is missing.
|
being offline (socket not connected), connected with no supported reader
|
||||||
|
attached (`available` false), and ready (`available` true). Until a status is
|
||||||
|
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
|
||||||
|
capture while no supported reader is attached, SHALL re-enable capture when a
|
||||||
|
reader becomes available, and SHALL clear availability when the agent
|
||||||
|
disconnects. Availability means "a supported reader is attached"; it does not
|
||||||
|
guarantee a capture will succeed.
|
||||||
|
|
||||||
#### Scenario: Lookup with OTP
|
#### Scenario: Reader attached on connect
|
||||||
- **WHEN** the operator submits a reference with an OTP
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
|
`available: true`
|
||||||
displays the returned staff details
|
- **THEN** the wizard shows the agent ready and leaves capture enabled
|
||||||
|
|
||||||
#### Scenario: Save requires a fresh OTP
|
#### Scenario: No reader attached on connect
|
||||||
|
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
|
||||||
|
`available: false`
|
||||||
|
- **THEN** the wizard shows that the scanner is not connected, explains captures
|
||||||
|
are disabled, and disables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Unplug blocks capture
|
||||||
|
- **WHEN** fpagent reports `available: false` while the operator is capturing or
|
||||||
|
between captures
|
||||||
|
- **THEN** the wizard disables the capture controls and a capture attempt is
|
||||||
|
refused
|
||||||
|
|
||||||
|
#### Scenario: Replug re-enables capture
|
||||||
|
- **WHEN** fpagent reports `available: true` after the reader is reconnected
|
||||||
|
- **THEN** the wizard re-enables the capture controls
|
||||||
|
|
||||||
|
#### Scenario: Status absent or old agent
|
||||||
|
- **WHEN** the connected agent never reports `fprint-device-status`
|
||||||
|
- **THEN** availability stays unknown and the wizard does not block capture
|
||||||
|
|
||||||
|
#### Scenario: Disconnect clears availability
|
||||||
|
- **WHEN** the agent socket disconnects
|
||||||
|
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||||
|
available
|
||||||
|
|
||||||
|
### Requirement: Token authenticated on every a2loft call
|
||||||
|
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||||
|
save — with the signed-in operator's PocketBase session token, sent by the
|
||||||
|
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||||
|
NOT collect an OTP.
|
||||||
|
|
||||||
|
#### Scenario: Lookup uses the session token
|
||||||
|
- **WHEN** the operator submits a reference
|
||||||
|
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||||
|
session token
|
||||||
|
|
||||||
|
#### Scenario: Save uses the session token
|
||||||
- **WHEN** the operator saves the captured fingerprints
|
- **WHEN** the operator saves the captured fingerprints
|
||||||
- **THEN** the wizard sends the OTP entered for the save call
|
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
|
||||||
|
the session token
|
||||||
|
|
||||||
#### Scenario: Missing OTP blocks submission
|
#### Scenario: No OTP field
|
||||||
- **WHEN** the OTP field is empty for a lookup or save
|
- **WHEN** the wizard page is served
|
||||||
- **THEN** the corresponding submit control is disabled
|
- **THEN** it offers no OTP input for lookup or save
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# fpagent-installer Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Lets a field laptop install the latest private `fpagent` release with a single
|
||||||
|
public command, without ever handling the forge credential — the tools app
|
||||||
|
proxies the private release and serves the installer.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Public installer endpoint
|
||||||
|
|
||||||
|
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||||
|
reachable without authentication. When run, the script SHALL determine the
|
||||||
|
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||||
|
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||||
|
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||||
|
bundle built for the host's distro codename when one exists for that
|
||||||
|
architecture, and otherwise use the generic bundle for that architecture. The
|
||||||
|
script SHALL be served uncached.
|
||||||
|
|
||||||
|
#### Scenario: Installer is fetched anonymously
|
||||||
|
|
||||||
|
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||||
|
- **THEN** the app returns the installer script and does not redirect to login
|
||||||
|
|
||||||
|
#### Scenario: Host-appropriate bundle is chosen
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||||
|
codename and a generic bundle
|
||||||
|
- **THEN** the installer uses the codename bundle
|
||||||
|
|
||||||
|
#### Scenario: Generic bundle is the fallback
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||||
|
codename
|
||||||
|
- **THEN** the installer uses the generic bundle
|
||||||
|
|
||||||
|
#### Scenario: Bundle matches the host architecture
|
||||||
|
|
||||||
|
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||||
|
bundle for another architecture
|
||||||
|
- **THEN** the installer chooses the bundle for the host architecture
|
||||||
|
- **AND** it never chooses a bundle for another architecture
|
||||||
|
|
||||||
|
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||||
|
generic 32-bit bundle
|
||||||
|
- **THEN** the installer uses that generic 32-bit bundle
|
||||||
|
|
||||||
|
#### Scenario: Checksum is verified before extraction
|
||||||
|
|
||||||
|
- **WHEN** the installer downloads a bundle and its published checksum
|
||||||
|
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||||
|
when the checksum does not match
|
||||||
|
|
||||||
|
#### Scenario: Options are forwarded to the bundled installer
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||||
|
`--check`
|
||||||
|
- **THEN** it passes those options through to the extracted bundle's installer
|
||||||
|
|
||||||
|
#### Scenario: No compatible bundle
|
||||||
|
|
||||||
|
- **WHEN** the latest release contains no bundle for the host architecture
|
||||||
|
- **THEN** the installer exits with a failure message that names the host
|
||||||
|
architecture and installs nothing
|
||||||
|
|
||||||
|
#### Scenario: Unsupported architecture
|
||||||
|
|
||||||
|
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||||
|
x86_64 nor i386
|
||||||
|
- **THEN** it exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns the latest release's
|
||||||
|
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
|
||||||
|
authenticate to the private forge using the configured release key, SHALL omit
|
||||||
|
asset names that are not valid asset names, and SHALL NOT reveal the release
|
||||||
|
key or the forge host to clients.
|
||||||
|
|
||||||
|
#### Scenario: Latest release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the latest-release endpoint and the forge returns
|
||||||
|
a release
|
||||||
|
- **THEN** the response carries the release tag and each asset's name and a
|
||||||
|
same-origin URL under `/fpagent/releases/download/`
|
||||||
|
|
||||||
|
#### Scenario: Unsafe asset names are omitted
|
||||||
|
|
||||||
|
- **WHEN** the forge release lists an asset whose name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** that asset does not appear in the response
|
||||||
|
|
||||||
|
#### Scenario: Release key is not exposed
|
||||||
|
|
||||||
|
- **WHEN** any client fetches the latest-release endpoint
|
||||||
|
- **THEN** the response body contains neither the release key nor the forge host
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
#### Scenario: Forge unavailable
|
||||||
|
|
||||||
|
- **WHEN** the forge cannot be reached or returns an error for the latest
|
||||||
|
release
|
||||||
|
- **THEN** the endpoint answers with a gateway error
|
||||||
|
|
||||||
|
### Requirement: Release asset download proxy
|
||||||
|
|
||||||
|
The app SHALL stream a requested release asset to the client through a
|
||||||
|
same-origin route, authenticating the upstream request with the release key so
|
||||||
|
the client sends no credentials. It SHALL reject asset names that are not valid
|
||||||
|
asset names and SHALL report a missing asset as not found.
|
||||||
|
|
||||||
|
#### Scenario: Asset is streamed without client credentials
|
||||||
|
|
||||||
|
- **WHEN** a client requests a release asset by tag and name without any
|
||||||
|
credential
|
||||||
|
- **THEN** the app fetches it from the forge with the release key and streams
|
||||||
|
its bytes to the client
|
||||||
|
|
||||||
|
#### Scenario: Invalid asset name is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests an asset whose tag or name is not a valid asset
|
||||||
|
name
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
#### Scenario: Missing asset
|
||||||
|
|
||||||
|
- **WHEN** the forge reports the requested asset as not found
|
||||||
|
- **THEN** the app answers with not found
|
||||||
|
|
||||||
|
#### Scenario: Installer not configured
|
||||||
|
|
||||||
|
- **WHEN** the release key is not configured
|
||||||
|
- **THEN** the endpoint answers that the installer is not configured
|
||||||
|
|
||||||
|
### Requirement: Installer credential stays server-side
|
||||||
|
|
||||||
|
The app SHALL read the private-forge release key from the
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
|
||||||
|
the installer script, in any release metadata response, or in any streamed
|
||||||
|
asset.
|
||||||
|
|
||||||
|
#### Scenario: Served installer carries no credential
|
||||||
|
|
||||||
|
- **WHEN** a client fetches `/fpagent/install.sh`
|
||||||
|
- **THEN** the response contains no release key
|
||||||
|
|
||||||
|
#### Scenario: Credential supplied by the environment
|
||||||
|
|
||||||
|
- **WHEN** the release key is supplied in the environment
|
||||||
|
- **THEN** the installer and proxy endpoints use it without any change to the
|
||||||
|
hardcoded configuration
|
||||||
|
|
||||||
|
### Requirement: Pinned release selection
|
||||||
|
|
||||||
|
The installer SHALL accept a release tag and install that release instead of the
|
||||||
|
latest. Without a tag it SHALL install the latest release.
|
||||||
|
|
||||||
|
#### Scenario: A pinned release is installed
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked with a release tag
|
||||||
|
- **THEN** it fetches that tag's metadata and installs its bundle
|
||||||
|
|
||||||
|
#### Scenario: Default is the latest release
|
||||||
|
|
||||||
|
- **WHEN** the installer is invoked without a release tag
|
||||||
|
- **THEN** it installs the latest release
|
||||||
|
|
||||||
|
#### Scenario: Unknown pinned release
|
||||||
|
|
||||||
|
- **WHEN** the pinned tag does not exist
|
||||||
|
- **THEN** the installer exits with a failure message and installs nothing
|
||||||
|
|
||||||
|
### Requirement: Pinned release metadata endpoint
|
||||||
|
|
||||||
|
The app SHALL expose a same-origin endpoint that returns a specific release's
|
||||||
|
tag and asset names as same-origin URLs, with the same authentication, filtering
|
||||||
|
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
|
||||||
|
tag that is not a valid release tag without contacting the forge.
|
||||||
|
|
||||||
|
#### Scenario: Tagged release is returned
|
||||||
|
|
||||||
|
- **WHEN** a client requests the metadata for an existing tag
|
||||||
|
- **THEN** the response carries that release's tag and its assets as same-origin
|
||||||
|
URLs
|
||||||
|
|
||||||
|
#### Scenario: Invalid tag is rejected
|
||||||
|
|
||||||
|
- **WHEN** a client requests metadata for a tag that is not a valid release tag
|
||||||
|
- **THEN** the app rejects the request without contacting the forge
|
||||||
|
|
||||||
|
### Requirement: Installed version is tracked and upgrades are reported
|
||||||
|
|
||||||
|
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||||
|
version, for every architecture it can install. It SHALL hand that version to
|
||||||
|
the bundle installer, which SHALL record it beside the installed binary and
|
||||||
|
SHALL report an upgrade with the previous and new versions on the next run.
|
||||||
|
Uninstalling SHALL remove the recorded version.
|
||||||
|
|
||||||
|
#### Scenario: Version is derived from the asset name
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Version is derived for a 32-bit bundle
|
||||||
|
|
||||||
|
- **WHEN** the installer selects a generic bundle named
|
||||||
|
`fpagent-<hash>-linux-i386.tar.gz`
|
||||||
|
- **THEN** it uses `<hash>` as the version
|
||||||
|
|
||||||
|
#### Scenario: Upgrade is reported
|
||||||
|
|
||||||
|
- **WHEN** the recorded version differs from the version being installed
|
||||||
|
- **THEN** the bundle installer reports the change from the old to the new
|
||||||
|
version
|
||||||
|
|
||||||
|
#### Scenario: First install records the version
|
||||||
|
|
||||||
|
- **WHEN** no version has been recorded yet
|
||||||
|
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||||
|
|
||||||
|
#### Scenario: Uninstall removes the recorded version
|
||||||
|
|
||||||
|
- **WHEN** the installer is asked to uninstall
|
||||||
|
- **THEN** the recorded version is removed
|
||||||
|
|
||||||
|
### Requirement: A managed running service is restarted on upgrade
|
||||||
|
|
||||||
|
When the installer is asked to manage the systemd service and a service is
|
||||||
|
already running, the installer SHALL restart it after installing the new binary
|
||||||
|
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
|
||||||
|
restart, and the installer SHALL NOT restart the service when service management
|
||||||
|
is not requested.
|
||||||
|
|
||||||
|
#### Scenario: Upgrade restarts the running service
|
||||||
|
|
||||||
|
- **WHEN** the running service is managed and a new binary is installed
|
||||||
|
- **THEN** the service is restarted on the new binary
|
||||||
|
|
||||||
|
#### Scenario: Fresh install starts the service
|
||||||
|
|
||||||
|
- **WHEN** the service is not running and service management is requested
|
||||||
|
- **THEN** the service is started and no separate restart is performed
|
||||||
|
|
||||||
|
#### Scenario: Service management not requested
|
||||||
|
|
||||||
|
- **WHEN** service management is not requested
|
||||||
|
- **THEN** the installer does not restart the service
|
||||||
@@ -60,24 +60,33 @@ only for permitted operators.
|
|||||||
|
|
||||||
### Requirement: Saving requires a live gate and an OTP
|
### Requirement: Saving requires a live gate and an OTP
|
||||||
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||||
permission live (bypassing the cache) and SHALL require an OTP from the
|
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||||
operator, forwarding the session phone as the validating identity together
|
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||||
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
|
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||||
SHALL not reach a2loft.
|
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||||
|
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||||
|
is used in the request. A denied save SHALL not reach a2loft.
|
||||||
|
|
||||||
#### Scenario: Successful save
|
#### Scenario: Successful save
|
||||||
- **WHEN** a permitted operator saves with an OTP
|
- **WHEN** a permitted operator saves
|
||||||
- **THEN** the route verifies the live gate and forwards the picture to
|
- **THEN** the route verifies the live gate and forwards the picture to
|
||||||
a2loft with the session phone, OTP, and picture domain
|
a2loft with the session token as a bearer credential and the profile's
|
||||||
|
resolved domain
|
||||||
|
|
||||||
#### Scenario: Denied save does not reach a2loft
|
#### Scenario: Denied save does not reach a2loft
|
||||||
- **WHEN** the live crop check denies, or the OTP is missing
|
- **WHEN** the live crop check denies, or the request carries no message body
|
||||||
- **THEN** the route answers 403/400 and makes no a2loft call
|
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||||
|
|
||||||
|
#### Scenario: Unsafe domain values are refused
|
||||||
|
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||||
|
domain
|
||||||
|
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||||
|
|
||||||
### Requirement: Unified login — no separate artist login
|
### Requirement: Unified login — no separate artist login
|
||||||
The cropper SHALL NOT implement its own login. The operator's identity comes
|
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||||
from the YOUreka session (phone prefilled), the picture's metadata carries the
|
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||||
domain, and the only credential the operator supplies is the save-time OTP.
|
token; the picture's own metadata carries the domain. The cropper collects no
|
||||||
|
other credential — there is no save-time OTP.
|
||||||
|
|
||||||
#### Scenario: Phone prefilled from the session
|
#### Scenario: Phone prefilled from the session
|
||||||
- **WHEN** a signed-in operator opens the cropper
|
- **WHEN** a signed-in operator opens the cropper
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
|
|||||||
- **WHEN** a user opens the app root URL
|
- **WHEN** a user opens the app root URL
|
||||||
- **THEN** the page shows a link to the Image Cropper pointing at
|
- **THEN** the page shows a link to the Image Cropper pointing at
|
||||||
`/cropper` and a link to `/fingerprint`
|
`/cropper` and a link to `/fingerprint`
|
||||||
|
|
||||||
### Requirement: YOUreka Auth login establishes a cookie session
|
### Requirement: YOUreka Auth login establishes a cookie session
|
||||||
The app SHALL authenticate operators through the YOUreka portal web-auth
|
The app SHALL authenticate operators through the YOUreka portal web-auth
|
||||||
flow: create a web auth session at the portal with a local callback URL,
|
flow: create a web auth session at the portal with a local callback URL,
|
||||||
@@ -100,17 +101,21 @@ portal.
|
|||||||
### Requirement: a2loft proxies inject operator identity
|
### Requirement: a2loft proxies inject operator identity
|
||||||
The app SHALL proxy staff lookup and fingerprint-save calls to
|
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||||
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||||
attach the operator's phone from the server-side session and forward the
|
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||||
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
|
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||||
the a2loft request and response shapes. The proxies SHALL require a
|
response shapes. The proxies SHALL require a session; a request without one
|
||||||
session; the OTP SHALL NOT be accepted without one.
|
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||||
|
require an OTP.
|
||||||
|
|
||||||
#### Scenario: Staff lookup via proxy
|
#### Scenario: Staff lookup via proxy
|
||||||
- **WHEN** the page requests staff details for a domain and reference with
|
- **WHEN** the page requests staff details for a domain and reference
|
||||||
an OTP
|
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||||
- **THEN** the app calls a2loft's identity endpoint with the operator's
|
bearer credential and returns a2loft's response unchanged
|
||||||
phone from the session and the supplied OTP, and returns a2loft's
|
|
||||||
response unchanged
|
#### Scenario: Fingerprint save via proxy
|
||||||
|
- **WHEN** the page saves fingerprints for a domain and reference
|
||||||
|
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||||
|
token as a bearer credential
|
||||||
|
|
||||||
#### Scenario: Unauthenticated proxy call rejected
|
#### Scenario: Unauthenticated proxy call rejected
|
||||||
- **WHEN** a proxy route is called without a valid session
|
- **WHEN** a proxy route is called without a valid session
|
||||||
@@ -119,11 +124,14 @@ session; the OTP SHALL NOT be accepted without one.
|
|||||||
### Requirement: Configuration is hardcoded
|
### Requirement: Configuration is hardcoded
|
||||||
The app SHALL hardcode all configuration in source: port `4173`, portal
|
The app SHALL hardcode all configuration in source: port `4173`, portal
|
||||||
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
||||||
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL be
|
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
|
||||||
no runtime configuration via environment variables or config files, with one
|
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
|
||||||
exception: the Image Cropper's service credentials (Minio keys, Azure storage
|
configuration via environment variables or config files, with two exceptions:
|
||||||
connection string, nodered credentials, cleanup-service key) SHALL come from
|
the Image Cropper's service credentials (Minio keys, Azure storage connection
|
||||||
environment variables and SHALL NOT be stored in source.
|
string, nodered credentials, cleanup-service key) SHALL come from environment
|
||||||
|
variables and SHALL NOT be stored in source; and the Fingerprint Agent
|
||||||
|
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
|
||||||
|
environment and SHALL NOT be stored in source.
|
||||||
|
|
||||||
#### Scenario: No configuration required to run
|
#### Scenario: No configuration required to run
|
||||||
- **WHEN** the app is started with no environment variables or config files
|
- **WHEN** the app is started with no environment variables or config files
|
||||||
@@ -134,6 +142,12 @@ environment variables and SHALL NOT be stored in source.
|
|||||||
- **THEN** the cropper routes use them without any change to the hardcoded
|
- **THEN** the cropper routes use them without any change to the hardcoded
|
||||||
URL/port configuration
|
URL/port configuration
|
||||||
|
|
||||||
|
#### Scenario: Installer key from the environment
|
||||||
|
- **WHEN** the fpagent installer release key is supplied as an environment
|
||||||
|
variable
|
||||||
|
- **THEN** the installer and release-proxy routes use it without any change to
|
||||||
|
the hardcoded URL/port configuration
|
||||||
|
|
||||||
### Requirement: Carbon Design System UI
|
### Requirement: Carbon Design System UI
|
||||||
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
||||||
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
session: null,
|
session: null,
|
||||||
tick: Date.now(),
|
tick: Date.now(),
|
||||||
socket: null,
|
socket: null,
|
||||||
agent: { connected: false, version: '', captureAgentId: '' },
|
// available is tri-state: null = not yet reported (old agent), true/false
|
||||||
|
// = the last fprint-device-status. Unknown is not blocked.
|
||||||
|
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
|
||||||
domain: '',
|
domain: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
otpLookup: '',
|
|
||||||
otpSave: '',
|
|
||||||
staff: null,
|
staff: null,
|
||||||
busy: { lookup: false, save: false },
|
busy: { lookup: false, save: false },
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
@@ -47,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.fingers['right-index-finger'].status === 'done';
|
this.fingers['right-index-finger'].status === 'done';
|
||||||
},
|
},
|
||||||
canLookup: function () {
|
canLookup: function () {
|
||||||
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
|
return !!(this.domain && this.reference) && !this.busy.lookup;
|
||||||
},
|
},
|
||||||
canSave: function () {
|
canSave: function () {
|
||||||
return this.bothCaptured && !!this.otpSave && !this.busy.save;
|
return this.bothCaptured && !this.busy.save;
|
||||||
},
|
},
|
||||||
refreshUnlocksAt: function () {
|
refreshUnlocksAt: function () {
|
||||||
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
||||||
@@ -68,6 +68,27 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
if (!this.session || !this.session.domainsCheckedAt) return '';
|
if (!this.session || !this.session.domainsCheckedAt) return '';
|
||||||
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
|
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
|
||||||
},
|
},
|
||||||
|
// Warning shown in the capture panel when the agent cannot capture.
|
||||||
|
scannerNotice: function () {
|
||||||
|
if (!this.agent.connected) {
|
||||||
|
return {
|
||||||
|
title: 'Capture agent offline',
|
||||||
|
subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.agent.available === false) {
|
||||||
|
return {
|
||||||
|
title: 'Scanner not connected',
|
||||||
|
subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
// Capture is allowed when the agent is connected and no status has said
|
||||||
|
// the scanner is missing. Unknown availability (old agent) stays allowed.
|
||||||
|
captureReady: function () {
|
||||||
|
return this.agent.connected && this.agent.available !== false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created: function () {
|
created: function () {
|
||||||
@@ -139,18 +160,38 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
renderAgentTag: function () {
|
renderAgentTag: function () {
|
||||||
var el = document.getElementById('agent-status');
|
var el = document.getElementById('agent-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
|
var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
|
||||||
el.setAttribute('type', this.agent.connected ? 'green' : 'red');
|
if (!this.agent.connected) {
|
||||||
el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
|
label = 'Agent offline'; type = 'red';
|
||||||
|
} else if (this.agent.available === false) {
|
||||||
|
label = 'Scanner not connected'; type = 'red';
|
||||||
|
} else if (this.agent.available === true) {
|
||||||
|
label = 'Agent ready'; type = 'green';
|
||||||
|
} else {
|
||||||
|
// Connected but no status yet (or an agent that does not report it).
|
||||||
|
label = 'Agent connected'; type = 'green';
|
||||||
|
}
|
||||||
|
if (this.agent.available === true && this.agent.device) {
|
||||||
|
title += ' · ' + this.agent.device;
|
||||||
|
}
|
||||||
|
el.textContent = label;
|
||||||
|
el.setAttribute('type', type);
|
||||||
|
el.setAttribute('title', title);
|
||||||
},
|
},
|
||||||
|
|
||||||
connectAgent: function () {
|
connectAgent: function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
|
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
|
||||||
|
|
||||||
|
var offline = function () {
|
||||||
|
self.agent.connected = false;
|
||||||
|
self.agent.available = null;
|
||||||
|
self.agent.device = '';
|
||||||
|
self.renderAgentTag();
|
||||||
|
};
|
||||||
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
|
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
|
||||||
this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
|
this.socket.on('disconnect', offline);
|
||||||
this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
|
this.socket.on('connect_error', offline);
|
||||||
|
|
||||||
this.socket.on('agent-hello', function (data) {
|
this.socket.on('agent-hello', function (data) {
|
||||||
self.agent.version = (data && data.version) || '';
|
self.agent.version = (data && data.version) || '';
|
||||||
@@ -158,6 +199,12 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
self.renderAgentTag();
|
self.renderAgentTag();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on('fprint-device-status', function (data) {
|
||||||
|
self.agent.available = !!(data && data.available);
|
||||||
|
self.agent.device = (data && data.device) || '';
|
||||||
|
self.renderAgentTag();
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on('fprint-enroll-status', function (data) {
|
this.socket.on('fprint-enroll-status', function (data) {
|
||||||
if (!self.capturing) return;
|
if (!self.capturing) return;
|
||||||
self.fingers[self.capturing].stage = (data && data.stage) || 0;
|
self.fingers[self.capturing].stage = (data && data.stage) || 0;
|
||||||
@@ -213,6 +260,11 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
|
|
||||||
capture: function (finger) {
|
capture: function (finger) {
|
||||||
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
|
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
|
||||||
|
if (!this.captureReady) {
|
||||||
|
var notice = this.scannerNotice;
|
||||||
|
if (notice) this.showNotif('warning', notice.title, notice.subtitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Preserve a previous good capture so a cancelled/failed recapture
|
// Preserve a previous good capture so a cancelled/failed recapture
|
||||||
// can restore it instead of destroying it.
|
// can restore it instead of destroying it.
|
||||||
var old = this.fingers[finger];
|
var old = this.fingers[finger];
|
||||||
@@ -242,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
if (!this.canLookup) return;
|
if (!this.canLookup) return;
|
||||||
this.busy.lookup = true;
|
this.busy.lookup = true;
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
|
||||||
null,
|
|
||||||
{ params: { otp: this.otpLookup } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (!r.data.ok) {
|
if (!r.data.ok) {
|
||||||
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
||||||
@@ -268,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
});
|
});
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
||||||
{ fingerprints: fingerprints },
|
{ fingerprints: fingerprints }
|
||||||
{ params: { otp: this.otpSave } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (r.data.ok) {
|
if (r.data.ok) {
|
||||||
self.showNotif('success', 'Enrollment saved',
|
self.showNotif('success', 'Enrollment saved',
|
||||||
@@ -289,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
newEnrollment: function () {
|
newEnrollment: function () {
|
||||||
this.staff = null;
|
this.staff = null;
|
||||||
this.reference = '';
|
this.reference = '';
|
||||||
this.otpLookup = '';
|
|
||||||
this.otpSave = '';
|
|
||||||
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
||||||
this.capturing = null;
|
this.capturing = null;
|
||||||
this.cancelling = false;
|
this.cancelling = false;
|
||||||
|
|||||||
@@ -54,10 +54,6 @@
|
|||||||
<label class="cds--label" for="reference">Staff reference</label>
|
<label class="cds--label" for="reference">Staff reference</label>
|
||||||
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
|
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
|
||||||
</div>
|
</div>
|
||||||
<div style="min-width:130px; max-width:170px;">
|
|
||||||
<label class="cds--label" for="otpLookup">OTP</label>
|
|
||||||
<input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
|
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
|
||||||
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
|
||||||
<span v-if="!busy.lookup">Get details</span>
|
<span v-if="!busy.lookup">Get details</span>
|
||||||
@@ -84,6 +80,10 @@
|
|||||||
<span class="hint">Two fingers are required · place the finger on the scanner each scan</span>
|
<span class="hint">Two fingers are required · place the finger on the scanner each scan</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<cds-inline-notification v-if="scannerNotice" kind="warning" hide-close-button class="mt-1"
|
||||||
|
v-bind:title="scannerNotice.title" v-bind:subtitle="scannerNotice.subtitle">
|
||||||
|
</cds-inline-notification>
|
||||||
|
|
||||||
<dl class="details">
|
<dl class="details">
|
||||||
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
|
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
|
||||||
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
|
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
|
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
|
||||||
|
|
||||||
<div class="row-gap mt-1">
|
<div class="row-gap mt-1">
|
||||||
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
|
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
|
||||||
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>
|
||||||
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
|
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
|
||||||
</cds-button>
|
</cds-button>
|
||||||
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
|
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
|
||||||
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
|
||||||
Recapture
|
Recapture
|
||||||
</cds-button>
|
</cds-button>
|
||||||
@@ -132,10 +132,6 @@
|
|||||||
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
|
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-gap" style="align-items:flex-end;">
|
<div class="row-gap" style="align-items:flex-end;">
|
||||||
<div style="min-width:150px; max-width:200px;">
|
|
||||||
<label class="cds--label" for="otpSave">OTP</label>
|
|
||||||
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
|
|
||||||
</div>
|
|
||||||
<cds-button v-bind:disabled="!canSave" v-on:click="save">
|
<cds-button v-bind:disabled="!canSave" v-on:click="save">
|
||||||
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
|
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
|
||||||
<span v-if="!busy.save">Save enrollment</span>
|
<span v-if="!busy.save">Save enrollment</span>
|
||||||
|
|||||||
@@ -48,12 +48,12 @@
|
|||||||
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
||||||
<ol class="help-steps">
|
<ol class="help-steps">
|
||||||
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
|
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
|
||||||
<li>Choose the domain, enter the staff reference and the OTP, then look up the profile.</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>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>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
|
||||||
<li>Review the captured prints and save.</li>
|
<li>Review the captured prints and save.</li>
|
||||||
</ol>
|
</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.</p>
|
<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-item>
|
||||||
</cds-accordion>
|
</cds-accordion>
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ cds-side-nav[expanded] { pointer-events: auto; }
|
|||||||
.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 { 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; }
|
.side-nav-brand img { display: block; }
|
||||||
|
|
||||||
/* separator between the service links and Help in the header nav */
|
|
||||||
.header-nav-divider { width: 1px; align-self: stretch; margin: 0.7rem 0.25rem; background: var(--cds-border-subtle, #e0e0e0); }
|
|
||||||
|
|
||||||
/* ── layout ─────────────────────────────────────────────────────────────── */
|
/* ── layout ─────────────────────────────────────────────────────────────── */
|
||||||
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
|
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
|
||||||
main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||||
|
|||||||
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[@]}"
|
||||||
+120
-33
@@ -20,6 +20,11 @@ export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verif
|
|||||||
export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh
|
export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh
|
||||||
export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save
|
export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save
|
||||||
export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO
|
export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO
|
||||||
|
export const GITEA_URL = 'https://sources.yrka.app'; // fpagent release forge (private)
|
||||||
|
export const FPAGENT_REPO = 'YOUreka/fingerprint-agent';
|
||||||
|
// The Gitea token used to read the private fpagent releases. The operator's
|
||||||
|
// laptop never sees it: /fpagent/* proxies the bundles through this server.
|
||||||
|
const GITEA_RELEASE_KEY = process.env.GITEA_RELEASE_INSTALL_KEY || '';
|
||||||
export const DOMAINS = [
|
export const DOMAINS = [
|
||||||
{ code: 'uc', name: 'UNICAL' },
|
{ code: 'uc', name: 'UNICAL' },
|
||||||
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
||||||
@@ -134,10 +139,12 @@ const appHeader = (session, active) => {
|
|||||||
const sideLink = (href, label, key, icon) =>
|
const sideLink = (href, label, key, icon) =>
|
||||||
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
|
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
|
||||||
const name = session ? (session.firstName || session.phone || '') : '';
|
const name = session ? (session.firstName || session.phone || '') : '';
|
||||||
// Auth is a menu item now, so the header's right side only carries identity.
|
// 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
|
const headerAuth = session
|
||||||
? '<cds-header-nav-item href="/auth/logout" data-signout>Sign out</cds-header-nav-item>'
|
? headerAction('/auth/logout', 'Sign out', SIDE_NAV_ICONS.logout, ' data-signout')
|
||||||
: '<cds-header-nav-item href="/auth/login">Sign in</cds-header-nav-item>';
|
: headerAction('/auth/login', 'Sign in', SIDE_NAV_ICONS.login);
|
||||||
const sideAuth = session
|
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/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>`;
|
: `<cds-side-nav-link href="/auth/login">${SIDE_NAV_ICONS.login}Sign in</cds-side-nav-link>`;
|
||||||
@@ -150,13 +157,14 @@ const appHeader = (session, active) => {
|
|||||||
<cds-header-nav>
|
<cds-header-nav>
|
||||||
${toolLink('/', 'Home', 'home')}
|
${toolLink('/', 'Home', 'home')}
|
||||||
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
||||||
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
|
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}` : ''}
|
||||||
<span class="header-nav-divider" aria-hidden="true"></span>` : ''}
|
|
||||||
${toolLink('/help', 'Help', 'help')}
|
|
||||||
${headerAuth}
|
|
||||||
</cds-header-nav>
|
</cds-header-nav>
|
||||||
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
|
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
|
||||||
${session ? `<div class="header-right"><span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span></div>` : ''}
|
<div class="header-right">
|
||||||
|
${headerAction('/help', 'Help', SIDE_NAV_ICONS.help)}
|
||||||
|
${headerAuth}
|
||||||
|
${session ? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</cds-header>
|
</cds-header>
|
||||||
<cds-side-nav aria-label="Tools" id="side-nav">
|
<cds-side-nav aria-label="Tools" id="side-nav">
|
||||||
<div class="side-nav-brand">
|
<div class="side-nav-brand">
|
||||||
@@ -164,6 +172,7 @@ const appHeader = (session, active) => {
|
|||||||
</div>
|
</div>
|
||||||
<cds-side-nav-items>
|
<cds-side-nav-items>
|
||||||
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
|
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
|
||||||
|
<cds-side-nav-divider></cds-side-nav-divider>
|
||||||
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
|
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
|
||||||
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
|
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
|
||||||
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
|
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
|
||||||
@@ -268,6 +277,10 @@ export function createApp(overrides = {}) {
|
|||||||
agentUrl: AGENT_URL,
|
agentUrl: AGENT_URL,
|
||||||
domains: DOMAINS,
|
domains: DOMAINS,
|
||||||
ttlMs: DOMAIN_TTL_MS,
|
ttlMs: DOMAIN_TTL_MS,
|
||||||
|
giteaUrl: GITEA_URL,
|
||||||
|
fpagentRepo: FPAGENT_REPO,
|
||||||
|
giteaToken: GITEA_RELEASE_KEY,
|
||||||
|
giteaFetch: fetch,
|
||||||
fetch: fetch,
|
fetch: fetch,
|
||||||
now: Date.now,
|
now: Date.now,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -310,12 +323,95 @@ export function createApp(overrides = {}) {
|
|||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Cache-Control',
|
'Cache-Control',
|
||||||
filePath.endsWith('.html') ? 'no-store' : 'public, max-age=31536000, immutable'
|
filePath.endsWith('.html') || filePath.endsWith('.sh')
|
||||||
|
? 'no-store'
|
||||||
|
: 'public, max-age=31536000, immutable'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
); // assets only; '/' is session-aware
|
); // assets only; '/' is session-aware
|
||||||
|
|
||||||
|
// ── fpagent installer: public proxy for the private release bundles ───────
|
||||||
|
// The install script (public/fpagent/install.sh) runs on a field laptop with
|
||||||
|
// no Gitea credentials, so this server reads the private release with the
|
||||||
|
// token and streams the bundle back. The token and forge host never leave
|
||||||
|
// here. Registered before the session middleware: the installer is public.
|
||||||
|
const giteaBase = () => cfg.giteaUrl.replace(/\/+$/, '');
|
||||||
|
const giteaHeaders = () => (cfg.giteaToken ? { authorization: `token ${cfg.giteaToken}` } : {});
|
||||||
|
const SAFE_ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
const installerNotConfigured = (res) =>
|
||||||
|
res.status(503).json({
|
||||||
|
ok: false,
|
||||||
|
message: 'The Fingerprint Agent installer is not configured on this deployment.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const releasePayload = async (res, giteaPath) => {
|
||||||
|
try {
|
||||||
|
const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() });
|
||||||
|
if (!r.ok) {
|
||||||
|
return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
const release = await r.json();
|
||||||
|
const tag = String(release?.tag_name || '');
|
||||||
|
const assets = (Array.isArray(release?.assets) ? release.assets : [])
|
||||||
|
.map((a) => String(a?.name || ''))
|
||||||
|
.filter((name) => SAFE_ASSET.test(name))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
url: `/fpagent/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
|
||||||
|
}));
|
||||||
|
if (!tag || assets.length === 0) {
|
||||||
|
return res
|
||||||
|
.status(502)
|
||||||
|
.json({ ok: false, message: 'The Fingerprint Agent release has no downloadable bundles.' });
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store').json({ ok: true, tag, assets });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/latest', (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/tags/:tag', (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
if (!SAFE_ASSET.test(req.params.tag)) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'Unknown release tag.' });
|
||||||
|
}
|
||||||
|
return releasePayload(
|
||||||
|
res,
|
||||||
|
`/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/fpagent/releases/download/:tag/:name', async (req, res) => {
|
||||||
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
||||||
|
const { tag, name } = req.params;
|
||||||
|
if (!SAFE_ASSET.test(tag) || !SAFE_ASSET.test(name)) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'Unknown release asset.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await cfg.giteaFetch(
|
||||||
|
`${giteaBase()}/${cfg.fpagentRepo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
|
||||||
|
{ headers: giteaHeaders() }
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
return res
|
||||||
|
.status(r.status === 404 ? 404 : 502)
|
||||||
|
.json({ ok: false, message: 'Release asset not available.' });
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.set('Content-Type', r.headers.get('content-type') || 'application/octet-stream')
|
||||||
|
.set('Cache-Control', 'no-store')
|
||||||
|
.send(Buffer.from(await r.arrayBuffer()));
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
req.session = null;
|
req.session = null;
|
||||||
@@ -608,19 +704,11 @@ export function createApp(overrides = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── a2loft proxies: same-origin, phone injected from the session ──────────
|
// ── a2loft proxies: same-origin, bearer token from the session ────────────
|
||||||
const a2loftUrl = (domain, reference, otp, phone) => {
|
const a2loftUrl = (domain, reference) =>
|
||||||
const q = new URLSearchParams({ phone, otp });
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
|
||||||
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
|
const a2loftSaveUrl = (domain, reference) =>
|
||||||
reference
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`;
|
||||||
)}?${q.toString()}`;
|
|
||||||
};
|
|
||||||
const a2loftSaveUrl = (domain, reference, otp, phone) => {
|
|
||||||
const q = new URLSearchParams({ phone, otp });
|
|
||||||
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(
|
|
||||||
reference
|
|
||||||
)}?${q.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateProxy = (req, res) => {
|
const validateProxy = (req, res) => {
|
||||||
const { domain, reference } = req.params;
|
const { domain, reference } = req.params;
|
||||||
@@ -632,21 +720,15 @@ export function createApp(overrides = {}) {
|
|||||||
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!req.query.otp) {
|
|
||||||
res.status(400).json({ ok: false, message: 'OTP is required.' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!req.session.phone) {
|
|
||||||
res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
||||||
if (!validateProxy(req, res)) return;
|
if (!validateProxy(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
|
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference), {
|
||||||
|
headers: { authorization: `Bearer ${req.session.token}` },
|
||||||
|
});
|
||||||
res.status(r.status).type('json').send(await r.text());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
||||||
@@ -659,8 +741,13 @@ export function createApp(overrides = {}) {
|
|||||||
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
|
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
|
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${req.session.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
});
|
});
|
||||||
res.status(r.status).type('json').send(await r.text());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
+31
-28
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
|
|||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
|
calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null });
|
||||||
const staff = {
|
const staff = {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -207,11 +207,11 @@ test('domain refresh refuses while the cache is fresh, succeeds after the TTL',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('staff lookup proxy injects the session phone and forwards a2loft responses', async () => {
|
test('staff lookup proxy forwards the session token as a bearer credential', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, {
|
const res = await fetch(`${base}/api/uc/staff/UC%2F0231`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies },
|
headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
@@ -224,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
|
|||||||
assert.equal(call.method, 'GET');
|
assert.equal(call.method, 'GET');
|
||||||
const u = new URL(call.url, 'http://x');
|
const u = new URL(call.url, 'http://x');
|
||||||
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
||||||
assert.equal(u.searchParams.get('phone'), '+2348068287222');
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
||||||
assert.equal(u.searchParams.get('otp'), '4455');
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fingerprint save proxy forwards the body unchanged', async () => {
|
test('fingerprint save proxy forwards the body and the token, not OTP', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
||||||
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
|
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -245,24 +245,21 @@ test('fingerprint save proxy forwards the body unchanged', async () => {
|
|||||||
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
||||||
const call = a2loft.calls[0];
|
const call = a2loft.calls[0];
|
||||||
assert.equal(call.method, 'POST');
|
assert.equal(call.method, 'POST');
|
||||||
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
|
assert.equal(call.url, '/identity/uc/fingerprints/UC0231');
|
||||||
assert.deepEqual(call.body, payload);
|
assert.deepEqual(call.body, payload);
|
||||||
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => {
|
test('proxy validation: auth and domain are enforced before contacting a2loft', async () => {
|
||||||
const { base, a2loft, stop } = await bootApp();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' });
|
const anon = await fetch(`${base}/api/uc/staff/REF`, { method: 'POST' });
|
||||||
assert.equal(anon.status, 401);
|
assert.equal(anon.status, 401);
|
||||||
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
|
const badDomain = await fetch(`${base}/api/nope/staff/REF`, {
|
||||||
method: 'POST', headers: { cookie: cookies },
|
|
||||||
});
|
|
||||||
assert.equal(noOtp.status, 400);
|
|
||||||
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
|
|
||||||
method: 'POST', headers: { cookie: cookies },
|
method: 'POST', headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
assert.equal(badDomain.status, 400);
|
assert.equal(badDomain.status, 400);
|
||||||
@@ -298,6 +295,7 @@ test('pages receive the session injected server-side (no client fetch, no-store)
|
|||||||
assert.match(html, /window\.__SESSION__ = \{/);
|
assert.match(html, /window\.__SESSION__ = \{/);
|
||||||
assert.match(html, /2348068287222/);
|
assert.match(html, /2348068287222/);
|
||||||
assert.match(html, /"domains"/, 'permitted domains injected too');
|
assert.match(html, /"domains"/, 'permitted domains injected too');
|
||||||
|
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
@@ -353,16 +351,20 @@ test('landing header is server-rendered for the session', async () => {
|
|||||||
const { base, stop } = await bootApp();
|
const { base, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const anon = await (await fetch(`${base}/`)).text();
|
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');
|
assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced');
|
||||||
// signed out: no service links, but Help and Sign in are always there
|
// 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="/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.ok(!anon.includes('cds-header-nav-item href="/fingerprint"'), 'no fingerprint in header nav when signed out');
|
||||||
assert.match(anon, /cds-header-nav-item href="\/help"/);
|
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.match(anon, /cds-side-nav-link href="\/help"/);
|
||||||
// the divider only groups the services, so it is absent when signed out
|
assert.equal((anon.match(/<cds-side-nav-divider>/g) || []).length, 1, 'one side nav divider when signed out');
|
||||||
assert.ok(!anon.includes('cds-side-nav-divider'), 'no side nav divider without services');
|
|
||||||
assert.ok(!anon.includes('header-nav-divider'), 'no header divider without services');
|
|
||||||
// no identity or sign-out 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('user-chip'), 'no user chip when signed out');
|
||||||
assert.ok(!anon.includes('data-signout'), 'no sign-out when signed out');
|
assert.ok(!anon.includes('data-signout'), 'no sign-out when signed out');
|
||||||
@@ -378,16 +380,17 @@ test('landing header is server-rendered for the session', async () => {
|
|||||||
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
|
||||||
assert.match(authed, /Ada/);
|
assert.match(authed, /Ada/);
|
||||||
assert.match(authed, /user-chip/);
|
assert.match(authed, /user-chip/);
|
||||||
assert.match(authed, /href="\/auth\/logout"[^>]*>Sign out</);
|
// signed in: services live in the header nav; Help and Sign out are icon-only
|
||||||
// signed in: services live in the header nav and in the mobile side nav
|
|
||||||
assert.match(authed, /cds-header-nav-item href="\/cropper"/);
|
assert.match(authed, /cds-header-nav-item href="\/cropper"/);
|
||||||
assert.match(authed, /cds-header-nav-item href="\/fingerprint"/);
|
assert.match(authed, /cds-header-nav-item href="\/fingerprint"/);
|
||||||
assert.match(authed, /cds-header-nav-item href="\/help"/);
|
assert.ok(!authed.includes('cds-header-nav-item href="/help"'), 'Help is not a text header nav item');
|
||||||
assert.match(authed, /cds-header-nav-item href="\/auth\/logout" data-signout/);
|
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="\/cropper"/);
|
||||||
assert.match(authed, /cds-side-nav-link href="\/help"/);
|
assert.match(authed, /cds-side-nav-link href="\/help"/);
|
||||||
assert.match(authed, /cds-side-nav-divider/);
|
assert.equal((authed.match(/<cds-side-nav-divider>/g) || []).length, 2, 'two side nav dividers when signed in');
|
||||||
assert.match(authed, /header-nav-divider/);
|
|
||||||
// side nav items carry Carbon icons in the title-icon slot
|
// 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="\/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="\/fingerprint"[^>]*><svg slot="title-icon"/);
|
||||||
@@ -412,7 +415,7 @@ test('help page is public and documents each service', async () => {
|
|||||||
assert.match(html, /cds-accordion-item title="Image Cropper"/);
|
assert.match(html, /cds-accordion-item title="Image Cropper"/);
|
||||||
assert.match(html, /cds-accordion-item title="Fingerprint Agent"/);
|
assert.match(html, /cds-accordion-item title="Fingerprint Agent"/);
|
||||||
assert.match(html, /accordion\.min\.js/);
|
assert.match(html, /accordion\.min\.js/);
|
||||||
assert.match(html, /cds-header-nav-item href="\/help" aria-current="page"/);
|
assert.match(html, /cds-header-global-action href="\/help"/);
|
||||||
assert.match(html, /cds-side-nav-link href="\/help" aria-current="page"/);
|
assert.match(html, /cds-side-nav-link href="\/help" aria-current="page"/);
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user