verant_id_cloud_scan 1.4.5 → 1.4.6-beta.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/.github/workflows/publish_library.yml +9 -1
- package/Api.js +511 -65
- package/Api.test.js +338 -0
- package/CHANGELOG.md +254 -0
- package/CloudScan.js +185 -3
- package/FaceComparison.js +2 -4
- package/LocalApi.js +16 -22
- package/LocalScannerApi.js +47 -32
- package/ScannerApi.js +193 -76
- package/ScannerProfiles.js +132 -0
- package/WebSocketClient.js +281 -0
- package/index.d.ts +175 -2
- package/package.json +12 -3
|
@@ -24,4 +24,12 @@ jobs:
|
|
|
24
24
|
run: npm install -g npm@latest
|
|
25
25
|
|
|
26
26
|
- name: Publish to npm
|
|
27
|
-
run:
|
|
27
|
+
run: |
|
|
28
|
+
VERSION="$(node -p "require('./package.json').version")"
|
|
29
|
+
if [[ "$VERSION" == *-* ]]; then
|
|
30
|
+
echo "Publishing prerelease $VERSION under the 'beta' dist-tag"
|
|
31
|
+
npm publish --tag beta
|
|
32
|
+
else
|
|
33
|
+
echo "Publishing release $VERSION under 'latest'"
|
|
34
|
+
npm publish
|
|
35
|
+
fi
|
package/Api.js
CHANGED
|
@@ -3,17 +3,110 @@ import { formatDateToISO, DATE_FIELDS } from "./DateUtils.js";
|
|
|
3
3
|
import { FIELD_MAPPING, FIELD_DEFAULTS, getFieldValue } from "./FieldMapping.js";
|
|
4
4
|
import { sanitizeFieldForDmv } from "./FormatUtils.js";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Environment Detection & Management
|
|
8
|
+
*
|
|
9
|
+
* Defaults to PRODUCTION for all hosts (including localhost).
|
|
10
|
+
* Only verify-staging.verantid.com automatically uses staging.
|
|
11
|
+
* Host apps can explicitly override via setEnvironment('staging' | 'production').
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Current environment - starts as null to trigger detection on first use
|
|
15
|
+
let currentEnvironment = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect environment based on hostname
|
|
19
|
+
* @returns {'staging' | 'production'}
|
|
20
|
+
*/
|
|
21
|
+
function detectEnvironment() {
|
|
22
|
+
// Default to production for all hosts
|
|
23
|
+
let environment = 'production';
|
|
24
|
+
|
|
25
|
+
// Only auto-switch to staging for VerantID's staging domain
|
|
26
|
+
if (typeof window !== 'undefined') {
|
|
27
|
+
const hostname = window.location.hostname;
|
|
28
|
+
|
|
29
|
+
// ONLY verify-staging.verantid.com uses staging by default
|
|
30
|
+
if (hostname === 'verify-staging.verantid.com') {
|
|
31
|
+
environment = 'staging';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// All other hostnames (localhost, customer sites, verify.verantid.com, etc.) use production
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('[CloudScan] Environment detected:', environment, 'on', typeof window !== 'undefined' ? window.location.hostname : 'server');
|
|
38
|
+
return environment;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get current environment (detects if not already set)
|
|
43
|
+
* @returns {'staging' | 'production'}
|
|
44
|
+
*/
|
|
45
|
+
function getEnvironment() {
|
|
46
|
+
if (currentEnvironment === null) {
|
|
47
|
+
currentEnvironment = detectEnvironment();
|
|
48
|
+
}
|
|
49
|
+
return currentEnvironment;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Explicitly set the environment
|
|
54
|
+
* @param {'staging' | 'production'} environment
|
|
55
|
+
* @throws {Error} If environment is not 'staging' or 'production'
|
|
56
|
+
*/
|
|
57
|
+
export function setEnvironment(environment) {
|
|
58
|
+
if (environment !== 'staging' && environment !== 'production') {
|
|
59
|
+
throw new Error(`Invalid environment: "${environment}". Must be "staging" or "production".`);
|
|
60
|
+
}
|
|
61
|
+
currentEnvironment = environment;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get license server address based on current environment
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function getLicenseServerAddress() {
|
|
69
|
+
const env = getEnvironment();
|
|
70
|
+
return env === 'staging'
|
|
71
|
+
? "https://lic-staging.verantid.com/api/v1"
|
|
72
|
+
: "https://lic.verantid.com/api/v1";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get DMV server address based on current environment
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function getDmvServerAddress() {
|
|
80
|
+
const env = getEnvironment();
|
|
81
|
+
return env === 'staging'
|
|
82
|
+
? "https://dmv-check-server-staging-fcaab48bec21.herokuapp.com/api/v1/dmv_check"
|
|
83
|
+
: "https://dmv.verantid.com/api/v1/dmv_check";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get Face Comparison server address based on current environment
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function getFaceComparisonServerAddress() {
|
|
91
|
+
const env = getEnvironment();
|
|
92
|
+
return env === 'staging'
|
|
93
|
+
? "https://staging.face.verantid.bluerocket.us/compare_id_and_face"
|
|
94
|
+
: "https://faceid.verantid.com/compare_id_and_face";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get Mobile Verification server address based on current environment
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function getMobileVerificationServerAddress() {
|
|
102
|
+
const env = getEnvironment();
|
|
103
|
+
return env === 'staging'
|
|
104
|
+
? "https://staging.mdl.verantid.bluerocket.us"
|
|
105
|
+
: "https://mdl.verantid.bluerocket.us";
|
|
106
|
+
}
|
|
14
107
|
|
|
15
108
|
export const dmvCheck = async (clientId, scannerType, licenseData) => {
|
|
16
|
-
const url =
|
|
109
|
+
const url = getDmvServerAddress();
|
|
17
110
|
|
|
18
111
|
// Build the driver object dynamically.
|
|
19
112
|
const driver = {};
|
|
@@ -104,7 +197,7 @@ export const dmvCheck = async (clientId, scannerType, licenseData) => {
|
|
|
104
197
|
};
|
|
105
198
|
|
|
106
199
|
export const checkDmvFeatureLicense = async (clientId) => {
|
|
107
|
-
const url =
|
|
200
|
+
const url = getLicenseServerAddress() + "/dmv_verifications/check_license";
|
|
108
201
|
const data = { client_id: clientId };
|
|
109
202
|
|
|
110
203
|
try {
|
|
@@ -138,7 +231,7 @@ export const checkDmvFeatureLicense = async (clientId) => {
|
|
|
138
231
|
};
|
|
139
232
|
|
|
140
233
|
export const checkFaceComparisonFeatureLicense = async (clientId) => {
|
|
141
|
-
const url =
|
|
234
|
+
const url = getLicenseServerAddress() + "/facial_scans/check_license";
|
|
142
235
|
const data = { client_id: clientId };
|
|
143
236
|
|
|
144
237
|
try {
|
|
@@ -171,9 +264,60 @@ export const checkFaceComparisonFeatureLicense = async (clientId) => {
|
|
|
171
264
|
}
|
|
172
265
|
}
|
|
173
266
|
|
|
174
|
-
export const
|
|
175
|
-
const url =
|
|
176
|
-
const data = { client_id: clientId
|
|
267
|
+
export const checkDigitalIDFeatureLicense = async (clientId) => {
|
|
268
|
+
const url = getLicenseServerAddress() + "/mobile_id/check_license";
|
|
269
|
+
const data = { client_id: clientId };
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const response = await fetch(url, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
},
|
|
277
|
+
body: JSON.stringify(data),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Extract the JSON data from the response
|
|
281
|
+
const responseData = await response.json();
|
|
282
|
+
|
|
283
|
+
if (response.ok && responseData.licensed === true) {
|
|
284
|
+
return true; // Successful response
|
|
285
|
+
} else {
|
|
286
|
+
console.log(
|
|
287
|
+
"checkDigitalIDFeatureLicense returned false or non 200:",
|
|
288
|
+
responseData
|
|
289
|
+
);
|
|
290
|
+
return false; // Unsuccessful response
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(
|
|
294
|
+
"There was a problem with the checkDigitalIDFeatureLicense request:",
|
|
295
|
+
error
|
|
296
|
+
);
|
|
297
|
+
return false; // Error occurred
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Pass exactly one of macAddress / serialNumber. The license server rejects
|
|
302
|
+
// requests that supply both or neither; we also fail fast here so callers
|
|
303
|
+
// don't have to wait for a 400 to learn they passed a bad identifier.
|
|
304
|
+
export const checkLicense = async (clientId, identifier = {}) => {
|
|
305
|
+
if (identifier === null || typeof identifier !== "object") {
|
|
306
|
+
throw new TypeError(
|
|
307
|
+
`checkLicense: second argument must be an object like { macAddress } or { serialNumber }, got ${typeof identifier}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const { macAddress, serialNumber } = identifier;
|
|
311
|
+
if (Boolean(macAddress) === Boolean(serialNumber)) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"checkLicense: pass exactly one of macAddress / serialNumber",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const url = getLicenseServerAddress() + "/check_license";
|
|
318
|
+
const data = { client_id: clientId };
|
|
319
|
+
if (macAddress) data.mac_address = macAddress;
|
|
320
|
+
if (serialNumber) data.serial_number = serialNumber;
|
|
177
321
|
|
|
178
322
|
try {
|
|
179
323
|
const response = await fetch(url, {
|
|
@@ -207,7 +351,7 @@ export const checkLicense = async (clientId, macAddress) => {
|
|
|
207
351
|
* @returns {Promise<boolean>} True if autoDmv is enabled
|
|
208
352
|
*/
|
|
209
353
|
export const checkAutoDmvEnabled = async (clientId) => {
|
|
210
|
-
const url =
|
|
354
|
+
const url = getLicenseServerAddress() + "/check_feature_license";
|
|
211
355
|
const data = { client_id: clientId };
|
|
212
356
|
|
|
213
357
|
try {
|
|
@@ -232,7 +376,7 @@ export const checkAutoDmvEnabled = async (clientId) => {
|
|
|
232
376
|
}
|
|
233
377
|
};
|
|
234
378
|
export const getBarcodeLicenseKey = async () => {
|
|
235
|
-
const url =
|
|
379
|
+
const url = getLicenseServerAddress() + "/get_barcode_license_key";
|
|
236
380
|
|
|
237
381
|
try {
|
|
238
382
|
const response = await fetch(url, {
|
|
@@ -253,45 +397,148 @@ export const getBarcodeLicenseKey = async () => {
|
|
|
253
397
|
}
|
|
254
398
|
};
|
|
255
399
|
|
|
256
|
-
|
|
257
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Build the /securelink URL for the given scanner address.
|
|
402
|
+
*
|
|
403
|
+
* Mirrors the SDK's historical behaviour: if the caller didn't include a
|
|
404
|
+
* scheme, default to http://. The scheme test is case-insensitive per
|
|
405
|
+
* RFC 3986 (VNT-1522) — `HTTP://`, `HTTPS://`, and mixed-case forms are
|
|
406
|
+
* recognised so the SDK doesn't double-prefix them with another `http://`.
|
|
407
|
+
*/
|
|
408
|
+
export const buildSecurelinkUrl = (scannerAddress) => {
|
|
409
|
+
const hasScheme = /^https?:\/\//i.test(scannerAddress);
|
|
410
|
+
const withScheme = hasScheme ? scannerAddress : `http://${scannerAddress}`;
|
|
411
|
+
return `${withScheme.replace(/\/+$/, "")}/securelink`;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Lower-level POST that returns a structured result instead of throwing.
|
|
416
|
+
* Used by `checkScannerPresentDetailed` (VNT-1522) so the caller can
|
|
417
|
+
* distinguish network failures, HTTP errors, parse failures, and scanner
|
|
418
|
+
* application-layer errors. Existing callers go through `postToScannerApi`
|
|
419
|
+
* which preserves the throw-on-error contract.
|
|
420
|
+
*
|
|
421
|
+
* Supports an external `AbortSignal` so host apps can cancel an in-flight
|
|
422
|
+
* probe on retry / unmount without waiting for the internal timeout.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} scannerAddress
|
|
425
|
+
* @param {object} commandObj
|
|
426
|
+
* @param {number} [timeout=10000] timeout in ms before the internal AbortController aborts
|
|
427
|
+
* @param {AbortSignal} [externalSignal] optional external signal; when aborted, the request is cancelled
|
|
428
|
+
* @returns {Promise<{
|
|
429
|
+
* threw: boolean,
|
|
430
|
+
* error?: Error,
|
|
431
|
+
* ok?: boolean,
|
|
432
|
+
* status?: number,
|
|
433
|
+
* statusText?: string,
|
|
434
|
+
* body?: unknown,
|
|
435
|
+
* attemptedUrl: string,
|
|
436
|
+
* }>}
|
|
437
|
+
*/
|
|
438
|
+
const postToScannerApiDetailed = async (
|
|
258
439
|
scannerAddress,
|
|
259
440
|
commandObj,
|
|
260
|
-
timeout = 10000
|
|
441
|
+
timeout = 10000,
|
|
442
|
+
externalSignal,
|
|
261
443
|
) => {
|
|
262
|
-
|
|
263
|
-
let formattedAddress = scannerAddress;
|
|
264
|
-
if (!scannerAddress.startsWith('http://') && !scannerAddress.startsWith('https://')) {
|
|
265
|
-
formattedAddress = `http://${scannerAddress}`;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
let apiAddress = `${formattedAddress}/securelink`;
|
|
444
|
+
const attemptedUrl = buildSecurelinkUrl(scannerAddress);
|
|
269
445
|
|
|
270
446
|
const controller = new AbortController();
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
447
|
+
const fetchTimeout = setTimeout(() => controller.abort(), timeout);
|
|
448
|
+
|
|
449
|
+
// Forward an external abort signal to our controller so the host app's
|
|
450
|
+
// cancellation (retry, unmount) propagates to the in-flight fetch.
|
|
451
|
+
let externalAbortHandler;
|
|
452
|
+
if (externalSignal) {
|
|
453
|
+
if (externalSignal.aborted) {
|
|
454
|
+
controller.abort();
|
|
455
|
+
} else {
|
|
456
|
+
externalAbortHandler = () => controller.abort();
|
|
457
|
+
externalSignal.addEventListener("abort", externalAbortHandler, { once: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
276
460
|
|
|
277
461
|
try {
|
|
278
|
-
const response = await fetch(
|
|
462
|
+
const response = await fetch(attemptedUrl, {
|
|
279
463
|
method: "POST",
|
|
280
464
|
headers: { "Content-Type": "application/json" },
|
|
281
465
|
body: JSON.stringify(commandObj),
|
|
282
|
-
signal: signal,
|
|
466
|
+
signal: controller.signal,
|
|
283
467
|
});
|
|
284
468
|
|
|
285
|
-
|
|
469
|
+
// Read the body once as text, then try to JSON-parse — keeps a malformed
|
|
470
|
+
// body from making us discard otherwise-useful status info.
|
|
471
|
+
let body = null;
|
|
472
|
+
try {
|
|
473
|
+
const text = await response.text();
|
|
474
|
+
if (text) {
|
|
475
|
+
try {
|
|
476
|
+
body = JSON.parse(text);
|
|
477
|
+
} catch {
|
|
478
|
+
body = text; // not JSON, but still meaningful for diagnostics
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
// body read failed — leave body=null, caller can decide
|
|
483
|
+
}
|
|
286
484
|
|
|
287
|
-
|
|
288
|
-
|
|
485
|
+
return {
|
|
486
|
+
threw: false,
|
|
487
|
+
ok: response.ok,
|
|
488
|
+
status: response.status,
|
|
489
|
+
statusText: response.statusText,
|
|
490
|
+
body,
|
|
491
|
+
attemptedUrl,
|
|
492
|
+
};
|
|
289
493
|
} catch (error) {
|
|
290
|
-
|
|
494
|
+
return { threw: true, error, attemptedUrl };
|
|
495
|
+
} finally {
|
|
496
|
+
clearTimeout(fetchTimeout);
|
|
497
|
+
if (externalSignal && externalAbortHandler) {
|
|
498
|
+
externalSignal.removeEventListener("abort", externalAbortHandler);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// Helper function for making POST requests to the scanner API. Thin wrapper
|
|
504
|
+
// around `postToScannerApiDetailed` that preserves the historical contract
|
|
505
|
+
// (throws on timeout / network / non-2xx; returns parsed JSON on success).
|
|
506
|
+
const postToScannerApi = async (
|
|
507
|
+
scannerAddress,
|
|
508
|
+
commandObj,
|
|
509
|
+
timeout = 10000
|
|
510
|
+
) => {
|
|
511
|
+
const result = await postToScannerApiDetailed(
|
|
512
|
+
scannerAddress,
|
|
513
|
+
commandObj,
|
|
514
|
+
timeout,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
if (result.threw) {
|
|
518
|
+
if (result.error?.name === "AbortError") {
|
|
291
519
|
throw new Error("Request timed out");
|
|
292
520
|
}
|
|
293
|
-
throw error;
|
|
521
|
+
throw result.error;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
throw new Error(result.statusText || `HTTP ${result.status}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Match the original `response.json()` contract: throw if the 2xx body
|
|
529
|
+
// wasn't a parseable JSON object. The detailed wrapper deliberately
|
|
530
|
+
// preserves non-JSON bodies as strings (or `null` for empty) so error
|
|
531
|
+
// surfaces like `checkScannerPresentDetailed` can use them — but every
|
|
532
|
+
// existing caller of `postToScannerApi` (`getNetworkInfo`,
|
|
533
|
+
// `establishConnection`, `getParams`, `resetParams`, `setParameters`,
|
|
534
|
+
// `scanDocument`, …) reads object properties off the return value, so
|
|
535
|
+
// returning a string or null here would silently produce `undefined`
|
|
536
|
+
// downstream instead of failing loudly.
|
|
537
|
+
if (result.body === null || typeof result.body !== "object") {
|
|
538
|
+
throw new SyntaxError("Scanner response was not a JSON object");
|
|
294
539
|
}
|
|
540
|
+
|
|
541
|
+
return result.body;
|
|
295
542
|
};
|
|
296
543
|
|
|
297
544
|
export const getNetworkInfo = (scannerAddress, commandObj) =>
|
|
@@ -318,40 +565,239 @@ export const stopScanning = (scannerAddress, commandObj) =>
|
|
|
318
565
|
export const closeConnection = (scannerAddress, commandObj) =>
|
|
319
566
|
postToScannerApi(scannerAddress, commandObj);
|
|
320
567
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Classify a thrown fetch error into one of the documented codes (VNT-1522).
|
|
570
|
+
*
|
|
571
|
+
* `externallyAborted` distinguishes a host-app cancellation (retry, unmount)
|
|
572
|
+
* from an internal timeout — both surface as `AbortError` from fetch, but
|
|
573
|
+
* callers branching on `code` for UI copy (e.g. "scanner timed out") should
|
|
574
|
+
* not see the timeout label when the user themselves cancelled.
|
|
575
|
+
*
|
|
576
|
+
* @param {unknown} error
|
|
577
|
+
* @param {string} attemptedUrl
|
|
578
|
+
* @param {boolean} [externallyAborted=false]
|
|
579
|
+
* @returns {{ code: string, name: string, message: string, attemptedUrl: string }}
|
|
580
|
+
*/
|
|
581
|
+
const classifyFetchError = (error, attemptedUrl, externallyAborted = false) => {
|
|
582
|
+
if (error?.name === "AbortError") {
|
|
583
|
+
return {
|
|
584
|
+
code: externallyAborted ? "ABORTED" : "TIMEOUT",
|
|
585
|
+
name: "AbortError",
|
|
586
|
+
message: externallyAborted ? "Request aborted" : "Request timed out",
|
|
587
|
+
attemptedUrl,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (error instanceof TypeError) {
|
|
591
|
+
// Browser fetch surfaces network failures (DNS, connection refused,
|
|
592
|
+
// mixed-content blocks, CORS preflight rejection) as `TypeError`.
|
|
593
|
+
return {
|
|
594
|
+
code: "NETWORK_ERROR",
|
|
595
|
+
name: "TypeError",
|
|
596
|
+
message: error.message || "Network error",
|
|
597
|
+
attemptedUrl,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
code: "NETWORK_ERROR",
|
|
602
|
+
name: error?.name || "Unknown",
|
|
603
|
+
message: error?.message || String(error),
|
|
604
|
+
attemptedUrl,
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Structured version of `checkScannerPresent` (VNT-1522).
|
|
610
|
+
*
|
|
611
|
+
* Returns `{ ok: true }` on success or `{ ok: false, error }` with a tagged
|
|
612
|
+
* `error.code` so host apps can distinguish:
|
|
613
|
+
* - `TIMEOUT` — internal 5s timeout fired
|
|
614
|
+
* - `ABORTED` — host app cancelled via the external `AbortSignal`
|
|
615
|
+
* - `NETWORK_ERROR` — fetch threw (DNS, refused, mixed content, CORS, etc.)
|
|
616
|
+
* - `HTTP_ERROR` — server replied non-2xx
|
|
617
|
+
* - `INVALID_RESPONSE` — 2xx but the body wasn't JSON
|
|
618
|
+
* - `SCANNER_ERROR` — 2xx JSON but no `SessionId` (the scanner returned
|
|
619
|
+
* an application-layer error in the body, e.g. an HTTPS-only scanner
|
|
620
|
+
* rejecting an HTTP probe with `{HttpRC:"403", ExtInformation:"..."}`)
|
|
621
|
+
*
|
|
622
|
+
* The error object also carries `attemptedUrl`, `status` (when a response
|
|
623
|
+
* was received), and the raw `scannerResponse` body for SCANNER_ERROR so
|
|
624
|
+
* callers can surface vendor-specific fields like `ExtInformation`.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} scannerAddress
|
|
627
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
628
|
+
*/
|
|
629
|
+
export const checkScannerPresentDetailed = async (
|
|
630
|
+
scannerAddress,
|
|
631
|
+
options = {},
|
|
632
|
+
) => {
|
|
633
|
+
const { signal } = options;
|
|
634
|
+
const attemptedUrl = buildSecurelinkUrl(scannerAddress);
|
|
635
|
+
|
|
636
|
+
const connectCommand = {
|
|
637
|
+
Command: "Connect",
|
|
638
|
+
MessageId: new Date().toISOString(),
|
|
639
|
+
Exclusive: "false", // non-exclusive — we're only probing
|
|
640
|
+
IdleTimeout: "5",
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const probe = await postToScannerApiDetailed(
|
|
644
|
+
scannerAddress,
|
|
645
|
+
connectCommand,
|
|
646
|
+
5000,
|
|
647
|
+
signal,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
if (probe.threw) {
|
|
651
|
+
return {
|
|
652
|
+
ok: false,
|
|
653
|
+
error: classifyFetchError(
|
|
654
|
+
probe.error,
|
|
655
|
+
attemptedUrl,
|
|
656
|
+
Boolean(signal?.aborted),
|
|
657
|
+
),
|
|
333
658
|
};
|
|
659
|
+
}
|
|
334
660
|
|
|
335
|
-
|
|
661
|
+
if (!probe.ok) {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
error: {
|
|
665
|
+
code: "HTTP_ERROR",
|
|
666
|
+
name: "HTTPError",
|
|
667
|
+
message: probe.statusText || `HTTP ${probe.status}`,
|
|
668
|
+
status: probe.status,
|
|
669
|
+
attemptedUrl,
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
336
673
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
674
|
+
if (!probe.body || typeof probe.body !== "object") {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
error: {
|
|
678
|
+
code: "INVALID_RESPONSE",
|
|
679
|
+
name: "InvalidResponse",
|
|
680
|
+
message: "Scanner returned a non-JSON or empty body",
|
|
681
|
+
status: probe.status,
|
|
682
|
+
attemptedUrl,
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
341
686
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
687
|
+
if (!probe.body.SessionId) {
|
|
688
|
+
// App-layer scanner error — common when an HTTPS-only scanner is hit
|
|
689
|
+
// over HTTP. The body usually contains vendor-specific fields like
|
|
690
|
+
// `ExtInformation` ("Scanner not configured for HTTP") that we forward
|
|
691
|
+
// so callers can present an actionable message.
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
error: {
|
|
695
|
+
code: "SCANNER_ERROR",
|
|
696
|
+
name: "ScannerError",
|
|
697
|
+
message:
|
|
698
|
+
probe.body.ExtInformation ||
|
|
699
|
+
probe.body.ErrorInformation ||
|
|
700
|
+
"Scanner returned no SessionId",
|
|
701
|
+
status:
|
|
702
|
+
typeof probe.body.HttpRC === "string"
|
|
703
|
+
? parseInt(probe.body.HttpRC, 10) || probe.status
|
|
704
|
+
: probe.status,
|
|
705
|
+
attemptedUrl,
|
|
706
|
+
scannerResponse: probe.body,
|
|
707
|
+
},
|
|
347
708
|
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Got a SessionId — clean up by disconnecting. We don't surface a
|
|
712
|
+
// disconnect failure: from the host app's perspective the scanner IS
|
|
713
|
+
// present and the leaked session will time out server-side per the
|
|
714
|
+
// scanner's IdleTimeout.
|
|
715
|
+
const disconnectCommand = {
|
|
716
|
+
Command: "Disconnect",
|
|
717
|
+
MessageId: new Date().toISOString(),
|
|
718
|
+
SessionId: probe.body.SessionId,
|
|
719
|
+
};
|
|
720
|
+
await postToScannerApiDetailed(
|
|
721
|
+
scannerAddress,
|
|
722
|
+
disconnectCommand,
|
|
723
|
+
5000,
|
|
724
|
+
signal,
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
return { ok: true };
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// Boolean form of scanner presence check. Thin wrapper around
|
|
731
|
+
// `checkScannerPresentDetailed` (VNT-1522) — preserved for existing
|
|
732
|
+
// callers; new code should prefer the detailed version.
|
|
733
|
+
export const checkScannerPresent = async (scannerAddress) => {
|
|
734
|
+
const result = await checkScannerPresentDetailed(scannerAddress);
|
|
735
|
+
return result.ok;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Create a verification session for mobile ID verification
|
|
740
|
+
*
|
|
741
|
+
* POST /sessions
|
|
742
|
+
* Returns: session_id, session_url (for QR code), websocket_url (for real-time updates)
|
|
743
|
+
*
|
|
744
|
+
* @param {string} userId - User ID for the verification session
|
|
745
|
+
* @param {string} clientId - Client ID for licensing and tracking
|
|
746
|
+
* @param {string} [origin] - Holder page origin; binds the server's
|
|
747
|
+
* SessionTranscript to the wallet's view (HPKE info). Required on non-default hosts.
|
|
748
|
+
* @returns {Promise<Object>} Session data with session_id, session_url, and websocket_url
|
|
749
|
+
*/
|
|
750
|
+
export const createVerificationSession = async (userId, clientId, origin) => {
|
|
751
|
+
const url = getMobileVerificationServerAddress() + "/sessions";
|
|
752
|
+
const data = { user_id: userId, client_id: clientId };
|
|
753
|
+
if (origin) {
|
|
754
|
+
data.origin = origin;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const response = await fetch(url, {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: {
|
|
761
|
+
"Content-Type": "application/json",
|
|
762
|
+
},
|
|
763
|
+
body: JSON.stringify(data),
|
|
764
|
+
});
|
|
348
765
|
|
|
349
|
-
|
|
766
|
+
if (response.ok) {
|
|
767
|
+
const responseData = await response.json();
|
|
768
|
+
return responseData;
|
|
769
|
+
} else {
|
|
770
|
+
// Read the body once as text — fetch consumes the stream on first read,
|
|
771
|
+
// so a `response.json()` then `response.text()` fallback would throw
|
|
772
|
+
// `TypeError: body stream already read` and mask the original error.
|
|
773
|
+
const errorText = await response.text();
|
|
774
|
+
let errorData = null;
|
|
775
|
+
try {
|
|
776
|
+
errorData = JSON.parse(errorText);
|
|
777
|
+
} catch {
|
|
778
|
+
// Non-JSON body (e.g. plain text 502 from API Gateway).
|
|
779
|
+
}
|
|
350
780
|
|
|
351
|
-
|
|
352
|
-
|
|
781
|
+
if (errorData) {
|
|
782
|
+
console.error("Verification session creation failed:", errorData);
|
|
783
|
+
return {
|
|
784
|
+
status: 'error',
|
|
785
|
+
message: errorData.message || 'Failed to create verification session',
|
|
786
|
+
error: errorData,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
console.error("Verification session creation failed:", errorText);
|
|
790
|
+
return {
|
|
791
|
+
status: 'error',
|
|
792
|
+
message: 'Failed to create verification session',
|
|
793
|
+
raw_error: errorText,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
353
796
|
} catch (error) {
|
|
354
|
-
|
|
355
|
-
return
|
|
797
|
+
console.error("There was a problem with the verification session request:", error);
|
|
798
|
+
return {
|
|
799
|
+
status: 'error',
|
|
800
|
+
message: error.message || 'Unable to connect to verification service.'
|
|
801
|
+
};
|
|
356
802
|
}
|
|
357
803
|
};
|