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