From fe65ba9dd93aad1e16b181c2abe8ba099d81472e Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 18:24:49 +0000 Subject: [PATCH 1/4] feat(models): add facenet512 FP16 to fetch-models manifest (SP-A persistence) The 47 MB Facenet512 FP16 model is now hosted at the manifest's base_url_default (app.fivucsas.com/models). Adding it to manifest.json makes prebuild->fetch-models fetch + SHA256-verify + bundle it into dist/models so the deploy rsync can't wipe a manual upload, and it fans out to the verify build. Matches DEFAULT_FACENET_MODEL_URL / DEFAULT_FACENET_MODEL_SHA256 in facenetEmbedder.ts. --- public/models/manifest.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/models/manifest.json b/public/models/manifest.json index 17afd98..a731afe 100644 --- a/public/models/manifest.json +++ b/public/models/manifest.json @@ -13,6 +13,12 @@ "sha256": "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3", "bytes": 2327524, "purpose": "Voice activity detection — skip upload on silent captures. Canonical release from github.com/snakers4/silero-vad." + }, + { + "name": "facenet512-1ad91552.fp16.onnx", + "sha256": "1ad9155214adb83595b60492b0624bbd44300427c5c3921c876f390c7b44bd66", + "bytes": 47072627, + "purpose": "Client-side FACE recognition embedding (SP-A) — Facenet512 FP16 export (47 MB, opset 17). When VITE_CLIENT_SIDE_EMBEDDING is ON the browser runs this via onnxruntime-web to compute the authoritative 512-d L2-normalized embedding locally and uploads ONLY the vector (the raw face image never leaves the device). The CPU-only server expects the embedding and 400s the legacy image path while the identity flag app.auth.client-side-embedding is ON. Fetched + SHA256-verified at build time (prebuild → fetch-models) into dist/models so a deploy rsync can't wipe a manual upload, and fanned out to the verify build. INT8 is NOT web-compatible (ConvInteger unimplemented on the onnxruntime-web WASM EP) — FP16 is the ship format." } ] } From 3baf8cb5d9539698a877f7ae97c2c530a23f9e80 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 18:25:48 +0000 Subject: [PATCH 2/4] feat(face): remove silent image fallback when client-side embedding ON (GPU-less enforcement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CPU-only server now EXPECTS the embedding and 400s the legacy image path while app.auth.client-side-embedding is ON. Previously a null embedding (no model / ORT load fail / inference error) silently fell back to verifyStep(FACE, { image }) — an upload the server now rejects, so the login failed with an opaque error. Now, when the flag is ON and embedCapturedFace returns null, surface a clear retryable error (mfa.face.clientPrepFailed, EN+TR) via onError so it renders in FaceCaptureStep's existing error Alert, and return WITHOUT sending the image. The captured frame stays so the user can re-submit. Flag OFF is unchanged: byte-identical legacy { image } upload path. Also adds the mfa.face.preparingSecure key (EN+TR) consumed by the next commit. --- .../auth/login-shared/MfaStepRenderer.tsx | 18 +++++++++++++---- .../MfaStepRendererFaceEmbedding.test.tsx | 20 +++++++++++++------ src/i18n/locales/en.json | 4 +++- src/i18n/locales/tr.json | 4 +++- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/features/auth/login-shared/MfaStepRenderer.tsx b/src/features/auth/login-shared/MfaStepRenderer.tsx index 49365b9..12cec6e 100644 --- a/src/features/auth/login-shared/MfaStepRenderer.tsx +++ b/src/features/auth/login-shared/MfaStepRenderer.tsx @@ -198,17 +198,27 @@ export default function MfaStepRenderer({ // makes the client embedding self-consistent capture-to- // capture; without it the embedding is an unaligned crop. // - // Resilient: a null embedding (no model / ORT load fail / - // inference error) falls back to the unchanged legacy - // image upload so a login is never blocked. Flag OFF is - // byte-identical to the legacy `{ image }` path. + // GPU-LESS ENFORCEMENT (SP-A go-live): the CPU-only server + // EXPECTS the embedding and 400s the legacy image path when + // the identity flag `app.auth.client-side-embedding` is ON. + // So a null embedding (no model / ORT load fail / inference + // error) must NOT silently fall back to uploading the image + // — that upload would just be rejected by the server. Surface + // a clear, RETRYABLE error instead and let the user try again + // (the model cache self-heals on the next attempt + the + // prefetch re-warms it). NEVER send `{ image }` while ON. if (isClientSideEmbeddingEnabled()) { const embedding = await embedCapturedFace(image, faceLandmarks) if (embedding) { verifyStep(AuthMethodType.FACE, { embedding, ...advisory }) return } + // On-device prep failed — show a retryable message; the + // captured frame stays so the user can simply re-submit. + onError(t('mfa.face.clientPrepFailed')) + return } + // Flag OFF: legacy image upload, byte-identical to before. verifyStep(AuthMethodType.FACE, { image, ...advisory }) }} loading={loading} diff --git a/src/features/auth/login-shared/__tests__/MfaStepRendererFaceEmbedding.test.tsx b/src/features/auth/login-shared/__tests__/MfaStepRendererFaceEmbedding.test.tsx index 3fa4f36..d145a62 100644 --- a/src/features/auth/login-shared/__tests__/MfaStepRendererFaceEmbedding.test.tsx +++ b/src/features/auth/login-shared/__tests__/MfaStepRendererFaceEmbedding.test.tsx @@ -8,6 +8,9 @@ * * Contract verified here (deterministic — embedder + flag are mocked, no ONNX): * - Flag ON → verifyStep(FACE, { embedding: number[512] }) and NOT { image }. + * - Flag ON + embedding fails → onError(clientPrepFailed) + NO verifyStep + * (GPU-less enforcement: the CPU-only server 400s the image path when ON, + * so we must NEVER silently fall back to uploading the image). * - Flag OFF → verifyStep(FACE, { image }) exactly as the legacy path did. * * The FaceCaptureStep internals (camera, detection, capture) are mocked to a @@ -73,6 +76,7 @@ import MfaStepRenderer from '../MfaStepRenderer' function renderFaceStep() { const verifyStep = vi.fn() + const onError = vi.fn() render( , ) - return { verifyStep } + return { verifyStep, onError } } describe('MfaStepRenderer — FACE submit: client embedding vs legacy image', () => { @@ -133,14 +137,18 @@ describe('MfaStepRenderer — FACE submit: client embedding vs legacy image', () expect((payload as { embedding: number[] }).embedding).toHaveLength(512) }) - it('flag ON but embedding fails: falls back to the legacy { image } upload (never blocks auth)', async () => { + it('flag ON but embedding fails: surfaces a retryable error and NEVER uploads the image (GPU-less enforcement)', async () => { flagState.enabled = true embedCapturedFaceMock.mockResolvedValue(null) - const { verifyStep } = renderFaceStep() + const { verifyStep, onError } = renderFaceStep() await userEvent.click(screen.getByRole('button', { name: 'face-submit' })) - await waitFor(() => expect(verifyStep).toHaveBeenCalledTimes(1)) - expect(verifyStep).toHaveBeenCalledWith(AuthMethodType.FACE, { image: CAPTURED_IMAGE }) + // The CPU-only server 400s the image path when the flag is ON, so a null + // embedding must NOT fall back to { image }. Instead a clear, retryable + // error is surfaced and verifyStep is never called. + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)) + expect(onError).toHaveBeenCalledWith(expect.any(String)) + expect(verifyStep).not.toHaveBeenCalled() }) }) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d815be2..7d38166 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1681,7 +1681,9 @@ "retryTipGlasses": "Remove glasses or face coverings if possible", "modelLoading": "Preparing face detection… this takes a moment on first use.", "modelLoadFailed": "Face detection couldn't initialize. Check your connection or try a different verification method.", - "padScore": "Liveness {{score}}%" + "padScore": "Liveness {{score}}%", + "preparingSecure": "Preparing secure verification…", + "clientPrepFailed": "Couldn't prepare on-device face verification. Please check your connection and try again." }, "voice": { "title": "Voice Verification", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index dcda997..7f4b2ac 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1681,7 +1681,9 @@ "retryTipGlasses": "Mümkünse gözlük veya yüz örtüsünü çıkarın", "modelLoading": "Yüz algılama hazırlanıyor… ilk kullanımda biraz zaman alabilir.", "modelLoadFailed": "Yüz algılama başlatılamadı. Bağlantınızı kontrol edin veya farklı bir doğrulama yöntemi deneyin.", - "padScore": "Canlılık %{{score}}" + "padScore": "Canlılık %{{score}}", + "preparingSecure": "Güvenli doğrulama hazırlanıyor…", + "clientPrepFailed": "Cihaz üzerinde yüz doğrulaması hazırlanamadı. Lütfen bağlantınızı kontrol edip tekrar deneyin." }, "voice": { "title": "Ses Doğrulama", From 038504f3b00acb21856dc5d9b9d32c8c3c00536f Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 18:31:56 +0000 Subject: [PATCH 3/4] feat(face): prefetch Facenet512 model on login-surface mount (no frozen 47 MB-on-submit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds prefetchFacenetModel() + scheduleFacenetPrefetch() in the embedding feature: flag-gated (no-op when VITE_CLIENT_SIDE_EMBEDDING is OFF), de-duped (concurrent/repeat calls share one in-flight download), and never-throws (a failed warm-up is harmless — embedCapturedFace retries on submit + GPU-less-enforcement surfaces a real failure there). Wired fire-and-forget via requestIdleCallback (fallback setTimeout) on LoginPage (dashboard) and HostedLoginApp (hosted) mount, with effect- cleanup cancelling a still-pending idle schedule. So with the flag ON the ~47 MB model downloads on idle while the user types their identifier, and a later FACE step is a cache hit instead of a frozen on-submit download. Flag OFF: no schedule, no download — surfaces byte-identical to before. Unit-tested: flag-gating, single canonical-URL+SHA fetch, de-dup, never-rejects+retry, requestIdleCallback-vs-setTimeout + cleanup. --- src/features/auth/components/LoginPage.tsx | 10 ++ .../__tests__/prefetchFacenetModel.test.ts | 164 ++++++++++++++++++ .../embedding/prefetchFacenetModel.ts | 89 ++++++++++ src/verify-app/HostedLoginApp.tsx | 14 ++ 4 files changed, 277 insertions(+) create mode 100644 src/features/biometrics/embedding/__tests__/prefetchFacenetModel.test.ts create mode 100644 src/features/biometrics/embedding/prefetchFacenetModel.ts diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx index b464e7e..918c552 100644 --- a/src/features/auth/components/LoginPage.tsx +++ b/src/features/auth/components/LoginPage.tsx @@ -57,6 +57,7 @@ import StepProgress from '../../../verify-app/StepProgress' import { usePrefersReducedMotion } from '@hooks/usePrefersReducedMotion' import { loginShellBackgroundSx, glassCardBackdropFilter } from './loginBackground' import FloatingShape from './FloatingShape' +import { scheduleFacenetPrefetch } from '@features/biometrics/embedding/prefetchFacenetModel' /** * Login form validation schema @@ -189,6 +190,15 @@ export default function LoginPage() { return () => { cancelled = true; clearTimeout(fallback) } }, [httpClient]) + // Warm the Facenet512 model cache on login-surface mount (SP-A go-live). + // Flag-gated + de-duped inside the helper, so with VITE_CLIENT_SIDE_EMBEDDING + // OFF this is a NO-OP (no schedule, no download) and the dashboard login is + // byte-identical to before. When ON, the ~47 MB model downloads on idle while + // the user types their identifier/password, so by the time a FACE step is + // reached the embed is a cache hit (no frozen 47 MB-on-submit download). The + // cleanup cancels a still-pending idle schedule on unmount. + useEffect(() => scheduleFacenetPrefetch(), []) + // Re-fetch the login-config when the user taps "Retry" on the // config-unavailable banner (the initial fetch failed, almost always because // the API was unreachable). On success the banner clears and the real Layer-1 diff --git a/src/features/biometrics/embedding/__tests__/prefetchFacenetModel.test.ts b/src/features/biometrics/embedding/__tests__/prefetchFacenetModel.test.ts new file mode 100644 index 0000000..8bb14ef --- /dev/null +++ b/src/features/biometrics/embedding/__tests__/prefetchFacenetModel.test.ts @@ -0,0 +1,164 @@ +/** + * Unit tests for prefetchFacenetModel — the fire-and-forget warm-up of the + * Facenet512 model cache (SP-A go-live, so the ~47 MB download happens on the + * login surface's idle time instead of freezing the FACE-step submit). + * + * Contract under test (getModel + the flag are mocked — no ONNX, no 47 MB fetch): + * - Flag OFF → no-op: getModel is never called; scheduleFacenetPrefetch never + * schedules idle work. + * - Flag ON → getModel is called once with the canonical URL + SHA256. + * - De-dup: concurrent calls share ONE in-flight getModel (one download). + * - Never throws / never rejects: a failing getModel resolves the prefetch and + * resets so a later call can retry. + * - scheduleFacenetPrefetch uses requestIdleCallback when present (fallback + * setTimeout) and the returned cleanup cancels a still-pending schedule. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import { + DEFAULT_FACENET_MODEL_URL, + DEFAULT_FACENET_MODEL_SHA256, +} from '../facenetEmbedder' + +// Injectable flag + getModel mock so the test is deterministic. +const flagState = { enabled: false } +const getModelMock = vi.fn<(url: string, sha: string) => Promise>() + +vi.mock('../clientEmbeddingFlag', () => ({ + isClientSideEmbeddingEnabled: () => flagState.enabled, +})) +vi.mock('../modelCache', () => ({ + getModel: (url: string, sha: string) => getModelMock(url, sha), +})) + +// Import AFTER the mocks so the module binds to them. Use a dynamic re-import per +// test via vi.resetModules so the module-scoped `inFlight` de-dup cache is fresh. +async function loadModule() { + vi.resetModules() + return import('../prefetchFacenetModel') +} + +describe('prefetchFacenetModel', () => { + beforeEach(() => { + flagState.enabled = false + getModelMock.mockReset() + getModelMock.mockResolvedValue(new ArrayBuffer(8)) + }) + + it('flag OFF: is a no-op — never calls getModel', async () => { + flagState.enabled = false + const { prefetchFacenetModel } = await loadModule() + + await prefetchFacenetModel() + + expect(getModelMock).not.toHaveBeenCalled() + }) + + it('flag ON: warms the cache with the canonical URL + SHA256', async () => { + flagState.enabled = true + const { prefetchFacenetModel } = await loadModule() + + await prefetchFacenetModel() + + expect(getModelMock).toHaveBeenCalledTimes(1) + expect(getModelMock).toHaveBeenCalledWith( + DEFAULT_FACENET_MODEL_URL, + DEFAULT_FACENET_MODEL_SHA256, + ) + }) + + it('flag ON: concurrent calls share ONE in-flight download (de-dup)', async () => { + flagState.enabled = true + const { prefetchFacenetModel } = await loadModule() + + await Promise.all([ + prefetchFacenetModel(), + prefetchFacenetModel(), + prefetchFacenetModel(), + ]) + + expect(getModelMock).toHaveBeenCalledTimes(1) + }) + + it('flag ON: a failing getModel never rejects and resets for a later retry', async () => { + flagState.enabled = true + getModelMock.mockRejectedValueOnce(new Error('network down')) + const { prefetchFacenetModel } = await loadModule() + + // Must not reject despite getModel throwing. + await expect(prefetchFacenetModel()).resolves.toBeUndefined() + + // After a failure the in-flight cache resets, so a later call retries. + getModelMock.mockResolvedValueOnce(new ArrayBuffer(8)) + await prefetchFacenetModel() + expect(getModelMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('scheduleFacenetPrefetch', () => { + beforeEach(() => { + flagState.enabled = false + getModelMock.mockReset() + getModelMock.mockResolvedValue(new ArrayBuffer(8)) + }) + + it('flag OFF: returns a no-op cleanup and schedules nothing', async () => { + flagState.enabled = false + const ric = vi.fn() + vi.stubGlobal('requestIdleCallback', ric) + const { scheduleFacenetPrefetch } = await loadModule() + + const cleanup = scheduleFacenetPrefetch() + + expect(ric).not.toHaveBeenCalled() + expect(() => cleanup()).not.toThrow() + vi.unstubAllGlobals() + }) + + it('flag ON: schedules via requestIdleCallback when available', async () => { + flagState.enabled = true + let idleCb: (() => void) | null = null + const ric = vi.fn((cb: () => void) => { + idleCb = cb + return 42 + }) + const cic = vi.fn() + vi.stubGlobal('requestIdleCallback', ric) + vi.stubGlobal('cancelIdleCallback', cic) + const { scheduleFacenetPrefetch } = await loadModule() + + const cleanup = scheduleFacenetPrefetch() + expect(ric).toHaveBeenCalledTimes(1) + + // Fire the idle callback → the prefetch runs and downloads. + idleCb?.() + await Promise.resolve() + await Promise.resolve() + expect(getModelMock).toHaveBeenCalledTimes(1) + + // Cleanup cancels the idle handle. + cleanup() + expect(cic).toHaveBeenCalledWith(42) + vi.unstubAllGlobals() + }) + + it('flag ON: falls back to setTimeout when requestIdleCallback is absent', async () => { + flagState.enabled = true + vi.stubGlobal('requestIdleCallback', undefined) + vi.stubGlobal('cancelIdleCallback', undefined) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + const { scheduleFacenetPrefetch } = await loadModule() + + const cleanup = scheduleFacenetPrefetch() + expect(setTimeoutSpy).toHaveBeenCalled() + + cleanup() + expect(clearTimeoutSpy).toHaveBeenCalled() + + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + vi.unstubAllGlobals() + }) +}) diff --git a/src/features/biometrics/embedding/prefetchFacenetModel.ts b/src/features/biometrics/embedding/prefetchFacenetModel.ts new file mode 100644 index 0000000..69ffaf7 --- /dev/null +++ b/src/features/biometrics/embedding/prefetchFacenetModel.ts @@ -0,0 +1,89 @@ +/** + * prefetchFacenetModel — warm the Facenet512 model cache BEFORE the FACE capture + * step needs it, so by capture+submit time the ~47 MB model is already on the + * device and the in-browser embedding is effectively instant. + * + * The download-once cache (`modelCache.getModel` → Cache API + IndexedDB, with + * SHA256 re-verification on every read) means a successful prefetch makes the + * later `embedCapturedFace` call a cache hit — no 47 MB download blocking the + * submit (which on mobile looked like a frozen screen; see SP-A Phase-1). + * + * Resilient + side-effect-only by design: + * - It is a no-op unless the client-side-embedding flag is ON (mirrors the FACE + * submit path; with the flag OFF the legacy image upload is used and the model + * is never needed, so there is nothing to warm). + * - It NEVER throws and NEVER rejects to the caller (errors are swallowed). A + * failed prefetch is harmless — the real `embedCapturedFace` retries the + * download on submit, and the surface-level error handling (GPU-less + * enforcement) covers a genuine failure there. + * - It de-dupes: concurrent / repeated calls share the SAME in-flight promise, + * so mounting both the login surface and the FaceCaptureStep does not kick off + * two parallel 47 MB downloads. + * + * Call it fire-and-forget from a `requestIdleCallback` (fallback `setTimeout`) on + * the login surfaces — see `scheduleFacenetPrefetch`. + */ + +import { isClientSideEmbeddingEnabled } from './clientEmbeddingFlag' +import { DEFAULT_FACENET_MODEL_URL, DEFAULT_FACENET_MODEL_SHA256 } from './facenetEmbedder' +import { getModel } from './modelCache' + +/** In-flight prefetch promise, so repeat/concurrent calls don't re-download. */ +let inFlight: Promise | null = null + +/** + * Warm the Facenet512 model cache (download-once + SHA256-verify) when the + * client-side-embedding flag is ON. No-op when the flag is OFF. Never throws. + * + * @returns a promise that resolves when the warm-up settles (success OR a + * swallowed failure). Callers should fire-and-forget; the return value + * exists only for tests / explicit awaiting. + */ +export function prefetchFacenetModel(): Promise { + if (!isClientSideEmbeddingEnabled()) return Promise.resolve() + if (inFlight) return inFlight + + inFlight = getModel(DEFAULT_FACENET_MODEL_URL, DEFAULT_FACENET_MODEL_SHA256) + .then(() => undefined) + .catch(() => { + // Swallow: a failed prefetch is harmless — embedCapturedFace retries + // the download on submit, and the GPU-less-enforcement path surfaces a + // real failure there. Reset so a later attempt (e.g. a retry) can warm + // the cache again. + inFlight = null + }) + + return inFlight +} + +/** + * Schedule {@link prefetchFacenetModel} on the browser idle queue (falling back + * to a short `setTimeout` where `requestIdleCallback` is unavailable, e.g. Safari + * / jsdom). Returns a cleanup function that cancels the still-pending schedule — + * wire it as a React effect cleanup so an unmount before idle does not fire a + * stray download. + * + * The actual download work is in `prefetchFacenetModel` (flag-gated + de-duped), + * so this is safe to call from every login surface's mount effect. + */ +export function scheduleFacenetPrefetch(): () => void { + // No-op when the flag is OFF — avoid even scheduling idle work. + if (!isClientSideEmbeddingEnabled()) return () => {} + + const win = window as Window & { + requestIdleCallback?: (cb: () => void) => number + cancelIdleCallback?: (handle: number) => void + } + + if (typeof win.requestIdleCallback === 'function') { + const handle = win.requestIdleCallback(() => { + void prefetchFacenetModel() + }) + return () => win.cancelIdleCallback?.(handle) + } + + const timer = setTimeout(() => { + void prefetchFacenetModel() + }, 1) + return () => clearTimeout(timer) +} diff --git a/src/verify-app/HostedLoginApp.tsx b/src/verify-app/HostedLoginApp.tsx index bf8585c..10c64f9 100644 --- a/src/verify-app/HostedLoginApp.tsx +++ b/src/verify-app/HostedLoginApp.tsx @@ -48,6 +48,7 @@ import type { LoginConfig } from '@domain/models/LoginConfig' import type { Layer1Continuation } from '@features/auth/login-shared/layer1Continuation' import { assertSafeRedirectScheme } from './sdk/FivucsasAuth' import { config as envConfig } from '@config/env' +import { scheduleFacenetPrefetch } from '@features/biometrics/embedding/prefetchFacenetModel' /** Server login response shape shared by password, passkey, and approve-login. */ interface LoginSuccessResponse { @@ -287,6 +288,19 @@ export default function HostedLoginApp() { // true (the flow always opens on an identity-entry screen). const [onInitialLoginPhase, setOnInitialLoginPhase] = useState(true) + // Warm the Facenet512 model cache on hosted-login mount (SP-A go-live). + // Flag-gated + de-duped inside the helper, so with VITE_CLIENT_SIDE_EMBEDDING + // OFF this is a NO-OP and the hosted login is byte-identical to before. When + // ON, the ~47 MB model downloads on idle while the user works through the + // identifier/password steps, so a later FACE step is a cache hit (no frozen + // 47 MB-on-submit download). Skipped when framed (the frame-bust below + // navigates the top window away — no FACE step renders here). Cleanup cancels + // a still-pending idle schedule on unmount. + useEffect(() => { + if (isFramed) return + return scheduleFacenetPrefetch() + }, [isFramed]) + // Frame-bust redirect (B9): runs as an effect so hook order stays stable // regardless of whether the page happens to be framed. The early render // return below prevents any framed content from being shown. From bf96f7da80ed9a3121333500d454af1928e8b2bf Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 18:32:07 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat(face):=20show=20'Preparing=20secure=20?= =?UTF-8?q?verification=E2=80=A6'=20during=20on-device=20embed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FACE submit awaits embedCapturedFace (model download on first use + ONNX init + inference) BEFORE the verify call, with no feedback — on mobile the multi-second embed looked frozen. Adds a distinct `preparing` state (separate from the verify `loading`): handleSubmit now awaits onSubmit (type widened to void|Promise) and toggles `preparing` around it, disabling both buttons and rendering a spinner + the i18n mfa.face.preparingSecure status line (role=status, aria-live=polite) on the submit button. Also schedules the flag-gated Facenet prefetch on FACE-step mount so the model is warm by capture time. Flag OFF: onSubmit returns synchronously, `preparing` flips within one tick (no visible flicker), and the prefetch is a no-op — unchanged UX. --- .../auth/components/steps/FaceCaptureStep.tsx | 75 +++++++++++++++---- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/src/features/auth/components/steps/FaceCaptureStep.tsx b/src/features/auth/components/steps/FaceCaptureStep.tsx index 868de7d..b044f07 100644 --- a/src/features/auth/components/steps/FaceCaptureStep.tsx +++ b/src/features/auth/components/steps/FaceCaptureStep.tsx @@ -27,6 +27,7 @@ import { BiometricEngine } from '../../../../lib/biometric-engine/core/Biometric import { dataURLToImageData } from '../../utils/faceCropper' import { isClientPadAdvisoryEnabled } from '@features/biometrics/pad/clientPadFlag' import { computeClientPadScore } from '@features/biometrics/pad/computeClientPadScore' +import { scheduleFacenetPrefetch } from '@features/biometrics/embedding/prefetchFacenetModel' import StepLayout from './StepLayout' import { stepItemVariants as itemVariants } from './stepMotion' @@ -51,7 +52,7 @@ interface FaceCaptureStepProps { clientEmbedding?: number[], faceLandmarks?: NormalizedLandmark[], clientPadScore?: number, - ) => void + ) => void | Promise loading: boolean error?: string } @@ -68,6 +69,14 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur const [capturedImage, setCapturedImage] = useState(null) const [cameraError, setCameraError] = useState(null) + // True while the parent `onSubmit` is running its on-device preparation + // (client-side Facenet512 embed: ~47 MB model download on first use + WASM + // init + inference) BEFORE the actual /auth/mfa/step verify call. Distinct + // from `loading` (the in-flight verify owned by the surface's flow): on + // mobile the embed alone can take seconds, and without this the submit button + // looks frozen. Surfaced as a "Preparing secure verification…" button state. + const [preparing, setPreparing] = useState(false) + // ADVISORY client-side PAD / passive-liveness confidence (0..1) for the // captured frame (SP-D). Computed only when the advisory flag is on and the // analyzer succeeds; null otherwise. DISPLAY + forward only — the client @@ -185,6 +194,16 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Warm the Facenet512 model cache as soon as the FACE step mounts (the user + // is now seconds from capturing). Flag-gated + de-duped inside the helper, so + // with client-side embedding OFF this is a no-op, and a login surface that + // already prefetched shares the same in-flight download. On idle (fallback + // setTimeout) so it never competes with camera start / face-detector load. + // By submit time the ~47 MB model is cached → the embed is instant instead of + // a frozen 47 MB-on-submit download. The cleanup cancels a still-pending + // idle schedule if the step unmounts first. + useEffect(() => scheduleFacenetPrefetch(), []) + const captureImage = useCallback(() => { if (!videoRef.current || !canvasRef.current) return @@ -270,15 +289,26 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur // Non-critical: embedding extraction failure is silently ignored. } - onSubmit( - capturedImage, - clientEmbedding, - capturedLandmarksRef.current ?? undefined, - // ADVISORY ONLY (SP-D): forward the client PAD score if one was - // computed. undefined when the flag is off or the analyzer failed — - // the submit is identical to the legacy path in that case. - padScoreRef.current ?? undefined, - ) + // Show the "Preparing secure verification…" state for the duration of the + // parent's onSubmit. When client-side embedding is ON this covers the model + // download (first use) + ONNX init + inference that happens BEFORE the + // verify call — so the user sees it is working, not frozen, on mobile. When + // the flag is OFF, onSubmit returns synchronously (legacy { image } upload) + // and `preparing` flips on/off within the same tick (no visible flicker). + setPreparing(true) + try { + await onSubmit( + capturedImage, + clientEmbedding, + capturedLandmarksRef.current ?? undefined, + // ADVISORY ONLY (SP-D): forward the client PAD score if one was + // computed. undefined when the flag is off or the analyzer failed — + // the submit is identical to the legacy path in that case. + padScoreRef.current ?? undefined, + ) + } finally { + setPreparing(false) + } }, [capturedImage, onSubmit]) // Determine bounding box overlay color @@ -676,7 +706,7 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur variant="outlined" size="large" onClick={retakePhoto} - disabled={loading} + disabled={loading || preparing} startIcon={} sx={{ flex: 1, @@ -691,8 +721,8 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur variant="contained" size="large" onClick={handleSubmit} - disabled={loading} - endIcon={!loading && } + disabled={loading || preparing} + endIcon={!loading && !preparing && } sx={{ flex: 1, py: 1.5, @@ -706,8 +736,27 @@ export default function FaceCaptureStep({ onSubmit, loading, error }: FaceCaptur transition: 'all 0.3s ease', }} > + {/* `preparing` = on-device prep (client-side embed: model + download + ONNX init + inference) BEFORE the verify + call; `loading` = the in-flight verify. Both show a + spinner; `preparing` adds a status line so the user + knows the (potentially multi-second, mobile) embed is + working, not frozen. */} {loading ? ( + ) : preparing ? ( + + + {t('mfa.face.preparingSecure')} + ) : ( t('mfa.face.submit') )}