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:
@@ -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.
|
||||
+156
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
context: |
|
||||
YOUreka IDID fingerprint enrollment tooling. Capture hardware is owned by the
|
||||
fpagent binary (repo: fingerprint-agent; Socket.IO on 127.0.0.1:9000,
|
||||
enroll-only, PocketBase + OpenFGA guarded). This repo hosts the tools web
|
||||
front-end: Node.js + Express, no build step, static pages on CDN stacks
|
||||
(Vue 2 + Bootstrap 4 + socket.io). Auth is YOUreka Auth cookie session
|
||||
(portal web-auth redirect + PocketBase authRefresh), pattern proven in
|
||||
one-school-chat. a2loft (apis.a2loft.net) remains the staff-lookup and
|
||||
fingerprint-save backend. All endpoints and the port are hardcoded in
|
||||
source by explicit decision — no env vars or config files.
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
|
||||
# Per-operation guidance (optional)
|
||||
# Add advisory guidance for how apply and archive work should be conducted.
|
||||
# This is separate from artifact rules above.
|
||||
# Example:
|
||||
# operations:
|
||||
# apply:
|
||||
# guidance:
|
||||
# - Keep test summaries concise
|
||||
# archive:
|
||||
# guidance:
|
||||
# - Summarize the archive outcome before finishing
|
||||
@@ -0,0 +1,156 @@
|
||||
# fingerprint-wizard Specification
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 @@
|
||||
# tools-app Specification
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user