The fingerprint wizard's staff-lookup and fingerprint-save proxies forwarded the page's OTP with the session phone. a2loft resolves that phone against the domain being addressed, so an operator who holds only capture_biometric was rejected unless they were a domain super-user. The cropper already saves with the session token.
- tools/server.js: both a2loft proxies send Authorization: Bearer <session token>, no phone/otp; validateProxy drops the OTP and phone checks.
- tools/public/assets/fp: both OTP fields, otpLookup/otpSave state, request params and resets removed; canLookup/canSave no longer gate on OTP.
- Tests assert the Bearer header, no query auth, no OTP field in the served page, and both gates without a credential (66/66 pass).
- README and Help page updated.
Requires the a2loft fingerprint-token-auth change deployed first. OpenSpec change: fingerprint-token-auth.
Consume fpagent's fprint-device-status {available, device, captureAgentId}
in the /fingerprint wizard. Availability is tracked tri-state so an agent
that does not report it leaves capture working as before; the header tag
shows offline / no-scanner / ready, the capture panel warns, and capture is
disabled until a supported reader is attached.
Adds a vm-sandbox test for the wizard logic and the OpenSpec change
fpagent-device-availability, archived with the fingerprint-wizard spec.
The installer gains --tag to install a specific release (gateway route
/fpagent/releases/tags/:tag), derives the git hash from the asset name,
and hands it to the bundle installer as VERSION so upgrades are reported
and the installed version is recorded. Service restart on upgrade lives
in the bundle installer, which owns the systemd unit.
The EXIT trap cannot run on SIGKILL, so a killed run could leave its
fpagent-install.* temp dir behind. Sweep those before any network work,
scoped to our prefix and this user and only dirs older than a day so a
concurrent install is never touched.
Serve /fpagent/install.sh (also curl|bash) and proxy the private
fingerprint-agent releases through /fpagent/releases/* using
GITEA_RELEASE_INSTALL_KEY, so a field laptop needs no forge credentials.
The installer picks the offline bundle for the host codename when present,
verifies the published sha256, then hands off to the bundle's own installer.
- Help and Sign in/out become icon-only Carbon cds-header-global-action
items (real links, accessible name in a visually-hidden span); the
header nav keeps just Home + the tools.
- Side nav gains a separator so Home, the tools, and Help/Sign In/Out
form three groups.
- Drop the now-unused header-nav-divider rule.
- Landing shows the tool tiles only when signed in and a sign-in card
otherwise; the login callback now returns to /.
- New public /help page: intro plus a per-tool Carbon accordion.
- Nav restructured to Home, {services}, divider, Help, Sign In/Out in
both the header and the side nav; the header right keeps only the
identity chip. Side-nav logo enlarged, border removed, spacing
equalised. Sign-out modal no longer flashes before it is defined.
- Per-deploy asset versioning (?v=) with immutable asset caching and
no-store HTML; help step lists show their numbers again.
- Tests updated and extended (48 total).
Consumes the a2loft cropper-token-auth change: the save now sends the
session's PocketBase token as a bearer credential and no phone/otp. The
backend authorizes with OpenFGA crop_image (domain:<code> -> domain:idid),
so an operator no longer needs super-user rights in the profile's domain —
which is what made saves fail with a blanket 'Invalid OTP specified.'.
- the save's domain comes from a2loft's own record of the picture
(dims/picture/{uploadId} -> domainCode), falling back to the queue label,
guarded by a path-safety pattern instead of the catalog check
- the 'core' -> 'uc' alias is deleted: 'core' is a2loft's own pseudo-domain
for a profile picture (special-cased there), so rewriting it changed the
attribution and the ID-card notification
- queue loads are enriched with the profile's domainCode; the panel shows
the profile's domain and, when it differs, the picture target
- the cropper's OTP field, state and parameters are gone; the fingerprint
wizard is untouched (its own OTP flow on its own endpoints)
- tests updated: bearer + no query auth, resolved domain wins, unsafe values
refused, no OTP field served (46 pass)
Requires the a2loft change deployed first.
Saving failed with 400 'Unknown domain.' because queue items carry the
legacy domain 'core', which the old bot mapped to 'uc' for a2loft URLs —
my domain validation (added in the review pass) rejected it. The save now
applies the same alias before validating and forwarding.
Also: the client showed axios's generic 'Request failed with status code
400' because the catch destructured error.message; all cropper handlers now
prefer the server's response.data.message (errMessage helper), so the real
reason is visible.
Verified: save with a 'core' item forwards to /identity/uc/profile/picture;
a 400 shows 'Unknown domain.'; 46 tests.
Crop / fingerprint-recognition / logout Carbon icons (16px, inlined into the
shared header so they inherit the theme colour) in the side-nav-link
'title-icon' slot — the slot is named title-icon, not nav-icon.
'Load next queued' did nothing because the running server had been started
without its env file (a restart used '-f .env' from tools/, where no .env
exists), so the cropper was unconfigured and every action answered 503 —
and none of that was visible, because notifications never rendered either:
- notifications used cds-toast/cds-toast-item, which the Carbon bundle does
not define; a cds-toast-notification element renders nothing standalone in
this build. The cropper now uses cds-inline-notification in the fixed
toast viewport (renders, themed, close button) like the wizard
- an unconfigured deployment now says so on the page (503 'Cropper
unavailable') instead of rendering actions that all fail, and logs a
startup warning naming the missing credentials
- tests: notifications use a registered element; unconfigured page 503
- favicon is the Youreka logo (64px PNG generated from the source mark),
on the tools pages and server error pages
- signing out asks first: a Carbon cds-modal (Cancel / danger Sign out)
shared by the header and the mobile side nav; Cancel closes, Sign out
confirms and logs out
- the current tool is now visibly selected: Carbon marks active nav via
a[aria-current=page] INSIDE the shadow, but aria-current sits on the
host, so the rule never matched — the header item now gets a blue
underline and the side-nav link a blue left border (via ::part(link)
for colour, plus the host for the marker)
- logo and wordmark are middle-aligned inside a flex brand wrapper
- tests: favicon/modal/signout-trigger markup and the active-tool marking
on both navs (45 total)
- all pages (landing, both tools, server error pages) render on Carbon's
g100 theme via <body class="app cds--g100">; token consumers follow
automatically, including component shadow styles through the inherited
--cds-* custom properties
- dark base on <html> so no light flash around the viewport
- step chips and tool icons use the brand blue: layer-selected-inverse
inverts to a LIGHT token in g100, which had made them white-on-white
- page titles breathe: .page-head top margin 1.5rem -> 2.75rem
- new test asserting every page renders on g100 (44 total)
- header: Carbon ui-shell (cds-header) with the Youreka logo (from
idid-enterprise-portal), Image Cropper / Fingerprint Agent nav items
(aria-current on the active tool), the agent status tag on the wizard,
and the operator chip + Sign out on desktop
- mobile: cds-side-nav (logo above the items, both tools, a divider, then
Sign out) toggled by the ui-shell menu button; account actions move out
of the header below 1056px, the nav is hidden at desktop widths
- the header is built once in server.js (appHeader) and injected into the
landing, both tools and error pages — no per-page duplication
- removed my duplicate menu-button toggle: ui-shell coordinates the menu
button and side nav itself (the duplicate cancelled it out)
- fixes: 'cds-header' was hidden by a selector-list typo
(cds-header, cds-side-nav:not(:defined)); the collapsed side nav no
longer captures taps
Tools banner logo asset added (public/assets/youreka-logo.png).
The pages are rendered by the server, which already holds the session — so
/fingerprint, /cropper and the landing now embed window.__SESSION__ (JSON,
with '<' escaped) instead of each fetching /auth/session on load:
- /fingerprint gets token, identity, agentUrl and the permitted domains
(the same cached context the endpoint returned); /cropper gets identity
- landing nav is now server-rendered (Sign in vs user chip + Sign out);
its client fetch script is deleted
- per-user HTML is sent with Cache-Control: no-store and Vary: Cookie
- express.static runs with index:false so '/' can be session-aware while
assets stay session-free
- GET /auth/session remains as the documented JSON contract (and the
canonical place the domain-filter caching is exercised in tests)
Client code shrinks by a fetch + error path per page; 3 new tests
(injection, escaping, landing header) — 43 total.
HIGH
- async handlers: Express 4 does not catch rejected promises and Node 22
exits on unhandled rejections — a malformed body ({} to /cropper/dequeue
etc.) could hang a request or kill the process. Every cropper route is
now wrapped (throw → 500 via next), request bodies are validated up
front (400), and JSON.parse of queue items is guarded. Server-wide
error middleware added, honouring err.status (413 for oversized bodies).
- error pages HTML-escape all interpolated values — the login failure page
rendered upstream (portal) error text raw.
MEDIUM
- outbound calls carry AbortSignal timeouts (5s OpenFGA verify, 15s
cropper upstreams) — a hung upstream can no longer pin a request.
- body limits scoped: /cropper gets 50mb (picture data URLs), everything
else 1mb (previously 50mb globally, memory-DoS surface pre-auth).
- cropper save validates the client-supplied domain against the catalog;
picture fetches validate the Minio object key (id-domains/ prefix, no ..).
LOW
- static assets served before the session middleware (no PocketBase load
per asset); landing header builds DOM nodes with textContent instead of
innerHTML; picture load checks upstream ok.
5 new tests (40 total) covering malformed bodies, malformed queue items,
unknown domains, object-key traversal, and error-page escaping.
Centered card with navbar, lock icon, sans-serif stack (error pages
previously rendered in the browser default serif), and action-aware
copy: permission denial offers only 'Back to Tools' instead of a
pointless 'Try again'; auth failures keep Try again + Back to Tools.
- Smart Photo Editor ported from id-image-bot: queue/by-ID picture
loading, vue-croppa editing, carvekit background cleanup, nodered
review flow, a2loft save — restyled to the tools design language
- unified login: YOUreka session identity, phone prefilled, OTP only at
save (a2loft's requirement); artist-login step removed
- crop_image @ domain:idid OpenFGA gate on every cropper route: cached
5-min on reads, live on save, fail closed
- credentials (minio/azure/nodered/carvekit) via env, sole exception to
hardcoded config; 503 when unconfigured; native FormData replaces
form-data; new deps minio + azure-storage
- 10 new tests (35 total) with fake minio/queue/nodered/removebg/a2loft