Compare commits
5
Commits
14f6fd6a8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528eaafb18 | ||
|
|
8a13fb527e | ||
|
|
8d42faf707 | ||
|
|
e432585f04 | ||
|
|
fa33c6e179 |
@@ -161,22 +161,25 @@ is claimed or a scan does not complete.
|
||||
### Installing the agent
|
||||
|
||||
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
|
||||
fetches the newest bundle from the private
|
||||
fetches the newest bundle for the host architecture — x86_64 or 32-bit i386,
|
||||
preferring the bundle built for the machine's Ubuntu codename — from the private
|
||||
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
|
||||
forge, verifies its sha256, then runs the bundle's own installer, which handles
|
||||
libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service
|
||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
|
||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only
|
||||
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0 --enable-service
|
||||
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 with
|
||||
`--enable-service` 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.
|
||||
for a pinned one), the bundle installer reports the version change, and it
|
||||
restarts a running service so the new binary takes effect. The version is the
|
||||
git hash carried in the asset name, stamped next to the binary
|
||||
(`/usr/local/bin/fpagent.version`) and compared on the next run. The systemd
|
||||
service and the self-update timer are on by default; pass `--no-service` or
|
||||
`--no-updates` to leave them off.
|
||||
|
||||
The forge is private, so the operator's laptop needs no credentials: the app
|
||||
reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through
|
||||
|
||||
+3
-2
@@ -25,5 +25,6 @@
|
||||
|
||||
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
|
||||
(66/66 pass.)
|
||||
- [ ] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
||||
reference and save both fingerprints with no OTP.
|
||||
- [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
|
||||
reference and save both fingerprints with no OTP. Verified in production:
|
||||
the lookup and both fingerprint saves succeeded with no OTP.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-06
|
||||
@@ -0,0 +1,72 @@
|
||||
# Design: fpagent-installer-multiarch
|
||||
|
||||
## Context
|
||||
|
||||
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
|
||||
laptop and proxies the private release through `tools.yrka.app`. It resolves
|
||||
the release's asset names with a small `python3` snippet and hardcodes the
|
||||
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
|
||||
|
||||
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
|
||||
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
|
||||
|
||||
The bundle's own installer already refuses a binary whose ELF class does not
|
||||
match the host, so the hosted installer only has to pick the right asset.
|
||||
|
||||
## Goals / Non-goals
|
||||
|
||||
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
|
||||
that architecture, and record its version.
|
||||
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
|
||||
and asset filtering/naming is unchanged.
|
||||
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
|
||||
x86_64-only; i386 hosts fall back to the generic online bundle whose
|
||||
installer apt-get's libfprint.
|
||||
- Non-goal: adding a production environment knob for the architecture. The
|
||||
host's own `uname -m` is the source of truth.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Architecture from `uname -m`, resolved once
|
||||
|
||||
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
|
||||
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
|
||||
so the hosted and bundled installers agree on what is supported.
|
||||
|
||||
### Selection order: codename bundle, then generic, both arch-scoped
|
||||
|
||||
With `codename` from `/etc/os-release`:
|
||||
|
||||
1. `-ubuntu-<codename>-<arch>.tar.gz`
|
||||
2. `-linux-<arch>.tar.gz`
|
||||
|
||||
The codename attempt is skipped when the host has no codename. Because offline
|
||||
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
|
||||
to step 2. The suffix match keeps one architecture's bundle from ever matching
|
||||
another's (`-linux-i386` does not end with `-linux-x86_64`).
|
||||
|
||||
### Version regex derived from the architecture
|
||||
|
||||
The version is the git hash embedded in the asset name. The existing regex only
|
||||
listed the x86_64 endings, so it produced an empty version for an i386 bundle
|
||||
and nothing was recorded. The regex must accept the same set of endings the
|
||||
selection can produce: `linux-x86_64`, `linux-i386` and
|
||||
`ubuntu-<codename>-x86_64`.
|
||||
|
||||
### Tests stub `uname`
|
||||
|
||||
The installer takes the architecture from the host, so a test cannot assert the
|
||||
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
|
||||
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
|
||||
production-only override. Assertions are made on the gateway requests the script
|
||||
makes (which asset it asks for), the way the existing `--tag` test does.
|
||||
|
||||
## Risks / trade-offs
|
||||
|
||||
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
|
||||
choice for the bundled installer, so the hosted one follows it rather than
|
||||
trying a second heuristic. `ponytail:` if such hosts appear, add a
|
||||
`getconf LONG_BIT` fallback in both installers at once.
|
||||
- The generated `install.sh --help` text duplicates the bundle installer's
|
||||
flags list. It can drift again; keeping it short and pointing at the bundle
|
||||
installer's own help limits the cost.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proposal: fpagent-installer-multiarch
|
||||
|
||||
## Why
|
||||
|
||||
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
|
||||
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
|
||||
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
|
||||
"no compatible bundle" and installs nothing, even though a matching release
|
||||
asset exists.
|
||||
|
||||
## What Changes
|
||||
|
||||
- The installer detects the host architecture (`uname -m` → `x86_64` or
|
||||
`i386`) and selects the matching bundle: the distro-codename bundle when one
|
||||
exists for that architecture, else the generic `-linux-<arch>` bundle.
|
||||
- An unsupported architecture fails with a message that names the host machine,
|
||||
and installs nothing.
|
||||
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
|
||||
32-bit install records and reports its version the same way x86_64 does.
|
||||
- The installer's help text matches the bundle installer: the systemd service
|
||||
and the self-update timer are on by default, so `--no-service`,
|
||||
`--no-updates` and `--update-base` are documented instead of
|
||||
`--enable-service` being described as required to get a service.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
|
||||
distro-codename aware), the no-compatible-bundle failure names the host
|
||||
architecture, and the version is derived for every published architecture.
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
|
||||
version regex, help text.
|
||||
- `tools/test/fpagent-install.test.js`: architecture selection and version
|
||||
parsing tests.
|
||||
- `README.md`: the installer section (flags and architectures).
|
||||
- Depends on the `fingerprint-agent` bundle naming
|
||||
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
|
||||
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
|
||||
whatever assets the release lists.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public installer endpoint
|
||||
|
||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||
reachable without authentication. When run, the script SHALL determine the
|
||||
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||
bundle built for the host's distro codename when one exists for that
|
||||
architecture, and otherwise use the generic bundle for that architecture. The
|
||||
script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Installer is fetched anonymously
|
||||
|
||||
- **WHEN** a client requests `/fpagent/install.sh` without a session
|
||||
- **THEN** the app returns the installer script and does not redirect to login
|
||||
|
||||
#### Scenario: Host-appropriate bundle is chosen
|
||||
|
||||
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
|
||||
codename and a generic bundle
|
||||
- **THEN** the installer uses the codename bundle
|
||||
|
||||
#### Scenario: Generic bundle is the fallback
|
||||
|
||||
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
|
||||
codename
|
||||
- **THEN** the installer uses the generic bundle
|
||||
|
||||
#### Scenario: Bundle matches the host architecture
|
||||
|
||||
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||
bundle for another architecture
|
||||
- **THEN** the installer chooses the bundle for the host architecture
|
||||
- **AND** it never chooses a bundle for another architecture
|
||||
|
||||
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||
|
||||
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||
generic 32-bit bundle
|
||||
- **THEN** the installer uses that generic 32-bit bundle
|
||||
|
||||
#### Scenario: Checksum is verified before extraction
|
||||
|
||||
- **WHEN** the installer downloads a bundle and its published checksum
|
||||
- **THEN** it verifies the checksum before extracting and refuses to install
|
||||
when the checksum does not match
|
||||
|
||||
#### Scenario: Options are forwarded to the bundled installer
|
||||
|
||||
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||
`--check`
|
||||
- **THEN** it passes those options through to the extracted bundle's installer
|
||||
|
||||
#### Scenario: No compatible bundle
|
||||
|
||||
- **WHEN** the latest release contains no bundle for the host architecture
|
||||
- **THEN** the installer exits with a failure message that names the host
|
||||
architecture and installs nothing
|
||||
|
||||
#### Scenario: Unsupported architecture
|
||||
|
||||
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||
x86_64 nor i386
|
||||
- **THEN** it exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Installed version is tracked and upgrades are reported
|
||||
|
||||
The installer SHALL treat the git hash in the bundle's asset name as the
|
||||
version, for every architecture it can install. It SHALL hand that version to
|
||||
the bundle installer, which SHALL record it beside the installed binary and
|
||||
SHALL report an upgrade with the previous and new versions on the next run.
|
||||
Uninstalling SHALL remove the recorded version.
|
||||
|
||||
#### Scenario: Version is derived from the asset name
|
||||
|
||||
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Version is derived for a 32-bit bundle
|
||||
|
||||
- **WHEN** the installer selects a generic bundle named
|
||||
`fpagent-<hash>-linux-i386.tar.gz`
|
||||
- **THEN** it uses `<hash>` as the version
|
||||
|
||||
#### Scenario: Upgrade is reported
|
||||
|
||||
- **WHEN** the recorded version differs from the version being installed
|
||||
- **THEN** the bundle installer reports the change from the old to the new
|
||||
version
|
||||
|
||||
#### Scenario: First install records the version
|
||||
|
||||
- **WHEN** no version has been recorded yet
|
||||
- **THEN** the bundle installer records the new version and reports no upgrade
|
||||
|
||||
#### Scenario: Uninstall removes the recorded version
|
||||
|
||||
- **WHEN** the installer is asked to uninstall
|
||||
- **THEN** the recorded version is removed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Tasks
|
||||
|
||||
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
|
||||
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
|
||||
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
|
||||
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
|
||||
dies without fetching.
|
||||
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
|
||||
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
|
||||
host architecture. Verify: with a release offering only
|
||||
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
|
||||
with only `-linux-x86_64`, the same host fails and installs nothing.
|
||||
- [x] 3. Hosted installer: derive the version from the asset name for every
|
||||
supported architecture (`linux-x86_64`, `linux-i386`,
|
||||
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
|
||||
not an empty string.
|
||||
- [x] 4. Hosted installer: correct the help text — service and self-update are
|
||||
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
|
||||
the note that options are forwarded to the bundle installer.
|
||||
Verify: `install.sh --help` mentions the three flags and no longer implies
|
||||
`--enable-service` is required.
|
||||
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
|
||||
and assert i386 selection, cross-architecture rejection, and i386 version
|
||||
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
|
||||
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
|
||||
and the on-by-default service/self-update. Verify: reading the section, no
|
||||
flag claims contradict `install.sh --help`.
|
||||
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
|
||||
|
||||
### Requirement: Two-finger enrollment flow
|
||||
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
|
||||
up staff by reference (OTP-validated through the a2loft proxy), displaying
|
||||
up staff by reference (token-validated through the a2loft proxy), displaying
|
||||
the staff details, capturing the right thumb and right index finger, and
|
||||
saving both fingerprints through the a2loft proxy with an OTP. The wizard
|
||||
SHALL offer per-finger recapture until save, SHALL require both fingers
|
||||
before save, and SHALL reset domain-independent state (reference, staff
|
||||
details, captured artifacts) when starting a new enrollment.
|
||||
saving both fingerprints through the a2loft proxy authenticated with the
|
||||
session token. The wizard SHALL offer per-finger recapture until save, SHALL
|
||||
require both fingers before save, and SHALL reset domain-independent state
|
||||
(reference, staff details, captured artifacts) when starting a new
|
||||
enrollment.
|
||||
|
||||
#### Scenario: Complete enrollment
|
||||
- **WHEN** the operator looks up staff, captures both fingers, and saves
|
||||
with an OTP
|
||||
- **THEN** both fingerprints are saved through the a2loft proxy and the
|
||||
wizard reports success
|
||||
|
||||
@@ -137,24 +137,6 @@ details, captured artifacts) when starting a new enrollment.
|
||||
- **THEN** the reference, staff details, and captured finger artifacts are
|
||||
cleared while the permitted domain list persists
|
||||
|
||||
### Requirement: OTP validated on every a2loft call
|
||||
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
|
||||
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
|
||||
Submission SHALL be blocked while the OTP is missing.
|
||||
|
||||
#### Scenario: Lookup with OTP
|
||||
- **WHEN** the operator submits a reference with an OTP
|
||||
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
|
||||
displays the returned staff details
|
||||
|
||||
#### Scenario: Save requires a fresh OTP
|
||||
- **WHEN** the operator saves the captured fingerprints
|
||||
- **THEN** the wizard sends the OTP entered for the save call
|
||||
|
||||
#### Scenario: Missing OTP blocks submission
|
||||
- **WHEN** the OTP field is empty for a lookup or save
|
||||
- **THEN** the corresponding submit control is disabled
|
||||
|
||||
### 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
|
||||
@@ -195,3 +177,23 @@ guarantee a capture will succeed.
|
||||
- **WHEN** the agent socket disconnects
|
||||
- **THEN** the wizard shows the agent offline and does not present the reader as
|
||||
available
|
||||
|
||||
### Requirement: Token authenticated on every a2loft call
|
||||
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
|
||||
save — with the signed-in operator's PocketBase session token, sent by the
|
||||
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
|
||||
NOT collect an OTP.
|
||||
|
||||
#### Scenario: Lookup uses the session token
|
||||
- **WHEN** the operator submits a reference
|
||||
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
|
||||
session token
|
||||
|
||||
#### Scenario: Save uses the session token
|
||||
- **WHEN** the operator saves the captured fingerprints
|
||||
- **THEN** the wizard 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
|
||||
|
||||
@@ -11,10 +11,13 @@ proxies the private release and serves the installer.
|
||||
### Requirement: Public installer endpoint
|
||||
|
||||
The app SHALL serve an installer script at `/fpagent/install.sh` that is
|
||||
reachable without authentication. When run, the script SHALL fetch the latest
|
||||
release metadata, choose a bundle for the host, verify the bundle's published
|
||||
sha256 checksum, extract it, and hand off to the extracted bundle's own
|
||||
installer. The script SHALL be served uncached.
|
||||
reachable without authentication. When run, the script SHALL determine the
|
||||
host's architecture, fetch the latest release metadata, choose the bundle for
|
||||
the host architecture, verify the bundle's published sha256 checksum, extract
|
||||
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
|
||||
bundle built for the host's distro codename when one exists for that
|
||||
architecture, and otherwise use the generic bundle for that architecture. The
|
||||
script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Installer is fetched anonymously
|
||||
|
||||
@@ -33,6 +36,19 @@ installer. The script SHALL be served uncached.
|
||||
codename
|
||||
- **THEN** the installer uses the generic bundle
|
||||
|
||||
#### Scenario: Bundle matches the host architecture
|
||||
|
||||
- **WHEN** the latest release offers a bundle for the host architecture and a
|
||||
bundle for another architecture
|
||||
- **THEN** the installer chooses the bundle for the host architecture
|
||||
- **AND** it never chooses a bundle for another architecture
|
||||
|
||||
#### Scenario: 32-bit host uses the 32-bit bundle
|
||||
|
||||
- **WHEN** the installer runs on an i386 host and the release offers only a
|
||||
generic 32-bit bundle
|
||||
- **THEN** the installer uses that generic 32-bit bundle
|
||||
|
||||
#### Scenario: Checksum is verified before extraction
|
||||
|
||||
- **WHEN** the installer downloads a bundle and its published checksum
|
||||
@@ -41,14 +57,21 @@ installer. The script SHALL be served uncached.
|
||||
|
||||
#### Scenario: Options are forwarded to the bundled installer
|
||||
|
||||
- **WHEN** the installer is invoked with options such as `--enable-service` or
|
||||
- **WHEN** the installer is invoked with options such as `--no-service` or
|
||||
`--check`
|
||||
- **THEN** it passes those options through to the extracted bundle's installer
|
||||
|
||||
#### Scenario: No compatible bundle
|
||||
|
||||
- **WHEN** the latest release contains no bundle for the host architecture
|
||||
- **THEN** the installer exits with a failure message and installs nothing
|
||||
- **THEN** the installer exits with a failure message that names the host
|
||||
architecture and installs nothing
|
||||
|
||||
#### Scenario: Unsupported architecture
|
||||
|
||||
- **WHEN** the installer runs on a machine whose architecture is neither
|
||||
x86_64 nor i386
|
||||
- **THEN** it exits with a failure message and installs nothing
|
||||
|
||||
### Requirement: Release metadata endpoint
|
||||
|
||||
@@ -175,16 +198,23 @@ tag that is not a valid release tag 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.
|
||||
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
|
||||
|
||||
@@ -60,24 +60,33 @@ only for permitted operators.
|
||||
|
||||
### Requirement: Saving requires a live gate and an OTP
|
||||
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
|
||||
permission live (bypassing the cache) and SHALL require an OTP from the
|
||||
operator, forwarding the session phone as the validating identity together
|
||||
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
|
||||
SHALL not reach a2loft.
|
||||
permission live (bypassing the cache) and SHALL authenticate the operator with
|
||||
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
|
||||
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
|
||||
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
|
||||
back to the queue item's label, and SHALL match a path-safe pattern before it
|
||||
is used in the request. A denied save SHALL not reach a2loft.
|
||||
|
||||
#### Scenario: Successful save
|
||||
- **WHEN** a permitted operator saves with an OTP
|
||||
- **WHEN** a permitted operator saves
|
||||
- **THEN** the route verifies the live gate and forwards the picture to
|
||||
a2loft with the session phone, OTP, and picture domain
|
||||
a2loft with the session token as a bearer credential and the profile's
|
||||
resolved domain
|
||||
|
||||
#### Scenario: Denied save does not reach a2loft
|
||||
- **WHEN** the live crop check denies, or the OTP is missing
|
||||
- **WHEN** the live crop check denies, or the request carries no message body
|
||||
- **THEN** the route answers 403/400 and makes no a2loft call
|
||||
|
||||
#### Scenario: Unsafe domain values are refused
|
||||
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
|
||||
domain
|
||||
- **THEN** the save is refused with 400 and a2loft is not contacted
|
||||
|
||||
### Requirement: Unified login — no separate artist login
|
||||
The cropper SHALL NOT implement its own login. The operator's identity comes
|
||||
from the YOUreka session (phone prefilled), the picture's metadata carries the
|
||||
domain, and the only credential the operator supplies is the save-time OTP.
|
||||
from the YOUreka session and is presented to a2loft as the session's PocketBase
|
||||
token; the picture's own metadata carries the domain. The cropper collects no
|
||||
other credential — there is no save-time OTP.
|
||||
|
||||
#### Scenario: Phone prefilled from the session
|
||||
- **WHEN** a signed-in operator opens the cropper
|
||||
|
||||
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
|
||||
- **WHEN** a user opens the app root URL
|
||||
- **THEN** the page shows a link to the Image Cropper pointing at
|
||||
`/cropper` and a link to `/fingerprint`
|
||||
|
||||
### Requirement: YOUreka Auth login establishes a cookie session
|
||||
The app SHALL authenticate operators through the YOUreka portal web-auth
|
||||
flow: create a web auth session at the portal with a local callback URL,
|
||||
@@ -100,17 +101,21 @@ portal.
|
||||
### Requirement: a2loft proxies inject operator identity
|
||||
The app SHALL proxy staff lookup and fingerprint-save calls to
|
||||
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
|
||||
attach the operator's phone from the server-side session and forward the
|
||||
OTP supplied by the page as query parameters (`phone`, `otp`), preserving
|
||||
the a2loft request and response shapes. The proxies SHALL require a
|
||||
session; the OTP SHALL NOT be accepted without one.
|
||||
authenticate the operator with the signed-in session's PocketBase token, sent
|
||||
as an `Authorization: Bearer` credential, preserving the a2loft request and
|
||||
response shapes. The proxies SHALL require a session; a request without one
|
||||
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
|
||||
require an OTP.
|
||||
|
||||
#### Scenario: Staff lookup via proxy
|
||||
- **WHEN** the page requests staff details for a domain and reference with
|
||||
an OTP
|
||||
- **THEN** the app calls a2loft's identity endpoint with the operator's
|
||||
phone from the session and the supplied OTP, and returns a2loft's
|
||||
response unchanged
|
||||
- **WHEN** the page requests staff details for a domain and reference
|
||||
- **THEN** the app calls a2loft's identity endpoint with the session token as a
|
||||
bearer credential and returns a2loft's response unchanged
|
||||
|
||||
#### Scenario: Fingerprint save via proxy
|
||||
- **WHEN** the page saves fingerprints for a domain and reference
|
||||
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
|
||||
token as a bearer credential
|
||||
|
||||
#### Scenario: Unauthenticated proxy call rejected
|
||||
- **WHEN** a proxy route is called without a valid session
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# own installer, which handles libfprint, the udev rule, plugdev and the
|
||||
# systemd service. Every flag is forwarded to that installer, e.g.:
|
||||
#
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --service-user alice
|
||||
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check
|
||||
#
|
||||
# The private release is reached through the tools app, so no credentials are
|
||||
@@ -35,12 +35,18 @@ Options:
|
||||
Options below are forwarded to the bundled installer:
|
||||
--check preflight and report only; change nothing
|
||||
--offline never touch the network; fail if dependencies are missing
|
||||
--enable-service install and enable a systemd service (starts at boot)
|
||||
--no-service do not install the systemd service (also disables updates)
|
||||
--no-updates do not install the self-update timer
|
||||
--update-base URL release base for self-update
|
||||
(default: https://tools.yrka.app/fpagent)
|
||||
--service-user NAME user for the service (default: the invoking user)
|
||||
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
|
||||
--uninstall remove the binary, the udev rule and the service
|
||||
-h, --help the installer's help
|
||||
|
||||
The systemd service and the self-update timer are installed and enabled by
|
||||
default; --no-service and --no-updates turn them off.
|
||||
|
||||
Environment:
|
||||
FPAGENT_BASE_URL override the download base (default: ${BASE})
|
||||
EOF
|
||||
@@ -85,6 +91,15 @@ find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
|
||||
# online bundle (whose installer apt-get's the dependency).
|
||||
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
||||
|
||||
# Releases are named for the target architecture; only these two are published.
|
||||
# Mirrors the bundled installer's own preflight so both reject the same hosts.
|
||||
host_machine="$(uname -m)"
|
||||
case "$host_machine" in
|
||||
x86_64 | amd64) arch="x86_64" ;;
|
||||
i386 | i486 | i586 | i686) arch="i386" ;;
|
||||
*) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;;
|
||||
esac
|
||||
|
||||
if [ "$RELEASE_TAG" = "latest" ]; then
|
||||
meta_url="${BASE}/releases/latest"
|
||||
else
|
||||
@@ -96,25 +111,25 @@ meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from $
|
||||
|
||||
readout="$(python3 -c '
|
||||
import json, re, sys
|
||||
codename = sys.argv[1]
|
||||
release = json.loads(sys.argv[2])
|
||||
codename, arch = sys.argv[1], sys.argv[2]
|
||||
release = json.loads(sys.argv[3])
|
||||
assets = [a["name"] for a in release.get("assets", [])]
|
||||
|
||||
def pick(suffix):
|
||||
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "")
|
||||
|
||||
name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else ""
|
||||
name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
|
||||
if not name:
|
||||
name = pick("-linux-x86_64.tar.gz")
|
||||
name = pick("-linux-" + arch + ".tar.gz")
|
||||
# The git hash is baked into the asset name (bundle.sh --version) and into
|
||||
# the binary itself; it is the version we record and later compare against.
|
||||
m = re.match(r"^fpagent-(.+)-(?:linux-x86_64|ubuntu-[a-z0-9]+-x86_64)\.tar\.gz$", name)
|
||||
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" "$meta")" || die "could not parse the release metadata"
|
||||
' "$codename" "$arch" "$meta")" || die "could not parse the release metadata"
|
||||
|
||||
IFS=$'\t' read -r tag asset version <<<"$readout"
|
||||
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (x86_64 required)"
|
||||
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)"
|
||||
|
||||
say " release ${tag}"
|
||||
say " bundle ${asset}"
|
||||
|
||||
@@ -37,6 +37,17 @@ const RELEASE = {
|
||||
],
|
||||
};
|
||||
|
||||
// A directory holding a fake `uname` that reports the given machine, so the
|
||||
// installer's architecture-sensitive picker runs on any CI host. PATH is
|
||||
// prepended with it; everything else resolves as usual.
|
||||
function fakeUname(machine) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
|
||||
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Fake Gitea: refuses without the token, serves the release and its assets.
|
||||
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
@@ -231,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', () => {
|
||||
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user