@corbado/observe 1.7.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/analysis/client.mjs +1877 -0
  2. package/dist/analysis/config-manager.mjs +108 -0
  3. package/dist/analysis/config.mjs +216 -0
  4. package/dist/analysis/device-info-manager.mjs +168 -0
  5. package/dist/analysis/device.mjs +640 -0
  6. package/dist/analysis/handoff.mjs +7 -0
  7. package/dist/analysis/index.mjs +118 -0
  8. package/dist/analysis/logger.mjs +50 -0
  9. package/dist/analysis/low-events/capture-sequence.mjs +11 -0
  10. package/dist/analysis/low-events/ceremony-initiator.mjs +26 -0
  11. package/dist/analysis/low-events/form-input-binding.mjs +46 -0
  12. package/dist/analysis/low-events/grouped-input-tracker.mjs +248 -0
  13. package/dist/analysis/low-events/input-change-recorder.mjs +200 -0
  14. package/dist/analysis/low-events/input-tracker.mjs +302 -0
  15. package/dist/analysis/low-events/otp-input-binding.mjs +67 -0
  16. package/dist/analysis/low-events/shared-window-observer.mjs +296 -0
  17. package/dist/analysis/low-events/sink.mjs +0 -0
  18. package/dist/analysis/low-events/viewport-batcher.mjs +42 -0
  19. package/dist/analysis/low-events/window-tracker.mjs +40 -0
  20. package/dist/analysis/queue.mjs +954 -0
  21. package/dist/analysis/readers/device.mjs +73 -0
  22. package/dist/analysis/readers/input-paste.mjs +91 -0
  23. package/dist/analysis/readers/input.mjs +61 -0
  24. package/dist/analysis/readers/loader-global.mjs +15 -0
  25. package/dist/analysis/readers/meta.mjs +18 -0
  26. package/dist/analysis/readers/network.mjs +14 -0
  27. package/dist/analysis/readers/page.mjs +30 -0
  28. package/dist/analysis/readers/password-manager-dom.mjs +112 -0
  29. package/dist/analysis/readers/password-manager-extensions.mjs +247 -0
  30. package/dist/analysis/readers/private-mode.mjs +9 -0
  31. package/dist/analysis/readers/storage.mjs +28 -0
  32. package/dist/analysis/readers/web-lock.mjs +56 -0
  33. package/dist/analysis/readers/window-events.mjs +48 -0
  34. package/dist/analysis/storage/index.mjs +249 -0
  35. package/dist/analysis/storage/migration.mjs +56 -0
  36. package/dist/analysis/store/event-store.mjs +132 -0
  37. package/dist/analysis/transport.mjs +0 -0
  38. package/dist/analysis/transports/fetch.mjs +150 -0
  39. package/dist/analysis/types.mjs +25 -0
  40. package/dist/analysis/utils/appconfirmation-operation-full.mjs +19 -0
  41. package/dist/analysis/utils/blocked-user-agents.mjs +37 -0
  42. package/dist/analysis/utils/captcha-operation-full.mjs +17 -0
  43. package/dist/analysis/utils/email-link-continuity.mjs +50 -0
  44. package/dist/analysis/utils/email-link-operation-full.mjs +162 -0
  45. package/dist/analysis/utils/email-otp-operation-full.mjs +38 -0
  46. package/dist/analysis/utils/form-operation-full.mjs +80 -0
  47. package/dist/analysis/utils/key-operation-full.mjs +38 -0
  48. package/dist/analysis/utils/normalize-error.mjs +29 -0
  49. package/dist/analysis/utils/operation-full-provide-identifier-with-cui.mjs +46 -0
  50. package/dist/analysis/utils/operation-full.mjs +235 -0
  51. package/dist/analysis/utils/passkey-deletion-operation-full.mjs +17 -0
  52. package/dist/analysis/utils/passkey-enrollment-operation-full.mjs +35 -0
  53. package/dist/analysis/utils/passkey-login-operation-full.mjs +39 -0
  54. package/dist/analysis/utils/password-enrollment-operation-full.mjs +27 -0
  55. package/dist/analysis/utils/password-login-operation-full.mjs +65 -0
  56. package/dist/analysis/utils/password-manager-sampler.mjs +170 -0
  57. package/dist/analysis/utils/provide-data-operation-full.mjs +28 -0
  58. package/dist/analysis/utils/serialize-raw-error.mjs +205 -0
  59. package/dist/analysis/utils/sms-otp-operation-full.mjs +38 -0
  60. package/dist/analysis/utils/social-operation-login.mjs +17 -0
  61. package/dist/analysis/utils/source-path.mjs +39 -0
  62. package/dist/analysis/utils/sso-operation-login.mjs +37 -0
  63. package/dist/analysis/utils/totp-operation-full.mjs +34 -0
  64. package/dist/analysis/utils/trusted-device-operation-full.mjs +100 -0
  65. package/dist/analysis/utils/webauthn-payloads.mjs +101 -0
  66. package/dist/analysis/version.mjs +7 -0
  67. package/dist/cdn/script.global.js +4 -4
  68. package/dist/index.d.mts +285 -21
  69. package/dist/index.d.ts +285 -21
  70. package/dist/index.js +4 -4
  71. package/dist/index.mjs +4 -4
  72. package/package.json +10 -3
  73. package/tools/reader-meta-text.d.mts +2 -0
  74. package/tools/reader-meta-text.mjs +26 -0
  75. package/tools/strip-reader-meta.d.mts +33 -0
  76. package/tools/strip-reader-meta.mjs +131 -0
@@ -0,0 +1,640 @@
1
+ // src/device.ts
2
+ import { normalizeError } from "./utils/normalize-error.mjs";
3
+ import { v7 as uuidv7 } from "uuid";
4
+ import {
5
+ readBluetoothAvailability,
6
+ readClientCapabilities,
7
+ readConditionalMediationAvailable,
8
+ readJavaScriptHighEntropy,
9
+ readMaxTouchPoints,
10
+ readUserAgent,
11
+ readUserVerifyingPlatformAuthenticatorAvailable,
12
+ readWebdriver
13
+ } from "./readers/device.mjs";
14
+ import { readPrivateMode } from "./readers/private-mode.mjs";
15
+ var DEVICE_INFO_FAST_TIMEOUT_MS = 300;
16
+ var DEVICE_INFO_LATE_TIMEOUT_MS = 6e3;
17
+ var PRIVATE_MODE_CACHE_KEY = "cbo_pm";
18
+ var PRIVATE_MODE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
19
+ var PRIVATE_MODE_DETECTOR_VERSION = "detectincognitojs@1.9.0";
20
+ var DEFAULT_SETTINGS = {
21
+ timing: false
22
+ };
23
+ var TAB_ID_KEY = "cbo_tab_id";
24
+ var TIMING_FIELD_CODES = {
25
+ bluetoothAvailable: "bt",
26
+ clientCapabilities: "cc",
27
+ userVerifyingPlatformAuthenticatorAvailable: "uvpaa",
28
+ conditionalMediationAvailable: "cm",
29
+ javaScriptHighEntropy: "he",
30
+ privateMode: "pm",
31
+ webdriver: "wd",
32
+ maxTouchPoints: "mtp"
33
+ };
34
+ function nowMs() {
35
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
36
+ }
37
+ function timeoutError(field, source, timeoutMs) {
38
+ return {
39
+ field,
40
+ source,
41
+ error: { name: "TimeoutError", message: `Device info collection timed out after ${timeoutMs}ms` }
42
+ };
43
+ }
44
+ async function settlesWithin(promise, timeoutMs) {
45
+ let timer;
46
+ try {
47
+ return await Promise.race([
48
+ promise.then(
49
+ () => true,
50
+ () => true
51
+ ),
52
+ new Promise((resolve) => {
53
+ timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
54
+ })
55
+ ]);
56
+ } finally {
57
+ if (timer) {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ }
62
+ var DeviceCollector = class {
63
+ constructor(logger, storage, storageSource = "localstorage", sessionStorage, settings = () => DEFAULT_SETTINGS, emitTelemetry, privateModeEnabled = true) {
64
+ this.logger = logger;
65
+ this.storage = storage;
66
+ this.storageSource = storageSource;
67
+ this.sessionStorage = sessionStorage;
68
+ this.settings = settings;
69
+ this.emitTelemetry = emitTelemetry;
70
+ this.privateModeEnabled = privateModeEnabled;
71
+ }
72
+ /** privateMode known on this page (from the cache or a detection), reused by every refresh. */
73
+ pagePrivateMode;
74
+ /** Detection runs at most once per page: a refresh never reruns the IndexedDB benchmark. */
75
+ privateModeDetectionStarted = false;
76
+ /** Last known value per fast field, so a refresh's fast snapshot does not drop a slow-but-stable field. */
77
+ lastKnown = {};
78
+ /**
79
+ * Collect device info in two steps and hand each snapshot to `publish`:
80
+ * 1. The fast snapshot: every fast field, published once all of them settled or
81
+ * {@link DEVICE_INFO_FAST_TIMEOUT_MS} passed. An uncached privateMode is detected once per page,
82
+ * started together with the fast fields; the fast snapshot includes privateMode when it is known
83
+ * already (cache, an earlier detection on this page) or the detection finished in time.
84
+ * 2. At most one update: fields and a detection still running keep going until
85
+ * {@link DEVICE_INFO_LATE_TIMEOUT_MS} after the collection start. Published when that work is done.
86
+ *
87
+ * Resolves once the run is complete. On a refresh (`refresh: true`), fast fields that did not
88
+ * settle in time fall back to their last known value in the fast snapshot.
89
+ *
90
+ * Error situations:
91
+ * - Any thrown or rejected error while collecting an individual field: log "Failed to collect device info field", set the field to undefined, and add an entry to collectionErrors
92
+ * - data.bluetoothAvailable is undefined when navigator is not available or navigator.bluetooth is falsy
93
+ * - data.userVerifyingPlatformAuthenticatorAvailable is undefined when PublicKeyCredential is not available or isUserVerifyingPlatformAuthenticatorAvailable is missing
94
+ * - data.conditionalMediationAvailable is undefined when PublicKeyCredential is not available or isConditionalMediationAvailable is missing
95
+ * - data.clientCapabilities is undefined when PublicKeyCredential is not available or getClientCapabilities is missing
96
+ * - data.javaScriptHighEntropy is undefined when navigator is not available or navigator.userAgentData is falsy, or when high-entropy values omit platform, mobile, or platformVersion
97
+ * - data.webdriver is undefined when navigator is not available
98
+ * - data.maxTouchPoints is undefined when navigator is not available or navigator.maxTouchPoints is not a number
99
+ * - A field (or the privateMode detection) that does not settle within the late deadline: a TimeoutError entry "Device info collection timed out after 6000ms" in collectionErrors of the update
100
+ * - The privateMode cache is unreadable, malformed, expired or from another detector version: detect instead; a failing cache write is logged and ignored
101
+ * - The settings provider throwing: log "Failed to resolve device info collector settings" and collect with the built-in defaults
102
+ * - `publish` throwing: log "Failed to publish device info" and continue
103
+ * - Any other unexpected error: log "Failed to collect device info" and publish nothing further
104
+ */
105
+ async collect(publish, options = {}) {
106
+ try {
107
+ const settings = this.resolveSettings();
108
+ const startedAt = nowMs();
109
+ const clientEnvHandleEntry = this.getClientEnvHandle();
110
+ const base = {
111
+ type: "web",
112
+ clientEnvHandle: clientEnvHandleEntry.value,
113
+ clientEnvHandleMeta: {
114
+ timestamp: clientEnvHandleEntry.timestamp,
115
+ source: clientEnvHandleEntry.source
116
+ },
117
+ tabId: this.getTabId()
118
+ };
119
+ const privateModeOn = typeof __CBO_DATA_PRIVATE_MODE__ === "boolean" ? __CBO_DATA_PRIVATE_MODE__ : this.privateModeEnabled;
120
+ const privateMode = privateModeOn ? this.knownPrivateMode(settings.timing) : void 0;
121
+ let detected;
122
+ const detection = privateModeOn && privateMode === void 0 && !this.privateModeDetectionStarted ? this.detectPrivateMode(settings.timing).then((result) => {
123
+ detected = result;
124
+ }) : void 0;
125
+ const tasks = this.startFastFields();
126
+ await settlesWithin(Promise.all(tasks.map((task) => task.promise)), DEVICE_INFO_FAST_TIMEOUT_MS);
127
+ const fastMs = nowMs() - startedAt;
128
+ const fallback = options.refresh ? this.lastKnown : {};
129
+ if (settings.timing) {
130
+ this.reportFastTiming(fastMs, tasks);
131
+ }
132
+ const detectionErrors = () => detected?.error ? [detected.error] : [];
133
+ this.safePublish(
134
+ publish,
135
+ this.buildInfo(base, tasks, detected ? detected.value : privateMode, fallback, detectionErrors()),
136
+ "fast"
137
+ );
138
+ const late = tasks.filter((task) => !task.settled);
139
+ const detectionPending = detection !== void 0 && detected === void 0;
140
+ if (late.length === 0 && !detectionPending) {
141
+ return;
142
+ }
143
+ const lateStartedAt = nowMs();
144
+ await Promise.all([
145
+ settlesWithin(
146
+ Promise.all(late.map((task) => task.promise)),
147
+ DEVICE_INFO_LATE_TIMEOUT_MS - (lateStartedAt - startedAt)
148
+ ),
149
+ detection
150
+ ]);
151
+ const lateErrors = late.filter((task) => !task.settled).map((task) => timeoutError(task.field, task.source, DEVICE_INFO_LATE_TIMEOUT_MS));
152
+ lateErrors.push(...detectionErrors());
153
+ if (settings.timing && late.length > 0) {
154
+ this.reportLateTiming(nowMs() - startedAt, late);
155
+ }
156
+ this.safePublish(
157
+ publish,
158
+ this.buildInfo(base, tasks, detected ? detected.value : privateMode, fallback, lateErrors),
159
+ "update"
160
+ );
161
+ } catch (error) {
162
+ this.logger.error("Failed to collect device info", error);
163
+ }
164
+ }
165
+ /**
166
+ * Collect a complete snapshot: runs {@link collect} to the end and resolves with its last published
167
+ * snapshot (the update when there was one), or an empty object when nothing could be collected.
168
+ */
169
+ async getInfo() {
170
+ let latest;
171
+ await this.collect((info) => {
172
+ latest = info;
173
+ });
174
+ return latest ?? {};
175
+ }
176
+ startFastFields() {
177
+ return [
178
+ this.startField(
179
+ "bluetoothAvailable",
180
+ "navigator.bluetooth.getAvailability",
181
+ () => readBluetoothAvailability({
182
+ key: "browser-api:device.bluetooth-availability",
183
+ title: "Bluetooth availability",
184
+ accessNote: "navigator.bluetooth.getAvailability()",
185
+ group: "Device information",
186
+ origin: "observe-external",
187
+ fields: [
188
+ {
189
+ path: "result",
190
+ label: "Whether Bluetooth is available",
191
+ exposure: "enum",
192
+ transform: { values: [true, false] }
193
+ }
194
+ ]
195
+ })
196
+ ),
197
+ this.startField(
198
+ "clientCapabilities",
199
+ "PublicKeyCredential.getClientCapabilities",
200
+ () => readClientCapabilities({
201
+ key: "browser-api:device.client-capabilities",
202
+ title: "Passkey features supported by the browser",
203
+ accessNote: "PublicKeyCredential.getClientCapabilities(), every returned key",
204
+ group: "Device information",
205
+ origin: "observe-external",
206
+ fields: [
207
+ {
208
+ path: "result.<capability>",
209
+ label: "Supported passkey features",
210
+ exposure: "enum",
211
+ transform: { values: ["true", "false"], note: "open set of capability names, kept as reported" }
212
+ }
213
+ ]
214
+ })
215
+ ),
216
+ this.startField(
217
+ "userVerifyingPlatformAuthenticatorAvailable",
218
+ "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable",
219
+ () => readUserVerifyingPlatformAuthenticatorAvailable({
220
+ key: "browser-api:device.uvpaa",
221
+ title: "Built-in passkey support on the device",
222
+ accessNote: "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()",
223
+ group: "Device information",
224
+ origin: "observe-external",
225
+ fields: [
226
+ {
227
+ path: "result",
228
+ label: "Whether the device has a built-in passkey authenticator",
229
+ exposure: "enum",
230
+ transform: { values: [true, false] }
231
+ }
232
+ ]
233
+ })
234
+ ),
235
+ this.startField(
236
+ "conditionalMediationAvailable",
237
+ "PublicKeyCredential.isConditionalMediationAvailable",
238
+ () => readConditionalMediationAvailable({
239
+ key: "browser-api:device.conditional-mediation",
240
+ title: "Passkey autofill availability",
241
+ accessNote: "PublicKeyCredential.isConditionalMediationAvailable()",
242
+ group: "Device information",
243
+ origin: "observe-external",
244
+ fields: [
245
+ {
246
+ path: "result",
247
+ label: "Whether passkey autofill is available",
248
+ exposure: "enum",
249
+ transform: { values: [true, false] }
250
+ }
251
+ ]
252
+ })
253
+ ),
254
+ this.startField(
255
+ "javaScriptHighEntropy",
256
+ "navigator.userAgentData.getHighEntropyValues",
257
+ () => readJavaScriptHighEntropy({
258
+ key: "browser-api:device.ua-high-entropy",
259
+ title: "Device platform, version and model reported by the browser",
260
+ accessNote: "User-Agent Client Hints: navigator.userAgentData.getHighEntropyValues()",
261
+ group: "Device information",
262
+ origin: "observe-external",
263
+ fields: [
264
+ { path: "result.platform", label: "Operating system", exposure: "raw" },
265
+ {
266
+ path: "result.mobile",
267
+ label: "Whether the device is a mobile device",
268
+ exposure: "enum",
269
+ transform: { values: [true, false] }
270
+ },
271
+ { path: "result.platformVersion", label: "Operating system version", exposure: "raw" },
272
+ { path: "result.model", label: "Device model", exposure: "raw" }
273
+ ]
274
+ })
275
+ ),
276
+ this.startField(
277
+ "webdriver",
278
+ "navigator.webdriver",
279
+ () => readWebdriver({
280
+ key: "browser-api:device.webdriver",
281
+ title: "Browser automation flag",
282
+ accessNote: "navigator.webdriver",
283
+ group: "Device information",
284
+ origin: "observe-external",
285
+ fields: [
286
+ {
287
+ path: "result",
288
+ label: "Whether the browser is controlled by automation",
289
+ exposure: "enum",
290
+ transform: { values: [true, false] }
291
+ }
292
+ ]
293
+ })
294
+ ),
295
+ this.startField(
296
+ "maxTouchPoints",
297
+ "navigator.maxTouchPoints",
298
+ () => readMaxTouchPoints({
299
+ key: "browser-api:device.max-touch-points",
300
+ title: "Touch screen support",
301
+ accessNote: "navigator.maxTouchPoints",
302
+ group: "Device information",
303
+ origin: "observe-external",
304
+ fields: [{ path: "result", label: "Number of touch points of the screen", exposure: "raw" }]
305
+ })
306
+ )
307
+ ];
308
+ }
309
+ /** Start one field; its promise never rejects, a failure becomes the field's collection error. */
310
+ startField(field, source, collect) {
311
+ const startedAt = nowMs();
312
+ const task = { field, source };
313
+ task.promise = Promise.resolve().then(collect).then(
314
+ (value) => ({ field, value, durationMs: nowMs() - startedAt }),
315
+ (error) => {
316
+ this.logger.error(`Failed to collect device info field "${field}"`, error);
317
+ return {
318
+ field,
319
+ durationMs: nowMs() - startedAt,
320
+ error: { field, source, error: normalizeError(error) ?? { message: "Unknown error" } }
321
+ };
322
+ }
323
+ ).then((result) => {
324
+ task.settled = result;
325
+ return result;
326
+ });
327
+ return task;
328
+ }
329
+ buildInfo(base, tasks, privateMode, fallback, extraErrors) {
330
+ const data = {};
331
+ const collectionErrors = [];
332
+ for (const task of tasks) {
333
+ if (task.settled) {
334
+ if (task.settled.error) {
335
+ collectionErrors.push(task.settled.error);
336
+ }
337
+ data[task.field] = task.settled.value;
338
+ this.remember(task.field, task.settled.value);
339
+ } else {
340
+ data[task.field] = fallback[task.field];
341
+ }
342
+ }
343
+ data.privateMode = privateMode;
344
+ collectionErrors.push(...extraErrors);
345
+ const deviceInfo = {
346
+ ...base,
347
+ data: {
348
+ bluetoothAvailable: data.bluetoothAvailable,
349
+ javaScriptHighEntropy: data.javaScriptHighEntropy,
350
+ privateMode: data.privateMode,
351
+ webdriver: data.webdriver,
352
+ conditionalMediationAvailable: data.conditionalMediationAvailable,
353
+ userVerifyingPlatformAuthenticatorAvailable: data.userVerifyingPlatformAuthenticatorAvailable,
354
+ clientCapabilities: data.clientCapabilities,
355
+ maxTouchPoints: data.maxTouchPoints
356
+ }
357
+ };
358
+ if (collectionErrors.length > 0) {
359
+ deviceInfo.collectionErrors = collectionErrors;
360
+ }
361
+ return deviceInfo;
362
+ }
363
+ remember(field, value) {
364
+ if (value !== void 0) {
365
+ this.lastKnown[field] = value;
366
+ }
367
+ }
368
+ safePublish(publish, info, phase) {
369
+ try {
370
+ publish(info, phase);
371
+ } catch (error) {
372
+ this.logger.error("Failed to publish device info", error);
373
+ }
374
+ }
375
+ /** privateMode as already known on this page, else from a valid cache entry (which then sticks for the page). */
376
+ knownPrivateMode(timing) {
377
+ if (this.pagePrivateMode !== void 0) {
378
+ return this.pagePrivateMode;
379
+ }
380
+ const cached = this.readPrivateModeCache();
381
+ if (cached) {
382
+ this.pagePrivateMode = cached.value;
383
+ if (timing) {
384
+ this.reportTelemetry(`device info privateMode from cache, age ${Math.round((Date.now() - cached.ts) / 1e3)}s`);
385
+ }
386
+ }
387
+ return this.pagePrivateMode;
388
+ }
389
+ /**
390
+ * The cache lives in localStorage only: in cookie storage mode the private-mode flag would be sent
391
+ * to the host's servers with every request, so it is not cached there (detection runs per page).
392
+ */
393
+ canCachePrivateMode() {
394
+ return this.storageSource === "localstorage";
395
+ }
396
+ readPrivateModeCache() {
397
+ if (!this.canCachePrivateMode()) {
398
+ return void 0;
399
+ }
400
+ try {
401
+ const entry = this.storage.getItem(PRIVATE_MODE_CACHE_KEY);
402
+ if (!entry || typeof entry !== "object" || typeof entry.value !== "boolean" || typeof entry.ts !== "number" || entry.dv !== PRIVATE_MODE_DETECTOR_VERSION) {
403
+ return void 0;
404
+ }
405
+ const age = Date.now() - entry.ts;
406
+ if (age < 0 || age >= PRIVATE_MODE_CACHE_TTL_MS) {
407
+ return void 0;
408
+ }
409
+ return entry;
410
+ } catch (error) {
411
+ this.logger.debug("Failed to read cached privateMode", error);
412
+ return void 0;
413
+ }
414
+ }
415
+ writePrivateModeCache(value) {
416
+ if (!this.canCachePrivateMode()) {
417
+ return;
418
+ }
419
+ try {
420
+ this.storage.setItem(PRIVATE_MODE_CACHE_KEY, {
421
+ value,
422
+ ts: Date.now(),
423
+ dv: PRIVATE_MODE_DETECTOR_VERSION
424
+ });
425
+ } catch (error) {
426
+ this.logger.error("Failed to cache privateMode", error);
427
+ }
428
+ }
429
+ /**
430
+ * Run the privateMode detection once for this page, bounded by the late deadline. A definite result
431
+ * is cached and reused by every later collection; a timeout or failure yields a collection error.
432
+ */
433
+ async detectPrivateMode(timing) {
434
+ this.privateModeDetectionStarted = true;
435
+ const source = "detectIncognito";
436
+ const startedAt = nowMs();
437
+ const task = this.startField(
438
+ "privateMode",
439
+ source,
440
+ () => typeof __CBO_DATA_PRIVATE_MODE__ === "boolean" && !__CBO_DATA_PRIVATE_MODE__ ? void 0 : readPrivateMode({
441
+ key: "browser-api:device.private-mode",
442
+ title: "Private browsing mode",
443
+ accessNote: "detectincognitojs library (IndexedDB, storage quota and browser-specific probes)",
444
+ group: "Device information",
445
+ origin: "observe-external",
446
+ fields: [
447
+ {
448
+ path: "result.isPrivate",
449
+ label: "Whether private browsing is on",
450
+ exposure: "enum",
451
+ transform: { values: [true, false] }
452
+ }
453
+ ],
454
+ dataConfig: "privateMode"
455
+ })
456
+ );
457
+ const settled = await settlesWithin(task.promise, DEVICE_INFO_LATE_TIMEOUT_MS);
458
+ if (!settled || !task.settled) {
459
+ if (timing) {
460
+ this.reportTelemetry(`device info privateMode timed out after ${DEVICE_INFO_LATE_TIMEOUT_MS}ms`);
461
+ }
462
+ return { error: timeoutError("privateMode", source, DEVICE_INFO_LATE_TIMEOUT_MS) };
463
+ }
464
+ if (timing) {
465
+ this.reportTelemetry(`device info privateMode detected in ${Math.round(nowMs() - startedAt)}ms`);
466
+ }
467
+ if (task.settled.error) {
468
+ return { error: task.settled.error };
469
+ }
470
+ const value = task.settled.value;
471
+ if (typeof value !== "boolean") {
472
+ return { value: void 0 };
473
+ }
474
+ this.pagePrivateMode = value;
475
+ this.writePrivateModeCache(value);
476
+ return { value };
477
+ }
478
+ /**
479
+ * Resolve the settings for one collection run. A broken provider must never stop collection, so
480
+ * anything thrown degrades to the built-in defaults.
481
+ */
482
+ resolveSettings() {
483
+ try {
484
+ return { timing: this.settings()?.timing === true };
485
+ } catch (error) {
486
+ this.logger.error("Failed to resolve device info collector settings", error);
487
+ return DEFAULT_SETTINGS;
488
+ }
489
+ }
490
+ /**
491
+ * Emit the fast snapshot's durations as an info telemetry entry: the total wall time until the fast
492
+ * snapshot plus every fast field's own time, in definition order so two entries can be compared field
493
+ * by field. Per-field values use the short codes from `TIMING_FIELD_CODES` and carry no unit — the
494
+ * unit is stated once on the total, and all values are milliseconds. A field still running at the
495
+ * fast deadline reports the time it had run so far (it is reported again by the late entry).
496
+ */
497
+ reportFastTiming(totalMs, tasks) {
498
+ const perField = tasks.map((task) => `${TIMING_FIELD_CODES[task.field]}=${Math.round(task.settled?.durationMs ?? totalMs)}`).join(",");
499
+ this.reportTelemetry(`device info collected in ${Math.round(totalMs)}ms (${perField})`);
500
+ }
501
+ /** Emit the late fields' outcome: their duration, or `timeout` when they missed the late deadline. */
502
+ reportLateTiming(totalMs, late) {
503
+ const perField = late.map(
504
+ (task) => `${TIMING_FIELD_CODES[task.field]}=${task.settled ? Math.round(task.settled.durationMs) : "timeout"}`
505
+ ).join(",");
506
+ this.reportTelemetry(`device info late fields done in ${Math.round(totalMs)}ms (${perField})`);
507
+ }
508
+ reportTelemetry(message) {
509
+ if (!this.emitTelemetry) {
510
+ return;
511
+ }
512
+ try {
513
+ this.emitTelemetry("info", message);
514
+ } catch (error) {
515
+ this.logger.error("Failed to report device info timing", error);
516
+ }
517
+ }
518
+ /**
519
+ * Error situations:
520
+ * - navigator is not available: user agent is treated as empty, osName and browserName are "other"
521
+ * - navigator is available but userAgent is null or undefined: same as above
522
+ */
523
+ static getOsAndBrowserInfo() {
524
+ return {
525
+ osName: this.getOsName(),
526
+ browserName: this.getBrowserName()
527
+ };
528
+ }
529
+ static getUserAgent() {
530
+ return readUserAgent({
531
+ key: "browser-api:device.user-agent",
532
+ title: "Browser and operating system",
533
+ accessNote: "navigator.userAgent",
534
+ group: "Device information",
535
+ origin: "observe-external",
536
+ fields: [
537
+ {
538
+ path: "result",
539
+ label: "Browser and operating system",
540
+ exposure: "class",
541
+ transform: {
542
+ classes: [
543
+ "iOS",
544
+ "Android",
545
+ "Windows",
546
+ "macOS",
547
+ "Linux",
548
+ "ChromeOS",
549
+ "Chrome",
550
+ "Firefox",
551
+ "Safari",
552
+ "Edge",
553
+ "Opera",
554
+ "IE",
555
+ "other"
556
+ ]
557
+ }
558
+ }
559
+ ]
560
+ });
561
+ }
562
+ static getOsName() {
563
+ const ua = this.getUserAgent();
564
+ if (ua.includes("iOS")) {
565
+ return "iOS";
566
+ } else if (ua.includes("Android")) {
567
+ return "Android";
568
+ } else if (ua.includes("Windows")) {
569
+ return "Windows";
570
+ } else if (ua.includes("Macintosh")) {
571
+ return "macOS";
572
+ } else if (ua.includes("Linux")) {
573
+ return "Linux";
574
+ } else if (ua.includes("ChromeOS")) {
575
+ return "ChromeOS";
576
+ }
577
+ return "other";
578
+ }
579
+ static getBrowserName() {
580
+ const ua = this.getUserAgent();
581
+ if (ua.includes("Chrome")) {
582
+ return "Chrome";
583
+ } else if (ua.includes("Firefox")) {
584
+ return "Firefox";
585
+ } else if (ua.includes("Safari")) {
586
+ return "Safari";
587
+ } else if (ua.includes("Edge")) {
588
+ return "Edge";
589
+ } else if (ua.includes("Opera")) {
590
+ return "Opera";
591
+ } else if (ua.includes("IE")) {
592
+ return "IE";
593
+ }
594
+ return "other";
595
+ }
596
+ getClientEnvHandle() {
597
+ const existingClientEnvHandle = this.storage.getItem("cbo_client_env_handle");
598
+ if (existingClientEnvHandle) {
599
+ return existingClientEnvHandle;
600
+ }
601
+ const newClientEnvHandleValue = uuidv7();
602
+ const newClientEnvHandle = {
603
+ timestamp: Date.now(),
604
+ source: this.storageSource,
605
+ value: newClientEnvHandleValue
606
+ };
607
+ this.storage.setItem("cbo_client_env_handle", newClientEnvHandle);
608
+ return newClientEnvHandle;
609
+ }
610
+ /**
611
+ * Resolve a per-tab id from sessionStorage (unique per browsing context). Returns undefined when no
612
+ * session storage is available so two tabs sharing one localStorage-backed session can be distinguished.
613
+ *
614
+ * Public because the tracker also stamps this id onto every event's meta at track() time (see
615
+ * `EventMeta.tabId`); both callers share the same sessionStorage-backed id.
616
+ */
617
+ getTabId() {
618
+ if (!this.sessionStorage) {
619
+ return void 0;
620
+ }
621
+ const existing = this.sessionStorage.getItem(TAB_ID_KEY);
622
+ if (existing) {
623
+ return existing;
624
+ }
625
+ const tabId = uuidv7();
626
+ this.sessionStorage.setItem(TAB_ID_KEY, tabId);
627
+ if (this.sessionStorage.getItem(TAB_ID_KEY) !== tabId) {
628
+ return void 0;
629
+ }
630
+ return tabId;
631
+ }
632
+ };
633
+ export {
634
+ DEVICE_INFO_FAST_TIMEOUT_MS,
635
+ DEVICE_INFO_LATE_TIMEOUT_MS,
636
+ DeviceCollector,
637
+ PRIVATE_MODE_CACHE_KEY,
638
+ PRIVATE_MODE_CACHE_TTL_MS,
639
+ PRIVATE_MODE_DETECTOR_VERSION
640
+ };
@@ -0,0 +1,7 @@
1
+ // src/handoff.ts
2
+ function acceptHandoffs(step) {
3
+ return step;
4
+ }
5
+ export {
6
+ acceptHandoffs
7
+ };