@sekyro/web 0.0.0-stage → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +20 -0
  3. package/README.md +92 -2
  4. package/dist/capture-constraints-BujkZcfb.d.cts +21 -0
  5. package/dist/capture-constraints-BujkZcfb.d.ts +21 -0
  6. package/dist/capture-session-v3-BJ8ciaWl.d.ts +457 -0
  7. package/dist/capture-session-v3-CAoWM8iT.d.cts +457 -0
  8. package/dist/chunk-BPYVZUJA.js +852 -0
  9. package/dist/chunk-BPYVZUJA.js.map +1 -0
  10. package/dist/chunk-LOTAJDRE.js +1094 -0
  11. package/dist/chunk-LOTAJDRE.js.map +1 -0
  12. package/dist/chunk-MWNFSNMP.js +52 -0
  13. package/dist/chunk-MWNFSNMP.js.map +1 -0
  14. package/dist/chunk-NILNKLK6.js +50 -0
  15. package/dist/chunk-NILNKLK6.js.map +1 -0
  16. package/dist/chunk-NWF46H7K.js +359 -0
  17. package/dist/chunk-NWF46H7K.js.map +1 -0
  18. package/dist/chunk-WY6U7AIK.js +193 -0
  19. package/dist/chunk-WY6U7AIK.js.map +1 -0
  20. package/dist/chunk-YVFF4Q4G.js +327 -0
  21. package/dist/chunk-YVFF4Q4G.js.map +1 -0
  22. package/dist/client/index.cjs +357 -0
  23. package/dist/client/index.cjs.map +1 -0
  24. package/dist/client/index.d.cts +39 -0
  25. package/dist/client/index.d.ts +39 -0
  26. package/dist/client/index.js +15 -0
  27. package/dist/client/index.js.map +1 -0
  28. package/dist/core/index.cjs +1605 -0
  29. package/dist/core/index.cjs.map +1 -0
  30. package/dist/core/index.d.cts +87 -0
  31. package/dist/core/index.d.ts +87 -0
  32. package/dist/core/index.js +139 -0
  33. package/dist/core/index.js.map +1 -0
  34. package/dist/core/v3/detect-worker.cjs +392 -0
  35. package/dist/core/v3/detect-worker.cjs.map +1 -0
  36. package/dist/core/v3/detect-worker.d.cts +1 -0
  37. package/dist/core/v3/detect-worker.d.ts +1 -0
  38. package/dist/core/v3/detect-worker.js +13 -0
  39. package/dist/core/v3/detect-worker.js.map +1 -0
  40. package/dist/create-verification-CjLnJ0w6.d.ts +348 -0
  41. package/dist/create-verification-EnZ13uY4.d.cts +348 -0
  42. package/dist/detect-worker-CKXHlXx9.d.cts +105 -0
  43. package/dist/detect-worker-CKXHlXx9.d.ts +105 -0
  44. package/dist/detector-scanic-4C6IEW2K.js +787 -0
  45. package/dist/detector-scanic-4C6IEW2K.js.map +1 -0
  46. package/dist/hosted-client-BpvBqu9f.d.cts +408 -0
  47. package/dist/hosted-client-BpvBqu9f.d.ts +408 -0
  48. package/dist/index.cjs +2636 -0
  49. package/dist/index.cjs.map +1 -0
  50. package/dist/index.d.cts +6 -0
  51. package/dist/index.d.ts +6 -0
  52. package/dist/index.js +28 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/internal/index.cjs +4166 -0
  55. package/dist/internal/index.cjs.map +1 -0
  56. package/dist/internal/index.d.cts +431 -0
  57. package/dist/internal/index.d.ts +431 -0
  58. package/dist/internal/index.js +647 -0
  59. package/dist/internal/index.js.map +1 -0
  60. package/dist/popup/index.cjs +76 -0
  61. package/dist/popup/index.cjs.map +1 -0
  62. package/dist/popup/index.d.cts +66 -0
  63. package/dist/popup/index.d.ts +66 -0
  64. package/dist/popup/index.js +11 -0
  65. package/dist/popup/index.js.map +1 -0
  66. package/dist/scanic-ml-stub-S2OZ6HEF.js +10 -0
  67. package/dist/scanic-ml-stub-S2OZ6HEF.js.map +1 -0
  68. package/package.json +118 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # @sekyro/web
2
+
3
+ ## 0.1.0-rc.2
4
+
5
+ ### Patch Changes
6
+
7
+ - ca2cd0e: Verification flow: a capture's submission chain now survives a `<LivenessCapture>` remount, and the flow resumes from the server's frontier after a `409`.
8
+
9
+ - Submission chains are **serialized per verification**: creating a new capture handle via `verification.liveness()` (a React remount, a partner re-rendering the stage) no longer cancels the previous capture's in-flight liveness → face_match chain. The new capture's artifacts queue behind it and are then evaluated against the current hosted view. Only `verification.destroy()` cancels. Post-capture states (`uploading` / `checking` / `done`) and the auto re-arm reach a handle only while it is alive and still on the attempt that produced the artifacts.
10
+ - The flow is **frontier-driven**: when `liveness` is the frontier it uploads the clip and submits liveness, then face_match from the same keyframe; when liveness already settled and `face_match` is the frontier it submits the keyframe only; a frontier the SDK cannot drive finishes with the mapped result. After a RESYNC code (`check_already_finalized`, `check_out_of_order`, `session_not_active`, `check_not_found`) the view is re-fetched and the frontier logic re-entered (bounded, `RESYNC_REENTRY_MAX`) instead of finishing on a stale view — a liveness that passed under an earlier run still gets its face_match.
11
+ - Idempotency / same-key replay semantics are unchanged; `result` is still emitted once per terminal verdict (and once per non-terminal finish of a run).
12
+
13
+ ## 0.1.0-rc.1
14
+
15
+ ### Minor Changes
16
+
17
+ - 466d3e1: Initial release line of the Sekyro SDKs: `@sekyro/web` (browser liveness capture, hosted-verification client and popup embed), `@sekyro/react` (the themeable, accessible `<LivenessCapture />` component over `@sekyro/web`, ar/fr/en, RTL-safe) and `@sekyro/node` (server-side sessions client and signed-webhook verification, zero runtime dependencies). Pre-releases ship as `0.1.0-rc.N` under the `next` dist-tag from `develop`; the first stable `0.1.0` ships from `main`. All three packages are proprietary (`UNLICENSED` — see each package's LICENSE).
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Sekyro SDK — Proprietary License
2
+
3
+ Copyright (c) 2026 Ibtikar Technologies. All rights reserved.
4
+
5
+ This software (the "SDK") is proprietary to Ibtikar Technologies. Use is
6
+ permitted only under a Sekyro customer agreement with Ibtikar Technologies, and
7
+ only to integrate the customer's own applications with the Sekyro
8
+ identity-verification service. For that purpose you may install the SDK
9
+ from the public npm registry and run it unmodified.
10
+
11
+ No other right or license, express or implied, is granted. You may not
12
+ modify, reverse-engineer, redistribute, sublicense, resell or create
13
+ derivative works of the SDK, nor remove or alter this notice. The source
14
+ code is published for transparency (build provenance, security review); its
15
+ publication grants no additional rights.
16
+
17
+ THE SDK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED. TO THE EXTENT PERMITTED BY LAW, IBTIKAR TECHNOLOGIES SHALL NOT BE
19
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE USE OF THE
20
+ SDK, EXCEPT AS PROVIDED IN THE APPLICABLE CUSTOMER AGREEMENT.
package/README.md CHANGED
@@ -1,3 +1,93 @@
1
- # Temporary Holding Version
1
+ # @sekyro/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Browser SDK for [Sekyro](https://sekyro.ai) identity verification: liveness capture
4
+ (v3 engine), the typed hosted-verification client, and the partner popup embed.
5
+ Framework-free; pair it with `@sekyro/react` for a ready-made `<LivenessCapture>`.
6
+
7
+ ```ts
8
+ import { createVerification } from "@sekyro/web";
9
+
10
+ const v = createVerification({ clientToken, locale: "ar" }); // token from your server (@sekyro/node)
11
+ v.on("result", ({ status, reason }) => {
12
+ // approved | rejected | review | retry | unavailable — for display;
13
+ // the verdict that counts is read server-side (sessions.get + webhooks).
14
+ });
15
+ const cap = v.liveness({ video }); // your <video playsinline muted>
16
+ cap.on("gate", ({ progress, hint }) => {/* ring 0..1, hint code */});
17
+ await v.consent();
18
+ void v.startRegistry();
19
+ await cap.start();
20
+ ```
21
+
22
+ **Read the [Web SDK guide](../../docs/partner/web-sdk.md)** for the full integration:
23
+ server-bound NNI, results, webhooks, registered origins, CSP, model assets
24
+ (`assetBaseUrl`), reason codes, billing. Hosted-flow embed only? The
25
+ [quickstart](../../docs/partner/quickstart.md) covers `openHosted`.
26
+
27
+ Requirements: a secure context (HTTPS or `localhost`), self-hosted detector assets
28
+ under `/models-v3` (or `assetBaseUrl`), and your page origin registered with Sekyro.
29
+
30
+ > Status: `0.1.0-rc.0`, still `private` — the public npm release is wired in a later
31
+ > task (Changesets + Trusted Publishing). Sekyro's own hosted page consumes it via a
32
+ > `file:` link today. Formerly `@ibtikar/identity-web-sdk` (internal).
33
+
34
+ ## Entry points
35
+
36
+ | Export | Contents | Public API |
37
+ |---|---|---|
38
+ | `@sekyro/web` | **The partner API** (spec §3.1): `createVerification` (consent → early registry → liveness capture → upload → Turnstile attestation → `liveness` + `face_match` → `result`), `createLivenessCapture` (standalone capture handle), `openHosted` (popup/redirect embed) + the contract types. Surface pinned by `etc/sekyro-web.api.md` (api-extractor) | Yes |
39
+ | `@sekyro/web/core` | Framework-free capture: `CaptureSessionV3`, `createDetector`, `FaceGateV3`, frame quality, device profile, `media`/`keyframe`/`messages`/`ClipRecorder` (calibration-pinned v1 port), camera constraints | Yes |
40
+ | `@sekyro/web/core/v3/detect-worker` | Standalone detection-worker entry — construct with `new Worker(new URL('@sekyro/web/core/v3/detect-worker', import.meta.url))` so its worker-scope auto-install never rides a main-thread bundle | Yes |
41
+ | `@sekyro/web/client` | Typed client for the hosted session API: `HostedClient`, error codes, check input/view types | Yes |
42
+ | `@sekyro/web/popup` | Partner-facing popup/redirect embed (`open`, `SekyroEvent`) | Yes |
43
+ | `@sekyro/web/internal` | **Not public.** Document scanner (doc catalog, `DocGateV1`, MRZ band, frame analysis, feedback aggregator, `CaptureSessionDoc`) + gate-debug geometry constants + the verification internals (`createVerificationInternal` / `createLivenessCaptureInternal` seams, `runLivenessFlow`, `runSubmission`, result mapping, Turnstile loader), consumed by Sekyro's hosted page / `@sekyro/react` only. No semver guarantees; may change in any release | No |
44
+
45
+ Every entry ships ESM + CJS + `.d.ts` (`tsup`). `./core` deliberately does **not**
46
+ re-export the document scanner (that lives only under `./internal`) nor the worker
47
+ entry.
48
+
49
+ ## Calibration pin — do not "improve" `src/core/`
50
+
51
+ `src/core/media.ts`, `keyframe.ts`, and `messages.ts` are **byte-identical ports** of
52
+ v1's `kyc-ibtikar/lib/faceCaptureV2/*`. The only allowed delta is the 3-line port
53
+ header block (two comment lines + one blank separator line) prepended to each file.
54
+ `src/core/clip-recorder.ts` is the de-hooked `useClipRecorder` with identical
55
+ constants and behavior (`CLIP_MS=2500`, `TIMESLICE_MS=1000`, `STOP_GRACE_MS=1500`,
56
+ `VIDEO_BPS=2_500_000`, `MAX_CLIP_BYTES=8MiB`, mime ladder, guarded double construction,
57
+ incremental cap enforcement, stop-grace finalize, single-settle, stop-all-tracks).
58
+
59
+ The FAS-CLIP liveness model was calibrated on clips produced by exactly this v1
60
+ pipeline (spec §6 / risk 1). Any drift silently invalidates the calibration.
61
+ `test/port-parity.test.ts` enforces this in two layers: a sha256 checksum pin against
62
+ the committed `test/port-manifest.json` that runs **everywhere, CI included**, and a
63
+ byte-level comparison against the real v1 sources that runs on every dev machine with
64
+ the `kyc-ibtikar/` checkout alongside (that layer self-skips in CI, where the
65
+ gitignored v1 checkout is absent — and it also verifies the manifest itself matches
66
+ header + v1 bytes, so the manifest cannot drift). Do NOT edit `src/core/` without a
67
+ calibration re-validation; if a re-validated change ever lands, regenerate the
68
+ manifest hashes alongside it.
69
+
70
+ The parity test resolves the v1 checkout as `../../kyc-ibtikar/lib/faceCaptureV2`
71
+ from the package root, so this package must stay **two levels below the repo root**
72
+ (`packages/sekyro-web`).
73
+
74
+ Note: v1's `upload.ts` is intentionally NOT ported — uploading is the typed client's
75
+ job in v2 (`HostedClient.uploadAndSubmit`).
76
+
77
+ ## Develop
78
+
79
+ ```bash
80
+ npm ci
81
+ npm run build # tsup → dist/ (ESM + CJS + d.ts per entry)
82
+ npm test # vitest (jsdom)
83
+ npm run typecheck # tsc --noEmit
84
+ npm run api:check # api-extractor: fails when the ROOT public surface drifts from etc/sekyro-web.api.md
85
+ npm run api:report # regenerate etc/sekyro-web.api.md after an intentional, reviewed API change
86
+ ```
87
+
88
+ The api-extractor report covers the root `.` entry only (the partner contract); the
89
+ subpath entries (`./core`, `./client`, `./popup`) are not reported. `npm run build` must
90
+ precede `api:check` (it reads `dist/index.d.ts`).
91
+
92
+ Standalone package: own lockfile, no workspace wiring. `identity-hosted` consumes it
93
+ via `"@sekyro/web": "file:../packages/sekyro-web"` and needs `dist/` built first.
@@ -0,0 +1,21 @@
1
+ /** getUserMedia constraint tier — 720p-class on every device since 2026-07-06.
2
+ * The v2 FAS-CLIP liveness head loses its texture cues below ~720p: the SAME
3
+ * genuine clip scores heads 0.0001 at 720x1280 vs 0.355 at the old 480x360
4
+ * mobile tier, which pushed real users into review (first observed live on
5
+ * staging 2026-07-06; falsified on corpus clips the same day). Dimensions are
6
+ * sensor-relative (landscape); mobile browsers deliver portrait via rotation
7
+ * metadata, which the liveness service's decoder applies (cv2
8
+ * ORIENTATION_AUTO). `isMobile` is kept for call-site compat and future
9
+ * divergence — both tiers are 720p today. Bitrate pairs with VIDEO_BPS in
10
+ * clip-recorder.ts (2.5 Mbps). */
11
+ declare function buildCaptureConstraints(isMobile: boolean, facingMode: "user" | "environment"): MediaStreamConstraints;
12
+ /** v1 mobile heuristic (FaceCaptureV2.tsx L216-224). */
13
+ declare function detectIsMobile(): boolean;
14
+ /** Gate radius as a fraction of the guidance circle (v1 L501: circleSize × 0.45). */
15
+ declare const CIRCLE_RADIUS_FACTOR = 0.45;
16
+ /** `inCircle` tolerance as a fraction of the gate radius (v1 L508: r × 0.9). */
17
+ declare const IN_CIRCLE_FACTOR = 0.9;
18
+ /** `centered` tolerance as a fraction of the gate radius (v1 L509: r × 0.4). */
19
+ declare const CENTERED_FACTOR = 0.4;
20
+
21
+ export { CENTERED_FACTOR as C, IN_CIRCLE_FACTOR as I, CIRCLE_RADIUS_FACTOR as a, buildCaptureConstraints as b, detectIsMobile as d };
@@ -0,0 +1,21 @@
1
+ /** getUserMedia constraint tier — 720p-class on every device since 2026-07-06.
2
+ * The v2 FAS-CLIP liveness head loses its texture cues below ~720p: the SAME
3
+ * genuine clip scores heads 0.0001 at 720x1280 vs 0.355 at the old 480x360
4
+ * mobile tier, which pushed real users into review (first observed live on
5
+ * staging 2026-07-06; falsified on corpus clips the same day). Dimensions are
6
+ * sensor-relative (landscape); mobile browsers deliver portrait via rotation
7
+ * metadata, which the liveness service's decoder applies (cv2
8
+ * ORIENTATION_AUTO). `isMobile` is kept for call-site compat and future
9
+ * divergence — both tiers are 720p today. Bitrate pairs with VIDEO_BPS in
10
+ * clip-recorder.ts (2.5 Mbps). */
11
+ declare function buildCaptureConstraints(isMobile: boolean, facingMode: "user" | "environment"): MediaStreamConstraints;
12
+ /** v1 mobile heuristic (FaceCaptureV2.tsx L216-224). */
13
+ declare function detectIsMobile(): boolean;
14
+ /** Gate radius as a fraction of the guidance circle (v1 L501: circleSize × 0.45). */
15
+ declare const CIRCLE_RADIUS_FACTOR = 0.45;
16
+ /** `inCircle` tolerance as a fraction of the gate radius (v1 L508: r × 0.9). */
17
+ declare const IN_CIRCLE_FACTOR = 0.9;
18
+ /** `centered` tolerance as a fraction of the gate radius (v1 L509: r × 0.4). */
19
+ declare const CENTERED_FACTOR = 0.4;
20
+
21
+ export { CENTERED_FACTOR as C, IN_CIRCLE_FACTOR as I, CIRCLE_RADIUS_FACTOR as a, buildCaptureConstraints as b, detectIsMobile as d };
@@ -0,0 +1,457 @@
1
+ import { F as FaceObservation, b as Detector, W as WorkerLike } from './detect-worker-CKXHlXx9.js';
2
+
3
+ /** MP4-first ladder: one container (H.264 MP4) covers iOS/macOS Safari + Chrome 130+.
4
+ * WebM/VP9→VP8 only as the Firefox/older-Chromium fallback. */
5
+ declare const MIME_CANDIDATES: readonly ["video/mp4;codecs=avc1.42E01E,mp4a.40.2", "video/mp4;codecs=avc1.42E01E", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm;codecs=vp9", "video/webm;codecs=vp8,opus", "video/webm;codecs=vp8", "video/webm"];
6
+ type CameraErrorKind = "permission" | "notfound" | "inuse" | "overconstrained" | "insecure" | "unsupported" | "generic";
7
+ /** First supported candidate, or "" to let the browser pick its own default. */
8
+ declare function pickMimeType(): string;
9
+ /** Default clip container when pickMimeType() === "" — NEVER .bin/octet-stream (REVIEW FIX 5).
10
+ * Prefer the first webm in the ladder the browser most likely defaults to. */
11
+ declare const DEFAULT_CLIP_MIME = "video/webm";
12
+ /** Branch on err.name (never the message). TypeError ⇒ navigator.mediaDevices was undefined
13
+ * (insecure/unsupported context) — surfaced as "insecure". */
14
+ declare function mapGetUserMediaError(err: unknown): CameraErrorKind;
15
+ declare function cameraPreflight(): "INSECURE_CONTEXT" | "UNSUPPORTED" | "OK";
16
+ /** iOS Safari (and any browser) may lack MediaRecorder entirely — detect BEFORE prompting
17
+ * for the camera so we can fall back to the v1 single-photo path (REVIEW FIX 1). */
18
+ declare function isMediaRecorderSupported(): boolean;
19
+ declare const MAX_CLIP_BYTES: number;
20
+ declare function isClipTooLarge(blob: Blob): boolean;
21
+ declare function extForMime(mime: string): string;
22
+ /** Build the exact multipart body /api/liveness/v2 expects: fields `token`, files `clip` + `keyframe`.
23
+ * Caller must NOT set Content-Type — the browser adds the boundary. */
24
+ declare function buildLivenessV2FormData(args: {
25
+ token: string;
26
+ clip: Blob;
27
+ keyframe: Blob;
28
+ ts?: number;
29
+ }): FormData;
30
+
31
+ declare const CLIP_MS = 2500;
32
+ declare const TIMESLICE_MS = 1000;
33
+ declare const STOP_GRACE_MS = 1500;
34
+ declare const VIDEO_BPS = 2500000;
35
+ type ClipErrorKind = CameraErrorKind | "cliptoolarge";
36
+ declare class ClipError extends Error {
37
+ kind: ClipErrorKind;
38
+ constructor(kind: ClipErrorKind, msg?: string);
39
+ }
40
+ interface ClipResult {
41
+ blob: Blob;
42
+ /** Container mime the clip was finalized with (recorder.mimeType || picked || DEFAULT_CLIP_MIME). */
43
+ mimeType: string;
44
+ }
45
+ declare class ClipRecorder {
46
+ private recorder;
47
+ private stream;
48
+ private chunks;
49
+ private bytes;
50
+ private hardStopTimer;
51
+ private graceTimer;
52
+ private settled;
53
+ private clearTimers;
54
+ /** Records CLIP_MS of the stream. Resolves with the clip blob + its container mime;
55
+ * rejects with ClipError. Behavior contract is the v1 hook's `start`, verbatim
56
+ * (iOS workarounds included).
57
+ *
58
+ * ONE RECORDING AT A TIME: chunk/byte/timer state is shared per instance, so a
59
+ * second record() while one is in flight rejects ClipError("generic") instead of
60
+ * corrupting the in-flight clip. (v2 guard — v1's hook was gated by its owning
61
+ * component; a class API invites reuse. Zero impact on clip bytes.)
62
+ *
63
+ * `onStopped` (optional) fires ONCE the moment the CLIP_MS recording window
64
+ * elapses (the hard-stop), BEFORE finalize — which on iOS can lag up to
65
+ * STOP_GRACE_MS while onstop flushes the last chunk. It lets the host freeze
66
+ * the display on the already-captured keyframe the instant recording ends,
67
+ * instead of waiting for the blob. PURE NOTIFICATION — it does not touch
68
+ * timers, chunks, or the finalize path, so it is calibration-neutral (no
69
+ * effect on clip bytes). Never fires on a rejected capture (error /
70
+ * cliptoolarge settle before the hard-stop and clear the timer). */
71
+ record(stream: MediaStream, onStopped?: () => void): Promise<ClipResult>;
72
+ /** Idempotent teardown: stop recorder + ALL tracks (v1 `stopAndCleanup` + the unmount
73
+ * effect). Optional `stream` override mirrors v1's `stopAndCleanup(stream?)`.
74
+ *
75
+ * MID-RECORDING SEMANTICS (verbatim v1): calling stop() while a record() is in
76
+ * flight may LATER resolve the pending promise with a TRUNCATED clip (recorder.stop()
77
+ * fires onstop → finalize from whatever chunks arrived so far), or — because this
78
+ * method clears the hard-stop/grace timers — never settle it at all if onstop never
79
+ * fires (the iOS grace path has been torn down). Callers MUST NOT treat a resolution
80
+ * arriving after stop() as a full CLIP_MS clip, and must not await a record()
81
+ * promise past stop() without their own timeout. (Task 4's CaptureSession owns
82
+ * sequencing on top of this contract.) */
83
+ stop(stream?: MediaStream | null): void;
84
+ }
85
+
86
+ /** CaptureSession state machine (transition map in capture-session.ts):
87
+ * idle → preflight → acquiring → positioning → recording → captured
88
+ * (photo mode skips `recording`: positioning → captured); any → error. */
89
+ type CaptureState = "idle" | "preflight" | "acquiring" | "positioning" | "recording" | "captured" | "error";
90
+ /** Camera kinds (ported media.ts) + engine kinds:
91
+ * - 'models' detector init failed (the v3 native→blaze ladder was
92
+ * exhausted — e.g. BlazeFace wasm never initialized). Fail
93
+ * loud; there is no degraded mode (a mock capture-anyway mode
94
+ * is a liveness hole, and the face-api floor was dropped).
95
+ * - 'cliptoolarge' the 8 MB clip cap (shared with the liveness API contract)
96
+ * - 'recorder' MediaRecorder failed mid-clip. ClipRecorder's v1-verbatim kind for
97
+ * this is 'generic'; CaptureSession REMAPS it to 'recorder' so hosts
98
+ * can distinguish recorder failure from camera 'generic'. */
99
+ type CaptureErrorKind = CameraErrorKind | "models" | "cliptoolarge" | "recorder";
100
+ /** The five v1 gate checks (hysteresis-smoothed statuses). */
101
+ interface GateChecks {
102
+ faceDetected: boolean;
103
+ inCircle: boolean;
104
+ centered: boolean;
105
+ goodQuality: boolean;
106
+ stable: boolean;
107
+ }
108
+ /** Capture output. Upload is NOT the engine's job — hosts submit these themselves. */
109
+ interface CaptureArtifacts {
110
+ /** Absent in photo mode. */
111
+ clip?: Blob;
112
+ /** Container mime the clip finalized with. Absent in photo mode. */
113
+ clipMimeType?: string;
114
+ /** Mirrored JPEG keyframe (captureMirroredKeyframe). */
115
+ keyframe: Blob;
116
+ /** JPEG data URL of the keyframe canvas (v1 preview, quality 0.92). */
117
+ previewDataUrl: string;
118
+ }
119
+ interface CaptureSessionEvents {
120
+ statechange: {
121
+ state: CaptureState;
122
+ };
123
+ checks: GateChecks & {
124
+ counters: Record<keyof GateChecks, number>;
125
+ };
126
+ /** fcv2.* message KEY (messages.ts / guidanceCopy) — hosts translate; never prose. */
127
+ guidance: {
128
+ key: string;
129
+ };
130
+ artifacts: CaptureArtifacts;
131
+ error: {
132
+ kind: CaptureErrorKind;
133
+ retryable: boolean;
134
+ };
135
+ }
136
+
137
+ interface V3GateThresholds {
138
+ /** q at/above which progress rises; below it progress falls. */
139
+ Q_GOOD: number;
140
+ /** Integrator slopes, per second. */
141
+ RISE_PER_S: number;
142
+ FALL_PER_S: number;
143
+ /** Centering falloff radius = this × IOD (scale-invariant). The plan's
144
+ * formula is 0.45 × IOD × 3 — the 0.45 echoes v1's CIRCLE_RADIUS_FACTOR
145
+ * heritage — folded here to 1.35 × IOD. */
146
+ CENTER_FALLOFF_IOD_MULTIPLE: number;
147
+ /** Box-only tiers: gate point = bbox center shifted UP by this × height
148
+ * (the documented bbox-vs-eyes offset heuristic). */
149
+ BOX_EYE_OFFSET_FACTOR: number;
150
+ /** Box-only IOD proxy = this × box width. */
151
+ IOD_BOX_PROXY_FACTOR: number;
152
+ /** IOD / frame short side: ideal band + hard limits. */
153
+ SIZE_IDEAL_MIN: number;
154
+ SIZE_IDEAL_MAX: number;
155
+ SIZE_HARD_MIN: number;
156
+ SIZE_HARD_MAX: number;
157
+ /** Eye-line roll (deg) that zeroes the pose factor. */
158
+ ROLL_MAX_DEG: number;
159
+ /** Center velocity that zeroes the motion factor, in IOD-per-second. */
160
+ MOTION_VELOCITY_IOD_PER_S: number;
161
+ /** Velocity-EMA time constant (seconds) — the EMA weight is derived per
162
+ * tick as 1−exp(−dt/τ), so the smoothing HORIZON is cadence-invariant:
163
+ * a LOW device detecting at 5 Hz gets the same effective smoothing as a
164
+ * HIGH device at 10 Hz (a fixed per-sample alpha under-smoothed exactly
165
+ * the jittery low-cadence ticks that needed it most). */
166
+ MOTION_TAU_S: number;
167
+ /** Velocities at/below this (IOD/s) count as stillness — absorbs detector
168
+ * box jitter (largest on LOW's 128px input) before it reaches the EMA. */
169
+ MOTION_DEADBAND_IOD_PER_S: number;
170
+ /** ready = p ≥ 1 AND mean q over this window ≥ READY_MEAN_Q. The mean is
171
+ * TRIMMED: with ≥3 samples in the window the single worst is dropped, so
172
+ * one flaky tick (5 Hz LOW cadence = only 2–3 samples per window) cannot
173
+ * veto an otherwise-ready user. */
174
+ READY_WINDOW_MS: number;
175
+ READY_MEAN_Q: number;
176
+ /** Per-factor guidance trigger levels (lowest failing factor speaks). */
177
+ GUIDE_SIZE: number;
178
+ GUIDE_CENTERING: number;
179
+ GUIDE_POSE: number;
180
+ GUIDE_EXPOSURE: number;
181
+ GUIDE_SHARPNESS: number;
182
+ GUIDE_MOTION: number;
183
+ }
184
+ /** Defaults — retuned 2026-07-12 (operator ruling superseding the 2026-07
185
+ * freeze; the capture Task-15 refit set must be split at this boundary):
186
+ * Q_GOOD 0.55→0.6 so it aligns with the 0.6 guidance thresholds under
187
+ * min-q ("no complaint" ⇔ "progress rises"); SIZE_IDEAL_MIN 0.16→0.12 —
188
+ * wide-FOV front cameras (common low-end) put an arm's-length face below
189
+ * 0.16, silently forcing lean-in; the 720p clip floor keeps a 0.12-IOD
190
+ * face fully resolvable server-side. */
191
+ declare const V3_GATE_DEFAULTS: Readonly<V3GateThresholds>;
192
+ interface V3GateQuality {
193
+ /** From frame-quality.sharpness on the face crop. */
194
+ sharpness: number;
195
+ /** From frame-quality.exposure on the face crop. */
196
+ exposure: number;
197
+ /** Mean crop luma — picks more_light vs too_bright guidance. */
198
+ meanLuma: number;
199
+ }
200
+ interface V3GateTickInput {
201
+ timestampMs: number;
202
+ observation: FaceObservation | null;
203
+ quality: V3GateQuality;
204
+ frameWidth: number;
205
+ frameHeight: number;
206
+ }
207
+ type V3GateFactor = "presence" | "centering" | "size" | "pose" | "sharpness" | "exposure" | "motion";
208
+ /** Per-tick output — the telemetry + chrome contract. */
209
+ interface V3GateTick {
210
+ at: number;
211
+ /** Instantaneous quality 0..1 (MIN over the factors — the worst factor). */
212
+ q: number;
213
+ /** Integrated progress 0..1. */
214
+ p: number;
215
+ factors: Record<V3GateFactor, number>;
216
+ /** fcv3.* message key for the lowest failing factor (host translates). */
217
+ guidanceKey: string;
218
+ /** Fire signal: p ≥ 1 AND the READY_WINDOW mean q ≥ READY_MEAN_Q. */
219
+ ready: boolean;
220
+ }
221
+ declare class FaceGateV3 {
222
+ private readonly t;
223
+ private p;
224
+ private lastTickAt;
225
+ private lastCenter;
226
+ private velocityEma;
227
+ private qWindow;
228
+ constructor(overrides?: Partial<V3GateThresholds>);
229
+ reset(): void;
230
+ tick(input: V3GateTickInput): V3GateTick;
231
+ private geometryOf;
232
+ private motionOf;
233
+ /** Lowest failing factor speaks — fixed priority (diagnostic, incl. light):
234
+ * presence → size → centering → pose → exposure → sharpness → hold.
235
+ *
236
+ * HONESTY FALLBACK (operator ruling 2026-07-12): when every factor clears
237
+ * its guide threshold but q (= min factor) is still below Q_GOOD — i.e.
238
+ * progress is DRAINING — "hold still" would be a lie ("Perfect" while
239
+ * nothing advances; the field symptom was users discovering by accident
240
+ * that only leaning in fires the gate). In that state the WORST factor
241
+ * speaks even though it passed its guide level, so the user always hears
242
+ * the change that actually unblocks recording. */
243
+ private guidanceOf;
244
+ /** Direction from the size band set THIS tick (never inverted at the hard
245
+ * limits: past SIZE_HARD_MAX the direction is 'close' → move_back). */
246
+ private sizeGuidance;
247
+ /** Band midpoint — direction only (tracks EXPOSURE_BAND automatically). */
248
+ private exposureGuidance;
249
+ /** Which side of the ideal band the last size measurement fell on. */
250
+ private lastSizeDirection;
251
+ }
252
+
253
+ type DeviceClass = "LOW" | "MID" | "HIGH";
254
+ interface DeviceProfile {
255
+ /** Detection cadence — the scheduler's pace. */
256
+ cadenceHz: number;
257
+ /** Downscaled snapshot width handed to the detector. */
258
+ inputWidth: number;
259
+ /** Chrome hint: 'reduced' tones down ticks/breathe on LOW even without
260
+ * prefers-reduced-motion. */
261
+ animations: "full" | "reduced";
262
+ }
263
+ declare const DEVICE_PROFILES: Readonly<Record<DeviceClass, Readonly<DeviceProfile>>>;
264
+ interface DeviceProbe {
265
+ cores: number;
266
+ /** null where the UA hides it — iOS Safari NEVER exposes deviceMemory
267
+ * (field lesson: a 2GB default classified every iPhone LOW). Unknown is
268
+ * treated as NEUTRAL (4GB): never demotes to LOW on its own, never
269
+ * promotes to HIGH; the dynamic profiler corrects truly slow devices. */
270
+ memGB: number | null;
271
+ /** WebAssembly SIMD available (informs wasm-tier expectations/telemetry). */
272
+ simd: boolean;
273
+ }
274
+ declare function probeDevice(): DeviceProbe;
275
+ /** Static class: LOW (<4 cores or <3GB), HIGH (≥8 cores AND ≥6GB), else MID.
276
+ * Unknown memory counts as a neutral 4GB (see DeviceProbe.memGB). */
277
+ declare function classifyDevice(probe: DeviceProbe): DeviceClass;
278
+ /** Dynamic refinement: after the FIRST 10 measured detect ticks, a mean over
279
+ * 70ms demotes straight to LOW; under 25ms promotes ONE class — at most one
280
+ * adjustment ever (no oscillation). */
281
+ declare class DeviceProfiler {
282
+ private current;
283
+ private readonly samples;
284
+ private adjusted;
285
+ constructor(initial: DeviceClass);
286
+ get deviceClass(): DeviceClass;
287
+ get profile(): DeviceProfile;
288
+ record(detectMs: number): DeviceClass;
289
+ }
290
+
291
+ /** Best-of-N keyframe window: up to 4 candidates over the first 600ms. */
292
+ declare const KEYFRAME_CANDIDATES = 4;
293
+ declare const KEYFRAME_WINDOW_MS = 600;
294
+ interface V3ChecksPayload {
295
+ engine: "v3";
296
+ /** Gate integrator 0..1 — the ring drives off this directly. */
297
+ progress: number;
298
+ /** Instantaneous quality 0..1. */
299
+ q: number;
300
+ factors: Record<V3GateFactor, number>;
301
+ /** Attempt ordinal (bumps on retry/switchCamera). */
302
+ attempt: number;
303
+ }
304
+ interface CaptureSessionV3Events {
305
+ statechange: {
306
+ state: CaptureState;
307
+ };
308
+ checks: V3ChecksPayload;
309
+ /** fcv3.* message KEY — hosts translate; never prose. Deduped. */
310
+ guidance: {
311
+ key: string;
312
+ };
313
+ artifacts: CaptureArtifacts;
314
+ error: {
315
+ kind: CaptureErrorKind;
316
+ retryable: boolean;
317
+ };
318
+ /** v3-only, CHROME-only (no data flows here): fires the instant the clip
319
+ * recording window ends — BEFORE the blob finalizes (iOS onstop can lag up
320
+ * to STOP_GRACE_MS). Carries the already-captured keyframe's JPEG data URL so
321
+ * the host can freeze the display on the still immediately instead of leaving
322
+ * the live feed running while the clip flushes. Clip mode only (photo mode
323
+ * goes straight to `artifacts`). A host that ignores it is unaffected. */
324
+ frozen: {
325
+ previewDataUrl: string;
326
+ };
327
+ }
328
+ type V3TelemetryEvent = {
329
+ type: "detector";
330
+ tier: Detector["tier"];
331
+ fallbackReason?: string;
332
+ } | {
333
+ type: "profile";
334
+ staticClass: DeviceClass;
335
+ activeClass: DeviceClass;
336
+ simd: boolean;
337
+ } | {
338
+ type: "fired";
339
+ timeToGateMs: number;
340
+ degradeCount: number;
341
+ meanDetectMs: number;
342
+ } | {
343
+ type: "error";
344
+ kind: CaptureErrorKind;
345
+ fallbackReason?: string;
346
+ };
347
+ interface KeyframeCandidate {
348
+ blob: Blob;
349
+ previewDataUrl: string;
350
+ sharpness: number;
351
+ }
352
+ /** Challenge-clip artifact for the Phase 3 escalation path. */
353
+ interface EscalationClipArtifact {
354
+ clip: Blob;
355
+ clipMimeType: string;
356
+ }
357
+ /** INERT Phase 3 seam (docs/superpowers/specs/2026-07-07-active-liveness-
358
+ * escalation-spec.md): when the server settles a liveness verdict in the
359
+ * REVIEW band it may mint ONE gesture challenge; the host then asks the
360
+ * engine for a challenge clip through this hook. Typed and documented NOW so
361
+ * capture-v3+ starts from a design — NEVER invoked anywhere in the current
362
+ * flow (pinned by a test). */
363
+ interface EscalationHook {
364
+ onBorderline(challenge: "blink" | "turn"): Promise<EscalationClipArtifact>;
365
+ }
366
+ interface CaptureSessionV3Options {
367
+ /** Host-owned <video playsInline muted> — same iOS contract as v1. */
368
+ video: HTMLVideoElement;
369
+ /** v3 assets base (tasks-vision wasm + tflite); default /models-v3. */
370
+ v3AssetBaseUrl?: string;
371
+ mode?: "clip" | "photo";
372
+ /** FaceGateV3 threshold overrides (Phase 2 tuning). */
373
+ thresholds?: Partial<V3GateThresholds>;
374
+ telemetry?: (event: V3TelemetryEvent) => void;
375
+ facingMode?: "user" | "environment";
376
+ /** Static class override (tests / host pinning); default = boot probe. */
377
+ deviceClass?: DeviceClass;
378
+ /** (detector ladder) */
379
+ createDetector?: () => Promise<Detector>;
380
+ /** (fresh ClipRecorder per attempt) */
381
+ createRecorder?: () => Pick<ClipRecorder, "record" | "stop">;
382
+ /** (navigator.mediaDevices.getUserMedia) */
383
+ getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
384
+ /** (mirrored canvas keyframe + downscaled Laplacian sharpness) */
385
+ captureKeyframe?: (video: HTMLVideoElement) => Promise<KeyframeCandidate>;
386
+ /** (downscaled face-crop sharpness/exposure via canvas) */
387
+ measureQuality?: (video: HTMLVideoElement, box: {
388
+ x: number;
389
+ y: number;
390
+ width: number;
391
+ height: number;
392
+ }) => V3GateQuality;
393
+ /** (reused canvas at profile.inputWidth; falls back to the raw video where
394
+ * canvas is unavailable) */
395
+ makeSnapshot?: (video: HTMLVideoElement, targetWidth: number) => CanvasImageSource;
396
+ /** Worker offload opt-in (Task 9): when provided AND the platform supports
397
+ * it (OffscreenCanvas + createImageBitmap + Worker), detection runs
398
+ * worker-side through WorkerDetectorProxy. Hosts own Worker construction
399
+ * (bundler-specific `new Worker(new URL(...))`). */
400
+ createWorker?: () => WorkerLike;
401
+ /** INERT (Phase 3 escalation seam — see EscalationHook). Accepted and
402
+ * retained, never called in Phases 1–2. */
403
+ escalation?: EscalationHook;
404
+ /** (performance.now) */
405
+ now?: () => number;
406
+ }
407
+ type Handler<K extends keyof CaptureSessionV3Events> = (payload: CaptureSessionV3Events[K]) => void;
408
+ declare class CaptureSessionV3 {
409
+ private readonly options;
410
+ private readonly handlers;
411
+ private stateValue;
412
+ private attempt;
413
+ private facing;
414
+ private stream;
415
+ private detector;
416
+ private scheduler;
417
+ private gate;
418
+ private profiler;
419
+ private lastGuidanceKey;
420
+ private positioningStartedAt;
421
+ private fired;
422
+ private carriedDegrade;
423
+ private carriedMeans;
424
+ constructor(options: CaptureSessionV3Options);
425
+ get state(): CaptureState;
426
+ on<K extends keyof CaptureSessionV3Events>(event: K, handler: Handler<K>): () => void;
427
+ private emit;
428
+ start(): Promise<void>;
429
+ /** Full reset + camera restart — the documented recovery path (v1 parity). */
430
+ retry(): void;
431
+ switchCamera(): void;
432
+ destroy(): void;
433
+ private run;
434
+ /** Chrome hint (Task 11): 'reduced' on LOW even without the media query. */
435
+ get animations(): "full" | "reduced";
436
+ private startScheduler;
437
+ private detectTick;
438
+ private fire;
439
+ /** Up to KEYFRAME_CANDIDATES mirrored frames over KEYFRAME_WINDOW_MS —
440
+ * sharpest wins (reuses frame-quality.sharpness via the capture seam). */
441
+ private collectKeyframes;
442
+ private finishCapture;
443
+ private defaultKeyframe;
444
+ /** Reused downscale canvas — falls back to the raw video where canvas 2D
445
+ * is unavailable (jsdom) so tests and exotic hosts keep detecting. */
446
+ private snapshotCanvas;
447
+ private defaultSnapshot;
448
+ private defaultQuality;
449
+ private centerSharpness;
450
+ private teardownAttempt;
451
+ private stopTracks;
452
+ private fail;
453
+ private setState;
454
+ private now;
455
+ }
456
+
457
+ export { V3_GATE_DEFAULTS as A, VIDEO_BPS as B, type CameraErrorKind as C, DEFAULT_CLIP_MIME as D, type EscalationClipArtifact as E, FaceGateV3 as F, type GateChecks as G, buildLivenessV2FormData as H, cameraPreflight as I, classifyDevice as J, KEYFRAME_CANDIDATES as K, extForMime as L, MAX_CLIP_BYTES as M, isClipTooLarge as N, isMediaRecorderSupported as O, mapGetUserMediaError as P, pickMimeType as Q, probeDevice as R, STOP_GRACE_MS as S, TIMESLICE_MS as T, type V3ChecksPayload as V, CLIP_MS as a, type CaptureArtifacts as b, type CaptureErrorKind as c, type CaptureSessionEvents as d, CaptureSessionV3 as e, type CaptureSessionV3Events as f, type CaptureSessionV3Options as g, type CaptureState as h, ClipError as i, type ClipErrorKind as j, ClipRecorder as k, type ClipResult as l, DEVICE_PROFILES as m, type DeviceClass as n, type DeviceProbe as o, type DeviceProfile as p, DeviceProfiler as q, type EscalationHook as r, KEYFRAME_WINDOW_MS as s, MIME_CANDIDATES as t, type V3GateFactor as u, type V3GateQuality as v, type V3GateThresholds as w, type V3GateTick as x, type V3GateTickInput as y, type V3TelemetryEvent as z };