@incodetech/core 2.3.0-rc.9 → 2.3.0

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 (86) hide show
  1. package/dist/{BrowserDiagnosticsCryptoProvider-HO8YXP4h.esm.js → BrowserDiagnosticsCryptoProvider-BuzqvbhN.esm.js} +6 -24
  2. package/dist/{FaceDetectionProvider-C7bUZsH9.esm.js → FaceDetectionProvider-Lb6i4Ilt.esm.js} +7 -7
  3. package/dist/{MotionSensorProvider-BgTYwg9L.esm.js → MotionSensorProvider-DyhbM4Si.esm.js} +1 -1
  4. package/dist/ae-signature.esm.js +1 -1
  5. package/dist/{apiError-BWlcyGbw.esm.js → apiError-Dkl9_HOx.esm.js} +1 -1
  6. package/dist/authentication.d.ts +2 -2
  7. package/dist/authentication.esm.js +2 -2
  8. package/dist/{authenticationManager-DcTHmYmL.d.ts → authenticationManager-B23V74p7.d.ts} +1 -1
  9. package/dist/{authenticationManager-B321A87w.esm.js → authenticationManager-C7BaQC99.esm.js} +4 -4
  10. package/dist/{authenticationStateMachine-CSJtf13E.esm.js → authenticationStateMachine-CW7a9qpV.esm.js} +3 -3
  11. package/dist/camera.esm.js +1 -1
  12. package/dist/{createCameraCycleOwnership-CX3hJGKc.esm.js → createCameraCycleOwnership-DnwTl1ad.esm.js} +6 -1
  13. package/dist/{deepsightLoader-FOktaF-9.esm.js → deepsightLoader-DgaaacGJ.esm.js} +6 -6
  14. package/dist/document-capture.esm.js +2 -2
  15. package/dist/{documentCaptureManager-pprJUhsN.esm.js → documentCaptureManager-BRPxqYD2.esm.js} +1 -1
  16. package/dist/{documentCaptureStateMachine-DN2YOxdu.esm.js → documentCaptureStateMachine-D312sSIu.esm.js} +2 -2
  17. package/dist/dynamic-forms.esm.js +2 -2
  18. package/dist/{dynamicFormsManager-Pj67RmHb.esm.js → dynamicFormsManager-Cm33oGqC.esm.js} +1 -1
  19. package/dist/{dynamicFormsStateMachine-B00MVHEa.esm.js → dynamicFormsStateMachine-4EVsg15i.esm.js} +1 -1
  20. package/dist/ekyb.esm.js +3 -3
  21. package/dist/{ekybManager-CovxKSIS.esm.js → ekybManager-CgjQ2Thl.esm.js} +1 -1
  22. package/dist/{ekybStateMachine-DHCg1B5d.esm.js → ekybStateMachine-Dwgt5Dqa.esm.js} +1 -1
  23. package/dist/electronic-signature.esm.js +1 -1
  24. package/dist/{electronicSignatureManager-BnlLBzH6.esm.js → electronicSignatureManager-DGEUq9nY.esm.js} +1 -1
  25. package/dist/extensibility.d.ts +6 -6
  26. package/dist/extensibility.esm.js +12 -12
  27. package/dist/{faceCaptureManagerFactory-DTqP1t1a.esm.js → faceCaptureManagerFactory-3x7qaM76.esm.js} +3 -3
  28. package/dist/{faceCaptureManagerFactory-Cx-8SqRy.d.ts → faceCaptureManagerFactory-DMLstvui.d.ts} +35 -3
  29. package/dist/{faceCaptureSetup-DfetEHJm.esm.js → faceCaptureSetup-CJzsvv1I.esm.js} +10 -9
  30. package/dist/{feature-management-BjROP6fF.esm.js → feature-management-BG6sf4O4.esm.js} +1 -1
  31. package/dist/feature-management.esm.js +1 -1
  32. package/dist/flow.esm.js +3 -3
  33. package/dist/{flowCompletionService-iyH7fw8r.esm.js → flowCompletionService-BusrkMdp.esm.js} +1 -1
  34. package/dist/{flowServices-C8CBt9j3.esm.js → flowServices-pIc1cq8q.esm.js} +5 -5
  35. package/dist/geolocation.esm.js +2 -2
  36. package/dist/{geolocationManager-DQ5tHq5k.esm.js → geolocationManager-BbgMboZ-.esm.js} +1 -1
  37. package/dist/{geolocationStateMachine-BfNIvTHA.esm.js → geolocationStateMachine-Bixn87pI.esm.js} +1 -1
  38. package/dist/id-selfie-flow.d.ts +1 -1
  39. package/dist/id-selfie-flow.esm.js +9 -10
  40. package/dist/id.d.ts +1 -1
  41. package/dist/id.esm.js +3 -3
  42. package/dist/{idCaptureDefaults-DCmwAxkz.esm.js → idCaptureDefaults-BmnsDgZZ.esm.js} +3 -3
  43. package/dist/{idCaptureManager-De7O0TOD.d.ts → idCaptureManager-BaHEyQyr.d.ts} +0 -1
  44. package/dist/{idCaptureManager-C4gJNe18.esm.js → idCaptureManager-BpdRLyqZ.esm.js} +5 -5
  45. package/dist/{idCaptureServices-CPh--aZR.esm.js → idCaptureServices-Di_APvz_.esm.js} +9 -8
  46. package/dist/{idCaptureStateMachine-Du4Q40uJ.esm.js → idCaptureStateMachine-hY-RpDH_.esm.js} +14 -14
  47. package/dist/identity-reuse.d.ts +1 -1
  48. package/dist/{identityReuseManager-v2TH5g7e.d.ts → identityReuseManager-BFDI7iFK.d.ts} +4 -4
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.esm.js +1 -1
  51. package/dist/mandatory-consent.d.ts +1 -1
  52. package/dist/{mandatoryConsentManager-2O5wztkg.d.ts → mandatoryConsentManager-Csfl7_XQ.d.ts} +4 -4
  53. package/dist/{multipartRecordingService-DDAOXQKm.esm.js → multipartRecordingService-mwKFTlhR.esm.js} +730 -8
  54. package/dist/{permissionGuards-uYZYFu4J.esm.js → permissionGuards-D6TfVop2.esm.js} +1 -1
  55. package/dist/{permissionServices-Bj_fyWtv.esm.js → permissionServices-EY3_Vx6c.esm.js} +2 -2
  56. package/dist/qe-signature.esm.js +1 -1
  57. package/dist/{runChildModule-CHjWYSMf.esm.js → runChildModule-kZQwHJ21.esm.js} +1 -1
  58. package/dist/{safeStorage-B-trhxg6.esm.js → safeStorage-BdU9_m2E.esm.js} +1 -1
  59. package/dist/{sdkVersion-DCLQtexS.esm.js → sdkVersion-KY79FtZx.esm.js} +1 -1
  60. package/dist/selfie.d.ts +2 -2
  61. package/dist/selfie.esm.js +7 -7
  62. package/dist/{selfieManager-ys-X4hkv.esm.js → selfieManager-Bjane9c3.esm.js} +4 -4
  63. package/dist/{selfieManager-jXqwqosi.d.ts → selfieManager-CDCTOrcg.d.ts} +1 -1
  64. package/dist/{selfieStateMachine-BhzbXmKN.esm.js → selfieStateMachine-OsbjyRR6.esm.js} +3 -3
  65. package/dist/{session-oKiwoHdR.esm.js → session-D4EAR74k.esm.js} +3 -3
  66. package/dist/session.esm.js +4 -4
  67. package/dist/{sessionInitializer-DSf0jyab.esm.js → sessionInitializer-BqGb0lFN.esm.js} +3 -3
  68. package/dist/{setup-BttZ27hw.esm.js → setup-Cc6MzR5Q.esm.js} +7 -57
  69. package/dist/{setup-CLfBztP0.d.ts → setup-Cyko6cAn.d.ts} +4 -17
  70. package/dist/{trueSightService-C1JG8gAU.esm.js → trueSightService-Di5RGIls.esm.js} +16 -554
  71. package/dist/video-selfie.d.ts +61 -3
  72. package/dist/video-selfie.esm.js +60 -14
  73. package/dist/{videoSelfieCameraWarmer-BpaLtLvD.esm.js → videoSelfieCameraWarmer-PV8PLwmD.esm.js} +5 -5
  74. package/dist/{videoSelfieStepDetector-Co0Gu2k2.d.ts → videoSelfieStepDetector-CAqAG2G3.d.ts} +49 -0
  75. package/dist/{warmup-CUUnwYxe.esm.js → warmup-ChDwHSel.esm.js} +5 -0
  76. package/dist/wasm.d.ts +1 -1
  77. package/dist/wasm.esm.js +3 -3
  78. package/dist/{wasmDsFlagSync-B2e8p268.esm.js → wasmDsFlagSync-DYrLUEII.esm.js} +1 -1
  79. package/dist/workflow.d.ts +4 -4
  80. package/dist/workflow.esm.js +15 -15
  81. package/package.json +2 -7
  82. package/dist/FaceDetectionProvider-LdZ47bWy.d.ts +0 -36
  83. package/dist/StreamCanvasProcessingSession-CBnbTnOr.esm.js +0 -42
  84. package/dist/personhood.d.ts +0 -538
  85. package/dist/personhood.esm.js +0 -1574
  86. package/dist/types-BT_Oalip.d.ts +0 -1
@@ -1,1574 +0,0 @@
1
- import { b as getStoredHttpConfig, w as createApi$1, y as randomUUID } from "./trueSightService-C1JG8gAU.esm.js";
2
- import { r as WasmUtilProvider } from "./safeStorage-B-trhxg6.esm.js";
3
- import { l as BrowserStorageProvider } from "./setup-BttZ27hw.esm.js";
4
- import { t as BrowserTimerProvider } from "./BrowserTimerProvider-BXhgNxLt.esm.js";
5
- import { t as api } from "./api-Bx3gKW_y.esm.js";
6
- import { n as eventModuleNames } from "./events-ijlbX_Wx.esm.js";
7
- import { b as BrowserEnvironmentProvider, h as isBrowserSimulation, n as getFingerprintEnabled, v as getThumbmarkId, y as getDeviceFingerprintInfo } from "./sessionInitializer-DSf0jyab.esm.js";
8
- import { t as endpoints } from "./endpoints-B-3Q-LUw.esm.js";
9
- import { i as assign, s as createActor, t as setup, u as createManager } from "./xstate.esm-BuFLFuzz.esm.js";
10
- import { n as fromPromise, t as fromCallback } from "./xstate-actors.esm-BXeQ-Ucz.esm.js";
11
- import { i as requestCameraAccess, o as stopCameraStream } from "./camera-sIa9ceW2.esm.js";
12
- import { i as requestPermission, t as checkPermission } from "./permissionServices-Bj_fyWtv.esm.js";
13
- import { i as initializeDeepsightSession, o as startDetection, s as stopStream, t as FaceDetectionProvider } from "./FaceDetectionProvider-C7bUZsH9.esm.js";
14
- import { t as sleep } from "./ITimerCapability-DgdDzp8G.esm.js";
15
- import { t as StreamCanvasCapture } from "./StreamCanvasCapture-C9jtgBdT.esm.js";
16
- //#region src/modules/personhood/personhoodServices.ts
17
- /**
18
- * Config used for the passive liveness pass. Deliberately stripped down from
19
- * selfie: no recording, no validation rituals, no headwear/mask checks — we
20
- * want the lowest-friction path to a single head-pose-tracked liveness shot.
21
- */
22
- const PERSONHOOD_FACE_CAPTURE_CONFIG = {
23
- showTutorial: false,
24
- showPreview: false,
25
- assistedOnboarding: false,
26
- enableFaceRecording: false,
27
- autoCaptureTimeout: 8e3,
28
- captureAttempts: 2,
29
- validateLenses: false,
30
- validateFaceMask: false,
31
- validateHeadCover: false,
32
- validateClosedEyes: false,
33
- validateBrightness: false,
34
- deepsightLiveness: "SINGLE_FRAME",
35
- ds: false
36
- };
37
- const PERSONHOOD_CAMERA_CONSTRAINTS = {
38
- video: {
39
- facingMode: "user",
40
- height: { ideal: 480 },
41
- width: { ideal: 640 }
42
- },
43
- audio: false
44
- };
45
- /**
46
- * Boots the Deepsight session used to back-stop the on-device liveness check.
47
- * Independent of `initializePersonhoodCamera` so the two can proceed in
48
- * parallel during the permission / collect-signals phase.
49
- */
50
- async function initializePersonhoodDeepsight(params) {
51
- return initializeDeepsightSession({
52
- storage: params.storage,
53
- ds: params.ds
54
- });
55
- }
56
- /**
57
- * Boots the camera stream and WASM face-detection pipeline as fast as
58
- * possible after the user grants permission. Optimized for low latency
59
- * between "permission granted" and "avatar begins tracking":
60
- *
61
- * 1. WASM model loading and `getUserMedia` run **in parallel** — they're
62
- * independent I/O and were previously sequential, which stacked
63
- * 50-500ms of avoidable latency.
64
- * 2. Deepsight PRC warmup (anti-spoof pipeline priming) is fired as a
65
- * **background task** after the real stream is open. It's not needed
66
- * for face detection or pose tracking — only for the post-capture
67
- * `analyzeFrame` step. `analyzeLiveness` degrades gracefully if PRC
68
- * hasn't finished in time.
69
- *
70
- * Earlier versions opened a second hidden camera stream for PRC warmup
71
- * BEFORE opening the real one, which on Safari/Firefox serialized the
72
- * two `getUserMedia` calls and added visible lag before the avatar came
73
- * alive.
74
- */
75
- async function initializePersonhoodCamera(params) {
76
- const provider = new FaceDetectionProvider();
77
- const [initResult, streamResult] = await Promise.allSettled([provider.initialize({ autocaptureInterval: PERSONHOOD_FACE_CAPTURE_CONFIG.autoCaptureTimeout * 1e3 }), requestCameraAccess(PERSONHOOD_CAMERA_CONSTRAINTS)]);
78
- if (streamResult.status === "rejected") {
79
- if (initResult.status === "fulfilled") provider.dispose();
80
- throw streamResult.reason;
81
- }
82
- const stream = streamResult.value;
83
- if (initResult.status === "rejected") {
84
- stopCameraStream(stream);
85
- throw initResult.reason;
86
- }
87
- provider.setChecksEnabled({
88
- lenses: false,
89
- mask: false,
90
- closedEyes: false,
91
- headWear: false,
92
- occlusion: false
93
- });
94
- if (params.deepsightService) params.deepsightService.performPrcCheck({ constraints: PERSONHOOD_CAMERA_CONSTRAINTS }).catch(() => {});
95
- return {
96
- stream,
97
- provider
98
- };
99
- }
100
- /**
101
- * Wraps the existing face-capture detection loop for the Personhood flow.
102
- *
103
- * - Drops the captured canvas (we never upload the raw frame).
104
- * - Forwards per-frame `onFaceData` into `onPose` so the avatar can mirror
105
- * the user's head in real time.
106
- */
107
- const AUTO_COMPLETE_DEADLINE_MS = 4e3;
108
- function runPersonhoodDetection(params) {
109
- let forcedCaptureFired = false;
110
- const timer = BrowserTimerProvider.getInstance();
111
- let autoCompleteTimer = null;
112
- let autoCompleteArmed = false;
113
- const cancelAutoComplete = () => {
114
- if (autoCompleteTimer !== null) {
115
- timer.clearTimeout(autoCompleteTimer);
116
- autoCompleteTimer = null;
117
- }
118
- };
119
- const fireForceCapture = () => {
120
- if (forcedCaptureFired) return;
121
- forcedCaptureFired = true;
122
- cancelAutoComplete();
123
- params.provider.forceCapture();
124
- };
125
- const armAutoComplete = () => {
126
- if (autoCompleteArmed) return;
127
- autoCompleteArmed = true;
128
- if (!params.autoComplete) return;
129
- autoCompleteTimer = timer.setTimeout(() => {
130
- autoCompleteTimer = null;
131
- fireForceCapture();
132
- }, AUTO_COMPLETE_DEADLINE_MS);
133
- };
134
- const innerResult = startDetection({
135
- config: PERSONHOOD_FACE_CAPTURE_CONFIG,
136
- capturer: params.capturer,
137
- provider: params.provider,
138
- onUpdate: params.onStatus,
139
- onFrame: () => armAutoComplete(),
140
- onPose: (face, frameSize) => {
141
- let faceSizeRatio;
142
- if (frameSize && frameSize.width > 0 && frameSize.height > 0 && face.rect.width > 0 && face.rect.height > 0) faceSizeRatio = Math.sqrt(face.rect.width * face.rect.height) / Math.min(frameSize.width, frameSize.height);
143
- params.onPose({
144
- pitch: face.pitch,
145
- yaw: face.yaw,
146
- roll: face.roll,
147
- faceSizeRatio
148
- });
149
- },
150
- onSuccess: (canvas) => {
151
- cancelAutoComplete();
152
- params.onSuccess(canvas);
153
- }
154
- });
155
- return {
156
- cleanup: () => {
157
- cancelAutoComplete();
158
- innerResult.cleanup();
159
- },
160
- reset: innerResult.reset
161
- };
162
- }
163
- /**
164
- * Runs the Deepsight pipeline against the stream + captured frame and pairs
165
- * the result with the locally-observed pose motion. All work happens on the
166
- * device — no network round-trip.
167
- *
168
- * The returned `LivenessOutcome` is fed into {@link produceLocalVerdict}:
169
- * - `virtualCamera: true` → hard reject (OBS/ManyCam)
170
- * - `analysisStatus !== 'PASS'` → reject with `spoof_suspected`
171
- * - `yawRange + pitchRange < MIN_POSE_MOTION_DEG` → reject with `no_motion`
172
- */
173
- async function analyzeLiveness(params) {
174
- let virtualCamera = false;
175
- let analysisStatus = "";
176
- let motionStatus = "";
177
- let metadata = "";
178
- let pipelineState = "";
179
- const deepsight = params.deepsightService;
180
- if (deepsight) {
181
- try {
182
- const videoTrack = params.stream?.getVideoTracks()[0];
183
- if (videoTrack) virtualCamera = await deepsight.checkVirtualCamera(videoTrack);
184
- } catch {}
185
- try {
186
- await deepsight.performVirtualCameraCheck(params.token ?? null, "SELFIE");
187
- } catch {}
188
- try {
189
- const imageData = params.capturedImage?.getImageData();
190
- if (imageData) await deepsight.analyzeFrame(imageData);
191
- } catch {}
192
- try {
193
- analysisStatus = deepsight.getAnalysisStatus();
194
- motionStatus = deepsight.getMotionStatus();
195
- pipelineState = deepsight.getPipelineState();
196
- metadata = deepsight.getMetadata();
197
- } catch {}
198
- }
199
- return {
200
- virtualCamera,
201
- analysisStatus,
202
- motionStatus,
203
- yawRange: params.yawRange,
204
- pitchRange: params.pitchRange,
205
- metadata,
206
- pipelineState
207
- };
208
- }
209
- /**
210
- * Ceiling for on-device verdicts. Intentionally below 1.0 — nothing short
211
- * of a signed server round-trip should claim perfect confidence, and the
212
- * ceiling is strictly below what a server verdict would return so the two
213
- * paths are distinguishable to integrators.
214
- */
215
- const LOCAL_CONFIDENCE_CEILING = .95;
216
- /**
217
- * Produces a verdict entirely on-device using an **additive** confidence
218
- * model. Each positive signal contributes a measurable amount to the score;
219
- * penalties subtract. This means clean passes vary — a user who swings
220
- * through a full head turn scores higher than one who barely moves — which
221
- * is useful for integrators that want to tune their own thresholds.
222
- *
223
- * Positive signals (contribute up to +0.95):
224
- * - Successful detection → +0.25
225
- * - Pose motion (linear from min to max) → up to +0.25
226
- * - Clean device signals (no automation) → +0.05
227
- * - Deepsight analysis: no explicit spoof → +0.05
228
- * - Deepsight motion status == PASS → +0.05
229
- * - Baseline presence → +0.30
230
- *
231
- * Penalties (hard rejects or large deductions):
232
- * - `navigator.webdriver` true → -0.9
233
- * - Automation markers → -0.9
234
- * - Headless hints → -0.3
235
- * - Virtual camera detected → -0.9
236
- * - Explicit spoof/injection in analysis → -0.4
237
- * - No head motion (below MIN_POSE_MOTION) → -0.5
238
- * - Detection didn't reach 'success' → -0.2
239
- */
240
- function produceLocalVerdict(params) {
241
- const threshold = params.threshold ?? .75;
242
- const reasons = [];
243
- let confidence = .4;
244
- if (params.detectionOutcome === "success") confidence += .25;
245
- else {
246
- reasons.push(`detection:${params.detectionOutcome}`);
247
- confidence -= .2;
248
- }
249
- if (params.liveness) {
250
- const motion = params.liveness.yawRange + params.liveness.pitchRange;
251
- if (motion < 4) {
252
- reasons.push("no_motion");
253
- confidence -= .5;
254
- } else {
255
- const motionRatio = Math.min(1, Math.max(0, (motion - 4) / 46));
256
- confidence += .25 * motionRatio;
257
- }
258
- if (!hasExplicitSpoofSignal(params.liveness.analysisStatus)) confidence += .05;
259
- else {
260
- reasons.push("spoof_suspected");
261
- confidence -= .4;
262
- }
263
- if (isMotionStatusPass(params.liveness.motionStatus)) confidence += .05;
264
- }
265
- if (!(params.signals.webdriver || params.signals.emulator || params.signals.headlessHints.length > 0)) confidence += .05;
266
- if (params.signals.webdriver) {
267
- reasons.push("webdriver");
268
- confidence -= .9;
269
- }
270
- if (params.signals.emulator) {
271
- reasons.push("emulator");
272
- confidence -= .9;
273
- }
274
- const SUBSUMED_HINTS = /* @__PURE__ */ new Set(["navigator.webdriver", "automation-marker"]);
275
- if (params.signals.headlessHints.filter((hint) => !SUBSUMED_HINTS.has(hint)).length > 0) {
276
- reasons.push("headless");
277
- confidence -= .3;
278
- }
279
- if (params.liveness?.virtualCamera) {
280
- reasons.push("virtual_camera");
281
- confidence -= .9;
282
- }
283
- confidence = Math.min(LOCAL_CONFIDENCE_CEILING, Math.max(0, confidence));
284
- confidence = Math.round(confidence * 1e3) / 1e3;
285
- if (confidence < threshold && reasons.length === 0) reasons.push("low_confidence");
286
- const human = confidence >= threshold && reasons.length === 0;
287
- return {
288
- human,
289
- confidence,
290
- evidenceId: generateEvidenceId(params.siteKey),
291
- reason: human ? void 0 : reasons.join(","),
292
- signals: params.signals,
293
- liveness: params.liveness,
294
- spoofCheckResults: buildSpoofCheckResults({
295
- signals: params.signals,
296
- liveness: params.liveness,
297
- detectionOutcome: params.detectionOutcome
298
- })
299
- };
300
- }
301
- function isMotionStatusPass(status) {
302
- return status.trim().toUpperCase() === "PASS";
303
- }
304
- /**
305
- * Tests whether the Deepsight analysis status contains an explicit spoof /
306
- * injection / fail signal.
307
- *
308
- * The WASM's `getCheck()` returns a pipeline-state blob intended primarily
309
- * for backend consumption — the server is what turns the blob into a
310
- * pass/fail. In phase 0 (no session, no backend round-trip) we intentionally
311
- * only treat the output as a negative signal when it contains an explicit
312
- * failure token; everything else is indeterminate and we defer to the
313
- * other local checks (virtual camera + pose motion + device signals).
314
- *
315
- * Without this, every clean capture in a session-less dev demo was
316
- * erroneously flagged as spoof_suspected because the WASM didn't emit
317
- * literally "PASS".
318
- */
319
- function hasExplicitSpoofSignal(status) {
320
- const normalized = status.toUpperCase();
321
- return normalized.includes("FAIL") || normalized.includes("SPOOF") || normalized.includes("INJECTION");
322
- }
323
- /**
324
- * Requests a server-side verdict when the browser can't run the on-device
325
- * liveness pass (no camera, blocked permission, incompatible browser). The
326
- * same API surface is returned to the widget so integrators see a single
327
- * universal `{ human, confidence, evidence_id }` shape.
328
- *
329
- * The confidence returned by this path is intentionally capped below the
330
- * on-device ceiling — with no camera we can only score device / behavioural
331
- * signals, which are weaker evidence of humanness.
332
- */
333
- async function requestServerFallbackVerdict(params) {
334
- if (params.mockFallback) return mockServerFallback(params);
335
- try {
336
- const res = await api.post(endpoints.personhoodServerFallback, {
337
- siteKey: params.siteKey,
338
- signals: params.signals,
339
- reason: params.reason
340
- }, { signal: params.signal });
341
- if (!res.ok) throw new Error(`POST ${endpoints.personhoodServerFallback} failed: ${res.status} ${res.statusText}`);
342
- return res.data;
343
- } catch (error) {
344
- if (isApiNotConfigured(error)) return mockServerFallback(params);
345
- throw error;
346
- }
347
- }
348
- async function mockServerFallback(params) {
349
- await sleep(200);
350
- const blocked = params.signals.webdriver || params.signals.emulator || params.signals.headlessHints.length > 0;
351
- const spoofCheckResults = buildSpoofCheckResults({
352
- signals: params.signals,
353
- liveness: void 0,
354
- detectionOutcome: "error"
355
- });
356
- if (blocked) return {
357
- human: false,
358
- confidence: .1,
359
- evidenceId: generateEvidenceId(params.siteKey),
360
- reason: "automation_markers",
361
- signals: params.signals,
362
- spoofCheckResults
363
- };
364
- return {
365
- human: true,
366
- confidence: .72,
367
- evidenceId: generateEvidenceId(params.siteKey),
368
- reason: `fallback:${params.reason}`,
369
- signals: params.signals,
370
- spoofCheckResults
371
- };
372
- }
373
- /**
374
- * Pulls a token string out of `/omni/personhood/token`'s response,
375
- * regardless of which envelope the backend uses. The Incode service
376
- * surface has historically returned tokens under several keys
377
- * (`token`, `interviewToken`, `sessionToken`, `access_token`, raw
378
- * `id`) and a few endpoints return the bare string in the body.
379
- * Trying each in order and returning the first non-empty value
380
- * means we don't have to second-guess the contract — when the
381
- * backend changes shape, the diagnostic log of `res.data` in the
382
- * caller still surfaces the raw response.
383
- */
384
- function extractTokenFromResponse(data) {
385
- if (typeof data === "string" && data.length > 0) return data;
386
- if (data && typeof data === "object") {
387
- const obj = data;
388
- const candidates = [
389
- obj.token,
390
- obj.interviewToken,
391
- obj.sessionToken,
392
- obj.access_token,
393
- obj.accessToken,
394
- obj.id,
395
- obj.data?.token,
396
- obj.data?.interviewToken
397
- ];
398
- for (const candidate of candidates) if (typeof candidate === "string" && candidate.length > 0) return candidate;
399
- }
400
- }
401
- /**
402
- * Pulls the verify endpoint's authoritative pass/fail boolean out
403
- * of its response, regardless of envelope. Current backend
404
- * contract: `{ "isHuman": boolean }`. Older / forward-compat
405
- * shapes also tried so a server-side rename doesn't silently break
406
- * the client. Returns `undefined` only when no recognizable field
407
- * is present — caller treats that as "trust the local verdict".
408
- */
409
- function extractIsHumanFromResponse(data) {
410
- if (typeof data === "boolean") return data;
411
- if (data && typeof data === "object") {
412
- const obj = data;
413
- const candidates = [
414
- obj.isHuman,
415
- obj.human,
416
- obj.passed,
417
- obj.data?.isHuman,
418
- obj.data?.human,
419
- obj.result?.isHuman
420
- ];
421
- for (const candidate of candidates) if (typeof candidate === "boolean") return candidate;
422
- }
423
- }
424
- /**
425
- * Mints the personhood `Authorization: Bearer` token via the OAuth2
426
- * client-credentials grant against {@link endpoints.personhoodOAuthToken}
427
- * (`POST /omni/oauth2/token`, `application/x-www-form-urlencoded`,
428
- * `grant_type=client_credentials&scope=openid`). Returns the
429
- * `access_token` (a signed JWT); the caller (state machine `fetchToken`
430
- * actor) lands it in actor context where downstream services receive it
431
- * as an explicit input. No module-level slot — concurrent personhood
432
- * instances stay isolated, and `actor.stop()` cleans up implicitly.
433
- *
434
- * Mock path returns a `mock-token-*` string after a short delay so
435
- * `mockVerify=true` flows complete without a configured backend.
436
- *
437
- * Replaces the removed `GET /omni/personhood/token` (ENG-45783).
438
- */
439
- async function fetchPersonhoodAuthToken(params = {}) {
440
- if (params.mockVerify) {
441
- await sleep(50);
442
- return `mock-token-${randomUUID()}`;
443
- }
444
- const form = {
445
- grant_type: "client_credentials",
446
- scope: "openid"
447
- };
448
- if (params.clientId) form.client_id = params.clientId;
449
- if (params.clientSecret) form.client_secret = params.clientSecret;
450
- const body = new URLSearchParams(form).toString();
451
- const authPath = params.authBaseUrl ? endpoints.personhoodOAuthTokenDirect : endpoints.personhoodOAuthToken;
452
- const reqConfig = {
453
- signal: params.signal,
454
- headers: { "Content-Type": "application/x-www-form-urlencoded" }
455
- };
456
- try {
457
- let res;
458
- if (params.authBaseUrl) res = await createApi$1({
459
- apiURL: params.authBaseUrl,
460
- customHeaders: getStoredHttpConfig()?.customHeaders
461
- }).post(authPath, body, reqConfig);
462
- else res = await api.post(authPath, body, reqConfig);
463
- if (!res.ok) throw new Error(`POST ${authPath} failed: ${res.status} ${res.statusText}`);
464
- const token = extractTokenFromResponse(res.data);
465
- if (!token) throw new Error(`POST ${authPath} returned no access_token`);
466
- return token;
467
- } catch (error) {
468
- if (isApiNotConfigured(error)) return fetchPersonhoodAuthToken({
469
- ...params,
470
- mockVerify: true
471
- });
472
- throw error;
473
- }
474
- }
475
- /**
476
- * Builds the full per-check breakdown that lands on
477
- * {@link PersonhoodVerdict.spoofCheckResults} and in the verify
478
- * POST body. One entry per check the SDK ran, with a stable id, a
479
- * `passed | failed | unknown` outcome, and (on failure) a short
480
- * tag the server can pattern-match on.
481
- *
482
- * This is the single source of truth for "what did the on-device
483
- * pipeline check?" — both {@link produceLocalVerdict} and the
484
- * verify POST consume it. Adding a new check here automatically
485
- * surfaces it to integrators and to the backend without touching
486
- * the state machine or the verify wire format.
487
- *
488
- * Conservative defaults: when a pipeline didn't run (no liveness
489
- * outcome / Deepsight not initialized), the dependent checks are
490
- * reported as `unknown`. This lets the server distinguish "we
491
- * tested and the user passed" from "we couldn't test", which is a
492
- * meaningful gradient for risk scoring.
493
- */
494
- function buildSpoofCheckResults(params) {
495
- const results = [];
496
- const { signals, liveness, detectionOutcome } = params;
497
- if (detectionOutcome === "success") results.push({
498
- check: "faceDetection",
499
- result: "passed"
500
- });
501
- else results.push({
502
- check: "faceDetection",
503
- result: "failed",
504
- reason: `detection:${detectionOutcome}`
505
- });
506
- if (liveness) {
507
- if (liveness.analysisStatus === "PASS") results.push({
508
- check: "physicalAttack",
509
- result: "passed"
510
- });
511
- else if (hasExplicitSpoofSignal(liveness.analysisStatus)) results.push({
512
- check: "physicalAttack",
513
- result: "failed",
514
- reason: liveness.analysisStatus || "spoof_suspected"
515
- });
516
- else results.push({
517
- check: "physicalAttack",
518
- result: "unknown"
519
- });
520
- results.push({
521
- check: "virtualCamera",
522
- result: liveness.virtualCamera ? "failed" : "passed",
523
- ...liveness.virtualCamera ? { reason: "virtual_camera" } : {}
524
- });
525
- if (liveness.motionStatus === "PASS") results.push({
526
- check: "motionLiveness",
527
- result: "passed"
528
- });
529
- else if (liveness.motionStatus === "FAIL") results.push({
530
- check: "motionLiveness",
531
- result: "failed",
532
- reason: "no_motion"
533
- });
534
- else results.push({
535
- check: "motionLiveness",
536
- result: "unknown"
537
- });
538
- if (liveness.yawRange + liveness.pitchRange >= 4) results.push({
539
- check: "headMotion",
540
- result: "passed"
541
- });
542
- else results.push({
543
- check: "headMotion",
544
- result: "failed",
545
- reason: "no_motion"
546
- });
547
- } else {
548
- results.push({
549
- check: "physicalAttack",
550
- result: "unknown"
551
- });
552
- results.push({
553
- check: "virtualCamera",
554
- result: "unknown"
555
- });
556
- results.push({
557
- check: "motionLiveness",
558
- result: "unknown"
559
- });
560
- results.push({
561
- check: "headMotion",
562
- result: "unknown"
563
- });
564
- }
565
- results.push({
566
- check: "webdriver",
567
- result: signals.webdriver ? "failed" : "passed",
568
- ...signals.webdriver ? { reason: "webdriver" } : {}
569
- });
570
- results.push({
571
- check: "emulator",
572
- result: signals.emulator ? "failed" : "passed",
573
- ...signals.emulator ? { reason: "emulator" } : {}
574
- });
575
- const independentHeadlessHints = signals.headlessHints.filter((hint) => hint !== "navigator.webdriver" && hint !== "automation-marker");
576
- results.push({
577
- check: "headlessBrowser",
578
- result: independentHeadlessHints.length > 0 ? "failed" : "passed",
579
- ...independentHeadlessHints.length > 0 ? { reason: independentHeadlessHints.join(",") } : {}
580
- });
581
- return results;
582
- }
583
- /**
584
- * Trims the rich client-side {@link SpoofCheckResult} array down to the wire
585
- * shape the verify POST expects: `check` + `result` only (the `reason` field is
586
- * dropped — not part of the contract). Entry order is preserved so the server
587
- * sees them in detection-pipeline order (face → physical → virtual → motion →
588
- * head → automation). `unknown` results are passed through as-is —
589
- * `SpoofCheckResultWire.result` is `'passed' | 'failed' | 'unknown'`. (Whether
590
- * the backend should instead receive only `passed | failed` is an open
591
- * server-contract question, tracked with the wider verify-contract work.)
592
- */
593
- function toWireSpoofCheckResults(results) {
594
- const wire = [];
595
- for (const entry of results) wire.push({
596
- check: entry.check,
597
- result: entry.result
598
- });
599
- return wire;
600
- }
601
- /**
602
- * On-device verify hop — POST `/omni/personhood/verify` with the
603
- * Deepsight metadata + derived `spoofCheckResults`. The personhood
604
- * Bearer token (passed in via `params.token`, minted by the OAuth2
605
- * client-credentials hop and threaded through actor context) is
606
- * attached as `Authorization: Bearer <token>`.
607
- *
608
- * When the server returns a recognizable `isHuman` boolean, that
609
- * value becomes the authoritative pass/fail on the returned
610
- * verdict. Otherwise (HTTP error, missing field, network blip,
611
- * SDK not configured) we fall back to the local on-device verdict
612
- * the caller passed in — keeping the widget functional in flaky
613
- * conditions. `mockVerify: true` short-circuits to the fallback
614
- * verdict without hitting the network.
615
- */
616
- async function verifyPersonhoodOnDevice(params) {
617
- if (params.mockVerify) {
618
- await sleep(50);
619
- return params.fallbackVerdict;
620
- }
621
- try {
622
- const personhoodToken = params.token;
623
- const res = await api.post(endpoints.personhoodOnDeviceVerify, {
624
- metadata: params.metadata,
625
- spoofCheckResults: toWireSpoofCheckResults(params.spoofCheckResults)
626
- }, {
627
- signal: params.signal,
628
- ...personhoodToken ? { headers: { Authorization: `Bearer ${personhoodToken}` } } : {}
629
- });
630
- if (!res.ok) throw new Error(`POST ${endpoints.personhoodOnDeviceVerify} failed: ${res.status} ${res.statusText}`);
631
- const serverData = res.data;
632
- const serverIsHuman = extractIsHumanFromResponse(serverData);
633
- if (typeof serverIsHuman !== "boolean") return params.fallbackVerdict;
634
- const finalReason = serverIsHuman ? void 0 : params.fallbackVerdict.reason ?? "server_rejected";
635
- return {
636
- ...params.fallbackVerdict,
637
- human: serverIsHuman,
638
- confidence: serverIsHuman ? .95 : .05,
639
- reason: finalReason
640
- };
641
- } catch {
642
- return params.fallbackVerdict;
643
- }
644
- }
645
- function isApiNotConfigured(error) {
646
- return error instanceof Error && error.message.includes("SDK not configured");
647
- }
648
- function generateEvidenceId(siteKey) {
649
- return `ph_${(siteKey || "site").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 12)}_${randomUUID().replace(/-/g, "")}`;
650
- }
651
- //#endregion
652
- //#region src/modules/personhood/personhoodSignals.ts
653
- /**
654
- * Returns a placeholder {@link DeviceBehavioralSignals} for the
655
- * narrow set of code paths that need to operate before the real
656
- * `collectDeviceBehavioralSignals()` has resolved (or where it
657
- * errored out and the parallel `signals` branch landed in `done`
658
- * with `context.signals` still undefined). Single source of truth
659
- * — every call site that previously inlined this literal now
660
- * imports from here.
661
- */
662
- function emptySignals() {
663
- return {
664
- deviceFingerprint: "",
665
- userAgentClass: "unknown",
666
- emulator: false,
667
- webdriver: false,
668
- headlessHints: []
669
- };
670
- }
671
- /**
672
- * Collects the device + behavioural bundle returned alongside the human
673
- * verdict. Intentionally offline-only (no IP geolookup) so a Personhood check
674
- * does not pay the latency of an external call in the hot path.
675
- *
676
- * The bundle is **opaque to the SDK** — the integrator uses these signals for
677
- * their own dedup/risk logic. Personhood itself does NOT re-identify the human.
678
- *
679
- * `deviceFingerprint` is gated by `setup({ fingerprint })` (default enabled)
680
- * — mirrors how `deepsightLoader` resolves `disableFingerprint` from the same
681
- * flag. When disabled, the Thumbmark call is skipped and `deviceFingerprint`
682
- * is `''`.
683
- */
684
- async function collectDeviceBehavioralSignals() {
685
- const browserEnv = new BrowserEnvironmentProvider();
686
- const [deviceFingerprint, deviceInfo] = await Promise.all([getFingerprintEnabled() ? safeGetFingerprint() : Promise.resolve(""), safeGetDeviceInfo()]);
687
- const webdriver = Boolean(browserEnv.getNavigatorProperty("webdriver"));
688
- const emulator = isBrowserSimulation(browserEnv);
689
- const headlessHints = [];
690
- if (webdriver) headlessHints.push("navigator.webdriver");
691
- if (emulator) headlessHints.push("automation-marker");
692
- const plugins = browserEnv.getNavigatorProperty("plugins");
693
- const userAgent = browserEnv.getNavigatorProperty("userAgent")?.toLowerCase();
694
- if (plugins && typeof plugins.length === "number" && plugins.length === 0 && userAgent?.includes("headless")) headlessHints.push("headless-user-agent");
695
- return {
696
- deviceFingerprint,
697
- userAgentClass: formatUserAgentClass(deviceInfo),
698
- emulator,
699
- webdriver,
700
- headlessHints
701
- };
702
- }
703
- function formatUserAgentClass(info) {
704
- if (!info) return "unknown";
705
- const parts = [
706
- info.browser?.name,
707
- info.browser?.version,
708
- info.os?.name,
709
- info.os?.version,
710
- info.device?.model
711
- ].filter((part) => typeof part === "string" && part.length > 0);
712
- return parts.length > 0 ? parts.join(" / ") : "unknown";
713
- }
714
- async function safeGetFingerprint() {
715
- try {
716
- return await getThumbmarkId();
717
- } catch {
718
- return "";
719
- }
720
- }
721
- async function safeGetDeviceInfo() {
722
- try {
723
- return await getDeviceFingerprintInfo();
724
- } catch {
725
- return;
726
- }
727
- }
728
- //#endregion
729
- //#region src/modules/personhood/types.ts
730
- const PERSONHOOD_ERROR_CODES = {
731
- CAMERA_DENIED: "CAMERA_DENIED",
732
- DETECTION_FAILED: "DETECTION_FAILED",
733
- /**
734
- * The OAuth2 token fetch (`POST /omni/oauth2/token`,
735
- * client-credentials) failed — non-2xx, network error, or no
736
- * `access_token` in the response. The flow halts before the user
737
- * opens the camera so a misconfigured `clientId` / `clientSecret`
738
- * surfaces immediately instead of walking them through a capture we
739
- * can't authenticate the verify hop for.
740
- */
741
- AUTH_TOKEN_FAILED: "AUTH_TOKEN_FAILED",
742
- VERIFY_FAILED: "VERIFY_FAILED",
743
- INJECTION_SUSPECTED: "INJECTION_SUSPECTED",
744
- EMULATOR_DETECTED: "EMULATOR_DETECTED",
745
- TIMEOUT: "TIMEOUT",
746
- UNKNOWN: "UNKNOWN"
747
- };
748
- /**
749
- * Public machine value. Typed as `AnyStateMachine` for declaration file
750
- * portability — mirrors the convention used by `selfieMachine` and the
751
- * other Core modules. Type safety is enforced by the `setup()` config
752
- * above, not by the exported binding.
753
- */
754
- const personhoodMachine = setup({
755
- types: {
756
- context: {},
757
- events: {},
758
- input: {}
759
- },
760
- actors: {
761
- checkPermission: fromPromise(async () => {
762
- return checkPermission();
763
- }),
764
- requestPermission: fromPromise(async () => {
765
- return requestPermission({ requestMotion: false });
766
- }),
767
- initializeCamera: fromPromise(async ({ input }) => {
768
- return initializePersonhoodCamera({ deepsightService: input.deepsightService });
769
- }),
770
- initializeDeepsight: fromPromise(async ({ input }) => {
771
- return initializePersonhoodDeepsight({
772
- storage: input.dependencies.storage,
773
- ds: input.ds
774
- });
775
- }),
776
- collectSignals: fromPromise(async () => {
777
- return collectDeviceBehavioralSignals();
778
- }),
779
- analyzeLiveness: fromPromise(async ({ input }) => {
780
- return analyzeLiveness(input);
781
- }),
782
- runDetection: fromCallback(({ input, sendBack }) => {
783
- let yawMin = Infinity;
784
- let yawMax = -Infinity;
785
- let pitchMin = Infinity;
786
- let pitchMax = -Infinity;
787
- const { cleanup, reset } = runPersonhoodDetection({
788
- provider: input.provider,
789
- capturer: input.frameCapturer,
790
- autoComplete: input.autoComplete,
791
- onStatus: (status) => sendBack({
792
- type: "DETECTION_UPDATE",
793
- status
794
- }),
795
- onPose: (pose) => {
796
- if (pose.yaw < yawMin) yawMin = pose.yaw;
797
- if (pose.yaw > yawMax) yawMax = pose.yaw;
798
- if (pose.pitch < pitchMin) pitchMin = pose.pitch;
799
- if (pose.pitch > pitchMax) pitchMax = pose.pitch;
800
- },
801
- onSuccess: (canvas) => {
802
- sendBack({
803
- type: "DETECTION_SUCCESS",
804
- canvas,
805
- yawRange: yawMax > yawMin ? yawMax - yawMin : 0,
806
- pitchRange: pitchMax > pitchMin ? pitchMax - pitchMin : 0
807
- });
808
- }
809
- });
810
- sendBack({
811
- type: "DETECTION_RESET_READY",
812
- reset
813
- });
814
- return cleanup;
815
- }),
816
- /**
817
- * Local verdict — the PRD's primary path. Runs entirely on-device, no
818
- * network round-trip. Wrapped in `fromPromise` only so the machine can
819
- * `invoke` it the same way it does an async actor; the body itself is
820
- * synchronous and returns immediately.
821
- */
822
- produceLocalVerdict: fromPromise(async ({ input }) => {
823
- return produceLocalVerdict({
824
- siteKey: input.config.siteKey,
825
- threshold: input.config.threshold,
826
- signals: input.signals,
827
- detectionOutcome: input.detectionStatus,
828
- liveness: input.liveness
829
- });
830
- }),
831
- /**
832
- * Server fallback — only reached when the on-device path is impossible
833
- * (no camera, permission denied, browser unsupported). Integrators see
834
- * the same verdict shape either way.
835
- */
836
- requestServerFallback: fromPromise(async ({ input, signal }) => {
837
- return requestServerFallbackVerdict({
838
- siteKey: input.config.siteKey,
839
- signals: input.signals,
840
- reason: input.reason,
841
- mockFallback: input.config.mockVerify,
842
- signal
843
- });
844
- }),
845
- /**
846
- * Mints the OAuth2 Bearer token (client-credentials grant) for the
847
- * verify hop. Hits `POST /omni/oauth2/token` on the omni base, or —
848
- * when `config.authBaseUrl` is set — the auth server's own
849
- * `/oauth2/token` directly (see {@link fetchPersonhoodAuthToken}).
850
- * Runs in parallel with the other init tasks (signals + Deepsight) so
851
- * the network round-trip overlaps
852
- * with the WASM and camera-permission work — token is available
853
- * by the time the user finishes the capture and we need to
854
- * verify. The fetched token is assigned into actor context
855
- * (`context.token`) by `setTokenFromEvent` and threaded into the
856
- * downstream `analyzeLiveness` and `verifyOnDevice` actors as an
857
- * explicit input, isolated from the main `currentToken` other
858
- * modules use and from any concurrent personhood instances.
859
- */
860
- fetchToken: fromPromise(async ({ input, signal }) => {
861
- return fetchPersonhoodAuthToken({
862
- clientId: input.config.clientId,
863
- clientSecret: input.config.clientSecret,
864
- authBaseUrl: input.config.authBaseUrl,
865
- mockVerify: input.config.mockVerify,
866
- signal
867
- });
868
- }),
869
- /**
870
- * On-device verify — POST `/omni/personhood/verify` with the
871
- * Deepsight metadata + derived `spoofCheckResults`, returns the
872
- * server's authoritative verdict. The local verdict from
873
- * {@link produceLocalVerdict} is used as a fallback if the
874
- * network call fails. This is what becomes the final
875
- * `state.verdict` the UI / integrator sees.
876
- */
877
- verifyOnDevice: fromPromise(async ({ input, signal }) => {
878
- return verifyPersonhoodOnDevice({
879
- metadata: input.liveness?.metadata ?? "",
880
- spoofCheckResults: input.localVerdict.spoofCheckResults,
881
- fallbackVerdict: input.localVerdict,
882
- mockVerify: input.config.mockVerify,
883
- token: input.token,
884
- signal
885
- });
886
- })
887
- },
888
- actions: {
889
- setStreamAndProvider: assign(({ event }) => {
890
- const output = event.output;
891
- return {
892
- stream: output.stream,
893
- provider: output.provider,
894
- frameCapturer: new StreamCanvasCapture(output.stream)
895
- };
896
- }),
897
- setDetectionStatus: assign({ detectionStatus: ({ event }) => event.status }),
898
- /**
899
- * Commits the final head-pose ranges reported by the detection callback
900
- * into context. This happens ONCE at DETECTION_SUCCESS, not per-frame —
901
- * keeping pose off the hot XState path prevents React re-renders on
902
- * every detector frame.
903
- */
904
- setCapturedImageAndRangeFromEvent: assign(({ event }) => {
905
- const e = event;
906
- return {
907
- capturedImage: e.canvas,
908
- yawRange: e.yawRange,
909
- pitchRange: e.pitchRange
910
- };
911
- }),
912
- setDeepsightServiceFromEvent: assign({ deepsightService: ({ event }) => event.output }),
913
- setLivenessOutcomeFromEvent: assign({ livenessOutcome: ({ event }) => event.output }),
914
- setResetDetection: assign({ resetDetection: ({ event }) => event.reset }),
915
- setSignalsFromEvent: assign({ signals: ({ event }) => event.output }),
916
- setTokenFromEvent: assign({ token: ({ event }) => event.output }),
917
- /**
918
- * Stash the local verdict from the `processing` step's actor
919
- * output so the subsequent `verifyOnServer` step can use it as a
920
- * fallback if the network call fails. The same event also drives
921
- * `setVerdictFromEvent` (the existing action), but that one
922
- * writes to `verdict` — which we DON'T want to set yet because
923
- * the final verdict comes from the server.
924
- */
925
- setLocalVerdictFromEvent: assign({ localVerdict: ({ event }) => event.output }),
926
- setVerdictFromEvent: assign({ verdict: ({ event }) => event.output }),
927
- setPermissionResultFromEvent: assign({ permissionResult: ({ event }) => event.output }),
928
- setPermissionDenied: assign({
929
- permissionResult: () => "denied",
930
- error: () => ({ code: PERSONHOOD_ERROR_CODES.CAMERA_DENIED })
931
- }),
932
- setDetectionError: assign({ error: () => ({ code: PERSONHOOD_ERROR_CODES.DETECTION_FAILED }) }),
933
- /**
934
- * Records that the server-fallback path is being taken because the camera
935
- * could not be initialized (no device / OS block), so the fallback verdict
936
- * reports `no_camera` rather than the default `permission_denied`.
937
- */
938
- setFallbackReasonNoCamera: assign({ fallbackReason: () => "no_camera" }),
939
- setTokenError: assign({ error: ({ event }) => ({
940
- code: PERSONHOOD_ERROR_CODES.AUTH_TOKEN_FAILED,
941
- message: String(event.error ?? "auth token fetch failed")
942
- }) }),
943
- setVerifyError: assign({ error: ({ event }) => ({
944
- code: PERSONHOOD_ERROR_CODES.VERIFY_FAILED,
945
- message: String(event.error ?? "verify failed")
946
- }) }),
947
- /**
948
- * Local-synthesis failure — the deterministic on-device verdict
949
- * computation threw. Distinct from `setVerifyError` which is for
950
- * the server verify hop; the verify hop never even ran here.
951
- */
952
- setLocalVerdictError: assign({ error: ({ event }) => ({
953
- code: PERSONHOOD_ERROR_CODES.UNKNOWN,
954
- message: String(event.error ?? "local verdict synthesis failed")
955
- }) }),
956
- /**
957
- * Server verify hop failed unrecoverably — fall back to the local
958
- * verdict the integrator can still trust. Named (vs. an inline
959
- * `assign`) so the machine config stays consistent with
960
- * `setup({ actions: { ... } })`'s typed-action contract.
961
- */
962
- setVerdictToLocal: assign({ verdict: ({ context }) => context.localVerdict }),
963
- stopMediaStream: assign(({ context }) => {
964
- context.frameCapturer?.dispose();
965
- if (context.stream) stopStream(context.stream);
966
- context.provider?.dispose();
967
- context.deepsightService?.cleanup();
968
- return {
969
- stream: void 0,
970
- provider: void 0,
971
- frameCapturer: void 0
972
- };
973
- }),
974
- resetContext: assign({
975
- permissionResult: () => void 0,
976
- stream: () => void 0,
977
- provider: () => void 0,
978
- frameCapturer: () => void 0,
979
- deepsightService: () => void 0,
980
- detectionStatus: () => "idle",
981
- yawRange: () => 0,
982
- pitchRange: () => 0,
983
- capturedImage: () => void 0,
984
- livenessOutcome: () => void 0,
985
- signals: () => void 0,
986
- fallbackReason: () => void 0,
987
- token: () => void 0,
988
- localVerdict: () => void 0,
989
- verdict: () => void 0,
990
- error: () => void 0,
991
- resetDetection: () => void 0
992
- }),
993
- emitSignal: ({ context, event }) => {
994
- const listener = context.config.onSignal;
995
- if (!listener) return;
996
- if (event.type === "DETECTION_UPDATE") listener({
997
- type: "detection",
998
- payload: event.status
999
- });
1000
- }
1001
- },
1002
- guards: {
1003
- hasShowTutorial: ({ context }) => context.config.showTutorial === true,
1004
- isPermissionGranted: ({ event }) => {
1005
- if ("output" in event) return event.output === "granted";
1006
- return false;
1007
- },
1008
- isPermissionDenied: ({ event }) => {
1009
- if ("output" in event) return event.output === "denied";
1010
- return false;
1011
- },
1012
- hasTokenError: ({ context }) => context.error?.code === PERSONHOOD_ERROR_CODES.AUTH_TOKEN_FAILED,
1013
- isDetectionError: ({ event }) => event.status === "error"
1014
- }
1015
- }).createMachine({
1016
- id: "personhood",
1017
- initial: "idle",
1018
- context: ({ input }) => ({
1019
- config: input.config,
1020
- dependencies: input.dependencies,
1021
- permissionResult: void 0,
1022
- fallbackReason: void 0,
1023
- stream: void 0,
1024
- provider: void 0,
1025
- frameCapturer: void 0,
1026
- deepsightService: void 0,
1027
- detectionStatus: "idle",
1028
- yawRange: 0,
1029
- pitchRange: 0,
1030
- capturedImage: void 0,
1031
- livenessOutcome: void 0,
1032
- signals: void 0,
1033
- token: void 0,
1034
- localVerdict: void 0,
1035
- verdict: void 0,
1036
- error: void 0,
1037
- resetDetection: void 0
1038
- }),
1039
- on: { QUIT: { target: "#personhood.closed" } },
1040
- states: {
1041
- idle: { on: { LOAD: [{
1042
- target: "tutorial",
1043
- guard: "hasShowTutorial"
1044
- }, { target: "collectingSignals" }] } },
1045
- tutorial: { on: { NEXT_STEP: { target: "collectingSignals" } } },
1046
- /**
1047
- * Two prep tasks run in parallel: collect device/behavioral signals and
1048
- * boot the Deepsight session (PRC warmup). Both are needed before we
1049
- * touch the camera; running them concurrently keeps the pre-capture
1050
- * latency budget under the PRD's 1.5s p50 target.
1051
- */
1052
- collectingSignals: {
1053
- type: "parallel",
1054
- states: {
1055
- signals: {
1056
- initial: "running",
1057
- states: {
1058
- running: { invoke: {
1059
- id: "collectSignals",
1060
- src: "collectSignals",
1061
- onDone: {
1062
- target: "done",
1063
- actions: "setSignalsFromEvent"
1064
- },
1065
- onError: { target: "done" }
1066
- } },
1067
- done: { type: "final" }
1068
- }
1069
- },
1070
- deepsight: {
1071
- initial: "running",
1072
- states: {
1073
- running: { invoke: {
1074
- id: "initializeDeepsight",
1075
- src: "initializeDeepsight",
1076
- input: ({ context }) => ({
1077
- dependencies: context.dependencies,
1078
- ds: context.config.ds
1079
- }),
1080
- onDone: {
1081
- target: "done",
1082
- actions: "setDeepsightServiceFromEvent"
1083
- },
1084
- onError: { target: "done" }
1085
- } },
1086
- done: { type: "final" }
1087
- }
1088
- },
1089
- /**
1090
- * Third parallel branch — fetches the personhood session
1091
- * token. Latency-overlap with signals + Deepsight init so
1092
- * the network round-trip is "free" by the time the user
1093
- * finishes capturing. The mock-verify path returns a
1094
- * synthetic token without hitting the network, so this
1095
- * branch only fails when a real backend was configured AND
1096
- * the call genuinely failed (non-2xx, network error,
1097
- * missing token in the response). In that case we record
1098
- * the error and let the parallel `onDone` route the flow
1099
- * to the terminal `error` state — there's no point opening
1100
- * the camera if we know we can't authenticate the verify
1101
- * hop afterwards.
1102
- */
1103
- token: {
1104
- initial: "running",
1105
- states: {
1106
- running: { invoke: {
1107
- id: "fetchToken",
1108
- src: "fetchToken",
1109
- input: ({ context }) => ({ config: context.config }),
1110
- onDone: {
1111
- target: "done",
1112
- actions: "setTokenFromEvent"
1113
- },
1114
- onError: {
1115
- target: "done",
1116
- actions: "setTokenError"
1117
- }
1118
- } },
1119
- done: { type: "final" }
1120
- }
1121
- }
1122
- },
1123
- onDone: [{
1124
- target: "error",
1125
- guard: "hasTokenError"
1126
- }, { target: "checkingPermission" }]
1127
- },
1128
- checkingPermission: { invoke: {
1129
- id: "checkPermission",
1130
- src: "checkPermission",
1131
- onDone: [
1132
- {
1133
- target: "capture",
1134
- guard: "isPermissionGranted",
1135
- actions: "setPermissionResultFromEvent"
1136
- },
1137
- {
1138
- target: "permissions.denied",
1139
- guard: "isPermissionDenied",
1140
- actions: "setPermissionResultFromEvent"
1141
- },
1142
- {
1143
- target: "permissions",
1144
- actions: "setPermissionResultFromEvent"
1145
- }
1146
- ]
1147
- } },
1148
- permissions: {
1149
- initial: "idle",
1150
- states: {
1151
- idle: { on: {
1152
- REQUEST_PERMISSION: "requesting",
1153
- GO_TO_LEARN_MORE: "learnMore"
1154
- } },
1155
- learnMore: { on: {
1156
- BACK: "idle",
1157
- REQUEST_PERMISSION: "requesting"
1158
- } },
1159
- requesting: { invoke: {
1160
- id: "requestPermission",
1161
- src: "requestPermission",
1162
- onDone: [
1163
- {
1164
- target: "#personhood.capture",
1165
- guard: "isPermissionGranted",
1166
- actions: "setPermissionResultFromEvent"
1167
- },
1168
- {
1169
- target: "denied",
1170
- guard: "isPermissionDenied",
1171
- actions: "setPermissionResultFromEvent"
1172
- },
1173
- {
1174
- target: "idle",
1175
- actions: "setPermissionResultFromEvent"
1176
- }
1177
- ],
1178
- onError: {
1179
- target: "denied",
1180
- actions: "setPermissionDenied"
1181
- }
1182
- } },
1183
- denied: {
1184
- entry: "setPermissionDenied",
1185
- on: { USE_FALLBACK: { target: "#personhood.serverFallback" } }
1186
- }
1187
- },
1188
- on: { USE_FALLBACK: { target: "#personhood.serverFallback" } }
1189
- },
1190
- capture: {
1191
- initial: "initializing",
1192
- states: {
1193
- initializing: { invoke: {
1194
- id: "initializeCamera",
1195
- src: "initializeCamera",
1196
- input: ({ context }) => ({ deepsightService: context.deepsightService }),
1197
- onDone: {
1198
- target: "detecting",
1199
- actions: "setStreamAndProvider"
1200
- },
1201
- onError: {
1202
- target: "#personhood.serverFallback",
1203
- actions: "setFallbackReasonNoCamera"
1204
- }
1205
- } },
1206
- detecting: {
1207
- invoke: {
1208
- id: "runDetection",
1209
- src: "runDetection",
1210
- input: ({ context }) => ({
1211
- provider: context.provider,
1212
- frameCapturer: context.frameCapturer,
1213
- autoComplete: context.config.autoComplete
1214
- }),
1215
- onError: {
1216
- target: "#personhood.error",
1217
- actions: "setDetectionError"
1218
- }
1219
- },
1220
- on: {
1221
- DETECTION_UPDATE: [{
1222
- guard: "isDetectionError",
1223
- target: "#personhood.error",
1224
- actions: [
1225
- "setDetectionStatus",
1226
- "setDetectionError",
1227
- "emitSignal"
1228
- ]
1229
- }, { actions: ["setDetectionStatus", "emitSignal"] }],
1230
- DETECTION_RESET_READY: { actions: "setResetDetection" },
1231
- DETECTION_SUCCESS: {
1232
- target: "#personhood.analyzing",
1233
- actions: "setCapturedImageAndRangeFromEvent"
1234
- }
1235
- }
1236
- }
1237
- }
1238
- },
1239
- /**
1240
- * On-device liveness analysis — Deepsight virtual-camera check + pipeline
1241
- * analysis + pose-motion range. Runs after a good capture but before we
1242
- * synthesize the verdict, so {@link produceLocalVerdict} can fold these
1243
- * signals in. This is the actual "local liveness" step per PRD #4.
1244
- */
1245
- analyzing: {
1246
- exit: "stopMediaStream",
1247
- invoke: {
1248
- id: "analyzeLiveness",
1249
- src: "analyzeLiveness",
1250
- input: ({ context }) => ({
1251
- deepsightService: context.deepsightService,
1252
- stream: context.stream,
1253
- capturedImage: context.capturedImage,
1254
- yawRange: context.yawRange,
1255
- pitchRange: context.pitchRange,
1256
- token: context.token
1257
- }),
1258
- onDone: {
1259
- target: "processing",
1260
- actions: "setLivenessOutcomeFromEvent"
1261
- },
1262
- onError: { target: "processing" }
1263
- }
1264
- },
1265
- processing: { invoke: {
1266
- id: "produceLocalVerdict",
1267
- src: "produceLocalVerdict",
1268
- input: ({ context }) => ({
1269
- config: context.config,
1270
- signals: context.signals ?? emptySignals(),
1271
- detectionStatus: context.detectionStatus,
1272
- liveness: context.livenessOutcome
1273
- }),
1274
- onDone: {
1275
- target: "verifyingOnServer",
1276
- actions: "setLocalVerdictFromEvent"
1277
- },
1278
- onError: {
1279
- target: "error",
1280
- actions: "setLocalVerdictError"
1281
- }
1282
- } },
1283
- /**
1284
- * On-device verify hop. Sends the Deepsight metadata + derived
1285
- * `spoofCheckResults` to `/omni/personhood/verify` (authenticated
1286
- * with `Authorization: Bearer <token>`) and uses the server's
1287
- * response as the final verdict. Falls back to the local
1288
- * verdict if the network call fails — better to deliver SOMETHING
1289
- * than nothing, but a reachable backend always wins.
1290
- */
1291
- verifyingOnServer: { invoke: {
1292
- id: "verifyOnDevice",
1293
- src: "verifyOnDevice",
1294
- input: ({ context }) => ({
1295
- config: context.config,
1296
- liveness: context.livenessOutcome,
1297
- localVerdict: context.localVerdict,
1298
- token: context.token
1299
- }),
1300
- onDone: {
1301
- target: "finished",
1302
- actions: "setVerdictFromEvent"
1303
- },
1304
- onError: {
1305
- target: "finished",
1306
- actions: "setVerdictToLocal"
1307
- }
1308
- } },
1309
- /**
1310
- * Server fallback path — only reached when on-device liveness can't run
1311
- * (currently: after the user explicitly denies camera permission). The
1312
- * network call is gated by `config.mockVerify` the same as the primary
1313
- * path, so the dev demo works offline.
1314
- */
1315
- serverFallback: { invoke: {
1316
- id: "requestServerFallback",
1317
- src: "requestServerFallback",
1318
- input: ({ context }) => ({
1319
- config: context.config,
1320
- signals: context.signals ?? emptySignals(),
1321
- reason: context.fallbackReason ?? "permission_denied"
1322
- }),
1323
- onDone: {
1324
- target: "finished",
1325
- actions: "setVerdictFromEvent"
1326
- },
1327
- onError: {
1328
- target: "error",
1329
- actions: "setVerifyError"
1330
- }
1331
- } },
1332
- finished: { type: "final" },
1333
- closed: {
1334
- entry: "stopMediaStream",
1335
- type: "final"
1336
- },
1337
- error: {
1338
- entry: "stopMediaStream",
1339
- on: { RESET: {
1340
- target: "idle",
1341
- actions: "resetContext"
1342
- } }
1343
- }
1344
- }
1345
- });
1346
- //#endregion
1347
- //#region src/modules/personhood/personhoodActor.ts
1348
- /**
1349
- * Production-default {@link PersonhoodDependencies}. Browser storage +
1350
- * the singleton WASM util. Single source of truth for what the actor
1351
- * and the manager wire up when the caller doesn't provide their own;
1352
- * tests and the extensibility layer pass `dependencies` explicitly.
1353
- */
1354
- function getDefaultPersonhoodDependencies() {
1355
- return {
1356
- storage: new BrowserStorageProvider(),
1357
- getWasmUtil: () => WasmUtilProvider.getInstance()
1358
- };
1359
- }
1360
- /**
1361
- * Creates and starts a Personhood XState actor. Defaults to the production
1362
- * browser storage + WASM providers; passing `dependencies` lets tests and the
1363
- * extensibility layer swap them for fakes.
1364
- */
1365
- function createPersonhoodActor(options) {
1366
- const dependencies = options.dependencies ?? getDefaultPersonhoodDependencies();
1367
- return createActor(personhoodMachine, { input: {
1368
- config: options.config,
1369
- dependencies
1370
- } }).start();
1371
- }
1372
- //#endregion
1373
- //#region src/modules/personhood/personhoodManager.ts
1374
- /**
1375
- * Personhood deliberately opts out of the SDK's manager-instrumentation
1376
- * analytics pipeline. The widget runs as a self-contained modal/inline
1377
- * captcha replacement, not as part of the broader onboarding funnel —
1378
- * it doesn't share an onboarding session token (`/omni/start`) with
1379
- * the rest of an integrator's flow, so the per-transition events the
1380
- * default instrumentation would emit can't authenticate against
1381
- * `/omni/interview-events`. Wiring them up only produced a cascade of
1382
- * 403s in integrators' devtools (one preflight + one POST per
1383
- * `screenOpened` / `elementClicked` / `captureAttemptFinished` /
1384
- * `errorTriggered` transition), with no usable analytics signal on
1385
- * the receiving end.
1386
- *
1387
- * `createManager()` requires the `instrumentation` option to be
1388
- * present, so we pass a no-op that satisfies the contract without
1389
- * firing any of the helpers. If a future use case calls for
1390
- * personhood analytics (integrator opt-in, internal QA dashboard,
1391
- * etc.) the right move is to re-enable `createManagerInstrumentation`
1392
- * here gated on a config flag — not to remove this no-op.
1393
- */
1394
- function createNoopInstrumentation() {
1395
- return {
1396
- moduleName: eventModuleNames.personhood,
1397
- onModuleOpened: () => void 0,
1398
- onModuleClosed: () => void 0,
1399
- getScreenName: () => void 0,
1400
- onScreenOpened: () => void 0,
1401
- onScreenClosed: () => void 0,
1402
- getErrorName: () => void 0,
1403
- getErrorPayload: () => void 0,
1404
- onErrorTriggered: () => void 0,
1405
- onElementClicked: () => void 0,
1406
- onCaptureAttemptFinished: () => void 0
1407
- };
1408
- }
1409
- function getPermissionSubState(snapshot) {
1410
- if (snapshot.matches({ permissions: "learnMore" })) return "learnMore";
1411
- if (snapshot.matches({ permissions: "requesting" })) return "requesting";
1412
- if (snapshot.matches({ permissions: "denied" })) return "denied";
1413
- return "idle";
1414
- }
1415
- function mapState(snapshot) {
1416
- const context = snapshot.context;
1417
- if (snapshot.matches("idle")) return { status: "idle" };
1418
- if (snapshot.matches("tutorial")) return { status: "tutorial" };
1419
- if (snapshot.matches("collectingSignals")) return {
1420
- status: "loading",
1421
- phase: "collectingSignals"
1422
- };
1423
- if (snapshot.matches("checkingPermission")) return {
1424
- status: "loading",
1425
- phase: "checkingPermission"
1426
- };
1427
- if (snapshot.matches("permissions")) return {
1428
- status: "permissions",
1429
- permissionStatus: getPermissionSubState(snapshot)
1430
- };
1431
- if (snapshot.matches({ capture: "initializing" })) return {
1432
- status: "capture",
1433
- captureStatus: "initializing",
1434
- detectionStatus: context.detectionStatus,
1435
- stream: context.stream
1436
- };
1437
- if (snapshot.matches("capture")) return {
1438
- status: "capture",
1439
- captureStatus: "detecting",
1440
- detectionStatus: context.detectionStatus,
1441
- stream: context.stream
1442
- };
1443
- if (snapshot.matches("analyzing")) return {
1444
- status: "processing",
1445
- source: "analyzing"
1446
- };
1447
- if (snapshot.matches("processing")) return {
1448
- status: "processing",
1449
- source: "local"
1450
- };
1451
- if (snapshot.matches("serverFallback")) return {
1452
- status: "processing",
1453
- source: "fallback"
1454
- };
1455
- if (snapshot.matches("verifyingOnServer")) return {
1456
- status: "processing",
1457
- source: "local"
1458
- };
1459
- if (snapshot.matches("finished")) return {
1460
- status: "finished",
1461
- verdict: context.verdict ?? {
1462
- human: false,
1463
- confidence: 0,
1464
- evidenceId: "missing",
1465
- signals: emptySignals(),
1466
- spoofCheckResults: []
1467
- }
1468
- };
1469
- if (snapshot.matches("closed")) return { status: "closed" };
1470
- if (snapshot.matches("error")) return {
1471
- status: "error",
1472
- error: context.error ?? { code: "UNKNOWN" }
1473
- };
1474
- return { status: "idle" };
1475
- }
1476
- function createApi({ actor, trackElementClicked }) {
1477
- return {
1478
- /** Starts the flow (goes to tutorial or capture depending on config). */
1479
- load() {
1480
- actor.send({ type: "LOAD" });
1481
- },
1482
- /** Advances from tutorial → capture. */
1483
- nextStep() {
1484
- trackElementClicked?.("nextStep");
1485
- actor.send({ type: "NEXT_STEP" });
1486
- },
1487
- /** Triggers `getUserMedia` when in `permissions.idle`. */
1488
- requestPermission() {
1489
- trackElementClicked?.("requestPermission");
1490
- actor.send({ type: "REQUEST_PERMISSION" });
1491
- },
1492
- /** Shows the permission rationale view. */
1493
- goToLearnMore() {
1494
- trackElementClicked?.("goToLearnMore");
1495
- actor.send({ type: "GO_TO_LEARN_MORE" });
1496
- },
1497
- /** Returns from learn-more → permission prompt. */
1498
- back() {
1499
- trackElementClicked?.("back");
1500
- actor.send({ type: "BACK" });
1501
- },
1502
- /** Dismisses the widget into the terminal `closed` state. */
1503
- close() {
1504
- trackElementClicked?.("close");
1505
- actor.send({ type: "QUIT" });
1506
- },
1507
- /** Resets from the (non-final) `error` state back to `idle`. */
1508
- reset() {
1509
- actor.send({ type: "RESET" });
1510
- },
1511
- /**
1512
- * Skip the on-device path and hit the server fallback. Wired to the
1513
- * "Continue without camera" button on the permission-denied screen; also
1514
- * dispatched automatically when the device has no camera at all.
1515
- */
1516
- useFallback() {
1517
- trackElementClicked?.("useFallback");
1518
- actor.send({ type: "USE_FALLBACK" });
1519
- }
1520
- };
1521
- }
1522
- /**
1523
- * Wraps a pre-built personhood actor in a manager. Internal helper
1524
- * shared by {@link createPersonhoodManager} (drop-in widget path) and
1525
- * {@link createPersonhoodManagerFromActor} (extensibility surface).
1526
- * Centralizing the `createManager(...)` + instrumentation call avoids
1527
- * two near-identical wrap blocks drifting apart.
1528
- */
1529
- function wrapActor(actor) {
1530
- return createManager({
1531
- actor,
1532
- mapState,
1533
- createApi,
1534
- instrumentation: createNoopInstrumentation()
1535
- });
1536
- }
1537
- /**
1538
- * Manager built from an externally-owned actor (extensibility path).
1539
- */
1540
- function createPersonhoodManagerFromActor(actor) {
1541
- return wrapActor(actor);
1542
- }
1543
- /**
1544
- * Creates a Personhood manager for the drop-in widget.
1545
- *
1546
- * The manager exposes:
1547
- * - `state: PersonhoodState` via `getState()` / `subscribe()`
1548
- * - Lifecycle controls: `load`, `nextStep`, `requestPermission`,
1549
- * `goToLearnMore`, `back`, `close`, `reset`, `useFallback`, `stop`
1550
- *
1551
- * Head-pose is intentionally **not** exposed as a stream. The SDK
1552
- * extracts pose internally for the server-side `headMotion` spoof
1553
- * check; UI consumers that need a frame-rate pose signal (avatar
1554
- * mirroring, etc.) should run their own face-tracking pipeline
1555
- * (e.g. MediaPipe FaceLandmarker) against `state.stream` instead.
1556
- *
1557
- * @example
1558
- * ```ts
1559
- * const manager = createPersonhoodManager({
1560
- * config: { siteKey: 'demo', mockVerify: true },
1561
- * });
1562
- * manager.subscribe((state) => {
1563
- * if (state.status === 'finished') {
1564
- * console.log('human?', state.verdict.human);
1565
- * }
1566
- * });
1567
- * manager.load();
1568
- * ```
1569
- */
1570
- function createPersonhoodManager(options) {
1571
- return wrapActor(createPersonhoodActor(options));
1572
- }
1573
- //#endregion
1574
- export { PERSONHOOD_ERROR_CODES, createPersonhoodActor, createPersonhoodManager, createPersonhoodManagerFromActor, personhoodMachine };