Files
adebisi-fa 109a69961a 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.
2026-09-30 23:12:51 +01:00

5.7 KiB
Raw Permalink Blame History

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.