Bio-Identity Kit
Hospital-side shared identity + attestation kit (face/voice modalities).
| Field | Value |
|---|---|
| Package | @ever-medos/bio-identity-kit (web/packages/bio-identity-kit/) |
| Status | Kernel shipped + verified (strict tsc + runtime assertions). Platform adapters pending. |
| Date | 2026-05-30 |
| Boundary | Hospital-side shared layer. The self-sovereign half (did:bio keys, on-device templates) lives in the Ever HealthWallet, NOT here. |
| Related | docs/integrations/ever-patient-wallet.md · docs/architecture/bia-rt-1-genomic-attestation-vocabulary.md · docs/architecture/bia-rt-2-fhir-careplan-profile.md · web/electron/native/biometric.ts · evernetwork.io (BIA-1 core spec) |
1. What it is & why
A framework-agnostic TypeScript kernel that gives the hospital a shared bio identity (one record per patient, referenced by every app/touchpoint) and a shared attestation library (verifiable credentials proving identity events). It exists so a captured face — or any biometric — can drive identity across mobile, Electron (laptop), web, edge, and services with one consistent contract.
Driving use cases: medication “five rights” (right-patient check), patient unlock, and plastic-surgery consultation.
The hard rule: this library never stores raw biometrics. It stores DIDs, time-boxed grants, and signed attestations. Raw templates stay on the patient’s device (wallet) or are resolved transiently by a matcher adapter.
2. BIA / did:bio relationship
BIA = Biological Identity Authentication — Ever’s decentralized identity system (did:bio), DNA-anchored via Poseidon commitments, expressed as W3C Verifiable Credentials with BBS+ selective disclosure, held in the Ever HealthWallet. The core spec (BIA-1) lives on evernetwork.io; the repo carries bia-rt-1/rt-2 (radiation-oncology genomic extensions).
Face is modelled as a second biometric modality bound to the same did:bio subject as the genomic BIA-RT claims — one identity hub, many attestation types. This kit reuses the BIA-RT-1 credential envelope shape for biometric attestations.
3. The model & boundary
┌─ Ever HealthWallet (patient device, self-sovereign) ──────────────┐
│ • did:bio subject DID (DNA-anchored, Poseidon commitments) │
│ • on-device biometric template (face / finger / iris) │
│ • issues a time-boxed, revocable VerificationGrant at admission │
└───────────────┬───────────────────────────────────────────────────┘
│ QR / NFC / deep-link (grant + sealed reference)
▼
┌─ @ever-medos/bio-identity-kit (hospital-side, shared) ────────────┐
│ IdentityStore → BioIdentity (shared handle ↔ did:bio) │
│ VerificationGrantStore → active grants (NO raw biometric) │
│ BioIdentityEngine → enroll / verify / attest │
│ AttestationStore → Verifiable Credentials = shared library │
│ ports: BiometricMatcher · CredentialSigner │
└───────────────┬───────────────────────────────────────────────────┘
│ same kernel; only adapters differ per platform
┌──────────┼───────────────┬───────────────┐
▼ ▼ ▼ ▼
Electron Mobile Web (opt) NestJS / edge
(laptop) (Expo) dashboards (server verify)
Three core records:
BioIdentity— the shared handle:subjectDid(self-sovereign),patientRef(HN),enrolledModalities,status.VerificationGrant— wallet-issued, time-boxed, revocable capability to run a 1:1 verify during an encounter. Carries an opaquereferenceHandle(resolved by the matcher adapter — sealed template / wallet round-trip / enclave key), never a raw template.AttestationEnvelope— a W3C VC (BIA-RT-1 shape): issuer DID, issuance date,credentialSubject(the claim),credentialStatus(revocation pointer),proof. Claim kinds:BiometricEnrollment,BiometricVerification,IdentityBinding.
4. Engine, ports & adapters (hexagonal)
BioIdentityEngine is pure domain logic; everything platform- or infra-specific is an injected port:
| Port | Responsibility | Stub / reference | Production |
|---|---|---|---|
BiometricMatcher |
compare probe vs reference; liveness | exact-equality stub | ArcFace / InsightFace cosine |
CredentialSigner |
sign / verify VC envelopes | djb2 hash stub | Ed25519 / BBS+ selective disclosure |
AttestationStore |
the shared VC library | in-memory | Supabase / NestJS |
IdentityStore |
shared identity records | in-memory | Supabase / NestJS |
VerificationGrantStore |
active grants | in-memory | Supabase / NestJS |
Clock / IdGenerator |
determinism / portability | fixed + seq | system + uuid |
Engine API: registerIdentity · enroll · acceptVerificationGrant · verify · verifyAttestation · getAttestationsForSubject · getAttestationsForTarget · revokeGrant · revokeIdentity.
5. Use-case flows
Five-rights “right patient” (the high-value one):
Nurse scans wristband (AN) ── claim: "this is patient X"
→ staff device captures patient face → BiometricProbe (embedding)
→ engine.verify({ context:'med-admin-five-rights', subjectDid, probe, targetRef:<MA id> })
1. resolve active BioIdentity
2. find active VerificationGrant (else → reason 'no-active-grant')
3. liveness gate (else → reason 'liveness-failed')
4. matcher.compare(probe, grant.referenceHandle)
5. on pass → sign + store BiometricVerificationClaim
→ attestation attached to the MedicationAdministration (safety gate + signed audit)
Patient unlock — native enclave releases the wallet key (no custom ML); optionally emits a verification attestation. Surgical consult (plastic) — NextFace 3D morphology captured as patient-owned imaging (wallet Bucket B), identity-bound by an attestation. (NextFace = 3D reconstruction for morphology, NOT identity matching — matching is ArcFace embeddings.)
6. Invariants
- No verify without an active wallet grant — the self-sovereign boundary; hospital cannot verify unilaterally.
- Only successful verifications are attested — a failed match files nothing.
- Attestations are tamper-evident — altering an envelope fails
verifyAttestation. - Verifications carry a
targetRef— attach to the record they protect (e.g. aMedicationAdministration), queryable. - No raw biometrics in this layer — DIDs, grants, and VCs only.
- Grants are time-boxed + revocable — expire automatically; revoke stops all future verifies.
7. Platform plan
The kernel already runs on all targets unchanged (pure TS; RN/Hermes-safe via guarded/injectable UUID). Difficulty lives in adapters.
Key split: unlock (patient on own device) needs no custom ML — use the native enclave everywhere; clinical capture (staff scans patient) needs ArcFace and happens on a bedside tablet/laptop. This is why web is optional.
| Camera | Embedding (ArcFace) | Enclave unlock | Secure storage | Verdict | |
|---|---|---|---|---|---|
| Electron (laptop) | getUserMedia |
onnxruntime-node |
✅ web/electron/native/biometric.ts |
safeStorage |
Build first |
| Mobile (Expo) | expo-camera (dep) |
onnxruntime-react-native (EAS build) |
expo-local-authentication |
expo-secure-store |
Feasible, native build |
| Web (optional) | getUserMedia |
onnxruntime-web (WASM/WebGPU) |
WebAuthn only | ❌ no enclave | Capture/match OK; no on-device template custody → WebAuthn + wallet/server reference |
Uniform move: one ArcFace .onnx model behind the BiometricMatcher port, with onnxruntime-{node,react-native,web} bindings. Embedding runs on-device → only the vector leaves the device, never the raw face. Compare can run on-device or server-side (NestJS + onnxruntime-node).
8. File inventory (shipped)
| File | Role |
|---|---|
web/packages/bio-identity-kit/package.json · tsconfig.json |
@ever-medos/* package (mirrors form-registry-types) |
src/types.ts |
BioIdentity, AttestationEnvelope, VerificationGrant, claim types |
src/ports.ts |
adapter interfaces (matcher, signer, stores, clock, ids) |
src/engine.ts |
BioIdentityEngine |
src/adapters/memory.ts |
in-memory IdentityStore / AttestationStore / VerificationGrantStore |
src/adapters/stub.ts |
deterministic stub matcher + signer, clocks, id generators |
src/index.ts |
barrel + createInMemoryEngine() |
src/bio-identity-engine.test.ts |
8 tests (enroll, five-rights, no-grant, revoke, no-match, patientRef resolve, tamper, expired-grant) |
9. Verification
- Strict typecheck —
tsc --strictclean (TypeScript 5.9.2). - Runtime — assertion script exercises enroll → grant → five-rights verify → target query → no-grant → revoke → tamper → expired-grant →
createInMemoryEngine; all pass. - Headless library — no UI/browser surface to verify.
10. Roadmap
- Capture port +
EmbeddingMatcher(pure TS, additive) — the seam every platform adapter implements. - Electron adapter —
getUserMedia+onnxruntime-node, reusebiometric.tsfor unlock. First working bedside capture→verify. - Mobile adapter (Expo EAS) —
expo-camera+onnxruntime-react-native+expo-local-authentication+expo-secure-store. - Real stores — Supabase migration (
bio_identities,bio_attestations,verification_grants) behind the ports. - Real signer — Ed25519 / BBS+ to match BIA-RT-1.
- Five-rights wiring — call
verifyfrom the e-MAR admin path; surface the attestation on the record. - Web (optional) —
onnxruntime-web+ WebAuthn; dashboards (read-only attestation views) work today via the kernel.
11. Deferred & caveats
- 1:N identify (walk-in / unconscious, no wallet) is the only thing needing a central searchable gallery. Deferred. If built: a separate, fenced-off service holding cancelable templates (revocable transforms, never raw embeddings), consent-gated, with its own DPIA — kept out of the attestation library.
- Regulatory: faces are special-category data (GDPR Art. 9 / Thailand PDPA) → explicit consent + DPIA regardless of platform. Biometrics are irrevocable, which is the core reason for on-device custody + the grant model over a central gallery.
- Liveness is weak against video replay — treat the capture gesture as quality-of-capture UX, not anti-spoofing. Real anti-spoofing is a separate passive/depth layer.
12. Update — bounded 1:N identify, opt-in config, list-patient on web (2026-05-30)
identify (bounded 1:N). engine.identify({ candidates, modality, context, probe, targetRef? }) matches a probe against a supplied candidate set (e.g. the visible list-patient rows). Safety: only candidates with an active grant are eligible (consent gate); a confident result needs a threshold pass and a margin over the runner-up, else ambiguous; a maxCandidates cap blocks unbounded search. Emits a BiometricVerification attestation on a confident match.
Opt-in configuration. BioIdentityConfig (src/config.ts) is a per-context capability matrix: enabled, allowed modalities, and per-context { enabled, operations:[verify|identify], requireLiveness, thresholds, identify:{maxCandidates,margin} }. The engine gates every verify/identify through it. Product default = disabledConfig() (off; a deployment opts in, sourced from a bio_identity_configs matrix / admin UI). permissiveConfig() is for dev/tests/sandbox; the engine constructor config? defaults to permissive for ergonomics — the product supplies a restrictive matrix.
Real matcher + capture seam. EmbeddingMatcher (src/adapters/embedding.ts) — cosine similarity over encoded embedding vectors (encodeEmbedding/decodeEmbedding), mapped to [0,1]. BiometricCapture port added to ports.ts for the camera→embedding step platform adapters implement.
list-patient on web — feasible. /list-patient (web/src/containers/list-patient/) is staff-facing, so the web “no enclave” limitation (which is about custodying a patient’s own template in their browser) does not apply — reference embeddings come from wallet grants, not browser storage. Two operations: 1:1 verify on a selected row (engine.verify) and bounded 1:N identify against the listed rows (engine.identify). Web specifics: embed in-browser via onnxruntime-web (light model, e.g. MobileFaceNet) so only the vector leaves; candidates = listed patients with an active grant; treat 1:N as a confirm-suggestion, never auto-select; bound the candidate set (current list/search), never hospital-wide.
New files: src/config.ts, src/adapters/embedding.ts. Tests: identify (correct / ambiguous / consent-gate skip) + config (disabled / per-context op restriction). All green under strict tsc + the tsx runtime proof.
13. Capture layer — Electron + web (2026-05-30)
src/capture.ts adds the camera→probe seam so one pipeline serves every platform:
FaceEmbedder(frame → vector) +CaptureSource(camera → frame) interfaces.EmbeddingCapture implements BiometricCapture— composes a source + embedder into aBiometricProbethat flows straight intoengine.verify/engine.identify.FunctionFaceEmbedder— wraps any(frame) => vector; the integration point for onnxruntime-web (browser/Electron renderer), onnxruntime-node (Electron main), or onnxruntime-react-native (mobile).MockFaceEmbedder+MockCaptureSourcefor headless tests.
src/adapters/browser-capture.ts — BrowserCameraSource (getUserMedia → canvas frame). One source serves both web and the Electron renderer (Chromium). DOM-dependent, so imported directly, not from the root barrel (keeps DOM out of the RN/edge kernel).
No new dependencies added — the ML model is a pluggable FaceEmbedder; wiring onnxruntime-web + a model file (e.g. MobileFaceNet) is the one remaining per-platform step. Capture orchestration is verified via the tsx proof (MockCaptureSource → MockFaceEmbedder → identify); the getUserMedia + onnx parts need a real browser + model to exercise.
Still open: mobile CaptureSource (expo-camera) + the real onnxruntime embedders (deps + manual camera testing); Supabase-backed stores.
14. Staff face-attestation login (2026-05-30)
The engine is now subject-generic: BioIdentity.subjectType: 'patient' | 'practitioner', patientRef → subjectRef (HN for patients, staff id for staff), new staff-login context.
Authorization differs by subject — same engine:
- Patients → wallet-issued, time-boxed
VerificationGrant(self-sovereign,source: 'wallet'). - Staff → no patient wallet, so
enroll({ reference, standing })creates a hospital-held standing grant (source: 'enrollment') from the enrolled reference. This is the enterprise-biometric path (employee, enrolled with consent/policy), distinct from the patient self-sovereign model.
Login flow (1:1 verify): staff claims identity (badge/username) → face scan → verify({ context: 'staff-login', subjectDid | subjectRef, probe }) → on match the app issues its session (JWT via services/ever-aaa) and the engine files a staff-login BiometricVerification attestation. A staff member who hasn’t enrolled has no standing grant → no-active-grant (cannot log in by face). Proven in tests + the tsx proof.
Not yet wired: the real login screen + ever-aaa JWT bridge, the enrollment admin flow, and (as everywhere) a real embedder + camera. Staff enrollment stores a biometric reference centrally → same template-protection / consent / DPIA obligations as any enterprise biometric; prefer a cancelable transform over a raw embedding at go-live.
15. Persistence — Supabase stores (2026-05-31)
Migration infrastructure/medbase/migrations/20260531a_bio_identity_attestation.sql creates three RLS service-role-only tables modeling patient and staff:
bio_identities(subject_didpk,subject_type,subject_ref,enrolled_modalities,status)verification_grants(grant_idpk,sourcewallet|enrollment,expires_at,scope_contexts,revoked_at)bio_attestations(idpk, denormalized subject/issuer/kind/context/target_ref/score + the fullenvelopejsonb)
src/adapters/supabase.ts implements all three ports (SupabaseIdentityStore / SupabaseVerificationGrantStore / SupabaseAttestationStore). Dependency-free — typed against a minimal BioSupabaseClient interface that the real @supabase/supabase-js client satisfies (pass a service-role client; cast if TS flags the structural match). Verified by running the full engine (patient verify + revoke + bounded identify + staff-login + subjectRef resolve) through a mock client — identical behavior to the in-memory stores.
Apply: run the migration manually (SQL editor / psql -f), per repo convention. Still open: page wiring (#2), camera widget (#3), real onnxruntime model (#4), and the ever-aaa JWT bridge for staff login.
16. Sandbox demo (2026-05-31)
web/sandbox/targets/BioIdentityListPatientTarget.tsx (registry key BioIdentityListPatient) — a clickable demo of the kit on a list-patient surface, wired end-to-end with the in-memory engine + a MOCK embedder (no real recognition, no DB). Run from web/: pnpm exec vite --config sandbox/vite.config.ts → http://localhost:5179/?target=BioIdentityListPatient.
Shows: a patient list (each with an active wallet grant); “simulate scan as” → bounded 1:N identify against the list (matched row highlights + confidence/score/margin + “confirm before proceeding”); and a staff member → 1:1 staff-login verify. Optional camera toggle exercises BrowserCameraSource (getUserMedia); matching uses the simulated probe so the demo is deterministic. Imports the kit by relative path (sandbox fs.allow = web/); no sandbox config changed.
Verified via a Playwright smoke: boot sandbox → scan default patient → asserts “Matched … Somchai, confidence high, score 0.998, margin 0.487”, zero console/page errors. (Staff-login + the rest of the engine are covered by the unit proof; the camera path is hand-off to a real browser.)
Still open (unchanged): real-page wiring (/list-patient, login), real onnxruntime model + migration applied, ever-aaa JWT bridge.
17. Real face embedder — onnxruntime (2026-05-31)
src/adapters/onnx-face-embedder.ts — the recognition piece, dependency-free (ort runtime + model session injected, like the Supabase client):
preprocessFaceFrame(frame, opts)— RGBA frame → normalized float tensor (nearest-neighbor resize to size², mean/std, NCHW/NHWC, RGB/BGR). MobileFaceNet/ArcFace defaults: 112², 127.5/128, NCHW, RGB. Pure + unit-tested (exact tensor asserted).l2normalize(vec)— pure + tested.OnnxFaceEmbedder implements FaceEmbedder— preprocess →new Tensor(...)→session.run()→ L2-normalize. Tested via an injected fake session.
Activate (the one remaining piece for real recognition):
const session = await ort.InferenceSession.create('/models/mobilefacenet.onnx')
const embedder = new OnnxFaceEmbedder({ session, Tensor: ort.Tensor, inputName: 'input', outputName: 'output' })
new EmbeddingCapture({ source: new BrowserCameraSource(), embedder }) // → engine.identify / verify
Needs: pnpm add onnxruntime-web + a MobileFaceNet/ArcFace .onnx in web/public/models/ + browser testing. Preprocessing/normalization are verified here; session.run() correctness needs the real model.
Truly remaining: (a) add dep + model file; (b) wire /list-patient + login pages (mount EmbeddingCapture, call identify/verify); © apply the migration; (d) ever-aaa JWT bridge for staff login.
18. Face-login demo + the ever-aaa contract (2026-05-31)
web/sandbox/targets/FaceLoginTarget.tsx (registry key FaceLogin) — staff and patient face login via the kit. Staff → staff-login verify (enrollment standing grant); patient → patient-unlock verify (wallet grant). On success the engine emits a signed BiometricVerification attestation; the demo shows the envelope a client would POST for a JWT. A “present wrong face” toggle demonstrates the deny gate. Verified via Playwright smoke: grant (staff authenticated, score 1.000) + deny (wrong face → no-match, no session/attestation) + zero console errors.
The real session (what a demo can’t fake) — proposed ever-aaa contract:
POST /auth/face-login { "attestation": <signed BiometricVerification VC> }
1. verify VC signature (issuer = this hospital's DID) + freshness (issuanceDate within N sec)
2. confirm context = 'staff-login', acceptable confidenceClass, liveness passed
3. map credentialSubject.id (did) → user account
4. issue the normal JWT (same path as password login)
This is new, security-critical auth work (a biometric auth strategy in services/auth / ever-api-aaa) — design + review + deploy required; not to be wired blind. Until it exists, face login is a verified UX demo, not a live auth path.
Truly remaining for live face login: the ever-aaa endpoint above; real model (onnxruntime + .onnx); editing the real login page to mount the capture + call it; migration applied.
19. Optional staff face-login endpoint + RUDS logging (2026-05-31)
Endpoint: POST /auth/face/sign-in (Moleculer action auth.faceSignIn) in services/auth. Optional — env FACE_LOGIN_ENABLED (default OFF); disabled → 403 FACE_LOGIN_DISABLED.
Flow (server-side verify = the trust anchor): client posts { subjectRef | subjectDid, template } (template = face embedding computed client-side) → server reads the active verification_grants reference from Supabase, cosine-matches (FACE_LOGIN_MIN_SCORE, default 0.7 mapped) → on match maps subjectRef → user (by username) → issues the same JWT as password signIn. Files: auth.service.ts#faceSignIn, bioFaceLogin.helper.ts, dto/faceSignIn.dto.ts, auth.controller.mixin.ts.
RUDS logging (the “face detection tag”): every attempt → user_action_events (FACE_LOGIN_SUCCESS / FACE_LOGIN_DENIED, service auth) with a faceDetection metadata tag (context, modality, outcome, matchScore, reason, subjectDid/Ref) — detection rules / SOC dashboard / risk baselines see it like any auth action. Reusable builder shipped in security-kit (packages/security-kit/src/ruds/faceLogin.ts: buildFaceLoginEvent, emitUserActionEvent, FACE_LOGIN_ACTIONS) for the gateway/edge to emit consistently.
Verified: whole auth service compiles clean (tsc -p → 0 errors) with the new module — deploy-safe; security-kit helper typechecks. Flag OFF = inert even if deployed.
SECURITY — must clear before enabling (FACE_LOGIN_ENABLED=true):
- Liveness / anti-spoofing NOT enforced server-side — a replayed embedding passes. Gate on a signed capture/liveness token first.
- Embedding is client-computed → bounded trust.
reference_handleshould be a cancelable transform / encrypted at rest.- Tune
FACE_LOGIN_MIN_SCOREagainst the real model (measure FAR/FRR); needs staff enrollment to populateverification_grants(sourceenrollment, scopestaff-login). - Apply migration
20260531a; security review of the auth path before deploy.
20. Liveness capture-challenge gate (2026-05-31)
Server-side anti-replay gate for face login (the #1 precondition from §19). New endpoint POST /auth/face/challenge (action auth.faceChallenge) issues a single-use, short-lived challenge { challengeId, nonce, gesture, expiresAt } (TTL FACE_LOGIN_CHALLENGE_TTL_SEC, default 120s; gesture ∈ turn-head-left/right, look-up/down, blink-twice). faceSignIn now requires challengeId (when FACE_LOGIN_REQUIRE_CHALLENGE !== 'false') and consumes it atomically (single UPDATE … WHERE consumed_at IS NULL AND expires_at > now) before verifying the face — so a challenge can’t be reused. Missing / expired / used → RUDS FACE_LOGIN_DENIED (reason challenge-*) + 400/401. New table bio_capture_challenges added to migration 20260531a (RLS service-role). Files: bioFaceLogin.helper.ts (issueCaptureChallenge / consumeCaptureChallenge / isChallengeRequired), auth.service.ts#faceChallenge + the gate in faceSignIn, dto/faceSignIn.dto.ts (FaceChallengeRequestDto + challengeId).
Flow: client POST /auth/face/challenge → performs the gesture (e.g. the head-rotation rig) → POST /auth/face/sign-in { challengeId, template }. Verified: whole auth service still compiles clean (0 errors).
This is anti-replay + freshness (weak active liveness via the gesture), NOT full anti-spoofing. A printed photo / replayed video performing the gesture still defeats it. True spoof-resistance needs a passive depth/texture liveness layer (or a trusted-enclave capture) — the remaining hard precondition before production enable.
21. Configurable liveness layer + UI-wiring status (2026-05-31)
web/packages/bio-identity-kit/src/liveness.ts — LivenessProvider port + LivenessMode (off | gesture | passive) + providers: NoOpLivenessProvider, GestureLivenessProvider (weak active — gesture completed), OnnxLivenessProvider (passive anti-spoof via an injected onnx model, dep-free), and resolveLivenessProvider(mode, deps) (passive → gesture fallback when no model is wired). Plugs into EmbeddingCapture.assessLiveness / the engine’s requireLiveness. Verified (tsc + proof + tests). The sandbox FaceLogin demo now has a configurable liveness selector (off/gesture/passive) — smoke still green (success + deny + 0 errors).
Face-login HTTP contract the real login page will call: POST /v2/aaa/auth/face/challenge → { challengeId, gesture, expiresAt }; POST /v2/aaa/auth/face/sign-in { subjectRef, template, challengeId } → { token } (then store auth_token + getMe, exactly like login()).
⚠️ Production login-page wiring is BLOCKED on a config change I won’t make unsolicited. The kit (web/packages/bio-identity-kit) is NOT in web’s module resolution — the sandbox reaches it via a relative path + fs.allow, but web/src can’t import it without adding it to web’s pnpm workspace deps + a tsconfig.json path alias + a vite.config.ts alias. Those are the explicitly-guardrailed build-config files. To wire face login into the real LoginForm, grant permission to add the kit to web’s workspace + aliases (then it’s a flag-gated `` mount + the requestFaceChallenge/faceSignIn client). Until then, in-app face login is demo-only; the backend endpoint + liveness gate + kit are done + verified.
22. Production login-page wiring (2026-05-31)
With permission, the kit was added to web’s module resolution: @ever-medos/bio-identity-kit alias in vite.config.ts, tsconfig.json (+ /*), and the sandbox config (mirrors the existing @*-kit pattern). Then:
FaceLoginButton(web/src/common/components/system-administration/login/FaceLoginButton.tsx) — config-gated (VITE_FACE_LOGIN_ENABLED), self-contained, injectedclient+capture(no hard model dependency; renders a disabled “requires a configured face model” state whencaptureis null). Flow: requestChallenge → gesture → capture → faceSignIn. Sandbox-verified (?target=FaceLoginButton, mock client + capture → “Authenticated”, 0 console errors).requestFaceChallenge/faceSignIninuser.service.ts— HTTP client mirroringlogin()(POST/v2/aaa/auth/face/challenge+/face/sign-in, storeauth_token,getMe).- Mounted in
LoginForm(after the Sign In button), flag-gated — default OFF,capture={null}, so the production login is unchanged until bothVITE_FACE_LOGIN_ENABLED=trueAND a real capture (onnxruntime + model) are wired.
Net: face login is now wired front-to-back and configurable (engine · persistence · endpoint · liveness gate · liveness providers · login UI). The only thing between here and a working production login is the real face model (onnxruntime-web + .onnx, your dep approval) feeding a real capture — plus the security preconditions (server-side passive liveness, staff enrollment, migration applied, security review).
23. Model wiring — code-complete (2026-05-31)
web/src/services/ever-aaa/faceCapture.ts — getFaceCapture() builds BrowserCameraSource + OnnxFaceEmbedder (+ optional OnnxLivenessProvider) from a model URL, via a variable-specifier dynamic import of onnxruntime-web so the build never hard-requires it. Returns null (→ disabled button) until configured. onnxruntime-web declared in web/package.json. FaceLoginButton now takes a lazy getCapture (model loads on first click); LoginForm passes getFaceCapture (flag-gated). Re-verified: sandbox smoke green, 0 errors.
The feature is now code-complete end-to-end (engine · persistence · /auth/face/* endpoint · liveness challenge gate · configurable liveness providers · login UI · model-loading capture factory), all flag-gated OFF by default.
To turn it on (all external / operator steps — no more code):
pnpm install(onnxruntime-web is declared).- Drop a face-recognition model (e.g.
web/public/models/mobilefacenet.onnx); setVITE_FACE_MODEL_URL(+ optionalVITE_FACE_LIVENESS_MODEL_URL+ tensor-name envs). - Apply migration
20260531a; enroll staff faces (populateverification_grants, sourceenrollment). - Backend
FACE_LOGIN_ENABLED=true(+FACE_LOGIN_REQUIRE_CHALLENGE,FACE_LOGIN_MIN_SCORE); frontendVITE_FACE_LOGIN_ENABLED=true. - Security review (server-side passive liveness, FAR/FRR threshold tuning, cancelable templates) + a real browser/camera test.
Nothing is committed/pushed.