face-verification-iyx 2.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Inocyx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # face-verification-iyx
2
+
3
+ Browser-based face liveness verification SDK — challenge-based liveness, identity check (catches mid-session face swaps), and built-in collection of device, network, geolocation and risk metadata with a signed payload.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install face-verification-iyx
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```html
14
+ <script type="module">
15
+ import LivenessSDK from "face-verification-iyx";
16
+
17
+ const sdk = new LivenessSDK({
18
+ apiUrl: "https://your-backend.example/verify",
19
+ challengeCount: 3,
20
+ verifyingFor: "Rathish Kumar",
21
+ enableIPCollection: true,
22
+ enableGeolocation: true,
23
+ enableDeviceFingerprint: true,
24
+ onSuccess: (r) => console.log("verified", r),
25
+ onError: (e) => console.error("failed", e),
26
+ });
27
+
28
+ await sdk.start();
29
+ </script>
30
+ ```
31
+
32
+ The SDK renders its own UI (circular camera, pulsing ring, live hints), runs three random liveness challenges (blink, head-left, head-right) plus a final straight-on hold, captures an image and a 3-second video, collects metadata, signs the payload, and POSTs it to `apiUrl`.
33
+
34
+ ## Config
35
+
36
+ ```ts
37
+ interface LivenessConfig {
38
+ apiUrl?: string;
39
+ method?: "POST" | "PUT";
40
+ headers?: Record<string, string>;
41
+ body?: Record<string, any>;
42
+ imageFieldName?: string; // enables multipart/form-data
43
+ videoFieldName?: string; // enables multipart/form-data
44
+ challengeCount?: number; // 1-3
45
+ ipResolverUrl?: string; // tried first; falls back to public providers
46
+ enableGeolocation?: boolean;
47
+ enableIPCollection?: boolean;
48
+ enableDeviceFingerprint?: boolean;
49
+ verifyingFor?: string; // shown in UI, included in payload
50
+ identityCheck?: boolean; // continuous same-person check (default true)
51
+ onSuccess?: (r) => void;
52
+ onError?: (e) => void;
53
+ onProgress?: (p) => void;
54
+ onChallengeStart?: (c) => void;
55
+ onChallengeComplete?: (c, passed) => void;
56
+ }
57
+ ```
58
+
59
+ ## Payload your backend receives
60
+
61
+ ```json
62
+ {
63
+ "session_id": "...",
64
+ "sdk_version": "2.0.0",
65
+ "verifying_for": "Rathish Kumar",
66
+ "face_image": "data:image/jpeg;base64,...",
67
+ "liveness_video": "data:video/webm;base64,...",
68
+ "location": { "latitude": 0, "longitude": 0, "accuracy": 0, "timestamp": "ISO" },
69
+ "network": { "ip_address": "", "ip_version": "IPv4", "user_agent": "", "timezone": "", "language": "" },
70
+ "device": { "fingerprint": "sha256", "platform": "", "screen_resolution": "1920x1080@24", "hardware_concurrency": 8 },
71
+ "risk_analysis": { "vpn_detected": false, "proxy_detected": false, "mock_location_suspected": false, "risk_score": 0 },
72
+ "timestamp": "ISO",
73
+ "nonce": "32-byte hex",
74
+ "integrity_hash": "sha256(canonicalJSON(payload_minus_hash) + nonce)"
75
+ }
76
+ ```
77
+
78
+ Optional fields are **omitted** (not sent as null) when their `enable*` flag is `false`.
79
+
80
+ ## Security
81
+
82
+ - 32-byte crypto-random nonce per session.
83
+ - SHA-256 integrity hash over canonical (sorted-keys) JSON. Backend re-hashes to detect tampering.
84
+ - Continuous face identity check via landmark geometry — flags mid-session person swaps.
85
+ - HTTPS required in production (geolocation + MediaPipe ESM).
86
+
87
+ ## Browser support
88
+
89
+ Modern evergreen browsers (Chrome 90+, Edge 90+, Firefox 88+, Safari 15+). Requires:
90
+ camera permission, `MediaRecorder`, `crypto.subtle`, `RTCPeerConnection` (optional, for WebRTC leak check).
91
+
92
+ ## License
93
+
94
+ MIT
package/dist/sdk.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ export interface LivenessConfig {
2
+ apiUrl?: string;
3
+ method?: "POST" | "PUT";
4
+ headers?: Record<string, string>;
5
+ body?: Record<string, any>;
6
+ imageFieldName?: string;
7
+ videoFieldName?: string;
8
+ challengeCount?: number;
9
+ ipResolverUrl?: string;
10
+ enableGeolocation?: boolean;
11
+ enableIPCollection?: boolean;
12
+ enableDeviceFingerprint?: boolean;
13
+ // Extras (not in strict LivenessConfig but accepted)
14
+ verifyingFor?: string;
15
+ userName?: string;
16
+ identityCheck?: boolean;
17
+ challengeTimeout?: number;
18
+ debug?: boolean;
19
+ theme?: Partial<ThemeConfig>;
20
+ onSuccess?: (result: LivenessResult) => void;
21
+ onError?: (error: LivenessError) => void;
22
+ onProgress?: (progress: ProgressEvent) => void;
23
+ onChallengeStart?: (challenge: ChallengeEvent) => void;
24
+ onChallengeComplete?: (challenge: ChallengeEvent, passed: boolean) => void;
25
+ }
26
+
27
+ export interface ThemeConfig {
28
+ primary: string; success: string; error: string;
29
+ bg: string; text: string; muted: string;
30
+ }
31
+
32
+ export interface ChallengeEvent {
33
+ type: "blink" | "head_left" | "head_right" | "center";
34
+ sequenceNumber: number;
35
+ totalSequence?: number;
36
+ }
37
+
38
+ export interface ProgressEvent {
39
+ stage: string;
40
+ progress: number;
41
+ message: string;
42
+ timestamp: number;
43
+ }
44
+
45
+ export interface LivenessError extends Error {
46
+ code: string;
47
+ details?: any;
48
+ recoverable: boolean;
49
+ }
50
+
51
+ export interface LivenessPayload {
52
+ session_id: string;
53
+ sdk_version: string;
54
+ verifying_for: string | null;
55
+ face_image: string;
56
+ liveness_video: string | null;
57
+ location?: { latitude: number; longitude: number; accuracy: number; timestamp: string };
58
+ network?: { ip_address: string | null; ip_version: "IPv4" | "IPv6" | null; user_agent: string; timezone: string; language: string };
59
+ device?: { fingerprint: string; platform: string; screen_resolution: string; hardware_concurrency: number };
60
+ risk_analysis?: { vpn_detected: boolean; proxy_detected: boolean; mock_location_suspected: boolean; risk_score: number };
61
+ timestamp: string;
62
+ nonce: string;
63
+ integrity_hash: string;
64
+ }
65
+
66
+ export interface LivenessResult {
67
+ success: boolean;
68
+ sessionId: string;
69
+ verifiedFor: string | null;
70
+ timestamp: number;
71
+ payload: LivenessPayload;
72
+ apiResponse: any;
73
+ }
74
+
75
+ export const VERSION: string;
76
+
77
+ export default class LivenessSDK {
78
+ constructor(config?: LivenessConfig);
79
+ start(): Promise<LivenessResult>;
80
+ stop(): void;
81
+ destroy(): void;
82
+ getSessionId(): string;
83
+ }
84
+
85
+ export { LivenessSDK };
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Advanced Liveness SDK - Production ESM Build (v2.0)
3
+ * Real face detection (MediaPipe) + 3 challenges + final straight-on hold,
4
+ * identity check, network/device/geo collectors, risk analysis,
5
+ * 3-second liveness video, signed payload, multipart or JSON transport.
6
+ */
7
+ import {
8
+ FilesetResolver,
9
+ FaceLandmarker,
10
+ } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/vision_bundle.mjs";
11
+
12
+ const VERSION = "2.0.0";
13
+ const STAGES = { INITIALIZING:"initializing", REQUESTING_PERMISSIONS:"requesting_permissions", SETTING_UP_CAMERA:"setting_up_camera", DETECTING_FACE:"detecting_face", RUNNING_ACTIVE_LIVENESS:"running_active_liveness", COLLECTING_METADATA:"collecting_metadata", CAPTURING_IMAGE:"capturing_image", UPLOADING:"uploading", COMPLETED:"completed" };
14
+ const CHALLENGE_DEFS = {
15
+ blink: { title: "Blink your eyes", hint: "Blink naturally a couple of times" },
16
+ head_left: { title: "Turn left", hint: "Slowly turn your head to the left" },
17
+ head_right: { title: "Turn right", hint: "Slowly turn your head to the right" },
18
+ center: { title: "Look straight", hint: "Face the camera and hold still" },
19
+ };
20
+ const POOL = ["blink", "head_left", "head_right"];
21
+ const IDENTITY_DRIFT_THRESHOLD = 0.30;
22
+ const BASELINE_FRAMES = 6;
23
+ const IDENTITY_YAW_MAX = 12;
24
+ const IDENTITY_PITCH_MAX = 12;
25
+ const SIGNATURE_LANDMARKS = [1, 33, 263, 61, 291, 199, 234, 454, 10, 152, 168, 6, 197, 132, 361];
26
+ const IP_PROVIDERS = [
27
+ { url: "https://api.ipify.org?format=json", parse: (d) => ({ ip: d.ip }) },
28
+ { url: "https://ipapi.co/json/", parse: (d) => ({ ip: d.ip, org: d.org, timezone: d.timezone, country: d.country_name }) },
29
+ { url: "https://www.cloudflare.com/cdn-cgi/trace", parse: (t) => { const m = {}; String(t).split("\n").forEach(l => { const [k,v] = l.split("="); if (k) m[k]=v; }); return { ip: m.ip, country: m.loc }; } },
30
+ ];
31
+
32
+ class LivenessSDK {
33
+ constructor(config = {}) {
34
+ this.config = {
35
+ apiUrl: config.apiUrl || null,
36
+ method: config.method || "POST",
37
+ headers: { ...(config.headers || {}) },
38
+ body: { ...(config.body || {}) },
39
+ imageFieldName: config.imageFieldName || null,
40
+ videoFieldName: config.videoFieldName || null,
41
+ challengeCount: Math.max(1, Math.min(3, config.challengeCount || 3)),
42
+ ipResolverUrl: config.ipResolverUrl || null,
43
+ enableGeolocation: config.enableGeolocation !== false,
44
+ enableIPCollection: config.enableIPCollection !== false,
45
+ enableDeviceFingerprint: config.enableDeviceFingerprint !== false,
46
+ // extras (not in strict LivenessConfig but useful)
47
+ verifyingFor: config.verifyingFor || config.userName || null,
48
+ identityCheck: config.identityCheck !== false,
49
+ challengeTimeout: config.challengeTimeout || 15000,
50
+ debug: !!config.debug,
51
+ theme: { primary:"#667eea", success:"#22c55e", error:"#ef4444", bg:"#ffffff", text:"#222222", muted:"#888888", ...(config.theme || {}) },
52
+ onSuccess: config.onSuccess || (() => {}),
53
+ onError: config.onError || (() => {}),
54
+ onProgress: config.onProgress || (() => {}),
55
+ onChallengeStart: config.onChallengeStart || (() => {}),
56
+ onChallengeComplete: config.onChallengeComplete || (() => {}),
57
+ };
58
+ this.sessionId = this._newSessionId();
59
+ this._state = this._freshState();
60
+ if (this.config.debug) console.log("[LivenessSDK]", "initialized", { sessionId: this.sessionId, version: VERSION });
61
+ }
62
+
63
+ _newSessionId() { return "sess_" + Date.now() + "_" + Math.random().toString(36).slice(2, 10); }
64
+ _freshState() {
65
+ const keep = this._state && this._state.landmarker;
66
+ return {
67
+ landmarker: keep || null, stream: null, video: null, rafId: null,
68
+ running: false, currentChallenge: null, challengeState: {},
69
+ capturedImage: null, livenessVideoBlob: null,
70
+ mediaRecorder: null, recordedChunks: [],
71
+ identitySignature: null, identityBaselineSamples: [], identityMatched: true,
72
+ };
73
+ }
74
+
75
+ async start() {
76
+ if (this._state.running) throw this._err("UNKNOWN_ERROR", "Verification already in progress");
77
+ this._state.running = true;
78
+ try {
79
+ this._buildUI();
80
+ this._showLoading("Loading face detection...");
81
+ this._progress(STAGES.INITIALIZING, 5, "Initializing");
82
+ await this._loadLandmarker();
83
+ this._progress(STAGES.SETTING_UP_CAMERA, 20, "Model loaded");
84
+ this._showLoading("Starting camera...");
85
+ this._progress(STAGES.REQUESTING_PERMISSIONS, 30, "Requesting camera permission");
86
+ await this._initCamera();
87
+ this._progress(STAGES.SETTING_UP_CAMERA, 40, "Camera ready");
88
+ this._showChallengeView();
89
+ this._progress(STAGES.RUNNING_ACTIVE_LIVENESS, 45, "Starting challenges");
90
+
91
+ const challenges = this._generateChallenges();
92
+ const completed = [];
93
+ for (let i = 0; i < challenges.length; i++) {
94
+ const ch = challenges[i];
95
+ this._state.currentChallenge = ch;
96
+ this._state.challengeState = { holdFrames: 0, blinkClosed: false, blinkCount: 0 };
97
+ this._setChallengeUI(ch, i, challenges.length);
98
+ this.config.onChallengeStart({ type: ch, sequenceNumber: i + 1, totalSequence: challenges.length });
99
+
100
+ // Start 3s video recording when entering the final "center" challenge
101
+ if (ch === "center") this._startVideoRecording();
102
+
103
+ const ok = await this._waitForChallenge(ch, this.config.challengeTimeout);
104
+ completed.push({ type: ch, completed: ok });
105
+ this.config.onChallengeComplete({ type: ch, sequenceNumber: i + 1 }, ok);
106
+ if (!ok) {
107
+ if (!this._state.identityMatched) throw this._err("IDENTITY_MISMATCH", "Different person detected. The same person must complete the entire verification.");
108
+ throw this._err("CHALLENGE_TIMEOUT", "Could not complete \"" + ch + "\" in time");
109
+ }
110
+ this._flashSuccess();
111
+ await this._sleep(700);
112
+ }
113
+
114
+ this._progress(STAGES.CAPTURING_IMAGE, 70, "Verifying identity");
115
+ if (!this._verifyFinalIdentity()) throw this._err("IDENTITY_MISMATCH", "Different person detected at capture. The same person must complete the entire verification.");
116
+
117
+ this._progress(STAGES.CAPTURING_IMAGE, 75, "Capturing image");
118
+ this._state.capturedImage = this._captureImage();
119
+ this._state.livenessVideoBlob = await this._stopVideoRecording();
120
+
121
+ this._showLoading("Collecting metadata...");
122
+ this._progress(STAGES.COLLECTING_METADATA, 82, "Collecting metadata");
123
+ const network = this.config.enableIPCollection ? await this._collectNetwork() : null;
124
+ const geolocation = this.config.enableGeolocation ? await this._collectGeolocation() : null;
125
+ const device = this.config.enableDeviceFingerprint ? await this._collectDevice() : null;
126
+ const risk_analysis = await this._analyzeRisk(network, geolocation);
127
+
128
+ this._progress(STAGES.UPLOADING, 90, "Building payload");
129
+ const payload = await this._buildPayload({ network, geolocation, device, risk_analysis });
130
+
131
+ let apiResponse = null;
132
+ if (this.config.apiUrl) {
133
+ this._showLoading("Verifying...");
134
+ this._progress(STAGES.UPLOADING, 95, "Uploading");
135
+ apiResponse = await this._postToApi(payload);
136
+ }
137
+ this._progress(STAGES.COMPLETED, 100, "Completed");
138
+
139
+ const result = {
140
+ success: true,
141
+ sessionId: this.sessionId,
142
+ verifiedFor: this.config.verifyingFor || null,
143
+ timestamp: Date.now(),
144
+ payload,
145
+ apiResponse,
146
+ };
147
+ const successTitle = this.config.verifyingFor ? "Verified - " + this.config.verifyingFor : "Verified";
148
+ this._showSuccess(successTitle, "You're all set.");
149
+ this.config.onSuccess(result);
150
+ return result;
151
+ } catch (error) {
152
+ const err = error && error.code ? error : this._err("UNKNOWN_ERROR", (error && error.message) || String(error));
153
+ this._showError(err.message || "Verification failed");
154
+ this.config.onError(err);
155
+ throw err;
156
+ } finally {
157
+ this._state.running = false;
158
+ this._stopDetectionLoop();
159
+ this._stopStream();
160
+ }
161
+ }
162
+
163
+ stop() { this._state.running = false; this._stopDetectionLoop(); this._stopStream(); this._removeUI(); }
164
+ destroy() { this.stop(); if (this._state.landmarker && this._state.landmarker.close) { try { this._state.landmarker.close(); } catch (_) {} } this._state.landmarker = null; }
165
+ getSessionId() { return this.sessionId; }
166
+
167
+ async _retry() {
168
+ this._stopDetectionLoop(); this._stopStream(); this._removeUI();
169
+ this._state = this._freshState();
170
+ this.sessionId = this._newSessionId();
171
+ try { await this.start(); } catch (_) {}
172
+ }
173
+
174
+ async _loadLandmarker() {
175
+ if (this._state.landmarker) return;
176
+ const fileset = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/wasm");
177
+ this._state.landmarker = await FaceLandmarker.createFromOptions(fileset, {
178
+ baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", delegate: "GPU" },
179
+ outputFaceBlendshapes: true, outputFacialTransformationMatrixes: true, runningMode: "VIDEO", numFaces: 1,
180
+ });
181
+ }
182
+
183
+ async _initCamera() {
184
+ try {
185
+ this._state.stream = await navigator.mediaDevices.getUserMedia({ video: { width:{ ideal:640 }, height:{ ideal:480 }, facingMode:"user" }, audio: false });
186
+ } catch (e) { throw this._err("CAMERA_PERMISSION_DENIED", "Camera permission denied or unavailable"); }
187
+ const video = this._state.video;
188
+ video.srcObject = this._state.stream;
189
+ await new Promise((r) => (video.onloadedmetadata = r));
190
+ await video.play();
191
+ }
192
+
193
+ _generateChallenges() {
194
+ const pool = [...POOL]; const out = [];
195
+ const n = Math.min(this.config.challengeCount, pool.length);
196
+ while (out.length < n && pool.length > 0) {
197
+ const idx = Math.floor(Math.random() * pool.length);
198
+ out.push(pool.splice(idx, 1)[0]);
199
+ }
200
+ out.push("center");
201
+ return out;
202
+ }
203
+
204
+ _waitForChallenge(type, timeoutMs) {
205
+ return new Promise((resolve) => {
206
+ let resolved = false;
207
+ const timer = setTimeout(() => { if (!resolved) { resolved = true; this._stopDetectionLoop(); resolve(false); } }, timeoutMs);
208
+ const loop = () => {
209
+ if (!this._state.running) return;
210
+ const video = this._state.video;
211
+ if (video && video.readyState >= 2 && this._state.landmarker) {
212
+ try {
213
+ const res = this._state.landmarker.detectForVideo(video, performance.now());
214
+ if (!this._state.identityMatched) { resolved = true; clearTimeout(timer); this._stopDetectionLoop(); resolve(false); return; }
215
+ if (this._evaluate(res, type) && !resolved) { resolved = true; clearTimeout(timer); this._stopDetectionLoop(); resolve(true); return; }
216
+ } catch (_) {}
217
+ }
218
+ this._state.rafId = requestAnimationFrame(loop);
219
+ };
220
+ this._state.rafId = requestAnimationFrame(loop);
221
+ });
222
+ }
223
+
224
+ _evaluate(res, type) {
225
+ if (!res.faceLandmarks || res.faceLandmarks.length === 0) { this._setHint("Position your face in the circle", "warn"); return false; }
226
+ if (res.faceLandmarks.length > 1) { this._setHint("Only one face should be visible", "warn"); return false; }
227
+ const landmarks = res.faceLandmarks[0];
228
+ const bs = {};
229
+ const cats = (res.faceBlendshapes && res.faceBlendshapes[0] && res.faceBlendshapes[0].categories) || [];
230
+ cats.forEach((c) => (bs[c.categoryName] = c.score));
231
+ const matrix = res.facialTransformationMatrixes && res.facialTransformationMatrixes[0] && res.facialTransformationMatrixes[0].data;
232
+ let yaw = 0, pitch = 0;
233
+ if (matrix) {
234
+ yaw = -(Math.atan2(-matrix[8], matrix[0]) * 180) / Math.PI;
235
+ pitch = (Math.asin(Math.max(-1, Math.min(1, matrix[9]))) * 180) / Math.PI;
236
+ }
237
+
238
+ if (this.config.identityCheck && Math.abs(yaw) < IDENTITY_YAW_MAX && Math.abs(pitch) < IDENTITY_PITCH_MAX) {
239
+ const sig = this._computeSignature(landmarks);
240
+ if (sig) {
241
+ if (!this._state.identitySignature) {
242
+ this._state.identityBaselineSamples.push(sig);
243
+ if (this._state.identityBaselineSamples.length >= BASELINE_FRAMES) {
244
+ this._state.identitySignature = this._averageSignatures(this._state.identityBaselineSamples);
245
+ this._state.identityBaselineSamples = [];
246
+ }
247
+ } else {
248
+ const drift = this._signatureDistance(this._state.identitySignature, sig);
249
+ if (drift > IDENTITY_DRIFT_THRESHOLD) {
250
+ this._state.identityMatched = false;
251
+ this._setHint("Same person must complete all steps", "warn");
252
+ return false;
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ const st = this._state.challengeState;
259
+ switch (type) {
260
+ case "blink": {
261
+ const closed = ((bs.eyeBlinkLeft || 0) + (bs.eyeBlinkRight || 0)) / 2 > 0.5;
262
+ if (closed && !st.blinkClosed) st.blinkClosed = true;
263
+ else if (!closed && st.blinkClosed) {
264
+ st.blinkClosed = false; st.blinkCount++;
265
+ if (st.blinkCount >= 2) return true;
266
+ this._setHint("Good - blink " + st.blinkCount + "/2", "ok");
267
+ } else if (st.blinkCount === 0) this._setHint(CHALLENGE_DEFS.blink.hint);
268
+ return false;
269
+ }
270
+ case "head_left":
271
+ if (yaw > 18) { st.holdFrames++; this._setHint("Almost there...", "ok"); }
272
+ else { st.holdFrames = 0; this._setHint(CHALLENGE_DEFS.head_left.hint); }
273
+ return st.holdFrames >= 3;
274
+ case "head_right":
275
+ if (yaw < -18) { st.holdFrames++; this._setHint("Almost there...", "ok"); }
276
+ else { st.holdFrames = 0; this._setHint(CHALLENGE_DEFS.head_right.hint); }
277
+ return st.holdFrames >= 3;
278
+ case "center": {
279
+ const centered = Math.abs(yaw) < 10 && Math.abs(pitch) < 12;
280
+ if (centered) { st.holdFrames++; this._setHint("Hold still...", "ok"); }
281
+ else { st.holdFrames = 0; this._setHint(CHALLENGE_DEFS.center.hint); }
282
+ return st.holdFrames >= 8;
283
+ }
284
+ }
285
+ return false;
286
+ }
287
+
288
+ _captureImage() {
289
+ const video = this._state.video;
290
+ const canvas = document.createElement("canvas");
291
+ canvas.width = video.videoWidth; canvas.height = video.videoHeight;
292
+ const ctx = canvas.getContext("2d");
293
+ ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
294
+ ctx.drawImage(video, 0, 0);
295
+ return canvas.toDataURL("image/jpeg", 0.85);
296
+ }
297
+
298
+ // ---- Video recording (final ~3s during center challenge) ----
299
+ _startVideoRecording() {
300
+ if (!this._state.stream || typeof MediaRecorder === "undefined") return;
301
+ try {
302
+ const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp8") ? "video/webm;codecs=vp8" : "video/webm";
303
+ const mr = new MediaRecorder(this._state.stream, { mimeType, videoBitsPerSecond: 600000 });
304
+ this._state.recordedChunks = [];
305
+ mr.ondataavailable = (e) => { if (e.data && e.data.size > 0) this._state.recordedChunks.push(e.data); };
306
+ mr.start(250);
307
+ this._state.mediaRecorder = mr;
308
+ } catch (_) { this._state.mediaRecorder = null; }
309
+ }
310
+
311
+ _stopVideoRecording() {
312
+ return new Promise((resolve) => {
313
+ const mr = this._state.mediaRecorder;
314
+ if (!mr) return resolve(null);
315
+ mr.onstop = () => {
316
+ const blob = new Blob(this._state.recordedChunks, { type: mr.mimeType || "video/webm" });
317
+ resolve(blob);
318
+ };
319
+ try { mr.stop(); } catch (_) { resolve(null); }
320
+ });
321
+ }
322
+
323
+ // ---- Identity signature ----
324
+ _computeSignature(landmarks) {
325
+ if (!landmarks || landmarks.length < 200) return null;
326
+ const pts = SIGNATURE_LANDMARKS.map((i) => landmarks[i]).filter(Boolean);
327
+ if (pts.length < SIGNATURE_LANDMARKS.length) return null;
328
+ const left = landmarks[33], right = landmarks[263];
329
+ const ioD = Math.hypot(right.x - left.x, right.y - left.y) || 1;
330
+ const sig = [];
331
+ for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) sig.push(Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y) / ioD);
332
+ return sig;
333
+ }
334
+ _averageSignatures(sigs) {
335
+ if (!sigs || sigs.length === 0) return null;
336
+ const n = sigs[0].length; const avg = new Array(n).fill(0);
337
+ for (let i = 0; i < sigs.length; i++) for (let j = 0; j < n; j++) avg[j] += sigs[i][j];
338
+ for (let j = 0; j < n; j++) avg[j] /= sigs.length;
339
+ return avg;
340
+ }
341
+ _signatureDistance(a, b) {
342
+ if (!a || !b || a.length !== b.length) return 1;
343
+ let sum = 0; for (let i = 0; i < a.length; i++) { const d = a[i] - b[i]; sum += d * d; }
344
+ return Math.sqrt(sum / a.length);
345
+ }
346
+ _verifyFinalIdentity() {
347
+ if (!this.config.identityCheck) return true;
348
+ if (!this._state.identityMatched) return false;
349
+ if (!this._state.landmarker || !this._state.video) return true;
350
+ if (!this._state.identitySignature) return true;
351
+ try {
352
+ const res = this._state.landmarker.detectForVideo(this._state.video, performance.now());
353
+ if (!res.faceLandmarks || res.faceLandmarks.length !== 1) return false;
354
+ const sig = this._computeSignature(res.faceLandmarks[0]);
355
+ if (!sig) return true;
356
+ return this._signatureDistance(this._state.identitySignature, sig) <= IDENTITY_DRIFT_THRESHOLD;
357
+ } catch (_) { return true; }
358
+ }
359
+
360
+ // ---- Collectors ----
361
+ async _fetchWithTimeout(url, ms = 5000) {
362
+ const ctrl = new AbortController();
363
+ const t = setTimeout(() => ctrl.abort(), ms);
364
+ try {
365
+ const r = await fetch(url, { signal: ctrl.signal });
366
+ if (!r.ok) throw new Error("HTTP " + r.status);
367
+ const ct = r.headers.get("content-type") || "";
368
+ return ct.includes("application/json") ? await r.json() : await r.text();
369
+ } finally { clearTimeout(t); }
370
+ }
371
+
372
+ async _collectNetwork() {
373
+ const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || "";
374
+ const lang = navigator.language || (navigator.languages && navigator.languages[0]) || "";
375
+ const ua = navigator.userAgent || "";
376
+ let ip = null, org = null, geoCountry = null, providerTz = null;
377
+ const providers = this.config.ipResolverUrl
378
+ ? [{ url: this.config.ipResolverUrl, parse: (d) => (typeof d === "object" ? { ip: d.ip || d.address || null, org: d.org, timezone: d.timezone, country: d.country } : { ip: String(d).trim() }) }, ...IP_PROVIDERS]
379
+ : IP_PROVIDERS;
380
+ for (const p of providers) {
381
+ try {
382
+ const data = await this._fetchWithTimeout(p.url, 5000);
383
+ const parsed = p.parse(data);
384
+ if (parsed && parsed.ip) {
385
+ ip = parsed.ip; org = parsed.org || null; geoCountry = parsed.country || null; providerTz = parsed.timezone || null;
386
+ break;
387
+ }
388
+ } catch (_) { /* try next */ }
389
+ }
390
+ const ipVersion = ip && ip.includes(":") ? "IPv6" : ip ? "IPv4" : null;
391
+ return { ipAddress: ip, ipVersion, userAgent: ua, timezone: tz, language: lang, _org: org, _providerTimezone: providerTz, _country: geoCountry };
392
+ }
393
+
394
+ _collectGeolocation() {
395
+ return new Promise((resolve) => {
396
+ if (!navigator.geolocation) return resolve(null);
397
+ const opts = { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 };
398
+ navigator.geolocation.getCurrentPosition(
399
+ (pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy, timestamp: new Date(pos.timestamp).toISOString() }),
400
+ () => resolve(null),
401
+ opts,
402
+ );
403
+ });
404
+ }
405
+
406
+ async _collectDevice() {
407
+ const screenRes = (screen.width || 0) + "x" + (screen.height || 0) + "@" + (screen.colorDepth || 0);
408
+ const platform = navigator.platform || "";
409
+ const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || "";
410
+ const lang = navigator.language || "";
411
+ const hwc = navigator.hardwareConcurrency || 0;
412
+ const touch = navigator.maxTouchPoints || 0;
413
+ const canvasFp = this._canvasFingerprint();
414
+ const webglFp = this._webglFingerprint();
415
+ let mediaDevices = { audioInputs: 0, videoInputs: 0, audioOutputs: 0 };
416
+ try {
417
+ const list = await navigator.mediaDevices.enumerateDevices();
418
+ mediaDevices = {
419
+ audioInputs: list.filter(d => d.kind === "audioinput").length,
420
+ videoInputs: list.filter(d => d.kind === "videoinput").length,
421
+ audioOutputs: list.filter(d => d.kind === "audiooutput").length,
422
+ };
423
+ } catch (_) {}
424
+ const raw = [navigator.userAgent, platform, screenRes, tz, lang, hwc, touch, canvasFp, webglFp, JSON.stringify(mediaDevices)].join("|");
425
+ const fingerprint = await this._sha256(raw);
426
+ return { fingerprint, platform, screen_resolution: screenRes, hardware_concurrency: hwc, _canvasFp: canvasFp, _webglFp: webglFp, _mediaDevices: mediaDevices, _maxTouchPoints: touch };
427
+ }
428
+
429
+ _canvasFingerprint() {
430
+ try {
431
+ const c = document.createElement("canvas"); c.width = 200; c.height = 50;
432
+ const ctx = c.getContext("2d");
433
+ ctx.textBaseline = "top"; ctx.font = "14px Arial";
434
+ ctx.fillStyle = "#f60"; ctx.fillRect(125, 1, 62, 20);
435
+ ctx.fillStyle = "#069"; ctx.fillText("Liveness-FP-1.0", 2, 15);
436
+ ctx.fillStyle = "rgba(102,204,0,0.7)"; ctx.fillText("Liveness-FP-1.0", 4, 17);
437
+ return c.toDataURL().slice(-100);
438
+ } catch (_) { return "na"; }
439
+ }
440
+
441
+ _webglFingerprint() {
442
+ try {
443
+ const c = document.createElement("canvas");
444
+ const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
445
+ if (!gl) return "na";
446
+ const dbg = gl.getExtension("WEBGL_debug_renderer_info");
447
+ const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
448
+ const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
449
+ return (vendor || "") + "|" + (renderer || "");
450
+ } catch (_) { return "na"; }
451
+ }
452
+
453
+ async _sha256(str) {
454
+ try {
455
+ const buf = new TextEncoder().encode(str);
456
+ const hash = await crypto.subtle.digest("SHA-256", buf);
457
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
458
+ } catch (_) { return String(str.length) + "_fallback"; }
459
+ }
460
+
461
+ // ---- Risk analysis (browser-only heuristics) ----
462
+ async _analyzeRisk(network, geo) {
463
+ let vpn_detected = false;
464
+ let proxy_detected = false;
465
+ let mock_location_suspected = false;
466
+ let timezone_mismatch = false;
467
+ let risk_score = 0;
468
+ const flags = [];
469
+
470
+ if (network && network._providerTimezone && network.timezone && network._providerTimezone !== network.timezone) {
471
+ timezone_mismatch = true;
472
+ vpn_detected = true; // strong VPN/proxy indicator
473
+ risk_score += 35;
474
+ flags.push({ type: "TIMEZONE_MISMATCH", severity: "high", description: "Browser timezone differs from IP-derived timezone" });
475
+ }
476
+
477
+ if (network && network._org && /amazon|aws|azure|google cloud|digitalocean|linode|ovh|hetzner|hosting|datacenter|vpn|proxy/i.test(network._org)) {
478
+ vpn_detected = true;
479
+ risk_score += 30;
480
+ flags.push({ type: "DATACENTER_IP", severity: "medium", description: "IP belongs to a hosting/datacenter ASN: " + network._org });
481
+ }
482
+
483
+ if (geo && geo.accuracy && geo.accuracy > 10000) {
484
+ mock_location_suspected = true;
485
+ risk_score += 10;
486
+ flags.push({ type: "LOW_GPS_ACCURACY", severity: "low", description: "GPS accuracy > 10km" });
487
+ }
488
+
489
+ // WebRTC leak check (proxy indicator) - best effort
490
+ try {
491
+ const localIp = await this._probeWebRTC(2000);
492
+ if (localIp && network && network.ipAddress && localIp !== network.ipAddress && /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/.test(localIp) === false) {
493
+ proxy_detected = true;
494
+ risk_score += 15;
495
+ flags.push({ type: "WEBRTC_PUBLIC_LEAK", severity: "medium", description: "WebRTC reveals different public IP" });
496
+ }
497
+ } catch (_) {}
498
+
499
+ if (risk_score > 100) risk_score = 100;
500
+ return { vpn_detected, proxy_detected, mock_location_suspected, timezone_mismatch, risk_score, flags };
501
+ }
502
+
503
+ _probeWebRTC(timeoutMs = 2000) {
504
+ return new Promise((resolve) => {
505
+ try {
506
+ const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
507
+ let resolved = false;
508
+ const done = (val) => { if (!resolved) { resolved = true; try { pc.close(); } catch(_) {} resolve(val); } };
509
+ pc.createDataChannel("p");
510
+ pc.onicecandidate = (e) => {
511
+ if (!e.candidate || !e.candidate.candidate) return;
512
+ const m = e.candidate.candidate.match(/(\d+\.\d+\.\d+\.\d+|[a-f0-9:]+:[a-f0-9:]+)/i);
513
+ if (m) done(m[1]);
514
+ };
515
+ pc.createOffer().then(o => pc.setLocalDescription(o)).catch(() => done(null));
516
+ setTimeout(() => done(null), timeoutMs);
517
+ } catch (_) { resolve(null); }
518
+ });
519
+ }
520
+
521
+ // ---- Payload + transport ----
522
+ async _buildPayload({ network, geolocation, device, risk_analysis }) {
523
+ const nonce = this._nonce();
524
+ const sessionPayload = {
525
+ session_id: this.sessionId,
526
+ sdk_version: VERSION,
527
+ verifying_for: this.config.verifyingFor || null,
528
+ face_image: this._state.capturedImage,
529
+ liveness_video: this._state.livenessVideoBlob ? await this._blobToBase64(this._state.livenessVideoBlob) : null,
530
+ timestamp: new Date().toISOString(),
531
+ nonce,
532
+ };
533
+ if (geolocation) sessionPayload.location = {
534
+ latitude: geolocation.latitude, longitude: geolocation.longitude,
535
+ accuracy: geolocation.accuracy, timestamp: geolocation.timestamp,
536
+ };
537
+ if (network) sessionPayload.network = {
538
+ ip_address: network.ipAddress || null,
539
+ ip_version: network.ipVersion || null,
540
+ user_agent: network.userAgent || "",
541
+ timezone: network.timezone || "",
542
+ language: network.language || "",
543
+ };
544
+ if (device) sessionPayload.device = {
545
+ fingerprint: device.fingerprint, platform: device.platform,
546
+ screen_resolution: device.screen_resolution, hardware_concurrency: device.hardware_concurrency,
547
+ };
548
+ if (risk_analysis) sessionPayload.risk_analysis = {
549
+ vpn_detected: risk_analysis.vpn_detected, proxy_detected: risk_analysis.proxy_detected,
550
+ mock_location_suspected: risk_analysis.mock_location_suspected, risk_score: risk_analysis.risk_score,
551
+ };
552
+ // Integrity hash over canonical payload (excluding the hash itself)
553
+ sessionPayload.integrity_hash = await this._sha256(this._canonical(sessionPayload) + nonce);
554
+ // Merge integrator body extras (last so they can override metadata if needed)
555
+ Object.assign(sessionPayload, this.config.body);
556
+ return sessionPayload;
557
+ }
558
+
559
+ _canonical(obj) {
560
+ const keys = Object.keys(obj).filter(k => k !== "integrity_hash").sort();
561
+ const out = {};
562
+ for (const k of keys) out[k] = obj[k];
563
+ return JSON.stringify(out);
564
+ }
565
+
566
+ _nonce() {
567
+ const arr = new Uint8Array(32);
568
+ crypto.getRandomValues(arr);
569
+ return Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("");
570
+ }
571
+
572
+ async _blobToBase64(blob) {
573
+ return new Promise((resolve, reject) => {
574
+ const r = new FileReader();
575
+ r.onloadend = () => resolve(r.result);
576
+ r.onerror = reject;
577
+ r.readAsDataURL(blob);
578
+ });
579
+ }
580
+
581
+ async _postToApi(payload) {
582
+ const useMultipart = !!(this.config.imageFieldName || this.config.videoFieldName);
583
+ let body, headers = { ...this.config.headers };
584
+ if (useMultipart) {
585
+ const fd = new FormData();
586
+ // Attach image as blob
587
+ if (this.config.imageFieldName && payload.face_image) {
588
+ const imgBlob = await (await fetch(payload.face_image)).blob();
589
+ fd.append(this.config.imageFieldName, imgBlob, "face.jpg");
590
+ }
591
+ // Attach video as blob
592
+ if (this.config.videoFieldName && this._state.livenessVideoBlob) {
593
+ fd.append(this.config.videoFieldName, this._state.livenessVideoBlob, "liveness.webm");
594
+ }
595
+ // Strip media from JSON-side payload to avoid duplication
596
+ const meta = { ...payload };
597
+ if (this.config.imageFieldName) delete meta.face_image;
598
+ if (this.config.videoFieldName) delete meta.liveness_video;
599
+ fd.append("metadata", JSON.stringify(meta));
600
+ body = fd;
601
+ // Let browser set Content-Type with boundary
602
+ delete headers["Content-Type"]; delete headers["content-type"];
603
+ } else {
604
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
605
+ body = JSON.stringify(payload);
606
+ }
607
+ try {
608
+ const r = await fetch(this.config.apiUrl, { method: this.config.method, headers, body });
609
+ if (!r.ok) throw this._err("UPLOAD_FAILED", "API returned " + r.status);
610
+ try { return await r.json(); } catch (_) { return null; }
611
+ } catch (e) { if (e && e.code) throw e; throw this._err("NETWORK_ERROR", "Could not reach verification server"); }
612
+ }
613
+
614
+ _stopDetectionLoop() { if (this._state.rafId) { cancelAnimationFrame(this._state.rafId); this._state.rafId = null; } }
615
+ _stopStream() { if (this._state.stream) { this._state.stream.getTracks().forEach((t) => t.stop()); this._state.stream = null; } }
616
+
617
+ // ---- UI ----
618
+ _buildUI() {
619
+ if (this._ui) return;
620
+ const t = this.config.theme;
621
+ const style = document.createElement("style");
622
+ style.id = "liveness-sdk-styles";
623
+ style.textContent = this._css(t);
624
+ document.head.appendChild(style);
625
+ const subtitle = this.config.verifyingFor ? "Verifying for <strong>" + this._escape(this.config.verifyingFor) + "</strong>" : "";
626
+ const overlay = document.createElement("div");
627
+ overlay.className = "lvs-overlay";
628
+ overlay.innerHTML = this._html(subtitle);
629
+ document.body.appendChild(overlay);
630
+ this._ui = {
631
+ style, overlay,
632
+ modal: overlay.querySelector(".lvs-modal"),
633
+ closeBtn: overlay.querySelector(".lvs-close"),
634
+ loadingView: overlay.querySelector(".lvs-loading-view"),
635
+ loadingText: overlay.querySelector(".lvs-loading-text"),
636
+ challengeView: overlay.querySelector(".lvs-challenge-view"),
637
+ ring: overlay.querySelector(".lvs-ring"),
638
+ titleEl: overlay.querySelector(".lvs-title"),
639
+ hintEl: overlay.querySelector(".lvs-hint"),
640
+ dotsEl: overlay.querySelector(".lvs-dots"),
641
+ resultView: overlay.querySelector(".lvs-result-view"),
642
+ resultIcon: overlay.querySelector(".lvs-result-icon"),
643
+ resultTitle: overlay.querySelector(".lvs-result-title"),
644
+ resultMsg: overlay.querySelector(".lvs-result-msg"),
645
+ resultBtn: overlay.querySelector(".lvs-result-btn"),
646
+ retryBtn: overlay.querySelector(".lvs-retry-btn"),
647
+ };
648
+ this._state.video = overlay.querySelector(".lvs-video");
649
+ this._ui.closeBtn.addEventListener("click", () => this._handleClose());
650
+ this._ui.resultBtn.addEventListener("click", () => this._removeUI());
651
+ this._ui.retryBtn.addEventListener("click", () => this._retry());
652
+ }
653
+
654
+ _css(t) {
655
+ return ".lvs-overlay{position:fixed;inset:0;background:rgba(20,22,30,.6);display:flex;align-items:center;justify-content:center;z-index:999999;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;backdrop-filter:blur(4px)}"
656
+ + ".lvs-modal{background:" + t.bg + ";border-radius:16px;padding:32px 28px;max-width:420px;width:92%;box-shadow:0 24px 60px rgba(0,0,0,.25);text-align:center;position:relative;box-sizing:border-box}"
657
+ + ".lvs-modal *{box-sizing:border-box}"
658
+ + ".lvs-close{position:absolute;top:12px;right:12px;background:none;border:none;font-size:22px;color:#aaa;cursor:pointer;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1}"
659
+ + ".lvs-close:hover{background:#f0f0f0;color:#666}"
660
+ + ".lvs-header{font-size:16px;color:" + t.text + ";font-weight:600;margin-bottom:4px}"
661
+ + ".lvs-subtitle{font-size:12px;color:" + t.muted + ";margin-bottom:18px;min-height:14px}"
662
+ + ".lvs-subtitle strong{color:" + t.text + ";font-weight:600}"
663
+ + ".lvs-camera-wrap{position:relative;width:220px;height:220px;margin:0 auto 22px}"
664
+ + ".lvs-ring{position:absolute;inset:0;border-radius:50%;pointer-events:none}"
665
+ + ".lvs-ring::before,.lvs-ring::after{content:'';position:absolute;inset:0;border-radius:50%;border:3px solid " + t.primary + ";box-sizing:border-box}"
666
+ + ".lvs-ring::before{animation:lvs-pulse 1.8s ease-out infinite}"
667
+ + ".lvs-ring::after{opacity:.4;animation:lvs-pulse 1.8s ease-out infinite;animation-delay:.9s}"
668
+ + ".lvs-ring.success::before,.lvs-ring.success::after{border-color:" + t.success + ";animation:lvs-flash .5s ease-out}"
669
+ + ".lvs-ring.idle::before,.lvs-ring.idle::after{animation:none;opacity:.3}"
670
+ + "@keyframes lvs-pulse{0%{transform:scale(1);opacity:.9}100%{transform:scale(1.18);opacity:0}}"
671
+ + "@keyframes lvs-flash{0%{transform:scale(1);opacity:1}50%{transform:scale(1.12);opacity:.8}100%{transform:scale(1);opacity:1}}"
672
+ + ".lvs-circle{width:200px;height:200px;border-radius:50%;background:#000;margin:10px;overflow:hidden;position:relative}"
673
+ + ".lvs-video{width:100%;height:100%;object-fit:cover;transform:scaleX(-1)}"
674
+ + ".lvs-title{font-size:20px;color:" + t.text + ";font-weight:600;margin-bottom:6px}"
675
+ + ".lvs-hint{font-size:13px;color:" + t.muted + ";min-height:18px;transition:color .2s}"
676
+ + ".lvs-hint.ok{color:" + t.success + "}"
677
+ + ".lvs-hint.warn{color:#f59e0b}"
678
+ + ".lvs-dots{display:flex;gap:6px;justify-content:center;margin-top:18px}"
679
+ + ".lvs-dot{width:7px;height:7px;border-radius:50%;background:#dfe2e8;transition:all .3s}"
680
+ + ".lvs-dot.active{background:" + t.primary + ";transform:scale(1.25)}"
681
+ + ".lvs-dot.done{background:" + t.success + "}"
682
+ + ".lvs-loading{display:flex;flex-direction:column;align-items:center;gap:14px;padding:30px 0}"
683
+ + ".lvs-spinner{width:36px;height:36px;border:3px solid #eee;border-top-color:" + t.primary + ";border-radius:50%;animation:lvs-spin .8s linear infinite}"
684
+ + "@keyframes lvs-spin{to{transform:rotate(360deg)}}"
685
+ + ".lvs-loading p{font-size:13px;color:" + t.muted + ";margin:0}"
686
+ + ".lvs-result-icon{width:72px;height:72px;margin:0 auto 18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:36px;color:#fff;font-weight:bold}"
687
+ + ".lvs-result-icon.success{background:" + t.success + "}"
688
+ + ".lvs-result-icon.error{background:" + t.error + "}"
689
+ + ".lvs-result-title{font-size:20px;color:" + t.text + ";font-weight:600;margin-bottom:6px}"
690
+ + ".lvs-result-msg{font-size:13px;color:" + t.muted + ";margin-bottom:22px}"
691
+ + ".lvs-btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;background:" + t.primary + ";color:#fff;margin-top:8px}"
692
+ + ".lvs-btn:first-of-type{margin-top:0}"
693
+ + ".lvs-btn:hover{opacity:.9}"
694
+ + ".lvs-btn-secondary{background:#f3f4f6;color:" + t.text + "}"
695
+ + ".lvs-btn-secondary:hover{background:#e5e7eb;opacity:1}";
696
+ }
697
+
698
+ _html(subtitle) {
699
+ return '<div class="lvs-modal">'
700
+ + '<button class="lvs-close" aria-label="Close">&times;</button>'
701
+ + '<div class="lvs-header">Identity Verification</div>'
702
+ + '<div class="lvs-subtitle">' + subtitle + '</div>'
703
+ + '<div class="lvs-loading-view lvs-loading"><div class="lvs-spinner"></div><p class="lvs-loading-text">Loading...</p></div>'
704
+ + '<div class="lvs-challenge-view" style="display:none">'
705
+ + '<div class="lvs-camera-wrap">'
706
+ + '<div class="lvs-ring idle"></div>'
707
+ + '<div class="lvs-circle"><video class="lvs-video" autoplay playsinline muted></video></div>'
708
+ + '</div>'
709
+ + '<div class="lvs-title">Get ready</div>'
710
+ + '<div class="lvs-hint">Position your face in the circle</div>'
711
+ + '<div class="lvs-dots"></div>'
712
+ + '</div>'
713
+ + '<div class="lvs-result-view" style="display:none">'
714
+ + '<div class="lvs-result-icon success">&#10003;</div>'
715
+ + '<div class="lvs-result-title">Verified</div>'
716
+ + '<div class="lvs-result-msg">All set.</div>'
717
+ + '<button class="lvs-btn lvs-retry-btn" style="display:none">Try Again</button>'
718
+ + '<button class="lvs-btn lvs-btn-secondary lvs-result-btn">Close</button>'
719
+ + '</div>'
720
+ + '</div>';
721
+ }
722
+
723
+ _handleClose() { this._state.running = false; this._stopDetectionLoop(); this._stopStream(); this._removeUI(); }
724
+ _removeUI() { if (!this._ui) return; this._ui.overlay.remove(); this._ui.style.remove(); this._ui = null; }
725
+ _showLoading(msg) { if (!this._ui) return; this._ui.loadingText.textContent = msg; this._ui.loadingView.style.display = "flex"; this._ui.challengeView.style.display = "none"; this._ui.resultView.style.display = "none"; }
726
+ _showChallengeView() { if (!this._ui) return; this._ui.loadingView.style.display = "none"; this._ui.challengeView.style.display = "block"; this._ui.resultView.style.display = "none"; }
727
+ _setChallengeUI(type, index, total) {
728
+ if (!this._ui) return;
729
+ const def = CHALLENGE_DEFS[type];
730
+ this._ui.titleEl.textContent = def.title;
731
+ this._setHint(def.hint);
732
+ this._ui.ring.className = "lvs-ring";
733
+ this._ui.dotsEl.innerHTML = "";
734
+ for (let i = 0; i < total; i++) {
735
+ const d = document.createElement("div");
736
+ d.className = "lvs-dot" + (i < index ? " done" : i === index ? " active" : "");
737
+ this._ui.dotsEl.appendChild(d);
738
+ }
739
+ }
740
+ _setHint(text, kind) { if (!this._ui) return; this._ui.hintEl.textContent = text; this._ui.hintEl.className = "lvs-hint" + (kind ? " " + kind : ""); }
741
+ _flashSuccess() { if (!this._ui) return; this._ui.ring.className = "lvs-ring success"; this._setHint("Great!", "ok"); }
742
+ _showSuccess(title, msg) { if (!this._ui) return; this._ui.loadingView.style.display = "none"; this._ui.challengeView.style.display = "none"; this._ui.resultView.style.display = "block"; this._ui.resultIcon.className = "lvs-result-icon success"; this._ui.resultIcon.innerHTML = "&#10003;"; this._ui.resultTitle.textContent = title; this._ui.resultMsg.textContent = msg; this._ui.retryBtn.style.display = "none"; }
743
+ _showError(msg) { if (!this._ui) return; this._ui.loadingView.style.display = "none"; this._ui.challengeView.style.display = "none"; this._ui.resultView.style.display = "block"; this._ui.resultIcon.className = "lvs-result-icon error"; this._ui.resultIcon.innerHTML = "&times;"; this._ui.resultTitle.textContent = "Verification failed"; this._ui.resultMsg.textContent = msg || "Please try again"; this._ui.retryBtn.style.display = "block"; }
744
+ _progress(stage, progress, message) { try { this.config.onProgress({ stage, progress, message, timestamp: Date.now() }); } catch (_) {} }
745
+ _err(code, message, details) { const e = new Error(message); e.code = code; e.details = details; e.recoverable = code !== "BROWSER_NOT_SUPPORTED"; return e; }
746
+ _sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
747
+ _escape(str) { return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
748
+ }
749
+
750
+ export default LivenessSDK;
751
+ export { LivenessSDK, VERSION };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "face-verification-iyx",
3
+ "version": "2.0.0",
4
+ "description": "Browser face liveness verification SDK — challenge-based liveness, identity check, device/network/risk metadata, signed payload.",
5
+ "type": "module",
6
+ "main": "./dist/sdk.esm.js",
7
+ "module": "./dist/sdk.esm.js",
8
+ "types": "./dist/sdk.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/sdk.esm.js",
12
+ "types": "./dist/sdk.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/sdk.esm.js",
17
+ "dist/sdk.d.ts",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "prepublishOnly": "node -e \"require('fs').accessSync('dist/sdk.esm.js')\"",
23
+ "publish:public": "npm publish --access public"
24
+ },
25
+ "keywords": [
26
+ "face-liveness",
27
+ "liveness-detection",
28
+ "face-verification",
29
+ "biometric",
30
+ "identity-verification",
31
+ "anti-spoofing",
32
+ "browser",
33
+ "sdk",
34
+ "mediapipe",
35
+ "kyc"
36
+ ],
37
+ "author": "Inocyx",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/rathish64/face-auth-sdk.git"
42
+ },
43
+ "homepage": "https://github.com/rathish64/face-auth-sdk.git",
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "sideEffects": false
48
+ }