docs(openspec): fpagent-tools-ui change — proposal, specs, design, tasks

Synced main specs (tools-app, fingerprint-wizard); change archived at
2026-09-30-fpagent-tools-ui. Includes openspec project config and the
agent skills installed with it.
This commit is contained in:
2026-09-30 23:12:51 +01:00
parent cdb402cd18
commit 109a69961a
35 changed files with 6255 additions and 0 deletions
View File
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-30
@@ -0,0 +1,104 @@
# Design
## Context
`app.py` (Flask + python-socketio) serves the UI from `public/{mode}` and talks
to libfprint via PyGObject; fpagent replaces the capture side entirely with a
guarded Socket.IO server on `127.0.0.1:9000` (enroll-only: `fprint-enroll`,
`fprint-enroll-status`, `fprint-enroll-done`, `fprint-enroll-stop`,
`agent-hello`). The YOUreka Auth pattern is proven in `one-school-chat`
(`src/lib/server/yrka-auth.ts` + `routes/login/+page.server.ts`): portal
`POST /api/web/auth` → redirect → `GET /api/web/auth/{sessionId}/cookie` →
store the PB cookie value under an httpOnly `session` cookie → PocketBase SDK
`authRefresh()` when near expiry. fpagent's permit logic
(`internal/permit/permit.go`) defines the OpenFGA check this app replicates for
domain filtering. See proposal.md for motivation and scope.
## Goals / Non-Goals
**Goals:**
- Zero-configuration operation: every URL and the port hardcoded in source.
- Smallest reliable stack: Express + static CDN pages, no build step, no
session store, no template engine.
- Credentials stay server-side where possible: a2loft calls proxied same-origin
with phone injected from the session; only the PocketBase token is handed to
the page (fpagent's contract requires the page to emit it).
**Non-Goals:**
- clockio/identify/verify (fpagent is enroll-only by design).
- TypeScript, bundlers, component frameworks, server-side templating.
- Permission caching or a domain-management UI (catalog is hardcoded).
- Changes to a2loft's API contract or fpagent itself.
## Decisions
1. **Static CDN front-end, no build step.** The existing `public/capture` page
is Vue 2 + Bootstrap 4 + socket.io + axios via CDN; the wizard reuses that
stack unchanged in shape. Alternative (SvelteKit like one-school-chat)
rejected: new toolchain in this repo for one page.
2. **Session = PB cookie value in an httpOnly cookie; no server store.** Same
as one-school-chat; any instance can serve any request, and refresh happens
server-side per request via the PocketBase SDK against `pb.itcass.net`.
Alternative (express-session + store) rejected: nothing server-side needs
more than the cookie already carries.
3. **Token delivery via `GET /auth/session` JSON** rather than templating: the
wizard is a static page; one fetch returns token, profile, agent URL, and
permitted domains. The token must live in the page because fpagent's
`fprint-enroll` requires it — that is the agent's designed flow, and fpagent
re-validates it per request.
4. **Domain filtering server-side, parallel, fail-closed, cached 5 min.**
Five domains × ≤2 sequential verify calls each, run with `Promise.all`
per (re)check; results cached in an in-memory `Map<phone, {allowed,
checkedAt}>` with a 5-minute TTL (one constant, matching the session
refresh buffer). `/auth/session` serves the cached set within the TTL;
a force-refresh endpoint re-checks all catalog domains, bypassing and
then replacing the cache, and is refused while the cache is fresh. The
filter is UX only; fpagent re-enforces at enroll time, so a revoked
permission degrades gracefully (enroll rejected) even mid-session, and
the wizard's refresh control covers the residual staleness window.
5. **a2loft behind same-origin proxies.** The page calls
`POST /api/:domain/staff/:reference` (lookup) and
`POST /api/:domain/staff/:reference/fingerprints` (save); the server
attaches `phone` from the session and forwards the page-supplied `otp` as
query params — a2loft's contract (`?phone=&otp=`, same response shapes) is
untouched. This keeps the operator's a2loft credential flow out of
page-to-third-party calls and lets the phone come from the trusted session.
6. **Hardcoded config incl. port 4173.** Moving an endpoint or the port is a
one-constant source change; env-var plumbing is unused flexibility.
7. **Python retirement in the same change, gated on a parity walkthrough.**
`app.py` + `requirement.txt` + `public/` are deleted only after the new
wizard completes a real capture-and-save round trip; git history preserves
them. Rollback = revert.
## Risks / Trade-offs
- [a2loft auth contract drifts (no longer `?phone=&otp=`)] → only the two
proxy handlers change; page and specs unaffected.
- [Revoked permission lingers in the UI up to 5 minutes] → bounded by the
cache TTL; fpagent re-enforces at enroll, and the wizard's refresh control
(enabled once the cache elapses) re-checks on demand.
- [Port 4173 already in use on a laptop] → deliberate hardcode; change the one
constant (and fpagent's `-allowed-origins`) if a machine conflicts.
- [Browser → `http://127.0.0.1:9000` blocked as mixed content] → loopback is a
potentially trustworthy origin in all current browsers; works from both http
and https pages. Watch for corporate-policy exceptions in the field.
- [CORS misconfig blocks the wizard silently] → README documents the fpagent
`-allowed-origins` requirement (`http://localhost:4173` in dev); wizard shows
connection state from `agent-hello` so an unreachable/blocked agent is
visible.
- [Vue 2 is EOL] → acceptable for an internal tool page already on it; the
page is isolated and small.
## Migration Plan
1. Build `tools/` alongside the untouched python app (different port; no
interference).
2. Parity walkthrough on field hardware: login → domain filter → lookup →
capture ×2 → save; fingerprints verify against a2loft as before.
3. Delete `app.py`, `requirement.txt`, `public/`; update README.
4. Deploy note: fpagent must include the tools origin in `-allowed-origins`.
## Open Questions
None — a2loft's contract (unchanged, `?phone=&otp=`), hardcoded endpoints, and
the domain catalog were all confirmed with the operator.
@@ -0,0 +1,62 @@
# Proposal: fpagent-tools-ui
## Why
The python server (`app.py`) that served this project's enrollment UI is being
replaced by the `fpagent` binary (see `fingerprint-agent`), which owns scanner
capture on `127.0.0.1:9000` and enforces PocketBase + OpenFGA authorization per
enroll. The UI must be re-modeled onto fpagent's enroll-only event vocabulary
and YOUreka Auth, or this repo has no reason to exist alongside fpagent.
## What Changes
- **BREAKING**: Add a new Express app in `tools/` that replaces `app.py` as the
UI host: a landing page linking two YOUreka tools — Image Cropper
(`https://smart-pxa.yrka.app`) and Fingerprint Agent (`/fingerprint`).
- New `/fingerprint` enrollment wizard (re-modeled from `public/capture/`) that
captures via fpagent Socket.IO: `fprint-enroll {token, domain, finger,
reference}` → `fprint-enroll-status` → `fprint-enroll-done`, with
`fprint-enroll-stop` cancel and `agent-hello` version display.
- Operator authentication via YOUreka Auth cookie session (portal
`/api/web/auth` redirect flow; PocketBase session refresh server-side), same
pattern as `one-school-chat`.
- Domain selection: a hardcoded catalog (UNICAL `uc`, Metropolitan Hotel `mtr`,
UCTH `ucth`, UNICAL Radio International `uri`, YOUreka `yrka`) filtered after
login against the same public OpenFGA verify URL fpagent uses; only permitted
domains are offered. Results are cached per operator for 5 minutes, with an
explicit refresh control available once the cache elapses.
- a2loft integration (staff lookup, fingerprint save) kept, but moved behind
same-origin Express proxies; OTP input retained per a2loft call, validated by
a2loft exactly as before (`?phone=&otp=`, phone injected server-side).
- **BREAKING**: Remove the python server and its UI: `app.py`,
`requirement.txt`, `public/` (after the new UI reaches parity).
- clockio mode is not ported: fpagent is enroll-only by design.
- All configuration is hardcoded in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`.
## Capabilities
### New Capabilities
- `tools-app`: the Express tools host — landing page, YOUreka Auth cookie
session, hardcoded configuration, same-origin a2loft proxies.
- `fingerprint-wizard`: the `/fingerprint` enrollment wizard — domain
permission filtering, fpagent capture wiring, OTP-validated lookup/save flow.
### Modified Capabilities
(none — this repo has no existing specs)
## Impact
- New code: `tools/` (Express app, static pages, auth session module, domain
permit module). New dependencies: `express`, `pocketbase`.
- Removed code: `app.py`, `requirement.txt`, `public/capture/`,
`public/clockio/`, `.config.json` handling.
- Deployment: fpagent must run with `-allowed-origins` including the tools app
origin (`http://localhost:4173` in dev); the wizard page calls fpagent
cross-origin from the browser.
- External systems: `portal.yrka.app` (web-auth + OpenFGA verify), `pb.itcass.net`
(PocketBase session), `apis.a2loft.net` (staff lookup + fingerprint save, unchanged
contract), fpagent on loopback.
@@ -0,0 +1,156 @@
# Spec Delta: fingerprint-wizard
## Purpose
The Fingerprint Agent enrollment wizard at `/fingerprint`: offers the operator
only the domains they may capture for, runs two-finger enrollment through the
local fpagent capture agent, and saves the resulting templates to a2loft with
per-call OTP validation.
## ADDED Requirements
### Requirement: Permitted domain selection
The wizard SHALL offer, from the hardcoded catalog — UNICAL (`uc`),
Metropolitan Hotel (`mtr`), UCTH (`ucth`), UNICAL Radio International (`uri`),
YOUreka (`yrka`) — only the domains the operator is permitted to capture for.
Permission SHALL be determined after login against the same public portal
OpenFGA verify endpoint fpagent uses (`https://portal.yrka.app/api/openfga/verify`),
requesting the `capture_biometric` relation for `domain:<code>` on subsystem
`idid` for the operator's phone (leading `+` stripped), with a fallback check
against `domain:idid`; the operator is permitted if either check allows. Each
domain's checks SHALL fail closed on error or denial. Catalog checks SHALL run
in parallel, and their results SHALL be cached per operator phone for 5
minutes: session fetches within that window SHALL serve the cached result
without contacting the portal, and all catalog domains SHALL be re-checked in
parallel once that period expires.
#### Scenario: Permitted domain is offered
- **WHEN** the portal verify endpoint allows the operator for a catalog
domain
- **THEN** that domain appears in the wizard's domain selection
#### Scenario: Denied or unavailable domain is hidden
- **WHEN** the portal verify endpoint denies a domain, or the check errors
- **THEN** that domain is not offered
#### Scenario: Fallback object allows capture
- **WHEN** the `domain:<code>` check denies but the `domain:idid` check
allows
- **THEN** the domain is offered
#### Scenario: Cached result within the TTL
- **WHEN** a session fetch occurs within 5 minutes of the last permission
check for the operator
- **THEN** the cached permitted-domain list is served without calling the
portal verify endpoint
#### Scenario: Re-check after the TTL
- **WHEN** a session fetch occurs after the 5-minute cache period has
elapsed
- **THEN** all catalog domains are re-checked and the list reflects current
permissions
#### Scenario: No permitted domains
- **WHEN** no catalog domain is permitted for the operator
- **THEN** the wizard explains they are not permitted to capture
fingerprints for any domain and offers no capture UI
### Requirement: Manual refresh of permitted domains
The wizard SHALL offer a refresh control for the permitted-domain list that
stays disabled until the 5-minute cache period since the last check has
elapsed. Using it SHALL call the app's force-refresh endpoint, update the
offered domains, and update the displayed last-checked time; a refresh
refused because the cache is still fresh SHALL leave the current list and
state unchanged.
#### Scenario: Refresh control locked while the cache is fresh
- **WHEN** fewer than 5 minutes have passed since the permitted-domain set
was last checked
- **THEN** the refresh control is disabled
#### Scenario: Refresh after the cache elapses
- **WHEN** the cache period has elapsed and the operator uses the refresh
control
- **THEN** the wizard calls the force-refresh endpoint and the offered
domains update to the re-checked result
#### Scenario: Refused refresh leaves state unchanged
- **WHEN** a refresh request is refused because the cache is still fresh
- **THEN** the wizard keeps the current domain list and selection
### Requirement: Enrollment captures via fpagent
The wizard SHALL capture fingerprints through fpagent at the URL provided by
the session endpoint, emitting `fprint-enroll` with the session's PocketBase
token, the selected domain code, the finger name, and the staff reference. It
SHALL display capture progress from `fprint-enroll-status` events, record the
returned fingerprint template, image, and enrollment id on
`fprint-enroll-done` with `ok: true`, and surface the failure message and
return to idle on `ok: false`. The wizard SHALL offer cancellation of an
in-flight capture (emitting `fprint-enroll-stop`), SHALL prevent starting a
capture while another is in flight (fpagent serves one enrollment at a time),
and SHALL display the agent version reported on connect.
#### Scenario: Successful capture
- **WHEN** the operator starts a capture for a finger and fpagent reports
`fprint-enroll-done` with `ok: true`
- **THEN** the wizard records the fingerprint, image, and enrollment id for
that finger and displays the enrollment id
#### Scenario: Failed capture
- **WHEN** fpagent reports `fprint-enroll-done` with `ok: false`
(authentication failed, authorization unavailable, not authorized, or
capture failed)
- **THEN** the wizard shows the failure message and returns to the idle
state without recording artifacts
#### Scenario: Cancel in-flight capture
- **WHEN** the operator cancels a capture in progress
- **THEN** the wizard emits the stop event and returns to the idle state
#### Scenario: No concurrent captures
- **WHEN** a capture is in flight
- **THEN** the capture controls for both fingers are disabled until it
completes or is cancelled
### 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
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.
#### 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
#### 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
### 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
@@ -0,0 +1,129 @@
# Spec Delta: tools-app
## Purpose
Host the YOUreka tools web front-end: a landing page linking the Image Cropper
and the Fingerprint Agent wizard, YOUreka Auth cookie-session login, and
same-origin proxies to the a2loft identity API so browser calls never carry
operator credentials directly to third parties.
## ADDED Requirements
### Requirement: Landing page links the YOUreka tools
The app SHALL serve a landing page at `/` containing links to the Image
Cropper (`https://smart-pxa.yrka.app`) and to the Fingerprint Agent page
(`/fingerprint`).
#### Scenario: Landing page renders both tool links
- **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at
`https://smart-pxa.yrka.app` 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,
redirect the operator to the portal's login URL, and on callback redeem the
returned session id for a PocketBase session cookie. The app SHALL persist
the PocketBase cookie value under its own httpOnly `session` cookie (30-day
max age) together with the operator's profile (first name, last name,
phone).
#### Scenario: Successful login round-trip
- **WHEN** the operator completes the portal login and returns with a
redeemable session id
- **THEN** the app sets the `session` and profile cookies and redirects to
`/fingerprint`
#### Scenario: Expired or rejected login link
- **WHEN** the callback session id cannot be redeemed (expired, timed out,
or incomplete verification)
- **THEN** the app shows an authentication error and does not set a session
cookie
### Requirement: Session persistence and refresh
The app SHALL recover the operator's PocketBase session from the `session`
cookie on every request, refreshing the token when it is invalid or expires
within 5 minutes, and SHALL persist the refreshed cookie value. When the
session cannot be recovered, the app SHALL treat the operator as anonymous
and clear the stale cookie.
#### Scenario: Refresh near expiry
- **WHEN** a request arrives with a session cookie whose token expires
within 5 minutes and the portal accepts a refresh
- **THEN** the app serves the request with the refreshed token and updates
the `session` cookie
#### Scenario: Unrecoverable session
- **WHEN** the stored token is expired and refresh fails
- **THEN** the request is treated as anonymous and the stale cookie is
cleared
### Requirement: Protected pages require a session
The app SHALL redirect anonymous operators to the login flow when they
request the Fingerprint Agent page, and SHALL return an authentication
error for session-requiring API calls made without a session.
#### Scenario: Anonymous access to the wizard page
- **WHEN** an operator without a valid session requests `/fingerprint`
- **THEN** the app redirects them to the login flow
### Requirement: Session endpoint exposes capture context
The app SHALL expose a session endpoint that, for an authenticated
operator, returns the current PocketBase token, the operator's phone and
name, the fpagent URL, the list of permitted domains (see the
fingerprint-wizard capability for the checking and caching rules), and the
time that set was last checked.
#### Scenario: Authenticated session fetch
- **WHEN** an authenticated operator fetches the session endpoint
- **THEN** the response includes their token, phone, name, the fpagent URL,
the permitted domains, and the time the set was last checked
### Requirement: Forced domain re-check endpoint
The app SHALL provide a force-refresh endpoint that, for the authenticated
operator, re-checks every catalog domain against the portal verify endpoint
bypassing the cache, replaces the cached result, and returns the updated
permitted-domain list with the new last-checked time. It SHALL refuse the
request while the 5-minute cache is still fresh, without contacting the
portal.
#### Scenario: Force-refresh after the cache elapses
- **WHEN** the cache period has elapsed and the operator's client calls the
force-refresh endpoint
- **THEN** every catalog domain is re-checked, the cache is replaced, and
the response carries the updated list and last-checked time
#### Scenario: Force-refresh refused while the cache is fresh
- **WHEN** the force-refresh endpoint is called within 5 minutes of the
last check
- **THEN** the request is refused with an error and no portal calls are
made
### 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.
#### 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
#### Scenario: Unauthenticated proxy call rejected
- **WHEN** a proxy route is called without a valid session
- **THEN** the app rejects the call without contacting a2loft
### Requirement: Configuration is hardcoded
The app SHALL hardcode all configuration in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL
be no runtime configuration via environment variables or config files.
#### Scenario: No configuration required to run
- **WHEN** the app is started with no environment variables or config files
- **THEN** it serves on port 4173 with the hardcoded external endpoints
@@ -0,0 +1,32 @@
# Tasks
## 1. Scaffold the tools app
- [x] 1.1 Create `tools/` with `package.json` (express, pocketbase; ESM), `server.js` with hardcoded constants (port 4173, portal, PocketBase, a2loft, agent URL, domain catalog) and static serving; verify `node server.js` boots and `GET /` serves the landing page linking `https://smart-pxa.yrka.app` and `/fingerprint`
## 2. YOUreka Auth session
- [x] 2.1 Port `yrka-auth.js` from `one-school-chat/src/lib/server/yrka-auth.ts` (cookie → PocketBase session load, refresh when invalid/expiring <5 min); verify with `node --test` and a stubbed PocketBase client covering valid, refresh-needed, and unrecoverable paths
- [x] 2.2 Add `GET /auth/login` (create portal web-auth session with callback URL, redirect to login URL), `GET /auth/callback` (redeem session id, set httpOnly `session` + profile cookies 30d, redirect `/fingerprint`; error page on unredeemable id), `GET /auth/logout` (clear cookies, redirect `/`); verify with a stubbed portal upstream that cookies are set on success and not set on failure
- [x] 2.3 Add session middleware (recover/refresh/clear per design) and route protection: `/fingerprint` redirects anonymous to login, API routes reject anonymous; verify with curl both with and without a session cookie
- [x] 2.4 Add `permit.js` (OpenFGA verify with `domain:<code>` then `domain:idid` fallback, fail-closed per domain, phone `+`-stripped), cache permitted domains per operator phone for 5 minutes (in-memory map storing the allowed set + checked time), have `GET /auth/session` return token, phone, name, agent URL, the cached domain list, and the last-checked time, and add a force-refresh endpoint that re-checks all domains bypassing the cache but refuses while the cache is fresh; verify with `node --test` and stubbed fetch covering allow, deny, fallback-allow, transport-error, cache-hit-with-zero-portal-calls, re-check-after-expiry, and refresh-refused-while-fresh cases
## 3. Fingerprint wizard page
- [x] 3.1 Port `public/capture/` UI to `tools/public/fingerprint/` (Vue 2 + Bootstrap 4 + socket.io + axios + swal via CDN); fetch `/auth/session` on load; replace the fixed domain with a dropdown of permitted domains; show the no-permitted-domains message when the list is empty; verify the page renders for a sessioned user and redirects anonymous users
- [x] 3.2 Wire the socket to the agent URL from the session: `agent-hello` → show version/`captureAgentId`; capture buttons emit `fprint-enroll {token, domain, finger, reference}`; `fprint-enroll-status` → "Scan N of 5" progress; `fprint-enroll-done` ok:true → store fingerprint/image/id and show enrollment id, ok:false → swal the failure message and reset to idle; cancel emits `fprint-enroll-stop`; disable both fingers' capture controls while a capture is in flight; verify against a running fpagent (hardware or its virtual-driver harness) for capture, fail, and cancel paths
- [x] 3.3 Add the permitted-domains refresh control to the wizard: disabled until 5 minutes have elapsed since the session response's last-checked time; on click call the force-refresh endpoint and update the dropdown and last-checked time (a refused refresh leaves the list and selection unchanged); verify in the browser that the control is disabled within the TTL, enables after, and a click refreshes the list
## 4. a2loft proxies
- [x] 4.1 Add session-required proxies `POST /api/:domain/staff/:reference` and `POST /api/:domain/staff/:reference/fingerprints` that call a2loft with `?phone=<session phone>&otp=<query>` and unchanged request/response shapes; verify with `node --test` and a fake a2loft upstream asserting the forwarded query and body
- [x] 4.2 Switch the wizard's lookup and save to the proxies with per-call OTP inputs (lookup step and save step), keeping the both-fingers-required rule and the reset-on-new-enrollment behavior; verify the full lookup → details → save round trip against a2loft
## 5. Cleanup and docs
- [x] 5.1 Run the parity walkthrough on field hardware (login → filtered domains → lookup → capture thumb + index → save; saved fingerprints verify as before) and only then delete `app.py`, `requirement.txt`, and `public/`
- [x] 5.2 Update README: run instructions (`node tools/server.js`, port 4173), hardcoded endpoints, and the fpagent `-allowed-origins` requirement including `http://localhost:4173`; verify a fresh clone can follow it end to end
## 6. End-to-end verification
- [x] 6.1 Run fpagent with `-allowed-origins http://localhost:4173` and the tools app; walk login → permitted domains → staff lookup → two captures → save against the real portal/PocketBase/a2loft, and confirm the saved fingerprints appear in a2loft for the profile