Compare commits

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

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

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

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

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

Adds a vm-sandbox test for the wizard logic and the OpenSpec change
fpagent-device-availability, archived with the fingerprint-wizard spec.
2026-10-02 15:12:16 +01:00
adebisi-fa 35d895574f docs(openspec): archive fpagent-install-upgrade and sync specs
Documents release pinning (--tag), the pinned-tag metadata route,
installed-version tracking with upgrade reporting, and restart of a
managed running service on upgrade; syncs the four requirements into the
fpagent-installer main spec.
2026-10-02 11:41:50 +01:00
adebisi-fa ff62770bcc docs(fpagent): document --tag and the upgrade path 2026-10-02 11:26:35 +01:00
adebisi-fa 2ad4aa63c6 feat(fpagent): pin releases, report upgrades, restart the service
The installer gains --tag to install a specific release (gateway route
/fpagent/releases/tags/:tag), derives the git hash from the asset name,
and hands it to the bundle installer as VERSION so upgrades are reported
and the installed version is recorded. Service restart on upgrade lives
in the bundle installer, which owns the systemd unit.
2026-10-02 11:26:35 +01:00
adebisi-fa 0698a9c9a9 fix(fpagent): sweep scratch left by a SIGKILLed installer run
The EXIT trap cannot run on SIGKILL, so a killed run could leave its
fpagent-install.* temp dir behind. Sweep those before any network work,
scoped to our prefix and this user and only dirs older than a day so a
concurrent install is never touched.
2026-10-02 10:49:07 +01:00
39 changed files with 1694 additions and 156 deletions
+26 -10
View File
@@ -107,11 +107,10 @@ is treated as anonymous and the stale cookie cleared.
### a2loft proxy
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
server-side session and forwards the OTP supplied by the page; the operator
never sends credentials to a third party directly. The cropper's save call
instead authenticates with the session token (`Bearer`) after a live permission
check.
under same-origin routes. The proxy authenticates the operator with the
signed-in session's PocketBase token, sent to a2loft as a `Bearer` credential;
the operator never sends credentials to a third party directly. The cropper's
save call uses the same token flow after a live permission check.
## The Image Cropper
@@ -145,26 +144,43 @@ dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
The `/fingerprint` wizard walks the operator through:
1. **Locate profile** — pick a permitted domain, enter the staff reference and
OTP, and look up the record through the a2loft proxy.
1. **Locate profile** — pick a permitted domain, enter the staff reference, and
look up the record through the a2loft proxy.
2. **Capture** — right thumb and right index finger, one capture at a time, with
per-scan progress and cancel. The PocketBase token, domain and reference
travel with every `fprint-enroll` to fpagent.
3. **Save** — both templates are posted to a2loft 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 from the private
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 -s -- --enable-service
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`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,72 @@
# Design
## Context
See `proposal.md` — Why. fpagent pushes `fprint-device-status`
`{available, device, captureAgentId}` to each connection on connect and to all
connections when availability changes (2 s watcher; probes skipped while an
enrollment runs). `available` means "a supported reader is attached", not that a
capture will succeed. The wizard currently learns only `agent-hello` and the
enrollment events, and its header tag reflects only the socket connection.
## Goals / Non-Goals
**Goals:** surface scanner availability in the wizard; stop a capture from
starting when no reader is attached; keep working against agents that do not
emit the event.
**Non-Goals:** changing the agent or the event contract; blocking capture when
the agent is merely unreachable (pre-existing behavior); a proactive device
picker or wiring guidance.
## Decisions
1. **The client is a pure listener.** The agent already emits on connect and on
change, so the wizard adds one handler and no polling or request event.
2. **Availability is tri-state, not boolean.** `agent.available` starts `null`
(unknown) and becomes `true`/`false` on the first status. An agent that never
emits the event leaves it `null`, so captures are not blocked — the feature
degrades to today's behavior instead of deadlocking older field laptops.
3. **The status tag has four labels.** Offline (socket down), "Scanner not
connected" (`available: false`), "Agent ready" (`available: true`), and
"Agent connected" (unknown). The `title` carries the version and the device
name when known.
4. **Capture is gated at both layers.** The buttons are disabled when
`available === false`, and `capture()` returns early on the same condition so
a race (availability changing between click and handler) cannot start an
enrollment. The agent remains authoritative and still rejects a capture with
no reader.
5. **The gating logic gets a small vm-sandbox test.** `app.js` is a browser
IIFE, so `test/fp-wizard.test.js` loads it in a `node:vm` sandbox with
stubbed `Vue`/`io`/`document` and exercises the availability state, the tag
labels, and the capture refusal directly. No new dependency and no browser
runtime; the remaining end-to-end pass against a real agent is manual, since
`/fingerprint` requires a session.
## Risks / Trade-offs
- [Old agent never emits the event] → availability stays `null`, captures are
allowed, behavior matches today. The status tag shows "Agent connected".
- [Brief wrong tag between socket connect and first status] → the agent emits
the status in the same connection handler as `agent-hello`, so the window is
negligible; the `null` state shows "Agent connected" rather than a false
"Scanner not connected".
- [`available` read as "ready to capture"] → the agent's contract says it means
"attached"; enrollment remains authoritative and a capture can still fail.
- [Watcher is skipped during a capture] → availability is not updated mid-scan,
which is correct: the reader is in use by that capture.
## Migration Plan
Ship the wizard; it is backward compatible with agents that lack the event.
No agent change is required to deploy the wizard, and the full benefit appears
once the field laptops run the agent that emits `fprint-device-status`. Rollback
= revert the wizard.
## Open Questions
None.
@@ -0,0 +1,43 @@
# Proposal: fpagent-device-availability
## Why
fpagent now reports whether a supported fingerprint reader is attached, as
`fprint-device-status {available, device, captureAgentId}`, sent to each portal
client on connect and whenever availability changes. The wizard does not consume
it: the header tag reflects only the socket connection, so a connected agent
with no scanner still reads "Agent connected" and capture is allowed, failing
only when the enrollment reaches the reader. Operators need to see that the
scanner is missing before they try.
## What Changes
- The wizard consumes `fprint-device-status` and tracks availability and the
device name alongside the connected state.
- The header status tag distinguishes agent offline, connected with no scanner,
and ready.
- While no supported reader is attached, the capture panel shows a warning and
the capture/recapture controls are disabled; the capture handler refuses a
racing attempt.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fingerprint-wizard`: shows scanner availability from `fprint-device-status`
and gates capture on a supported reader being attached.
## Impact
- `tools/public/assets/fp/app.js`: handle the event, a multi-state status tag,
and a gated `capture()`.
- `tools/public/assets/fp/index.html`: disabled controls and an inline warning
when no reader is attached.
- `README.md`, `tools/public/assets/help.html`: document the behavior.
- Depends on `fingerprint-agent`'s `fprint-device-status` event (change
`scanner-hotplug-recovery`). Agents that do not emit it are tolerated:
availability stays unknown and captures are not blocked.
@@ -0,0 +1,44 @@
# Spec Delta: fingerprint-wizard
## ADDED Requirements
### Requirement: Scanner availability is shown and gates capture
The wizard SHALL consume fpagent's `fprint-device-status`
`{available, device, captureAgentId}` event and SHALL distinguish the agent
being offline (socket not connected), connected with no supported reader
attached (`available` false), and ready (`available` true). Until a status is
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
capture while no supported reader is attached, SHALL re-enable capture when a
reader becomes available, and SHALL clear availability when the agent
disconnects. Availability means "a supported reader is attached"; it does not
guarantee a capture will succeed.
#### Scenario: Reader attached on connect
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
`available: true`
- **THEN** the wizard shows the agent ready and leaves capture enabled
#### Scenario: No reader attached on connect
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
`available: false`
- **THEN** the wizard shows that the scanner is not connected, explains captures
are disabled, and disables the capture controls
#### Scenario: Unplug blocks capture
- **WHEN** fpagent reports `available: false` while the operator is capturing or
between captures
- **THEN** the wizard disables the capture controls and a capture attempt is
refused
#### Scenario: Replug re-enables capture
- **WHEN** fpagent reports `available: true` after the reader is reconnected
- **THEN** the wizard re-enables the capture controls
#### Scenario: Status absent or old agent
- **WHEN** the connected agent never reports `fprint-device-status`
- **THEN** availability stays unknown and the wizard does not block capture
#### Scenario: Disconnect clears availability
- **WHEN** the agent socket disconnects
- **THEN** the wizard shows the agent offline and does not present the reader as
available
@@ -0,0 +1,40 @@
# Tasks
## 1. Consume the availability event
- [x] 1.1 In `tools/public/assets/fp/app.js`, add `agent.available` (default
`null`) and `agent.device` (default `''`); handle `fprint-device-status` to set
both and re-render the tag. Verify the handler is present.
- [x] 1.2 Clear `available` back to `null` and `device` to `''` on `disconnect`
and `connect_error`.
## 2. Show availability
- [x] 2.1 Extend `renderAgentTag` to the four states (offline, scanner not
connected, agent ready, agent connected/unknown) with the device name in the
tag `title`.
- [x] 2.2 Add a computed notice (title/subtitle) for agent-offline and
no-scanner, and render it as a warning `cds-inline-notification` in the
capture panel.
- [x] 2.3 Disable the capture and recapture buttons while
`agent.available === false`, and make `capture()` refuse an attempt in the
same state.
## 3. Docs
- [x] 3.1 Add a paragraph to `README.md` under "The Fingerprint Agent".
- [x] 3.2 Add a line to `tools/public/assets/help.html`.
## 4. Verification
- [x] 4.1 `cd tools && npm test` passes (65 tests, 6 new in
`test/fp-wizard.test.js`).
- [x] 4.2 `openspec validate 2026-10-02-fpagent-device-availability --strict`
passes.
- [x] 4.3 `test/fp-wizard.test.js` loads `app.js` in a vm sandbox and asserts the
availability states, tag labels, capture refusal, and disconnect reset.
- [x] 4.4 End-to-end browser pass against a running agent (tag transitions and
disabled controls) — done with Playwright driving the real page against a
stubbed agent socket; screenshots in the harness run.
- [x] 4.5 Archive the change (`openspec archive`), which syncs the
`fingerprint-wizard` spec.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,64 @@
# Design
## Context
See proposal.md — Why. The `fpagent-installer` capability (change
`fpagent-release-installer`) installs the newest release and hands off to the
bundle's `deploy/install.sh`, which replaces the binary but only warns when a
daemon is running. The bundle name already embeds the git hash
(`fpagent-<hash>-<platform>.tar.gz`) and `bundle.sh` already builds the binary
with that hash as its embedded `Version`.
## Goals / Non-Goals
**Goals:** install a pinned release; make a rerun a real upgrade that reports
what changed and takes effect; keep both install paths (hosted and direct
bundle) consistent.
**Non-Goals:** a separate version service or a new `--version` CLI flag; signing
or provenance beyond sha256; a downgrade-unsafe rollback.
## Decisions
1. **The version is the asset-name git hash.** It is already the binary's
embedded `Version` and needs no new endpoint or flag. The hosted installer
parses it, and also writes it as `VERSION` in the extracted bundle so the
bundle installer has it even for bundles built before this change.
2. **The bundle installer owns the upgrade reporting and the restart.** It
knows `INSTALL_PATH` and the service; the hosted installer only fetches,
verifies and hands off. `bundle.sh` ships `VERSION` so direct bundle installs
behave the same. The stamp lives at `${INSTALL_PATH}.version`, so a custom
`--install-path` keeps its own record, and uninstall removes it.
3. **Restart only on a managed, already-running service.** `enable --now` starts
a stopped unit; for an upgrade the installer checks `is-active` before
enabling and restarts only if it was active, avoiding a redundant restart on
a fresh install.
4. **`--tag` reuses the release handler.** A second same-origin metadata route
(`/fpagent/releases/tags/:tag`) shares the latest route's handler, so the
filtering and non-disclosure rules are identical; the installer switches only
the metadata URL, then downloads by the resolved tag.
## Risks / Trade-offs
- [Bundles built before this change carry no VERSION and do not restart] → the
hosted installer writes VERSION from the asset name (fixes reporting), but the
restart is in the bundle and appears only after a new `fingerprint-agent`
release. Until then upgrades still need a manual restart.
- [Upgrade without `--enable-service` leaves a running daemon on the old binary]
→ the installer warns; the documented install command uses `--enable-service`.
- [A pinned tag whose assets differ] → the tag's own metadata drives asset
selection, the same as latest.
## Migration Plan
Release a new `fingerprint-agent` bundle (ships VERSION and restarts on
upgrade), then deploy the app. The hosted installer is backward compatible: with
an older bundle it reports the version but the bundle's older installer ignores
the stamp. Rollback = revert the app; `--tag` can pin the previous release.
## Open Questions
None.
@@ -0,0 +1,42 @@
# Proposal: fpagent-install-upgrade
## Why
The installer always took the newest release and left any running `fpagent`
daemon on the old binary, so there was no way to hold a known-good version and
an "upgrade" did not take effect until a reboot. Operators need a controlled
version and a rerun that actually upgrades.
## What Changes
- The installer accepts `--tag TAG` to install a specific release instead of
`latest`, and the app proxies that tag's metadata through a same-origin route.
- The installed version is the git hash carried in the bundle's asset name. The
installer passes it to the bundle installer, which records it beside the
binary and reports `upgrading <old> -> <new>` on the next run.
- When the installer manages the systemd service and the service was already
running, an upgrade restarts it so the new binary takes effect. The version
stamp is removed on uninstall.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fpagent-installer`: adds release pinning, a pinned-tag metadata endpoint,
installed-version tracking with upgrade reporting, and restart of a managed
service on upgrade.
## Impact
- `tools/server.js`: `/fpagent/releases/tags/:tag` route (shares the release
metadata handler with `/fpagent/releases/latest`).
- `tools/public/fpagent/install.sh`: `--tag`, version parse from the asset name,
and handoff of the version to the bundle installer.
- `fingerprint-agent` `deploy/install.sh` and `deploy/bundle.sh`: read/stamp the
version and restart an already-running service; `bundle.sh` ships `VERSION`.
- Depends on the `fingerprint-agent` bundle naming, which already embeds the
git hash (`fpagent-<hash>-<platform>.tar.gz`).
@@ -0,0 +1,92 @@
# Spec Delta
## ADDED Requirements
### Requirement: Pinned release selection
The installer SHALL accept a release tag and install that release instead of the
latest. Without a tag it SHALL install the latest release.
#### Scenario: A pinned release is installed
- **WHEN** the installer is invoked with a release tag
- **THEN** it fetches that tag's metadata and installs its bundle
#### Scenario: Default is the latest release
- **WHEN** the installer is invoked without a release tag
- **THEN** it installs the latest release
#### Scenario: Unknown pinned release
- **WHEN** the pinned tag does not exist
- **THEN** the installer exits with a failure message and installs nothing
### Requirement: Pinned release metadata endpoint
The app SHALL expose a same-origin endpoint that returns a specific release's
tag and asset names as same-origin URLs, with the same authentication, filtering
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
tag that is not a valid release tag without contacting the forge.
#### Scenario: Tagged release is returned
- **WHEN** a client requests the metadata for an existing tag
- **THEN** the response carries that release's tag and its assets as same-origin
URLs
#### Scenario: Invalid tag is rejected
- **WHEN** a client requests metadata for a tag that is not a valid release tag
- **THEN** the app rejects the request without contacting the forge
### Requirement: Installed version is tracked and upgrades are reported
The installer SHALL treat the git hash in the bundle's asset name as the version.
It SHALL hand that version to the bundle installer, which SHALL record it beside
the installed binary and SHALL report an upgrade with the previous and new
versions on the next run. Uninstalling SHALL remove the recorded version.
#### Scenario: Version is derived from the asset name
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Upgrade is reported
- **WHEN** the recorded version differs from the version being installed
- **THEN** the bundle installer reports the change from the old to the new
version
#### Scenario: First install records the version
- **WHEN** no version has been recorded yet
- **THEN** the bundle installer records the new version and reports no upgrade
#### Scenario: Uninstall removes the recorded version
- **WHEN** the installer is asked to uninstall
- **THEN** the recorded version is removed
### Requirement: A managed running service is restarted on upgrade
When the installer is asked to manage the systemd service and a service is
already running, the installer SHALL restart it after installing the new binary
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
restart, and the installer SHALL NOT restart the service when service management
is not requested.
#### Scenario: Upgrade restarts the running service
- **WHEN** the running service is managed and a new binary is installed
- **THEN** the service is restarted on the new binary
#### Scenario: Fresh install starts the service
- **WHEN** the service is not running and service management is requested
- **THEN** the service is started and no separate restart is performed
#### Scenario: Service management not requested
- **WHEN** service management is not requested
- **THEN** the installer does not restart the service
@@ -0,0 +1,19 @@
# Tasks
- [x] 1. App: add `/fpagent/releases/tags/:tag`, sharing the release-metadata
handler with `/fpagent/releases/latest`; reject invalid tags without contacting
the forge.
- [x] 2. Hosted installer: parse `--tag`, fetch the pinned tag's metadata,
derive the git hash from the asset name, and write it as `VERSION` into the
extracted bundle before handoff.
- [x] 3. Bundle installer (`fingerprint-agent`): read `VERSION` beside the script
or the binary, report `upgrading <old> -> <new>` from `${INSTALL_PATH}.version`,
stamp the new version, remove it on uninstall.
- [x] 4. Bundle installer: restart an already-running `fpagent.service` after
`enable --now` (via `is-active`, so a fresh install does not restart).
- [x] 5. `bundle.sh`: ship `VERSION` in the bundle.
- [x] 6. Tests: tag-route metadata and invalid-tag rejection; `--tag` makes the
installer request the tag endpoint, not latest. Verified 59 tests pass.
- [x] 7. Verified end to end: a real patched bundle served through a fake gateway
→ hosted installer → bundle installer reported `upgrading fpagent deadbeef ->
cafebabe`, version `cafebabe`, with options forwarded.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,49 @@
# Design
## Context
See proposal.md for motivation. The wizard already holds the PocketBase token
in the page (`window.__SESSION__.token`) for fpagent enrollment, and the
cropper's save already sends `Authorization: Bearer ${req.session.token}` to
a2loft through its own proxy (`tools/cropper.js`). The two remaining OTP calls
are the wizard's staff-lookup and fingerprint-save proxies in `tools/server.js`;
their handlers receive the a2loft URL from `a2loftUrl`/`a2loftSaveUrl`, which
currently append `phone`+`otp`, and `validateProxy` rejects a missing `otp`.
## Goals / Non-Goals
**Goals:** both wizard a2loft calls use the session token; no OTP in the
wizard; proxy validation keeps rejecting unauthenticated calls; deployable
after the a2loft change with a clean rollback.
**Non-Goals:** fpagent capture (already carries the token); the cropper; a2loft's
own OTP compatibility; the legacy singular fingerprint endpoint.
## Decisions
1. **Bearer from the server session**, never the browser: the proxy attaches
`Authorization: Bearer ${req.session.token}`, mirroring `tools/cropper.js`.
The page's token copy stays for fpagent only.
2. **Drop the OTP check in `validateProxy`**, along with the now-unnecessary
phone check; keep the hardcoded-domain and reference checks. `requireApi`
already guarantees a session.
3. **Remove the OTP inputs and state** rather than hiding them;
`canLookup`/`canSave` no longer require an OTP.
## Risks / Trade-offs
- [a2loft not yet dual-mode] → deploy the a2loft change first; this proxy change
depends on it.
- [Operators outside a domain's uploader list can now enroll] → intended;
a2loft authorizes `capture_biometric` with the `domain:idid` fallback.
- [Rollback] → revert the proxy and re-add the OTP fields; a2loft stays
dual-mode.
## Migration Plan
Deploy a2loft dual-mode → deploy this app → field pass: look up a reference and
save both fingerprints with no OTP; a bogus-token call fails cleanly.
## Open Questions
None.
@@ -0,0 +1,45 @@
# Proposal: fingerprint-token-auth
## Why
The Fingerprint Agent wizard's two a2loft calls — staff lookup and fingerprint
save — prove the operator with a phone number and an OTP forwarded through
same-origin proxies. a2loft resolves that phone against the domain being
addressed, so an operator who holds the OpenFGA `capture_biometric` relation
but is not a super-user of that domain's uploader list is rejected. The cropper
already saves with the session's PocketBase token; the wizard's a2loft calls
should use the same token flow.
## What Changes
- The `/api/:domain/staff/:reference` and
`/api/:domain/staff/:reference/fingerprints` proxies send the session's
PocketBase token as an `Authorization: Bearer` header instead of forwarding
`phone`+`otp` query parameters.
- The OTP validation is removed from the proxies; the session token is the
credential.
- The wizard's lookup and save steps drop their OTP fields and OTP state.
- The Fingerprint Agent and tools-app specs, the README, and the Help page are
updated.
- Requires the a2loft `fingerprint-token-auth` change deployed first.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fingerprint-wizard`: the two-finger enrollment flow and the per-call OTP
requirement become token-authenticated.
- `tools-app`: the a2loft proxies requirement now injects the session token
instead of forwarding `phone`+`otp`.
## Impact
- `tools/server.js`: the two a2loft proxy handlers and `validateProxy`.
- `tools/public/assets/fp/index.html`, `tools/public/assets/fp/app.js`: the OTP
fields, state and request parameters removed.
- `tools/test/server.test.js`: proxy assertions.
- Documentation: README and the Help page.
@@ -0,0 +1,60 @@
# Spec Delta: fingerprint-wizard
## MODIFIED Requirements
### Requirement: Two-finger enrollment flow
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
up staff by reference (token-validated through the a2loft proxy), displaying
the staff details, capturing the right thumb and right index finger, and
saving both fingerprints through the a2loft proxy authenticated with the
session token. The wizard SHALL offer per-finger recapture until save, SHALL
require both fingers before save, and SHALL reset domain-independent state
(reference, staff details, captured artifacts) when starting a new
enrollment.
#### Scenario: Complete enrollment
- **WHEN** the operator looks up staff, captures both fingers, and saves
- **THEN** both fingerprints are saved through the a2loft proxy and the
wizard reports success
#### Scenario: Save requires both fingers
- **WHEN** the operator attempts to save with only one finger captured
- **THEN** the wizard rejects the save and explains both fingers are
required
#### Scenario: New enrollment resets state
- **WHEN** the operator starts a new enrollment after a save
- **THEN** the reference, staff details, and captured finger artifacts are
cleared while the permitted domain list persists
## REMOVED Requirements
### Requirement: OTP validated on every a2loft call
**Reason**: a2loft's fingerprint staff-lookup and save endpoints now accept the
session's PocketBase token, so the operator proves identity with the session
instead of a per-call OTP. The OTP scoped operators to the domain's uploader
list and rejected capture agents who hold only `capture_biometric`.
**Migration**: the wizard sends the session token through the same-origin
proxy and collects no OTP; a2loft keeps OTP mode for other clients.
## ADDED Requirements
### Requirement: Token authenticated on every a2loft call
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
save — with the signed-in operator's PocketBase session token, sent by the
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
NOT collect an OTP.
#### Scenario: Lookup uses the session token
- **WHEN** the operator submits a reference
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
session token
#### Scenario: Save uses the session token
- **WHEN** the operator saves the captured fingerprints
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
the session token
#### Scenario: No OTP field
- **WHEN** the wizard page is served
- **THEN** it offers no OTP input for lookup or save
@@ -0,0 +1,26 @@
# Spec Delta: tools-app
## MODIFIED Requirements
### Requirement: a2loft proxies inject operator identity
The app SHALL proxy staff lookup and fingerprint-save calls to
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
authenticate the operator with the signed-in session's PocketBase token, sent
as an `Authorization: Bearer` credential, preserving the a2loft request and
response shapes. The proxies SHALL require a session; a request without one
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
require an OTP.
#### Scenario: Staff lookup via proxy
- **WHEN** the page requests staff details for a domain and reference
- **THEN** the app calls a2loft's identity endpoint with the session token as a
bearer credential and returns a2loft's response unchanged
#### Scenario: Fingerprint save via proxy
- **WHEN** the page saves fingerprints for a domain and reference
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
token as a bearer credential
#### Scenario: Unauthenticated proxy call rejected
- **WHEN** a proxy route is called without a valid session
- **THEN** the app rejects the call without contacting a2loft
@@ -0,0 +1,30 @@
# Tasks
## 1. Proxies
- [x] 1.1 `tools/server.js`: `a2loftUrl`/`a2loftSaveUrl` drop `phone`/`otp`;
both handlers send `Authorization: Bearer ${req.session.token}`;
`validateProxy` drops the OTP/phone checks and keeps domain + reference.
Verified: the proxy tests assert the Bearer header and no query auth.
- [x] 1.2 Update the proxy tests: assert no `?otp`, assert the Bearer header,
and drop the no-OTP rejection case while the auth/domain rejection remains
(no a2loft call). Verified.
## 2. Wizard UI
- [x] 2.1 `tools/public/assets/fp/index.html`: remove the lookup and save OTP
inputs.
- [x] 2.2 `tools/public/assets/fp/app.js`: remove `otpLookup`/`otpSave`, the
`otp` request params, and the OTP resets; tighten `canLookup`/`canSave`.
Verified: the served page has no OTP field (server test) and both gates pass
without a credential (wizard test).
- [x] 2.3 Update the `fingerprint-wizard` and `tools-app` spec deltas, README
steps 1/3, the a2loft proxy paragraph, and the Help page.
## 3. Verify
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
(66/66 pass.)
- [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
reference and save both fingerprints with no OTP. Verified in production:
the lookup and both fingerprint saves succeeded with no OTP.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
@@ -0,0 +1,72 @@
# Design: fpagent-installer-multiarch
## Context
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
laptop and proxies the private release through `tools.yrka.app`. It resolves
the release's asset names with a small `python3` snippet and hardcodes the
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
The bundle's own installer already refuses a binary whose ELF class does not
match the host, so the hosted installer only has to pick the right asset.
## Goals / Non-goals
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
that architecture, and record its version.
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
and asset filtering/naming is unchanged.
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
x86_64-only; i386 hosts fall back to the generic online bundle whose
installer apt-get's libfprint.
- Non-goal: adding a production environment knob for the architecture. The
host's own `uname -m` is the source of truth.
## Decisions
### Architecture from `uname -m`, resolved once
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
so the hosted and bundled installers agree on what is supported.
### Selection order: codename bundle, then generic, both arch-scoped
With `codename` from `/etc/os-release`:
1. `-ubuntu-<codename>-<arch>.tar.gz`
2. `-linux-<arch>.tar.gz`
The codename attempt is skipped when the host has no codename. Because offline
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
to step 2. The suffix match keeps one architecture's bundle from ever matching
another's (`-linux-i386` does not end with `-linux-x86_64`).
### Version regex derived from the architecture
The version is the git hash embedded in the asset name. The existing regex only
listed the x86_64 endings, so it produced an empty version for an i386 bundle
and nothing was recorded. The regex must accept the same set of endings the
selection can produce: `linux-x86_64`, `linux-i386` and
`ubuntu-<codename>-x86_64`.
### Tests stub `uname`
The installer takes the architecture from the host, so a test cannot assert the
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
production-only override. Assertions are made on the gateway requests the script
makes (which asset it asks for), the way the existing `--tag` test does.
## Risks / trade-offs
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
choice for the bundled installer, so the hosted one follows it rather than
trying a second heuristic. `ponytail:` if such hosts appear, add a
`getconf LONG_BIT` fallback in both installers at once.
- The generated `install.sh --help` text duplicates the bundle installer's
flags list. It can drift again; keeping it short and pointing at the bundle
installer's own help limits the cost.
@@ -0,0 +1,47 @@
# Proposal: fpagent-installer-multiarch
## Why
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
"no compatible bundle" and installs nothing, even though a matching release
asset exists.
## What Changes
- The installer detects the host architecture (`uname -m` → `x86_64` or
`i386`) and selects the matching bundle: the distro-codename bundle when one
exists for that architecture, else the generic `-linux-<arch>` bundle.
- An unsupported architecture fails with a message that names the host machine,
and installs nothing.
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
32-bit install records and reports its version the same way x86_64 does.
- The installer's help text matches the bundle installer: the systemd service
and the self-update timer are on by default, so `--no-service`,
`--no-updates` and `--update-base` are documented instead of
`--enable-service` being described as required to get a service.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
distro-codename aware), the no-compatible-bundle failure names the host
architecture, and the version is derived for every published architecture.
## Impact
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
version regex, help text.
- `tools/test/fpagent-install.test.js`: architecture selection and version
parsing tests.
- `README.md`: the installer section (flags and architectures).
- Depends on the `fingerprint-agent` bundle naming
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
whatever assets the release lists.
@@ -0,0 +1,103 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Public installer endpoint
The app SHALL serve an installer script at `/fpagent/install.sh` that is
reachable without authentication. When run, the script SHALL determine the
host's architecture, fetch the latest release metadata, choose the bundle for
the host architecture, verify the bundle's published sha256 checksum, extract
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
bundle built for the host's distro codename when one exists for that
architecture, and otherwise use the generic bundle for that architecture. The
script SHALL be served uncached.
#### Scenario: Installer is fetched anonymously
- **WHEN** a client requests `/fpagent/install.sh` without a session
- **THEN** the app returns the installer script and does not redirect to login
#### Scenario: Host-appropriate bundle is chosen
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
codename and a generic bundle
- **THEN** the installer uses the codename bundle
#### Scenario: Generic bundle is the fallback
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
codename
- **THEN** the installer uses the generic bundle
#### Scenario: Bundle matches the host architecture
- **WHEN** the latest release offers a bundle for the host architecture and a
bundle for another architecture
- **THEN** the installer chooses the bundle for the host architecture
- **AND** it never chooses a bundle for another architecture
#### Scenario: 32-bit host uses the 32-bit bundle
- **WHEN** the installer runs on an i386 host and the release offers only a
generic 32-bit bundle
- **THEN** the installer uses that generic 32-bit bundle
#### Scenario: Checksum is verified before extraction
- **WHEN** the installer downloads a bundle and its published checksum
- **THEN** it verifies the checksum before extracting and refuses to install
when the checksum does not match
#### Scenario: Options are forwarded to the bundled installer
- **WHEN** the installer is invoked with options such as `--no-service` or
`--check`
- **THEN** it passes those options through to the extracted bundle's installer
#### Scenario: No compatible bundle
- **WHEN** the latest release contains no bundle for the host architecture
- **THEN** the installer exits with a failure message that names the host
architecture and installs nothing
#### Scenario: Unsupported architecture
- **WHEN** the installer runs on a machine whose architecture is neither
x86_64 nor i386
- **THEN** it exits with a failure message and installs nothing
### Requirement: Installed version is tracked and upgrades are reported
The installer SHALL treat the git hash in the bundle's asset name as the
version, for every architecture it can install. It SHALL hand that version to
the bundle installer, which SHALL record it beside the installed binary and
SHALL report an upgrade with the previous and new versions on the next run.
Uninstalling SHALL remove the recorded version.
#### Scenario: Version is derived from the asset name
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Version is derived for a 32-bit bundle
- **WHEN** the installer selects a generic bundle named
`fpagent-<hash>-linux-i386.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Upgrade is reported
- **WHEN** the recorded version differs from the version being installed
- **THEN** the bundle installer reports the change from the old to the new
version
#### Scenario: First install records the version
- **WHEN** no version has been recorded yet
- **THEN** the bundle installer records the new version and reports no upgrade
#### Scenario: Uninstall removes the recorded version
- **WHEN** the installer is asked to uninstall
- **THEN** the recorded version is removed
@@ -0,0 +1,27 @@
# Tasks
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
dies without fetching.
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
host architecture. Verify: with a release offering only
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
with only `-linux-x86_64`, the same host fails and installs nothing.
- [x] 3. Hosted installer: derive the version from the asset name for every
supported architecture (`linux-x86_64`, `linux-i386`,
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
not an empty string.
- [x] 4. Hosted installer: correct the help text — service and self-update are
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
the note that options are forwarded to the bundle installer.
Verify: `install.sh --help` mentions the three flags and no longer implies
`--enable-service` is required.
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
and assert i386 selection, cross-architecture rejection, and i386 version
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
and the on-by-default service/self-update. Verify: reading the section, no
flag claims contradict `install.sh --help`.
+62 -19
View File
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
### Requirement: Two-finger enrollment flow
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
up staff by reference (OTP-validated through the a2loft proxy), displaying
up staff by reference (token-validated through the a2loft proxy), displaying
the staff details, capturing the right thumb and right index finger, and
saving both fingerprints through the a2loft proxy with an OTP. The wizard
SHALL offer per-finger recapture until save, SHALL require both fingers
before save, and SHALL reset domain-independent state (reference, staff
details, captured artifacts) when starting a new enrollment.
saving both fingerprints through the a2loft proxy authenticated with the
session token. The wizard SHALL offer per-finger recapture until save, SHALL
require both fingers before save, and SHALL reset domain-independent state
(reference, staff details, captured artifacts) when starting a new
enrollment.
#### Scenario: Complete enrollment
- **WHEN** the operator looks up staff, captures both fingers, and saves
with an OTP
- **THEN** both fingerprints are saved through the a2loft proxy and the
wizard reports success
@@ -137,20 +137,63 @@ details, captured artifacts) when starting a new enrollment.
- **THEN** the reference, staff details, and captured finger artifacts are
cleared while the permitted domain list persists
### Requirement: OTP validated on every a2loft call
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
Submission SHALL be blocked while the OTP is missing.
### Requirement: Scanner availability is shown and gates capture
The wizard SHALL consume fpagent's `fprint-device-status`
`{available, device, captureAgentId}` event and SHALL distinguish the agent
being offline (socket not connected), connected with no supported reader
attached (`available` false), and ready (`available` true). Until a status is
received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
capture while no supported reader is attached, SHALL re-enable capture when a
reader becomes available, and SHALL clear availability when the agent
disconnects. Availability means "a supported reader is attached"; it does not
guarantee a capture will succeed.
#### Scenario: Lookup with OTP
- **WHEN** the operator submits a reference with an OTP
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
displays the returned staff details
#### Scenario: Reader attached on connect
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
`available: true`
- **THEN** the wizard shows the agent ready and leaves capture enabled
#### Scenario: Save requires a fresh OTP
#### Scenario: No reader attached on connect
- **WHEN** the page connects and fpagent reports `fprint-device-status` with
`available: false`
- **THEN** the wizard shows that the scanner is not connected, explains captures
are disabled, and disables the capture controls
#### Scenario: Unplug blocks capture
- **WHEN** fpagent reports `available: false` while the operator is capturing or
between captures
- **THEN** the wizard disables the capture controls and a capture attempt is
refused
#### Scenario: Replug re-enables capture
- **WHEN** fpagent reports `available: true` after the reader is reconnected
- **THEN** the wizard re-enables the capture controls
#### Scenario: Status absent or old agent
- **WHEN** the connected agent never reports `fprint-device-status`
- **THEN** availability stays unknown and the wizard does not block capture
#### Scenario: Disconnect clears availability
- **WHEN** the agent socket disconnects
- **THEN** the wizard shows the agent offline and does not present the reader as
available
### Requirement: Token authenticated on every a2loft call
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
save — with the signed-in operator's PocketBase session token, sent by the
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
NOT collect an OTP.
#### Scenario: Lookup uses the session token
- **WHEN** the operator submits a reference
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
session token
#### Scenario: Save uses the session token
- **WHEN** the operator saves the captured fingerprints
- **THEN** the wizard sends the OTP entered for the save call
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
the session token
#### Scenario: Missing OTP blocks submission
- **WHEN** the OTP field is empty for a lookup or save
- **THEN** the corresponding submit control is disabled
#### Scenario: No OTP field
- **WHEN** the wizard page is served
- **THEN** it offers no OTP input for lookup or save
+125 -6
View File
@@ -11,10 +11,13 @@ proxies the private release and serves the installer.
### 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.
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
@@ -33,6 +36,19 @@ installer. The script SHALL be served uncached.
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
@@ -41,14 +57,21 @@ installer. The script SHALL be served uncached.
#### Scenario: Options are forwarded to the bundled installer
- **WHEN** the installer is invoked with options such as `--enable-service` or
- **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 and installs nothing
- **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
@@ -134,3 +157,99 @@ asset.
- **WHEN** the release key is supplied in the environment
- **THEN** the installer and proxy endpoints use it without any change to the
hardcoded configuration
### Requirement: Pinned release selection
The installer SHALL accept a release tag and install that release instead of the
latest. Without a tag it SHALL install the latest release.
#### Scenario: A pinned release is installed
- **WHEN** the installer is invoked with a release tag
- **THEN** it fetches that tag's metadata and installs its bundle
#### Scenario: Default is the latest release
- **WHEN** the installer is invoked without a release tag
- **THEN** it installs the latest release
#### Scenario: Unknown pinned release
- **WHEN** the pinned tag does not exist
- **THEN** the installer exits with a failure message and installs nothing
### Requirement: Pinned release metadata endpoint
The app SHALL expose a same-origin endpoint that returns a specific release's
tag and asset names as same-origin URLs, with the same authentication, filtering
and non-disclosure guarantees as the latest-release endpoint. It SHALL reject a
tag that is not a valid release tag without contacting the forge.
#### Scenario: Tagged release is returned
- **WHEN** a client requests the metadata for an existing tag
- **THEN** the response carries that release's tag and its assets as same-origin
URLs
#### Scenario: Invalid tag is rejected
- **WHEN** a client requests metadata for a tag that is not a valid release tag
- **THEN** the app rejects the request without contacting the forge
### Requirement: Installed version is tracked and upgrades are reported
The installer SHALL treat the git hash in the bundle's asset name as the
version, for every architecture it can install. It SHALL hand that version to
the bundle installer, which SHALL record it beside the installed binary and
SHALL report an upgrade with the previous and new versions on the next run.
Uninstalling SHALL remove the recorded version.
#### Scenario: Version is derived from the asset name
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Version is derived for a 32-bit bundle
- **WHEN** the installer selects a generic bundle named
`fpagent-<hash>-linux-i386.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Upgrade is reported
- **WHEN** the recorded version differs from the version being installed
- **THEN** the bundle installer reports the change from the old to the new
version
#### Scenario: First install records the version
- **WHEN** no version has been recorded yet
- **THEN** the bundle installer records the new version and reports no upgrade
#### Scenario: Uninstall removes the recorded version
- **WHEN** the installer is asked to uninstall
- **THEN** the recorded version is removed
### Requirement: A managed running service is restarted on upgrade
When the installer is asked to manage the systemd service and a service is
already running, the installer SHALL restart it after installing the new binary
so the new binary takes effect. A fresh install SHALL NOT perform a redundant
restart, and the installer SHALL NOT restart the service when service management
is not requested.
#### Scenario: Upgrade restarts the running service
- **WHEN** the running service is managed and a new binary is installed
- **THEN** the service is restarted on the new binary
#### Scenario: Fresh install starts the service
- **WHEN** the service is not running and service management is requested
- **THEN** the service is started and no separate restart is performed
#### Scenario: Service management not requested
- **WHEN** service management is not requested
- **THEN** the installer does not restart the service
+18 -9
View File
@@ -60,24 +60,33 @@ only for permitted operators.
### Requirement: Saving requires a live gate and an OTP
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
permission live (bypassing the cache) and SHALL require an OTP from the
operator, forwarding the session phone as the validating identity together
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
SHALL not reach a2loft.
permission live (bypassing the cache) and SHALL authenticate the operator with
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
back to the queue item's label, and SHALL match a path-safe pattern before it
is used in the request. A denied save SHALL not reach a2loft.
#### Scenario: Successful save
- **WHEN** a permitted operator saves with an OTP
- **WHEN** a permitted operator saves
- **THEN** the route verifies the live gate and forwards the picture to
a2loft with the session phone, OTP, and picture domain
a2loft with the session token as a bearer credential and the profile's
resolved domain
#### Scenario: Denied save does not reach a2loft
- **WHEN** the live crop check denies, or the OTP is missing
- **WHEN** the live crop check denies, or the request carries no message body
- **THEN** the route answers 403/400 and makes no a2loft call
#### Scenario: Unsafe domain values are refused
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
domain
- **THEN** the save is refused with 400 and a2loft is not contacted
### Requirement: Unified login — no separate artist login
The cropper SHALL NOT implement its own login. The operator's identity comes
from the YOUreka session (phone prefilled), the picture's metadata carries the
domain, and the only credential the operator supplies is the save-time OTP.
from the YOUreka session and is presented to a2loft as the session's PocketBase
token; the picture's own metadata carries the domain. The cropper collects no
other credential — there is no save-time OTP.
#### Scenario: Phone prefilled from the session
- **WHEN** a signed-in operator opens the cropper
+14 -9
View File
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
- **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at
`/cropper` and a link to `/fingerprint`
### Requirement: YOUreka Auth login establishes a cookie session
The app SHALL authenticate operators through the YOUreka portal web-auth
flow: create a web auth session at the portal with a local callback URL,
@@ -100,17 +101,21 @@ portal.
### Requirement: a2loft proxies inject operator identity
The app SHALL proxy staff lookup and fingerprint-save calls to
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
attach the operator's phone from the server-side session and forward the
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
the a2loft request and response shapes. The proxies SHALL require a
session; the OTP SHALL NOT be accepted without one.
authenticate the operator with the signed-in session's PocketBase token, sent
as an `Authorization: Bearer` credential, preserving the a2loft request and
response shapes. The proxies SHALL require a session; a request without one
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
require an OTP.
#### Scenario: Staff lookup via proxy
- **WHEN** the page requests staff details for a domain and reference with
an OTP
- **THEN** the app calls a2loft's identity endpoint with the operator's
phone from the session and the supplied OTP, and returns a2loft's
response unchanged
- **WHEN** the page requests staff details for a domain and reference
- **THEN** the app calls a2loft's identity endpoint with the session token as a
bearer credential and returns a2loft's response unchanged
#### Scenario: Fingerprint save via proxy
- **WHEN** the page saves fingerprints for a domain and reference
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
token as a bearer credential
#### Scenario: Unauthenticated proxy call rejected
- **WHEN** a proxy route is called without a valid session
+64 -17
View File
@@ -20,11 +20,11 @@ Vue.config.ignoredElements = [/^cds-/];
session: null,
tick: Date.now(),
socket: null,
agent: { connected: false, version: '', captureAgentId: '' },
// available is tri-state: null = not yet reported (old agent), true/false
// = the last fprint-device-status. Unknown is not blocked.
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
domain: '',
reference: '',
otpLookup: '',
otpSave: '',
staff: null,
busy: { lookup: false, save: false },
refreshing: false,
@@ -47,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
this.fingers['right-index-finger'].status === 'done';
},
canLookup: function () {
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
return !!(this.domain && this.reference) && !this.busy.lookup;
},
canSave: function () {
return this.bothCaptured && !!this.otpSave && !this.busy.save;
return this.bothCaptured && !this.busy.save;
},
refreshUnlocksAt: function () {
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
@@ -68,6 +68,27 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.session || !this.session.domainsCheckedAt) return '';
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
},
// Warning shown in the capture panel when the agent cannot capture.
scannerNotice: function () {
if (!this.agent.connected) {
return {
title: 'Capture agent offline',
subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.',
};
}
if (this.agent.available === false) {
return {
title: 'Scanner not connected',
subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.',
};
}
return null;
},
// Capture is allowed when the agent is connected and no status has said
// the scanner is missing. Unknown availability (old agent) stays allowed.
captureReady: function () {
return this.agent.connected && this.agent.available !== false;
},
},
created: function () {
@@ -139,18 +160,38 @@ Vue.config.ignoredElements = [/^cds-/];
renderAgentTag: function () {
var el = document.getElementById('agent-status');
if (!el) return;
el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
el.setAttribute('type', this.agent.connected ? 'green' : 'red');
el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
if (!this.agent.connected) {
label = 'Agent offline'; type = 'red';
} else if (this.agent.available === false) {
label = 'Scanner not connected'; type = 'red';
} else if (this.agent.available === true) {
label = 'Agent ready'; type = 'green';
} else {
// Connected but no status yet (or an agent that does not report it).
label = 'Agent connected'; type = 'green';
}
if (this.agent.available === true && this.agent.device) {
title += ' · ' + this.agent.device;
}
el.textContent = label;
el.setAttribute('type', type);
el.setAttribute('title', title);
},
connectAgent: function () {
var self = this;
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
var offline = function () {
self.agent.connected = false;
self.agent.available = null;
self.agent.device = '';
self.renderAgentTag();
};
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('disconnect', offline);
this.socket.on('connect_error', offline);
this.socket.on('agent-hello', function (data) {
self.agent.version = (data && data.version) || '';
@@ -158,6 +199,12 @@ Vue.config.ignoredElements = [/^cds-/];
self.renderAgentTag();
});
this.socket.on('fprint-device-status', function (data) {
self.agent.available = !!(data && data.available);
self.agent.device = (data && data.device) || '';
self.renderAgentTag();
});
this.socket.on('fprint-enroll-status', function (data) {
if (!self.capturing) return;
self.fingers[self.capturing].stage = (data && data.stage) || 0;
@@ -213,6 +260,11 @@ Vue.config.ignoredElements = [/^cds-/];
capture: function (finger) {
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
if (!this.captureReady) {
var notice = this.scannerNotice;
if (notice) this.showNotif('warning', notice.title, notice.subtitle);
return;
}
// Preserve a previous good capture so a cancelled/failed recapture
// can restore it instead of destroying it.
var old = this.fingers[finger];
@@ -242,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.canLookup) return;
this.busy.lookup = true;
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
null,
{ params: { otp: this.otpLookup } }
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
).then(function (r) {
if (!r.data.ok) {
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
@@ -268,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
});
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
{ fingerprints: fingerprints },
{ params: { otp: this.otpSave } }
{ fingerprints: fingerprints }
).then(function (r) {
if (r.data.ok) {
self.showNotif('success', 'Enrollment saved',
@@ -289,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
newEnrollment: function () {
this.staff = null;
this.reference = '';
this.otpLookup = '';
this.otpSave = '';
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
this.capturing = null;
this.cancelling = false;
+6 -10
View File
@@ -54,10 +54,6 @@
<label class="cds--label" for="reference">Staff reference</label>
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
</div>
<div style="min-width:130px; max-width:170px;">
<label class="cds--label" for="otpLookup">OTP</label>
<input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
<span v-if="!busy.lookup">Get details</span>
@@ -84,6 +80,10 @@
<span class="hint">Two fingers are required &middot; place the finger on the scanner each scan</span>
</div>
<cds-inline-notification v-if="scannerNotice" kind="warning" hide-close-button class="mt-1"
v-bind:title="scannerNotice.title" v-bind:subtitle="scannerNotice.subtitle">
</cds-inline-notification>
<dl class="details">
<div><dt>Name</dt><dd>{{ staff.name }}</dd></div>
<div><dt>Reference</dt><dd>{{ staff.reference }}</dd></div>
@@ -107,11 +107,11 @@
<p class="meta" v-if="fingers[finger].status === 'failed'">The last attempt failed. You can try again.</p>
<div class="row-gap mt-1">
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
</cds-button>
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null || !captureReady" v-on:click="capture(finger)">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
Recapture
</cds-button>
@@ -132,10 +132,6 @@
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
</div>
<div class="row-gap" style="align-items:flex-end;">
<div style="min-width:150px; max-width:200px;">
<label class="cds--label" for="otpSave">OTP</label>
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canSave" v-on:click="save">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
<span v-if="!busy.save">Save enrollment</span>
+2 -2
View File
@@ -48,12 +48,12 @@
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<ol class="help-steps">
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
<li>Choose the domain, enter the staff reference 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>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
<li>Review the captured prints and save.</li>
</ol>
<p class="help-note">The scanner runs locally through the <em>fpagent</em> service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes.</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>
+76 -21
View File
@@ -11,7 +11,7 @@
# 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 -- --enable-service
# 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
@@ -27,65 +27,115 @@ usage() {
cat <<EOF
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
Downloads the latest fpagent release, verifies it, and runs its installer.
Options are forwarded to that installer:
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
--enable-service install and enable a systemd service (starts at boot)
--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
}
for arg in "$@"; do
case "$arg" in
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; do
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 '"')"
say "Fetching the latest Fingerprint Agent release..."
meta="$(curl -fsSL "${BASE}/releases/latest")" || die "could not fetch the release list from ${BASE}"
# 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, sys
codename = sys.argv[1]
release = json.loads(sys.argv[2])
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 + "-x86_64.tar.gz") if codename else ""
name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
if not name:
name = pick("-linux-x86_64.tar.gz")
print(release.get("tag", "") + "\t" + name)
' "$codename" "$meta")" || die "could not parse the release metadata"
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"
tag="${readout%%$'\t'*}"
asset="${readout#*$'\t'}"
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in the latest release (x86_64 required)"
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)"
tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")"
trap 'rm -rf "$tmp"' EXIT
get() { curl -fsSL "$1" -o "$2" || die "download failed: $1"; }
@@ -98,5 +148,10 @@ get "${BASE}/releases/download/${tag}/${asset}.sha256" "${tmp}/${asset}.sha256"
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" "$@"
"${tmp}/install.sh" "${FORWARD[@]}"
+33 -29
View File
@@ -345,12 +345,9 @@ export function createApp(overrides = {}) {
message: 'The Fingerprint Agent installer is not configured on this deployment.',
});
app.get('/fpagent/releases/latest', async (req, res) => {
if (!cfg.giteaToken) return installerNotConfigured(res);
const releasePayload = async (res, giteaPath) => {
try {
const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, {
headers: giteaHeaders(),
});
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.' });
}
@@ -372,6 +369,22 @@ export function createApp(overrides = {}) {
} 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) => {
@@ -691,19 +704,11 @@ export function createApp(overrides = {}) {
);
}
// ── a2loft proxies: same-origin, phone injected from the session ──────────
const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
const a2loftSaveUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
// ── a2loft proxies: same-origin, bearer token from the session ────────────
const a2loftUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
const a2loftSaveUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`;
const validateProxy = (req, res) => {
const { domain, reference } = req.params;
@@ -715,21 +720,15 @@ export function createApp(overrides = {}) {
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
return false;
}
if (!req.query.otp) {
res.status(400).json({ ok: false, message: 'OTP is required.' });
return false;
}
if (!req.session.phone) {
res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' });
return false;
}
return true;
};
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
if (!validateProxy(req, res)) return;
try {
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference), {
headers: { authorization: `Bearer ${req.session.token}` },
});
res.status(r.status).type('json').send(await r.text());
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
@@ -742,8 +741,13 @@ export function createApp(overrides = {}) {
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
}
try {
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${req.session.token}`,
},
body: JSON.stringify(req.body),
});
res.status(r.status).type('json').send(await r.text());
} catch {
+153
View File
@@ -0,0 +1,153 @@
// The /fingerprint wizard's scanner-availability logic. app.js is a browser
// IIFE, so it is loaded in a vm sandbox with stubbed browser globals and its
// Vue options are exercised directly (no Vue runtime needed).
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const SRC = fs.readFileSync(new URL('../public/assets/fp/app.js', import.meta.url), 'utf8');
function loadApp() {
let options = null;
let socket = null;
const els = {};
const document = {
getElementById(id) {
return (els[id] ||= {
textContent: '',
attrs: {},
setAttribute(k, v) { this.attrs[k] = v; },
getAttribute(k) { return this.attrs[k]; },
});
},
};
const sandbox = {
window: {
__SESSION__: { agentUrl: 'http://127.0.0.1:9000', domains: [] },
location: { href: '' },
},
document,
Vue: class Vue { static config = {}; constructor(o) { options = o; } },
io() {
socket = { handlers: {}, on(e, cb) { this.handlers[e] = cb; return this; } };
return socket;
},
setInterval: () => 0,
setTimeout: () => 0,
clearTimeout: () => {},
console,
};
vm.runInNewContext(SRC, sandbox);
return {
get options() { return options; },
get socket() { return socket; },
getElement: (id) => document.getElementById(id),
};
}
function makeVm(app) {
const o = app.options;
const inst = Object.assign({}, o.data);
for (const [k, fn] of Object.entries(o.methods)) inst[k] = fn.bind(inst);
for (const [k, fn] of Object.entries(o.computed)) {
Object.defineProperty(inst, k, { get: fn.bind(inst), configurable: true });
}
inst.$forceUpdate = () => {};
inst.loadSession();
inst.connectAgent();
return inst;
}
test('disconnected agent blocks capture and warns', () => {
const vmInst = makeVm(loadApp());
assert.equal(vmInst.captureReady, false);
assert.equal(vmInst.scannerNotice.title, 'Capture agent offline');
});
test('available:false blocks capture and warns; true re-enables', () => {
const app = loadApp();
const vmInst = makeVm(app);
const h = app.socket.handlers;
h.connect();
h['fprint-device-status']({ available: false, device: '' });
assert.equal(vmInst.captureReady, false);
assert.equal(vmInst.scannerNotice.title, 'Scanner not connected');
h['fprint-device-status']({ available: true, device: 'Digital Persona U.are.U' });
assert.equal(vmInst.captureReady, true);
assert.equal(vmInst.scannerNotice, null);
assert.equal(vmInst.agent.device, 'Digital Persona U.are.U');
});
test('unknown availability (old agent) does not block capture', () => {
const app = loadApp();
const vmInst = makeVm(app);
app.socket.handlers.connect();
assert.equal(vmInst.agent.available, null);
assert.equal(vmInst.captureReady, true);
assert.equal(vmInst.scannerNotice, null);
});
test('status tag reflects offline, connected, no-scanner and ready', () => {
const app = loadApp();
const vmInst = makeVm(app);
const h = app.socket.handlers;
const el = app.getElement('agent-status');
vmInst.renderAgentTag();
assert.equal(el.textContent, 'Agent offline');
assert.equal(el.getAttribute('type'), 'red');
h.connect();
assert.equal(el.textContent, 'Agent connected');
h['agent-hello']({ version: 'abc123', captureAgentId: 'aid' });
assert.equal(el.getAttribute('title'), 'fpagent abc123');
h['fprint-device-status']({ available: false });
assert.equal(el.textContent, 'Scanner not connected');
assert.equal(el.getAttribute('type'), 'red');
h['fprint-device-status']({ available: true, device: 'U.are.U 4500' });
assert.equal(el.textContent, 'Agent ready');
assert.equal(el.getAttribute('type'), 'green');
assert.match(el.getAttribute('title'), /abc123/);
assert.match(el.getAttribute('title'), /U\.are\.U 4500/);
});
test('capture is refused when no reader is attached', () => {
const app = loadApp();
const vmInst = makeVm(app);
const h = app.socket.handlers;
h.connect();
h['fprint-device-status']({ available: false });
vmInst.domain = 'uc';
vmInst.reference = 'UC/1';
vmInst.capture('right-thumb');
assert.equal(vmInst.capturing, null);
assert.equal(vmInst.notif.show, true);
});
test('lookup and save need no OTP credential', () => {
const vmInst = makeVm(loadApp());
vmInst.domain = 'uc';
vmInst.reference = 'UC/1';
assert.equal(vmInst.canLookup, true);
vmInst.fingers['right-thumb'].status = 'done';
vmInst.fingers['right-index-finger'].status = 'done';
assert.equal(vmInst.canSave, true);
});
test('disconnect clears availability', () => {
const app = loadApp();
const vmInst = makeVm(app);
const h = app.socket.handlers;
h.connect();
h['fprint-device-status']({ available: true, device: 'U.are.U' });
h.disconnect();
assert.equal(vmInst.agent.connected, false);
assert.equal(vmInst.agent.available, null);
assert.equal(vmInst.agent.device, '');
assert.equal(vmInst.captureReady, false);
});
+219 -9
View File
@@ -3,12 +3,25 @@
// laptop never needs credentials.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
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';
@@ -24,6 +37,17 @@ const RELEASE = {
],
};
// 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 = [];
@@ -39,6 +63,11 @@ async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}
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'));
@@ -88,6 +117,70 @@ test('latest release is proxied as tools-relative URLs, without the token', asyn
}
});
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));
@@ -149,13 +242,130 @@ test('install.sh is served publicly at /fpagent/install.sh', async () => {
}
});
test('install.sh is valid bash', () => {
const script = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'public',
'fpagent',
'install.sh'
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' },
],
})
);
execFileSync('bash', ['-n', script], { stdio: 'pipe' });
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 });
}
});
+13 -15
View File
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null });
const staff = {
ok: true,
data: {
@@ -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();
try {
const cookies = await login(base);
const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, {
const res = await fetch(`${base}/api/uc/staff/UC%2F0231`, {
method: 'POST',
headers: { cookie: cookies },
});
@@ -224,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
assert.equal(call.method, 'GET');
const u = new URL(call.url, 'http://x');
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
assert.equal(u.searchParams.get('phone'), '+2348068287222');
assert.equal(u.searchParams.get('otp'), '4455');
assert.equal(u.search, '', 'no phone/otp query parameters');
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
await stop();
}
});
test('fingerprint save proxy forwards the body unchanged', async () => {
test('fingerprint save proxy forwards the body and the token, not OTP', async () => {
const { base, a2loft, stop } = await bootApp();
try {
const cookies = await login(base);
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -245,24 +245,21 @@ test('fingerprint save proxy forwards the body unchanged', async () => {
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
const call = a2loft.calls[0];
assert.equal(call.method, 'POST');
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
assert.equal(call.url, '/identity/uc/fingerprints/UC0231');
assert.deepEqual(call.body, payload);
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
await stop();
}
});
test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => {
test('proxy validation: auth and domain are enforced before contacting a2loft', async () => {
const { base, a2loft, stop } = await bootApp();
try {
const cookies = await login(base);
const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' });
const anon = await fetch(`${base}/api/uc/staff/REF`, { method: 'POST' });
assert.equal(anon.status, 401);
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
assert.equal(noOtp.status, 400);
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
const badDomain = await fetch(`${base}/api/nope/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
assert.equal(badDomain.status, 400);
@@ -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, /2348068287222/);
assert.match(html, /"domains"/, 'permitted domains injected too');
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
} finally {
await stop();
}