verant_id_cloud_scan 1.4.5-beta.10 → 1.4.5-beta.11
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/Api.js +304 -55
- package/Api.test.js +338 -0
- package/CHANGELOG.md +40 -0
- package/CloudScan.js +2 -1
- package/LocalScannerApi.js +47 -32
- package/ScannerApi.js +34 -1
- package/index.d.ts +81 -0
- package/package.json +2 -2
package/Api.js
CHANGED
|
@@ -264,9 +264,26 @@ export const checkFaceComparisonFeatureLicense = async (clientId) => {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
// Pass exactly one of macAddress / serialNumber. The license server rejects
|
|
268
|
+
// requests that supply both or neither; we also fail fast here so callers
|
|
269
|
+
// don't have to wait for a 400 to learn they passed a bad identifier.
|
|
270
|
+
export const checkLicense = async (clientId, identifier = {}) => {
|
|
271
|
+
if (identifier === null || typeof identifier !== "object") {
|
|
272
|
+
throw new TypeError(
|
|
273
|
+
`checkLicense: second argument must be an object like { macAddress } or { serialNumber }, got ${typeof identifier}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const { macAddress, serialNumber } = identifier;
|
|
277
|
+
if (Boolean(macAddress) === Boolean(serialNumber)) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"checkLicense: pass exactly one of macAddress / serialNumber",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
268
283
|
const url = getLicenseServerAddress() + "/check_license";
|
|
269
|
-
const data = { client_id: clientId
|
|
284
|
+
const data = { client_id: clientId };
|
|
285
|
+
if (macAddress) data.mac_address = macAddress;
|
|
286
|
+
if (serialNumber) data.serial_number = serialNumber;
|
|
270
287
|
|
|
271
288
|
try {
|
|
272
289
|
const response = await fetch(url, {
|
|
@@ -346,48 +363,148 @@ export const getBarcodeLicenseKey = async () => {
|
|
|
346
363
|
}
|
|
347
364
|
};
|
|
348
365
|
|
|
349
|
-
|
|
350
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Build the /securelink URL for the given scanner address.
|
|
368
|
+
*
|
|
369
|
+
* Mirrors the SDK's historical behaviour: if the caller didn't include a
|
|
370
|
+
* scheme, default to http://. The scheme test is case-insensitive per
|
|
371
|
+
* RFC 3986 (VNT-1522) — `HTTP://`, `HTTPS://`, and mixed-case forms are
|
|
372
|
+
* recognised so the SDK doesn't double-prefix them with another `http://`.
|
|
373
|
+
*/
|
|
374
|
+
export const buildSecurelinkUrl = (scannerAddress) => {
|
|
375
|
+
const hasScheme = /^https?:\/\//i.test(scannerAddress);
|
|
376
|
+
const withScheme = hasScheme ? scannerAddress : `http://${scannerAddress}`;
|
|
377
|
+
return `${withScheme.replace(/\/+$/, "")}/securelink`;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Lower-level POST that returns a structured result instead of throwing.
|
|
382
|
+
* Used by `checkScannerPresentDetailed` (VNT-1522) so the caller can
|
|
383
|
+
* distinguish network failures, HTTP errors, parse failures, and scanner
|
|
384
|
+
* application-layer errors. Existing callers go through `postToScannerApi`
|
|
385
|
+
* which preserves the throw-on-error contract.
|
|
386
|
+
*
|
|
387
|
+
* Supports an external `AbortSignal` so host apps can cancel an in-flight
|
|
388
|
+
* probe on retry / unmount without waiting for the internal timeout.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} scannerAddress
|
|
391
|
+
* @param {object} commandObj
|
|
392
|
+
* @param {number} [timeout=10000] timeout in ms before the internal AbortController aborts
|
|
393
|
+
* @param {AbortSignal} [externalSignal] optional external signal; when aborted, the request is cancelled
|
|
394
|
+
* @returns {Promise<{
|
|
395
|
+
* threw: boolean,
|
|
396
|
+
* error?: Error,
|
|
397
|
+
* ok?: boolean,
|
|
398
|
+
* status?: number,
|
|
399
|
+
* statusText?: string,
|
|
400
|
+
* body?: unknown,
|
|
401
|
+
* attemptedUrl: string,
|
|
402
|
+
* }>}
|
|
403
|
+
*/
|
|
404
|
+
const postToScannerApiDetailed = async (
|
|
351
405
|
scannerAddress,
|
|
352
406
|
commandObj,
|
|
353
|
-
timeout = 10000
|
|
407
|
+
timeout = 10000,
|
|
408
|
+
externalSignal,
|
|
354
409
|
) => {
|
|
355
|
-
|
|
356
|
-
let formattedAddress = scannerAddress;
|
|
357
|
-
if (!scannerAddress.startsWith('http://') && !scannerAddress.startsWith('https://')) {
|
|
358
|
-
formattedAddress = `http://${scannerAddress}`;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Remove trailing slash to prevent double slashes in URL
|
|
362
|
-
formattedAddress = formattedAddress.replace(/\/+$/, '');
|
|
363
|
-
|
|
364
|
-
let apiAddress = `${formattedAddress}/securelink`;
|
|
410
|
+
const attemptedUrl = buildSecurelinkUrl(scannerAddress);
|
|
365
411
|
|
|
366
412
|
const controller = new AbortController();
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
413
|
+
const fetchTimeout = setTimeout(() => controller.abort(), timeout);
|
|
414
|
+
|
|
415
|
+
// Forward an external abort signal to our controller so the host app's
|
|
416
|
+
// cancellation (retry, unmount) propagates to the in-flight fetch.
|
|
417
|
+
let externalAbortHandler;
|
|
418
|
+
if (externalSignal) {
|
|
419
|
+
if (externalSignal.aborted) {
|
|
420
|
+
controller.abort();
|
|
421
|
+
} else {
|
|
422
|
+
externalAbortHandler = () => controller.abort();
|
|
423
|
+
externalSignal.addEventListener("abort", externalAbortHandler, { once: true });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
372
426
|
|
|
373
427
|
try {
|
|
374
|
-
const response = await fetch(
|
|
428
|
+
const response = await fetch(attemptedUrl, {
|
|
375
429
|
method: "POST",
|
|
376
430
|
headers: { "Content-Type": "application/json" },
|
|
377
431
|
body: JSON.stringify(commandObj),
|
|
378
|
-
signal: signal,
|
|
432
|
+
signal: controller.signal,
|
|
379
433
|
});
|
|
380
434
|
|
|
381
|
-
|
|
435
|
+
// Read the body once as text, then try to JSON-parse — keeps a malformed
|
|
436
|
+
// body from making us discard otherwise-useful status info.
|
|
437
|
+
let body = null;
|
|
438
|
+
try {
|
|
439
|
+
const text = await response.text();
|
|
440
|
+
if (text) {
|
|
441
|
+
try {
|
|
442
|
+
body = JSON.parse(text);
|
|
443
|
+
} catch {
|
|
444
|
+
body = text; // not JSON, but still meaningful for diagnostics
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
// body read failed — leave body=null, caller can decide
|
|
449
|
+
}
|
|
382
450
|
|
|
383
|
-
|
|
384
|
-
|
|
451
|
+
return {
|
|
452
|
+
threw: false,
|
|
453
|
+
ok: response.ok,
|
|
454
|
+
status: response.status,
|
|
455
|
+
statusText: response.statusText,
|
|
456
|
+
body,
|
|
457
|
+
attemptedUrl,
|
|
458
|
+
};
|
|
385
459
|
} catch (error) {
|
|
386
|
-
|
|
460
|
+
return { threw: true, error, attemptedUrl };
|
|
461
|
+
} finally {
|
|
462
|
+
clearTimeout(fetchTimeout);
|
|
463
|
+
if (externalSignal && externalAbortHandler) {
|
|
464
|
+
externalSignal.removeEventListener("abort", externalAbortHandler);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Helper function for making POST requests to the scanner API. Thin wrapper
|
|
470
|
+
// around `postToScannerApiDetailed` that preserves the historical contract
|
|
471
|
+
// (throws on timeout / network / non-2xx; returns parsed JSON on success).
|
|
472
|
+
const postToScannerApi = async (
|
|
473
|
+
scannerAddress,
|
|
474
|
+
commandObj,
|
|
475
|
+
timeout = 10000
|
|
476
|
+
) => {
|
|
477
|
+
const result = await postToScannerApiDetailed(
|
|
478
|
+
scannerAddress,
|
|
479
|
+
commandObj,
|
|
480
|
+
timeout,
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
if (result.threw) {
|
|
484
|
+
if (result.error?.name === "AbortError") {
|
|
387
485
|
throw new Error("Request timed out");
|
|
388
486
|
}
|
|
389
|
-
throw error;
|
|
487
|
+
throw result.error;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!result.ok) {
|
|
491
|
+
throw new Error(result.statusText || `HTTP ${result.status}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Match the original `response.json()` contract: throw if the 2xx body
|
|
495
|
+
// wasn't a parseable JSON object. The detailed wrapper deliberately
|
|
496
|
+
// preserves non-JSON bodies as strings (or `null` for empty) so error
|
|
497
|
+
// surfaces like `checkScannerPresentDetailed` can use them — but every
|
|
498
|
+
// existing caller of `postToScannerApi` (`getNetworkInfo`,
|
|
499
|
+
// `establishConnection`, `getParams`, `resetParams`, `setParameters`,
|
|
500
|
+
// `scanDocument`, …) reads object properties off the return value, so
|
|
501
|
+
// returning a string or null here would silently produce `undefined`
|
|
502
|
+
// downstream instead of failing loudly.
|
|
503
|
+
if (result.body === null || typeof result.body !== "object") {
|
|
504
|
+
throw new SyntaxError("Scanner response was not a JSON object");
|
|
390
505
|
}
|
|
506
|
+
|
|
507
|
+
return result.body;
|
|
391
508
|
};
|
|
392
509
|
|
|
393
510
|
export const getNetworkInfo = (scannerAddress, commandObj) =>
|
|
@@ -414,42 +531,174 @@ export const stopScanning = (scannerAddress, commandObj) =>
|
|
|
414
531
|
export const closeConnection = (scannerAddress, commandObj) =>
|
|
415
532
|
postToScannerApi(scannerAddress, commandObj);
|
|
416
533
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Classify a thrown fetch error into one of the documented codes (VNT-1522).
|
|
536
|
+
*
|
|
537
|
+
* `externallyAborted` distinguishes a host-app cancellation (retry, unmount)
|
|
538
|
+
* from an internal timeout — both surface as `AbortError` from fetch, but
|
|
539
|
+
* callers branching on `code` for UI copy (e.g. "scanner timed out") should
|
|
540
|
+
* not see the timeout label when the user themselves cancelled.
|
|
541
|
+
*
|
|
542
|
+
* @param {unknown} error
|
|
543
|
+
* @param {string} attemptedUrl
|
|
544
|
+
* @param {boolean} [externallyAborted=false]
|
|
545
|
+
* @returns {{ code: string, name: string, message: string, attemptedUrl: string }}
|
|
546
|
+
*/
|
|
547
|
+
const classifyFetchError = (error, attemptedUrl, externallyAborted = false) => {
|
|
548
|
+
if (error?.name === "AbortError") {
|
|
549
|
+
return {
|
|
550
|
+
code: externallyAborted ? "ABORTED" : "TIMEOUT",
|
|
551
|
+
name: "AbortError",
|
|
552
|
+
message: externallyAborted ? "Request aborted" : "Request timed out",
|
|
553
|
+
attemptedUrl,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (error instanceof TypeError) {
|
|
557
|
+
// Browser fetch surfaces network failures (DNS, connection refused,
|
|
558
|
+
// mixed-content blocks, CORS preflight rejection) as `TypeError`.
|
|
559
|
+
return {
|
|
560
|
+
code: "NETWORK_ERROR",
|
|
561
|
+
name: "TypeError",
|
|
562
|
+
message: error.message || "Network error",
|
|
563
|
+
attemptedUrl,
|
|
429
564
|
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
code: "NETWORK_ERROR",
|
|
568
|
+
name: error?.name || "Unknown",
|
|
569
|
+
message: error?.message || String(error),
|
|
570
|
+
attemptedUrl,
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Structured version of `checkScannerPresent` (VNT-1522).
|
|
576
|
+
*
|
|
577
|
+
* Returns `{ ok: true }` on success or `{ ok: false, error }` with a tagged
|
|
578
|
+
* `error.code` so host apps can distinguish:
|
|
579
|
+
* - `TIMEOUT` — internal 5s timeout fired
|
|
580
|
+
* - `ABORTED` — host app cancelled via the external `AbortSignal`
|
|
581
|
+
* - `NETWORK_ERROR` — fetch threw (DNS, refused, mixed content, CORS, etc.)
|
|
582
|
+
* - `HTTP_ERROR` — server replied non-2xx
|
|
583
|
+
* - `INVALID_RESPONSE` — 2xx but the body wasn't JSON
|
|
584
|
+
* - `SCANNER_ERROR` — 2xx JSON but no `SessionId` (the scanner returned
|
|
585
|
+
* an application-layer error in the body, e.g. an HTTPS-only scanner
|
|
586
|
+
* rejecting an HTTP probe with `{HttpRC:"403", ExtInformation:"..."}`)
|
|
587
|
+
*
|
|
588
|
+
* The error object also carries `attemptedUrl`, `status` (when a response
|
|
589
|
+
* was received), and the raw `scannerResponse` body for SCANNER_ERROR so
|
|
590
|
+
* callers can surface vendor-specific fields like `ExtInformation`.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} scannerAddress
|
|
593
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
594
|
+
*/
|
|
595
|
+
export const checkScannerPresentDetailed = async (
|
|
596
|
+
scannerAddress,
|
|
597
|
+
options = {},
|
|
598
|
+
) => {
|
|
599
|
+
const { signal } = options;
|
|
600
|
+
const attemptedUrl = buildSecurelinkUrl(scannerAddress);
|
|
601
|
+
|
|
602
|
+
const connectCommand = {
|
|
603
|
+
Command: "Connect",
|
|
604
|
+
MessageId: new Date().toISOString(),
|
|
605
|
+
Exclusive: "false", // non-exclusive — we're only probing
|
|
606
|
+
IdleTimeout: "5",
|
|
607
|
+
};
|
|
430
608
|
|
|
431
|
-
|
|
609
|
+
const probe = await postToScannerApiDetailed(
|
|
610
|
+
scannerAddress,
|
|
611
|
+
connectCommand,
|
|
612
|
+
5000,
|
|
613
|
+
signal,
|
|
614
|
+
);
|
|
432
615
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
616
|
+
if (probe.threw) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
error: classifyFetchError(
|
|
620
|
+
probe.error,
|
|
621
|
+
attemptedUrl,
|
|
622
|
+
Boolean(signal?.aborted),
|
|
623
|
+
),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
437
626
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
627
|
+
if (!probe.ok) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
error: {
|
|
631
|
+
code: "HTTP_ERROR",
|
|
632
|
+
name: "HTTPError",
|
|
633
|
+
message: probe.statusText || `HTTP ${probe.status}`,
|
|
634
|
+
status: probe.status,
|
|
635
|
+
attemptedUrl,
|
|
636
|
+
},
|
|
443
637
|
};
|
|
638
|
+
}
|
|
444
639
|
|
|
445
|
-
|
|
640
|
+
if (!probe.body || typeof probe.body !== "object") {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
error: {
|
|
644
|
+
code: "INVALID_RESPONSE",
|
|
645
|
+
name: "InvalidResponse",
|
|
646
|
+
message: "Scanner returned a non-JSON or empty body",
|
|
647
|
+
status: probe.status,
|
|
648
|
+
attemptedUrl,
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
446
652
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
|
|
653
|
+
if (!probe.body.SessionId) {
|
|
654
|
+
// App-layer scanner error — common when an HTTPS-only scanner is hit
|
|
655
|
+
// over HTTP. The body usually contains vendor-specific fields like
|
|
656
|
+
// `ExtInformation` ("Scanner not configured for HTTP") that we forward
|
|
657
|
+
// so callers can present an actionable message.
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
error: {
|
|
661
|
+
code: "SCANNER_ERROR",
|
|
662
|
+
name: "ScannerError",
|
|
663
|
+
message:
|
|
664
|
+
probe.body.ExtInformation ||
|
|
665
|
+
probe.body.ErrorInformation ||
|
|
666
|
+
"Scanner returned no SessionId",
|
|
667
|
+
status:
|
|
668
|
+
typeof probe.body.HttpRC === "string"
|
|
669
|
+
? parseInt(probe.body.HttpRC, 10) || probe.status
|
|
670
|
+
: probe.status,
|
|
671
|
+
attemptedUrl,
|
|
672
|
+
scannerResponse: probe.body,
|
|
673
|
+
},
|
|
674
|
+
};
|
|
452
675
|
}
|
|
676
|
+
|
|
677
|
+
// Got a SessionId — clean up by disconnecting. We don't surface a
|
|
678
|
+
// disconnect failure: from the host app's perspective the scanner IS
|
|
679
|
+
// present and the leaked session will time out server-side per the
|
|
680
|
+
// scanner's IdleTimeout.
|
|
681
|
+
const disconnectCommand = {
|
|
682
|
+
Command: "Disconnect",
|
|
683
|
+
MessageId: new Date().toISOString(),
|
|
684
|
+
SessionId: probe.body.SessionId,
|
|
685
|
+
};
|
|
686
|
+
await postToScannerApiDetailed(
|
|
687
|
+
scannerAddress,
|
|
688
|
+
disconnectCommand,
|
|
689
|
+
5000,
|
|
690
|
+
signal,
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
return { ok: true };
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Boolean form of scanner presence check. Thin wrapper around
|
|
697
|
+
// `checkScannerPresentDetailed` (VNT-1522) — preserved for existing
|
|
698
|
+
// callers; new code should prefer the detailed version.
|
|
699
|
+
export const checkScannerPresent = async (scannerAddress) => {
|
|
700
|
+
const result = await checkScannerPresentDetailed(scannerAddress);
|
|
701
|
+
return result.ok;
|
|
453
702
|
};
|
|
454
703
|
|
|
455
704
|
/**
|
package/Api.test.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the structured scanner-presence API (VNT-1522).
|
|
3
|
+
*
|
|
4
|
+
* Uses node:test (built-in, no extra deps) — run with `npm test`.
|
|
5
|
+
* `globalThis.fetch` is stubbed per test so we can simulate timeouts,
|
|
6
|
+
* network failures, HTTP errors, and scanner-layer body errors without
|
|
7
|
+
* needing a real local scanner.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
checkScannerPresentDetailed,
|
|
15
|
+
buildSecurelinkUrl,
|
|
16
|
+
getNetworkInfo,
|
|
17
|
+
} from "./Api.js";
|
|
18
|
+
|
|
19
|
+
let originalFetch;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
originalFetch = globalThis.fetch;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Build a Response-like object the production code will treat as a real Response. */
|
|
30
|
+
function jsonResponse(body, init = {}) {
|
|
31
|
+
return new Response(JSON.stringify(body), {
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
...init,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("buildSecurelinkUrl — case-insensitive scheme detection (VNT-1522)", () => {
|
|
39
|
+
it("prepends http:// when no scheme is given", () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
buildSecurelinkUrl("192.168.1.50"),
|
|
42
|
+
"http://192.168.1.50/securelink",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("preserves a lowercase https:// scheme", () => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
buildSecurelinkUrl("https://192.168.1.50"),
|
|
49
|
+
"https://192.168.1.50/securelink",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("preserves a lowercase http:// scheme", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
buildSecurelinkUrl("http://192.168.1.50"),
|
|
56
|
+
"http://192.168.1.50/securelink",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("preserves an UPPERCASE HTTPS:// scheme and does not double-prefix", () => {
|
|
61
|
+
// Previously this regressed to `http://HTTPS://...` because the SDK's
|
|
62
|
+
// `startsWith("https://")` check was case-sensitive.
|
|
63
|
+
assert.equal(
|
|
64
|
+
buildSecurelinkUrl("HTTPS://192.168.1.50"),
|
|
65
|
+
"HTTPS://192.168.1.50/securelink",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves a mixed-case Https:// scheme", () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
buildSecurelinkUrl("Https://192.168.1.50"),
|
|
72
|
+
"Https://192.168.1.50/securelink",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("strips trailing slashes so /securelink is appended cleanly", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
buildSecurelinkUrl("http://192.168.1.50/"),
|
|
79
|
+
"http://192.168.1.50/securelink",
|
|
80
|
+
);
|
|
81
|
+
assert.equal(
|
|
82
|
+
buildSecurelinkUrl("http://192.168.1.50///"),
|
|
83
|
+
"http://192.168.1.50/securelink",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("checkScannerPresentDetailed — happy path", () => {
|
|
89
|
+
it("returns ok=true when Connect responds with a SessionId", async () => {
|
|
90
|
+
let connectCalled = false;
|
|
91
|
+
let disconnectCalled = false;
|
|
92
|
+
|
|
93
|
+
globalThis.fetch = async (_url, init) => {
|
|
94
|
+
const body = JSON.parse(init.body);
|
|
95
|
+
if (body.Command === "Connect") {
|
|
96
|
+
connectCalled = true;
|
|
97
|
+
return jsonResponse({ SessionId: "abc-123" });
|
|
98
|
+
}
|
|
99
|
+
if (body.Command === "Disconnect") {
|
|
100
|
+
disconnectCalled = true;
|
|
101
|
+
return jsonResponse({});
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Unexpected command: ${body.Command}`);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
107
|
+
assert.deepEqual(result, { ok: true });
|
|
108
|
+
assert.equal(connectCalled, true, "Connect should have been called");
|
|
109
|
+
assert.equal(disconnectCalled, true, "Disconnect should have been called");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("still returns ok=true when Disconnect fails (scanner is present regardless)", async () => {
|
|
113
|
+
let attempt = 0;
|
|
114
|
+
globalThis.fetch = async (_url, init) => {
|
|
115
|
+
attempt += 1;
|
|
116
|
+
const body = JSON.parse(init.body);
|
|
117
|
+
if (body.Command === "Connect") {
|
|
118
|
+
return jsonResponse({ SessionId: "abc-123" });
|
|
119
|
+
}
|
|
120
|
+
// Disconnect call fails — should NOT degrade the ok=true return,
|
|
121
|
+
// since the host app's question ("is the scanner present?") has
|
|
122
|
+
// already been answered yes.
|
|
123
|
+
throw new TypeError("network error on disconnect");
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
127
|
+
assert.equal(result.ok, true);
|
|
128
|
+
assert.equal(attempt, 2, "both Connect and Disconnect should be attempted");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("checkScannerPresentDetailed — error codes", () => {
|
|
133
|
+
it("returns SCANNER_ERROR when 200 OK has no SessionId in body", async () => {
|
|
134
|
+
// Becca's case: HTTPS-only scanner being hit over HTTP returns 200 OK
|
|
135
|
+
// with a body like `{"HttpRC":"403", "ExtInformation":"Scanner not
|
|
136
|
+
// configured for HTTP"}`. The SDK's old boolean form collapsed this
|
|
137
|
+
// to `false`; the detailed form surfaces the vendor message.
|
|
138
|
+
globalThis.fetch = async () =>
|
|
139
|
+
jsonResponse({
|
|
140
|
+
HttpRC: "403",
|
|
141
|
+
Result: "12",
|
|
142
|
+
ErrorInformation: "Device specific error",
|
|
143
|
+
ExtInformation: "Scanner not configured for HTTP",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
147
|
+
assert.equal(result.ok, false);
|
|
148
|
+
assert.equal(result.error.code, "SCANNER_ERROR");
|
|
149
|
+
assert.equal(result.error.message, "Scanner not configured for HTTP");
|
|
150
|
+
assert.equal(result.error.status, 403);
|
|
151
|
+
assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
|
|
152
|
+
assert.equal(
|
|
153
|
+
result.error.scannerResponse.ExtInformation,
|
|
154
|
+
"Scanner not configured for HTTP",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns HTTP_ERROR when the server replies with a non-2xx status", async () => {
|
|
159
|
+
globalThis.fetch = async () =>
|
|
160
|
+
new Response("Forbidden", { status: 403, statusText: "Forbidden" });
|
|
161
|
+
|
|
162
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
163
|
+
assert.equal(result.ok, false);
|
|
164
|
+
assert.equal(result.error.code, "HTTP_ERROR");
|
|
165
|
+
assert.equal(result.error.status, 403);
|
|
166
|
+
assert.equal(result.error.message, "Forbidden");
|
|
167
|
+
assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns NETWORK_ERROR when fetch throws a TypeError (DNS, CORS, mixed content, etc.)", async () => {
|
|
171
|
+
globalThis.fetch = async () => {
|
|
172
|
+
throw new TypeError("Failed to fetch");
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
176
|
+
assert.equal(result.ok, false);
|
|
177
|
+
assert.equal(result.error.code, "NETWORK_ERROR");
|
|
178
|
+
assert.equal(result.error.name, "TypeError");
|
|
179
|
+
assert.match(result.error.message, /Failed to fetch/);
|
|
180
|
+
assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns ABORTED when an already-aborted external signal is passed", async () => {
|
|
184
|
+
// Real browser fetch rejects synchronously when given an already-
|
|
185
|
+
// aborted signal — mirror that contract in the mock so the test
|
|
186
|
+
// doesn't deadlock if the listener is attached post-abort.
|
|
187
|
+
globalThis.fetch = async (_url, init) => {
|
|
188
|
+
if (init.signal?.aborted) {
|
|
189
|
+
const err = new Error("aborted");
|
|
190
|
+
err.name = "AbortError";
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
return new Promise((_resolve, reject) => {
|
|
194
|
+
init.signal?.addEventListener("abort", () => {
|
|
195
|
+
const err = new Error("aborted");
|
|
196
|
+
err.name = "AbortError";
|
|
197
|
+
reject(err);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
controller.abort();
|
|
204
|
+
const result = await checkScannerPresentDetailed("192.168.1.50", {
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
assert.equal(result.ok, false);
|
|
209
|
+
assert.equal(result.error.code, "ABORTED");
|
|
210
|
+
assert.equal(result.error.name, "AbortError");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns TIMEOUT when fetch aborts and no external signal was supplied", async () => {
|
|
214
|
+
// Simulates the internal 5s AbortController firing — fetch throws
|
|
215
|
+
// AbortError, no external signal was passed in, so classification
|
|
216
|
+
// falls through to TIMEOUT rather than ABORTED.
|
|
217
|
+
globalThis.fetch = async () => {
|
|
218
|
+
const err = new Error("aborted");
|
|
219
|
+
err.name = "AbortError";
|
|
220
|
+
throw err;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
224
|
+
|
|
225
|
+
assert.equal(result.ok, false);
|
|
226
|
+
assert.equal(result.error.code, "TIMEOUT");
|
|
227
|
+
assert.equal(result.error.name, "AbortError");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns INVALID_RESPONSE when the body isn't JSON", async () => {
|
|
231
|
+
globalThis.fetch = async () =>
|
|
232
|
+
new Response("<html>not json</html>", {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: { "Content-Type": "text/html" },
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const result = await checkScannerPresentDetailed("192.168.1.50");
|
|
238
|
+
assert.equal(result.ok, false);
|
|
239
|
+
// The body parses as a non-object string, so the `typeof !== "object"`
|
|
240
|
+
// branch fires before the SessionId check.
|
|
241
|
+
assert.equal(result.error.code, "INVALID_RESPONSE");
|
|
242
|
+
assert.equal(result.error.status, 200);
|
|
243
|
+
assert.equal(result.error.attemptedUrl, "http://192.168.1.50/securelink");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("checkScannerPresentDetailed — AbortSignal support", () => {
|
|
248
|
+
it("propagates an external abort to the in-flight fetch", async () => {
|
|
249
|
+
let fetchSignal = null;
|
|
250
|
+
globalThis.fetch = async (_url, init) => {
|
|
251
|
+
fetchSignal = init.signal;
|
|
252
|
+
if (init.signal?.aborted) {
|
|
253
|
+
const err = new Error("aborted");
|
|
254
|
+
err.name = "AbortError";
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
return new Promise((_resolve, reject) => {
|
|
258
|
+
init.signal.addEventListener("abort", () => {
|
|
259
|
+
const err = new Error("aborted");
|
|
260
|
+
err.name = "AbortError";
|
|
261
|
+
reject(err);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const resultPromise = checkScannerPresentDetailed("192.168.1.50", {
|
|
268
|
+
signal: controller.signal,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Give the fetch microtask a beat to start, then abort externally.
|
|
272
|
+
await Promise.resolve();
|
|
273
|
+
controller.abort();
|
|
274
|
+
|
|
275
|
+
const result = await resultPromise;
|
|
276
|
+
assert.equal(result.ok, false);
|
|
277
|
+
assert.equal(result.error.code, "ABORTED");
|
|
278
|
+
assert.equal(
|
|
279
|
+
fetchSignal.aborted,
|
|
280
|
+
true,
|
|
281
|
+
"the fetch's signal should have been aborted",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("postToScannerApi (throw-on-error wrapper) — JSON contract (VNT-1522)", () => {
|
|
287
|
+
// Tested via `getNetworkInfo`, the simplest of the thin wrappers around
|
|
288
|
+
// `postToScannerApi`. Behaviour must match here for every wrapper —
|
|
289
|
+
// they're identical one-liners.
|
|
290
|
+
|
|
291
|
+
it("returns the parsed object on a 2xx JSON response", async () => {
|
|
292
|
+
globalThis.fetch = async () => jsonResponse({ NetworkInfo: { ip: "x" } });
|
|
293
|
+
const result = await getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" });
|
|
294
|
+
assert.deepEqual(result, { NetworkInfo: { ip: "x" } });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("throws on a 2xx response with a non-JSON body (preserves the response.json() contract)", async () => {
|
|
298
|
+
// Pre-VNT-1522 behaviour: the wrapper called `response.json()` which
|
|
299
|
+
// threw `SyntaxError` on invalid JSON. The detailed-form refactor
|
|
300
|
+
// accepted non-JSON bodies (kept them as strings for diagnostics);
|
|
301
|
+
// the wrapper has to re-throw so existing callers — which all read
|
|
302
|
+
// object properties off the return value — fail loudly instead of
|
|
303
|
+
// silently receiving a string.
|
|
304
|
+
globalThis.fetch = async () =>
|
|
305
|
+
new Response("not json", {
|
|
306
|
+
status: 200,
|
|
307
|
+
headers: { "Content-Type": "text/plain" },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await assert.rejects(
|
|
311
|
+
getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
|
|
312
|
+
/not a JSON object/,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("throws on a 2xx response with an empty body", async () => {
|
|
317
|
+
globalThis.fetch = async () =>
|
|
318
|
+
new Response("", {
|
|
319
|
+
status: 200,
|
|
320
|
+
headers: { "Content-Type": "application/json" },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await assert.rejects(
|
|
324
|
+
getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
|
|
325
|
+
/not a JSON object/,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("throws on non-2xx HTTP responses (unchanged)", async () => {
|
|
330
|
+
globalThis.fetch = async () =>
|
|
331
|
+
new Response("Forbidden", { status: 403, statusText: "Forbidden" });
|
|
332
|
+
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
getNetworkInfo("192.168.1.50", { Command: "GetNetworkInfo" }),
|
|
335
|
+
/Forbidden/,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
});
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.5-beta.11] - 2026-05-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`scannerPresentDetailed(scannerAddress, options?)`** (VNT-1522). Returns
|
|
12
|
+
a structured `{ ok, error? }` result so host apps can distinguish
|
|
13
|
+
timeouts, host-app cancellations, network errors, HTTP-level errors,
|
|
14
|
+
application-layer scanner errors (e.g. HTTPS-only scanner refusing HTTP
|
|
15
|
+
with `HttpRC:403` / `ExtInformation` in the body), and missing-
|
|
16
|
+
`SessionId` responses. The `error` object carries `code` (`TIMEOUT` |
|
|
17
|
+
`ABORTED` | `NETWORK_ERROR` | `HTTP_ERROR` | `INVALID_RESPONSE` |
|
|
18
|
+
`SCANNER_ERROR`), `name`, `message`, `status`, `attemptedUrl`, and the
|
|
19
|
+
raw `scannerResponse` body for the `SCANNER_ERROR` case so callers can
|
|
20
|
+
surface vendor-specific text. `ABORTED` is distinct from `TIMEOUT` so
|
|
21
|
+
UI copy that branches on "scanner timed out" doesn't mislabel a user-
|
|
22
|
+
cancelled probe.
|
|
23
|
+
- Optional `signal: AbortSignal` parameter on `scannerPresentDetailed` so
|
|
24
|
+
host apps can cancel an in-flight probe on retry / unmount instead of
|
|
25
|
+
waiting for the internal 5s timeout.
|
|
26
|
+
- `Api.test.js` — first test suite in this repo, using Node's built-in
|
|
27
|
+
`node:test` runner (zero new dependencies). Covers timeout, HTTP 4xx,
|
|
28
|
+
network error, scanner-error-in-body, invalid response, AbortSignal
|
|
29
|
+
propagation, happy path, and case-insensitive scheme detection.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Case-insensitive scheme detection in `postToScannerApi` URL builder
|
|
33
|
+
(VNT-1522). Previously `HTTPS://192.168.1.50` was treated as having no
|
|
34
|
+
scheme and double-prefixed to `http://HTTPS://...`; now matches `/^https?:\/\//i`
|
|
35
|
+
per RFC 3986.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- `scannerPresent` (boolean) now delegates to `scannerPresentDetailed`.
|
|
39
|
+
One small behaviour difference: a failed Disconnect after a successful
|
|
40
|
+
Connect no longer flips the return to `false`. From the host app's
|
|
41
|
+
perspective the scanner IS present at that point, and the leaked
|
|
42
|
+
session times out server-side per the scanner's `IdleTimeout`. The
|
|
43
|
+
pre-refactor behaviour was a side-effect of the older try/catch wrap;
|
|
44
|
+
treating the disconnect as best-effort is the intended contract.
|
|
45
|
+
- `package.json` `test` script now runs Node's built-in `node --test`
|
|
46
|
+
runner instead of stubbing an error.
|
|
47
|
+
|
|
8
48
|
## [1.4.5-beta.10] - 2026-05-11
|
|
9
49
|
|
|
10
50
|
### Fixed
|
package/CloudScan.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getBarcodeLicenseKey,
|
|
14
14
|
scannerChain,
|
|
15
15
|
scannerPresent,
|
|
16
|
+
scannerPresentDetailed,
|
|
16
17
|
} from "./ScannerApi.js";
|
|
17
18
|
import { VerificationModal } from "./VerificationModal.js";
|
|
18
19
|
import { checkAutoDmvEnabled, setEnvironment, createVerificationSession } from "./Api.js";
|
|
@@ -42,7 +43,7 @@ export const checkFaceCompareFeature = async (clientId) => {
|
|
|
42
43
|
return await checkFaceComparisonFeatureLicense(clientId);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export { scannerPresent, setEnvironment };
|
|
46
|
+
export { scannerPresent, scannerPresentDetailed, setEnvironment };
|
|
46
47
|
|
|
47
48
|
export async function readImageBarcode(imageFile, clientId = null, scannerType = 'Upload') {
|
|
48
49
|
let returnObj = {
|
package/LocalScannerApi.js
CHANGED
|
@@ -57,37 +57,49 @@ export async function localScannerChain(ambirApiAddress, clientId) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// //2-3 Get serial number and Check the license
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
60
|
+
try {
|
|
61
|
+
//2 check scanner serial
|
|
62
|
+
const scannerSerialNumberResult = await getLocalScannerSerialNumber();
|
|
63
|
+
// Intentionally NOT logging the serial — it's a stable device
|
|
64
|
+
// identifier and shouldn't appear in customer-facing browser logs.
|
|
65
|
+
// Use the network tab if you need to inspect it during dev.
|
|
66
|
+
if (!scannerSerialNumberResult) {
|
|
67
|
+
console.log("No serial retrieved, closing connection");
|
|
68
|
+
await clearImageAndCloseScannerConnection(); // Even if scanning has errors, always attempt to close the connection
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
errorMessages,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isValid = await checkLicense(clientId, { serialNumber: scannerSerialNumberResult });
|
|
76
|
+
|
|
77
|
+
if (isValid) {
|
|
78
|
+
// continue
|
|
79
|
+
} else {
|
|
80
|
+
// `checkLicense` collapses both "denied" and "request failed"
|
|
81
|
+
// (network blip, 5xx, etc.) into a bare `false`, so this branch
|
|
82
|
+
// is genuinely ambiguous. Copy reflects that until the SDK
|
|
83
|
+
// surfaces a structured result (VNT-1522 follow-up).
|
|
84
|
+
buildError(
|
|
85
|
+
"License check failed or this scanner is not licensed. " +
|
|
86
|
+
"Try again, or contact Verant ID if this persists."
|
|
87
|
+
);
|
|
88
|
+
await clearImageAndCloseScannerConnection();
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
errorMessages,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
buildError("Serial/license check error: " + (error?.message ?? error));
|
|
96
|
+
console.error("Serial/license check error:", error);
|
|
97
|
+
await clearImageAndCloseScannerConnection();
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
errorMessages,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
console.log("about to check scanner availability");
|
|
@@ -344,7 +356,10 @@ async function getLocalScannerSerialNumber() {
|
|
|
344
356
|
return false;
|
|
345
357
|
}
|
|
346
358
|
|
|
347
|
-
|
|
359
|
+
// Ambir's /getserialnumber response shape isn't pinned down yet — handle both
|
|
360
|
+
// a bare string and a structured payload so we don't send a Hash on the wire.
|
|
361
|
+
if (typeof responseData === "string") return responseData;
|
|
362
|
+
return responseData.SerialNumber || responseData.serialNumber || false;
|
|
348
363
|
}
|
|
349
364
|
|
|
350
365
|
// step 3 - check if scanner is licensed with verant
|
package/ScannerApi.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import * as API from "./Api.js";
|
|
2
2
|
import { getProfile } from "./ScannerProfiles.js";
|
|
3
3
|
|
|
4
|
+
// Maps a scanner-profile field name to the corresponding license-server
|
|
5
|
+
// request key. Static — defined at module scope so it's not re-allocated
|
|
6
|
+
// on every scannerChain() call.
|
|
7
|
+
const SCANNER_FIELD_TO_LICENSE_KEY = {
|
|
8
|
+
ScannerMAC: "macAddress",
|
|
9
|
+
ScannerSerial: "serialNumber",
|
|
10
|
+
};
|
|
11
|
+
|
|
4
12
|
let errorMessages = [];
|
|
5
13
|
let selectedScannerAddress = "";
|
|
6
14
|
|
|
@@ -46,6 +54,19 @@ export async function scannerPresent(scannerAddress) {
|
|
|
46
54
|
return await API.checkScannerPresent(scannerAddress);
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Detailed scanner-presence check (VNT-1522). Returns a structured result
|
|
59
|
+
* so callers can distinguish timeouts, network errors, HTTP errors,
|
|
60
|
+
* application-layer scanner errors (e.g. HTTPS-only scanner refusing HTTP),
|
|
61
|
+
* and missing-SessionId responses without inspecting the SDK's internals.
|
|
62
|
+
*
|
|
63
|
+
* Supports an optional `AbortSignal` so callers can cancel in-flight
|
|
64
|
+
* probes on retry / unmount.
|
|
65
|
+
*/
|
|
66
|
+
export async function scannerPresentDetailed(scannerAddress, options) {
|
|
67
|
+
return await API.checkScannerPresentDetailed(scannerAddress, options);
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
//This is the top level function that calls the other functions in order
|
|
50
71
|
export async function scannerChain(
|
|
51
72
|
scannerAddress,
|
|
@@ -111,10 +132,12 @@ export async function scannerChain(
|
|
|
111
132
|
const identifierField = profile.licenseIdentifierField;
|
|
112
133
|
const fallbackField = profile.licenseIdentifierFallback;
|
|
113
134
|
let scannerIdentifier = existingParams[identifierField];
|
|
135
|
+
let usedField = identifierField;
|
|
114
136
|
|
|
115
137
|
// Fall back to alternate field if primary is missing
|
|
116
138
|
if (!scannerIdentifier && fallbackField) {
|
|
117
139
|
scannerIdentifier = existingParams[fallbackField];
|
|
140
|
+
usedField = fallbackField;
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
if (!scannerIdentifier) {
|
|
@@ -128,9 +151,19 @@ export async function scannerChain(
|
|
|
128
151
|
};
|
|
129
152
|
}
|
|
130
153
|
|
|
154
|
+
const licenseKey = SCANNER_FIELD_TO_LICENSE_KEY[usedField];
|
|
155
|
+
if (!licenseKey) {
|
|
156
|
+
buildError(`Error: Unknown scanner identifier field "${usedField}" — cannot check license`);
|
|
157
|
+
await closeScannerConnection(currentSessionId);
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
errorMessages,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
131
164
|
// Check the license
|
|
132
165
|
try {
|
|
133
|
-
const isValid = await API.checkLicense(clientId, scannerIdentifier);
|
|
166
|
+
const isValid = await API.checkLicense(clientId, { [licenseKey]: scannerIdentifier });
|
|
134
167
|
|
|
135
168
|
if (isValid) {
|
|
136
169
|
// continue
|
package/index.d.ts
CHANGED
|
@@ -143,10 +143,91 @@ declare module "verant_id_cloud_scan" {
|
|
|
143
143
|
disconnect(): void;
|
|
144
144
|
getState(): ConnectionStateValue;
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
// Scanner presence — VNT-1522
|
|
148
|
+
//
|
|
149
|
+
// `scannerPresent` returns the simple boolean form (legacy); new code
|
|
150
|
+
// should prefer `scannerPresentDetailed` for richer error info.
|
|
151
|
+
|
|
152
|
+
export type ScannerPresentErrorCode =
|
|
153
|
+
| "TIMEOUT"
|
|
154
|
+
| "ABORTED"
|
|
155
|
+
| "NETWORK_ERROR"
|
|
156
|
+
| "HTTP_ERROR"
|
|
157
|
+
| "INVALID_RESPONSE"
|
|
158
|
+
| "SCANNER_ERROR";
|
|
159
|
+
|
|
160
|
+
export interface ScannerPresentError {
|
|
161
|
+
/** SDK-defined tag for branching UX without parsing `message`. */
|
|
162
|
+
code: ScannerPresentErrorCode;
|
|
163
|
+
/** Underlying error class name (e.g. "TypeError", "AbortError", "HTTPError"). */
|
|
164
|
+
name: string;
|
|
165
|
+
/** Human-readable detail; for `SCANNER_ERROR` this includes vendor-supplied text when present. */
|
|
166
|
+
message: string;
|
|
167
|
+
/** HTTP status when a response was received; for `SCANNER_ERROR` may be the scanner-layer `HttpRC` if it was a parseable number. */
|
|
168
|
+
status?: number;
|
|
169
|
+
/** The /securelink URL the SDK probed. */
|
|
170
|
+
attemptedUrl: string;
|
|
171
|
+
/** For `SCANNER_ERROR`: raw scanner response body (vendor-specific fields like `ExtInformation`, `ErrorInformation`). */
|
|
172
|
+
scannerResponse?: unknown;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type ScannerPresentResult =
|
|
176
|
+
| { ok: true }
|
|
177
|
+
| { ok: false; error: ScannerPresentError };
|
|
178
|
+
|
|
179
|
+
export interface ScannerPresentOptions {
|
|
180
|
+
/** External signal to cancel the probe (retry, unmount). Forwarded into the underlying fetch. */
|
|
181
|
+
signal?: AbortSignal;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Detailed scanner-presence check. Returns a structured result so callers
|
|
186
|
+
* can distinguish timeouts, network errors, HTTP errors, application-layer
|
|
187
|
+
* scanner errors (HTTPS-only scanner refusing HTTP, etc.), and missing-
|
|
188
|
+
* SessionId responses without inspecting the SDK's internals.
|
|
189
|
+
*/
|
|
190
|
+
export function scannerPresentDetailed(
|
|
191
|
+
scannerAddress: string,
|
|
192
|
+
options?: ScannerPresentOptions,
|
|
193
|
+
): Promise<ScannerPresentResult>;
|
|
146
194
|
}
|
|
147
195
|
|
|
148
196
|
export function scannerPresent(scannerAddress: string): Promise<boolean>;
|
|
149
197
|
|
|
198
|
+
// Same signature as the in-module declaration above; mirrored here so it
|
|
199
|
+
// resolves under both lookup paths (matches `scannerPresent`'s existing
|
|
200
|
+
// outside-the-module placement).
|
|
201
|
+
export type ScannerPresentErrorCode =
|
|
202
|
+
| "TIMEOUT"
|
|
203
|
+
| "ABORTED"
|
|
204
|
+
| "NETWORK_ERROR"
|
|
205
|
+
| "HTTP_ERROR"
|
|
206
|
+
| "INVALID_RESPONSE"
|
|
207
|
+
| "SCANNER_ERROR";
|
|
208
|
+
|
|
209
|
+
export interface ScannerPresentError {
|
|
210
|
+
code: ScannerPresentErrorCode;
|
|
211
|
+
name: string;
|
|
212
|
+
message: string;
|
|
213
|
+
status?: number;
|
|
214
|
+
attemptedUrl: string;
|
|
215
|
+
scannerResponse?: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ScannerPresentResult =
|
|
219
|
+
| { ok: true }
|
|
220
|
+
| { ok: false; error: ScannerPresentError };
|
|
221
|
+
|
|
222
|
+
export interface ScannerPresentOptions {
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function scannerPresentDetailed(
|
|
227
|
+
scannerAddress: string,
|
|
228
|
+
options?: ScannerPresentOptions,
|
|
229
|
+
): Promise<ScannerPresentResult>;
|
|
230
|
+
|
|
150
231
|
/**
|
|
151
232
|
* Explicitly set the API environment.
|
|
152
233
|
* By default, production is used for all hosts (including localhost).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verant_id_cloud_scan",
|
|
3
|
-
"version": "1.4.5-beta.
|
|
3
|
+
"version": "1.4.5-beta.11",
|
|
4
4
|
"description": "Verant ID Cloud Scan NPM Library",
|
|
5
5
|
"main": "CloudScan.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"ws": false
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "
|
|
11
|
+
"test": "node --test"
|
|
12
12
|
},
|
|
13
13
|
"author": "Verant ID Inc",
|
|
14
14
|
"license": "ISC",
|