@corbado/observe 1.7.2 → 1.8.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 (65) hide show
  1. package/dist/analysis/client.mjs +1851 -0
  2. package/dist/analysis/config-manager.mjs +83 -0
  3. package/dist/analysis/config.mjs +216 -0
  4. package/dist/analysis/device-info-manager.mjs +153 -0
  5. package/dist/analysis/device.mjs +562 -0
  6. package/dist/analysis/index.mjs +118 -0
  7. package/dist/analysis/logger.mjs +50 -0
  8. package/dist/analysis/low-events/capture-sequence.mjs +11 -0
  9. package/dist/analysis/low-events/ceremony-initiator.mjs +26 -0
  10. package/dist/analysis/low-events/form-input-binding.mjs +46 -0
  11. package/dist/analysis/low-events/grouped-input-tracker.mjs +133 -0
  12. package/dist/analysis/low-events/input-change-recorder.mjs +200 -0
  13. package/dist/analysis/low-events/input-paste-observer.mjs +91 -0
  14. package/dist/analysis/low-events/input-tracker.mjs +223 -0
  15. package/dist/analysis/low-events/otp-input-binding.mjs +67 -0
  16. package/dist/analysis/low-events/shared-window-observer.mjs +225 -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 +887 -0
  21. package/dist/analysis/readers/device.mjs +73 -0
  22. package/dist/analysis/readers/meta.mjs +18 -0
  23. package/dist/analysis/readers/password-manager-dom.mjs +112 -0
  24. package/dist/analysis/readers/password-manager-extensions.mjs +247 -0
  25. package/dist/analysis/readers/private-mode.mjs +9 -0
  26. package/dist/analysis/storage/index.mjs +197 -0
  27. package/dist/analysis/storage/migration.mjs +56 -0
  28. package/dist/analysis/store/event-store.mjs +114 -0
  29. package/dist/analysis/transport.mjs +0 -0
  30. package/dist/analysis/transports/fetch.mjs +108 -0
  31. package/dist/analysis/types.mjs +25 -0
  32. package/dist/analysis/utils/appconfirmation-operation-full.mjs +19 -0
  33. package/dist/analysis/utils/blocked-user-agents.mjs +37 -0
  34. package/dist/analysis/utils/captcha-operation-full.mjs +17 -0
  35. package/dist/analysis/utils/email-link-continuity.mjs +50 -0
  36. package/dist/analysis/utils/email-link-operation-full.mjs +142 -0
  37. package/dist/analysis/utils/email-otp-operation-full.mjs +39 -0
  38. package/dist/analysis/utils/form-operation-full.mjs +81 -0
  39. package/dist/analysis/utils/key-operation-full.mjs +38 -0
  40. package/dist/analysis/utils/normalize-error.mjs +29 -0
  41. package/dist/analysis/utils/operation-full-provide-identifier-with-cui.mjs +46 -0
  42. package/dist/analysis/utils/operation-full.mjs +235 -0
  43. package/dist/analysis/utils/passkey-deletion-operation-full.mjs +17 -0
  44. package/dist/analysis/utils/passkey-enrollment-operation-full.mjs +32 -0
  45. package/dist/analysis/utils/passkey-login-operation-full.mjs +34 -0
  46. package/dist/analysis/utils/password-enrollment-operation-full.mjs +27 -0
  47. package/dist/analysis/utils/password-login-operation-full.mjs +61 -0
  48. package/dist/analysis/utils/password-manager-sampler.mjs +130 -0
  49. package/dist/analysis/utils/provide-data-operation-full.mjs +28 -0
  50. package/dist/analysis/utils/serialize-raw-error.mjs +205 -0
  51. package/dist/analysis/utils/sms-otp-operation-full.mjs +39 -0
  52. package/dist/analysis/utils/social-operation-login.mjs +17 -0
  53. package/dist/analysis/utils/source-path.mjs +39 -0
  54. package/dist/analysis/utils/sso-operation-login.mjs +37 -0
  55. package/dist/analysis/utils/totp-operation-full.mjs +35 -0
  56. package/dist/analysis/utils/trusted-device-operation-full.mjs +100 -0
  57. package/dist/analysis/utils/web-lock.mjs +52 -0
  58. package/dist/analysis/utils/webauthn-payloads.mjs +101 -0
  59. package/dist/analysis/version.mjs +7 -0
  60. package/dist/cdn/script.global.js +4 -4
  61. package/dist/index.d.mts +156 -4
  62. package/dist/index.d.ts +156 -4
  63. package/dist/index.js +4 -4
  64. package/dist/index.mjs +4 -4
  65. package/package.json +7 -2
@@ -0,0 +1,1851 @@
1
+ // src/client.ts
2
+ import {
3
+ KeySigningOperationFull,
4
+ KeyRegistrationOperationFull
5
+ } from "./utils/key-operation-full.mjs";
6
+ import { reserveCaptureSeq } from "./low-events/capture-sequence.mjs";
7
+ import { applySourcePathQuery } from "./utils/source-path.mjs";
8
+ import { SharedWindowObserver, getCapturePageId } from "./low-events/shared-window-observer.mjs";
9
+ import {
10
+ AuthEventName
11
+ } from "./types.mjs";
12
+ import { FetchTransport } from "./transports/fetch.mjs";
13
+ import { RequestQueue } from "./queue.mjs";
14
+ import { DeviceCollector } from "./device.mjs";
15
+ import { DeviceInfoManager } from "./device-info-manager.mjs";
16
+ import {
17
+ CookieStorage,
18
+ LocalStorage,
19
+ namespacedKey,
20
+ NamespacedStorage,
21
+ SessionStorage
22
+ } from "./storage/index.mjs";
23
+ import { OUTBOX_LOCK_NAME } from "./store/event-store.mjs";
24
+ import { migrateLegacyStorage } from "./storage/migration.mjs";
25
+ import {
26
+ DEFAULT_RELIABILITY_CONFIG,
27
+ resolveFixedDataConfig,
28
+ resolveReliabilityConfig
29
+ } from "./config.mjs";
30
+ import { ConfigManager } from "./config-manager.mjs";
31
+ import { v7 as uuidv7 } from "uuid";
32
+ import { PasskeyLoginOperationFull } from "./utils/passkey-login-operation-full.mjs";
33
+ import { EmailOtpOperationFull } from "./utils/email-otp-operation-full.mjs";
34
+ import { SmsOtpOperationFull } from "./utils/sms-otp-operation-full.mjs";
35
+ import { isBlockedUserAgent } from "./utils/blocked-user-agents.mjs";
36
+ import { withWebLock } from "./utils/web-lock.mjs";
37
+ import { SDK_NAME, SDK_VERSION } from "./version.mjs";
38
+ import {
39
+ EmailLinkContinuationOperation,
40
+ EmailLinkOperationFull
41
+ } from "./utils/email-link-operation-full.mjs";
42
+ import {
43
+ PasskeyEnrollmentOperationFull
44
+ } from "./utils/passkey-enrollment-operation-full.mjs";
45
+ import { createLogger } from "./logger.mjs";
46
+ import { PasskeyDeletionOperationFull } from "./utils/passkey-deletion-operation-full.mjs";
47
+ import { PasswordLoginOperationFull } from "./utils/password-login-operation-full.mjs";
48
+ import { PasswordEnrollmentOperationFull } from "./utils/password-enrollment-operation-full.mjs";
49
+ import {
50
+ OperationFullProvideIdentifierWithCUI
51
+ } from "./utils/operation-full-provide-identifier-with-cui.mjs";
52
+ import { SocialLoginOperationFull } from "./utils/social-operation-login.mjs";
53
+ import { SsoLoginOperationFull } from "./utils/sso-operation-login.mjs";
54
+ import { TotpOperationFull } from "./utils/totp-operation-full.mjs";
55
+ import { AppConfirmationOperationFull } from "./utils/appconfirmation-operation-full.mjs";
56
+ import { CaptchaOperationFull } from "./utils/captcha-operation-full.mjs";
57
+ import { FormOperationFull } from "./utils/form-operation-full.mjs";
58
+ import {
59
+ TrustedDeviceCheckOperationFull,
60
+ TrustedDeviceEnrollmentOperationFull
61
+ } from "./utils/trusted-device-operation-full.mjs";
62
+ import { ProvideDataOperationFull } from "./utils/provide-data-operation-full.mjs";
63
+ import { PasswordManagerSampler } from "./utils/password-manager-sampler.mjs";
64
+ var SDK_INFO = {
65
+ name: SDK_NAME,
66
+ version: SDK_VERSION
67
+ };
68
+ var INTEGRATION_VERSION_PATTERN = /^[0-9A-Za-z.+_-]{1,32}$/;
69
+ function sdkInfo(integrationVersion, logger) {
70
+ if (integrationVersion === void 0) return SDK_INFO;
71
+ if (typeof integrationVersion === "string" && INTEGRATION_VERSION_PATTERN.test(integrationVersion)) {
72
+ return { ...SDK_INFO, integrationVersion };
73
+ }
74
+ logger.debug("Ignoring invalid integrationVersion (expected <= 32 chars of [0-9A-Za-z.+_-])");
75
+ return SDK_INFO;
76
+ }
77
+ var SESSION_STORAGE_KEY = "cbo_session_id";
78
+ var SESSION_CONTINUITY_KEY = "cbo_session";
79
+ var SEQ_STORAGE_KEY = "cbo_seq";
80
+ var EXPERIMENTS_STORAGE_KEY = "cbo_experiments";
81
+ var DATA_POLICY_STORAGE_KEY = "cbo_data_policy";
82
+ var DATA_POLICY_MAX = 255;
83
+ var EXPERIMENT_KEY_RE = /^[A-Za-z0-9_-]+$/;
84
+ var EXPERIMENT_KEY_MAX = 128;
85
+ var EXPERIMENT_VALUE_MAX = 256;
86
+ var TELEMETRY_MESSAGE_MAX = 1e3;
87
+ var PROBE_KIND_RE = /^[a-z][a-z0-9_-]{0,31}$/;
88
+ var PROBE_ID_RE = /^[A-Za-z0-9_-]{1,16}$/;
89
+ var PROBE_CTX_MAX = 64;
90
+ var PROBE_PAGE_IDS_MAX = 32;
91
+ var MAX_SELF_TELEMETRY = 50;
92
+ var SEQ_LOCK_NAME = "cbo_seq";
93
+ var SESSION_ACTIVITY_WRITE_INTERVAL_MS = 1e3;
94
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
95
+ var CorbadoTracker = class _CorbadoTracker {
96
+ constructor(options) {
97
+ this.options = options;
98
+ this.options.debug = options.debug ?? false;
99
+ this.logger = options.logger ?? createLogger({ debug: this.options.debug });
100
+ const localStorage = new LocalStorage(this.logger);
101
+ const sessionStorage = new SessionStorage(this.logger);
102
+ const isCookie = options.storage === "cookie";
103
+ const cookieStorage = isCookie ? new CookieStorage(this.logger, options.cookieDomain) : void 0;
104
+ migrateLegacyStorage(options.projectId, localStorage, sessionStorage, cookieStorage, this.logger);
105
+ const namespace = options.projectId;
106
+ const scoped = (engine) => new NamespacedStorage(engine, namespace);
107
+ this.seqLockName = namespacedKey(SEQ_LOCK_NAME, namespace);
108
+ this.sessionStorage = scoped(sessionStorage);
109
+ this.persistentStorage = scoped(localStorage);
110
+ this.storage = scoped(cookieStorage ?? localStorage);
111
+ this.configOverrides = {
112
+ ...options.sdkConfig,
113
+ ...options.sdkConfig?.retry && { retry: { ...options.sdkConfig.retry } },
114
+ ...options.sdkConfig?.flushOnFlowTypeFinished && {
115
+ flushOnFlowTypeFinished: [...options.sdkConfig.flushOnFlowTypeFinished]
116
+ },
117
+ ...options.sdkConfig?.sourcePathQueryAllow && {
118
+ sourcePathQueryAllow: [...options.sdkConfig.sourcePathQueryAllow]
119
+ }
120
+ };
121
+ this.fixedData = resolveFixedDataConfig(options.fixedDataConfig);
122
+ if (this.fixedData) {
123
+ this.configOverrides.pmd = this.fixedData.pmd;
124
+ }
125
+ const completeOverride = Object.keys(DEFAULT_RELIABILITY_CONFIG).every(
126
+ (key) => this.configOverrides[key] !== void 0
127
+ ) && Object.keys(DEFAULT_RELIABILITY_CONFIG.retry).every(
128
+ (key) => this.configOverrides.retry?.[key] !== void 0
129
+ );
130
+ if (!completeOverride) {
131
+ this.configManager = new ConfigManager(
132
+ options.apiBaseUrl + (options.apiConfigPath ?? "/v1/observe/config") + "/" + options.projectId,
133
+ this.persistentStorage,
134
+ this.logger
135
+ );
136
+ }
137
+ const cachedConfig = this.configManager?.initialConfig;
138
+ this.config = resolveReliabilityConfig(cachedConfig, this.configOverrides);
139
+ if (!completeOverride && !cachedConfig && options.flushInterval != null && this.configOverrides.flushIntervalMs === void 0) {
140
+ this.config = { ...this.config, flushIntervalMs: options.flushInterval };
141
+ }
142
+ if (!completeOverride && !cachedConfig && options.deviceInfoCollectorTimeout != null && this.configOverrides.deviceInfoCollectorTimeoutMs === void 0) {
143
+ this.config = { ...this.config, deviceInfoCollectorTimeoutMs: options.deviceInfoCollectorTimeout };
144
+ }
145
+ if (options.apiBaseUrl !== "") {
146
+ if (!/^https?:\/\//.test(options.apiBaseUrl)) {
147
+ this.logger.critical(
148
+ `Invalid apiBaseUrl: URL must start with http:// or https:// (got: "${options.apiBaseUrl}")`
149
+ );
150
+ this.reportTelemetry("error", "invalid apiBaseUrl: missing http(s) scheme");
151
+ }
152
+ if (options.apiBaseUrl.endsWith("/")) {
153
+ this.logger.critical(`Invalid apiBaseUrl: URL must not have a trailing slash (got: "${options.apiBaseUrl}")`);
154
+ this.reportTelemetry("error", "invalid apiBaseUrl: trailing slash");
155
+ }
156
+ }
157
+ const url = options.apiBaseUrl + (options.apiEventPath ?? "/v1/observe/events") + "/" + options.projectId;
158
+ this.logger.debug(`Using API endpoint: ${url}`);
159
+ const transport = new FetchTransport({
160
+ logger: this.logger,
161
+ url
162
+ });
163
+ this.sessionId = this.resolveSessionId();
164
+ this.queue = new RequestQueue(
165
+ this.logger,
166
+ transport,
167
+ this.sessionId,
168
+ sdkInfo(this.options.integrationVersion, this.logger),
169
+ {
170
+ debug: this.options.debug,
171
+ config: this.config,
172
+ requestConfig: false,
173
+ outboxLockName: namespacedKey(OUTBOX_LOCK_NAME, namespace),
174
+ getDataPolicy: () => this.dataPolicy,
175
+ fixedData: this.fixedData !== void 0 || typeof __CBO_DATA_PMD__ === "boolean" || typeof __CBO_DATA_PRIVATE_MODE__ === "boolean",
176
+ beforeEventBatchSend: (events) => this.attachUndeliveredDeviceInfo(events),
177
+ // The queue is created before the device info manager; both are only called once it exists.
178
+ standaloneDeviceInfo: {
179
+ has: () => this.deviceInfoManager?.hasStandalone() ?? false,
180
+ take: () => this.deviceInfoManager?.takeStandalone()
181
+ }
182
+ },
183
+ this.persistentStorage
184
+ );
185
+ this.telemetryReady = true;
186
+ this.logger.setTelemetrySink?.((level, message) => this.reportTelemetry(level, message));
187
+ try {
188
+ this.getWindowObserver();
189
+ } catch (error) {
190
+ this.logger.error("Failed to attach page low-event observer", error);
191
+ }
192
+ if (typeof __CBO_DATA_PMD__ === "boolean" ? __CBO_DATA_PMD__ : this.fixedData?.pmd !== false) {
193
+ try {
194
+ this.passwordManagerSampler = this.isTrackingBlocked() ? void 0 : new PasswordManagerSampler(
195
+ this.logger,
196
+ () => typeof __CBO_DATA_PMD__ === "boolean" ? __CBO_DATA_PMD__ : this.config.pmd,
197
+ this.getWindowObserver()
198
+ );
199
+ } catch (error) {
200
+ this.logger.error("Failed to start password manager detection", error);
201
+ }
202
+ }
203
+ const bufferedTelemetry = this.pendingSelfTelemetry;
204
+ this.pendingSelfTelemetry = [];
205
+ for (const item of bufferedTelemetry) {
206
+ this.telemetry(item.level, item.message);
207
+ }
208
+ const deviceInfoCollector = new DeviceCollector(
209
+ this.logger,
210
+ this.storage,
211
+ isCookie ? "cookie" : "localstorage",
212
+ this.sessionStorage,
213
+ () => ({
214
+ timing: this.config.deviceInfoTiming
215
+ }),
216
+ // Timing entries carry raw millisecond values, so they go through the public telemetry path
217
+ // rather than reportTelemetry: the latter dedupes by exact message and is capped per load,
218
+ // where ever-changing messages would crowd out real error diagnostics.
219
+ (level, message) => this.telemetry(level, message),
220
+ this.fixedData?.privateMode ?? true
221
+ );
222
+ this.deviceCollector = deviceInfoCollector;
223
+ const deviceInfoDebounceTime = options.deviceInfoDebounceTime ?? 3e4;
224
+ this.deviceInfoManager = new DeviceInfoManager(
225
+ this.logger,
226
+ deviceInfoCollector,
227
+ Math.max(1, deviceInfoDebounceTime),
228
+ deviceInfoDebounceTime,
229
+ {
230
+ timing: () => this.config.deviceInfoTiming,
231
+ emit: (level, message) => this.telemetry(level, message)
232
+ },
233
+ () => this.queue.notifyDeviceInfo()
234
+ );
235
+ this.deviceInfoManager.start();
236
+ this.experiments = this.loadPersistedExperiments();
237
+ if (options.experiments) {
238
+ this.setExperiments(options.experiments);
239
+ }
240
+ this.dataPolicy = this.loadPersistedDataPolicy();
241
+ if (options.dataPolicy !== void 0) {
242
+ this.setDataPolicy(options.dataPolicy);
243
+ }
244
+ this.configManager?.start((config) => this.handleConfig(config));
245
+ }
246
+ logger;
247
+ queue;
248
+ sessionId;
249
+ storage;
250
+ sessionStorage;
251
+ /** Dedicated localStorage engine for reliability state (config cache, outbox, continuity session, seq). */
252
+ persistentStorage;
253
+ /** Seq/continuity Web Lock name; project-scoped together with the storage keys it guards. */
254
+ seqLockName;
255
+ /** Current resolved policy. Continuity and persistence stay enabled once activated within a document. */
256
+ config;
257
+ configManager;
258
+ configOverrides;
259
+ /** Resolved `fixedDataConfig`; undefined when not given (server-driven data switches). */
260
+ fixedData;
261
+ deviceInfoManager;
262
+ deviceCollector;
263
+ /**
264
+ * Cached per-tab id stamped onto every event's meta (see {@link EventMeta.tabId}). Resolved
265
+ * lazily from the collector's sessionStorage-backed id; stable for the whole page lifetime.
266
+ */
267
+ eventTabId;
268
+ seq = 0;
269
+ /**
270
+ * When the continuity record's `lastActiveAt` was last persisted. Throttles that write inside the
271
+ * seq critical section (see {@link SESSION_ACTIVITY_WRITE_INTERVAL_MS}). 0 = not written yet on
272
+ * this load, so the first allocation always persists.
273
+ */
274
+ lastActivityWriteAt = 0;
275
+ /** Persistent experiment assignments (experiment key → variant), attached to every produced event. */
276
+ experiments = {};
277
+ /**
278
+ * Persistent data policy code, stamped into every batch's `meta.dataPolicy` once set. Never
279
+ * cleared (only overwritten): it survives reloads, session rotation, resetSession and destroy.
280
+ */
281
+ dataPolicy;
282
+ /** Self-instrumentation state: re-entrancy guard, readiness, per-load dedupe, and pre-queue buffer. */
283
+ telemetryReporting = false;
284
+ telemetryReady = false;
285
+ reportedTelemetryKeys = /* @__PURE__ */ new Set();
286
+ pendingSelfTelemetry = [];
287
+ /** Default probe page id (one per tracker instance) and the probe seq counter per page id. */
288
+ probePageId;
289
+ probeSeqs = /* @__PURE__ */ new Map();
290
+ windowObserver;
291
+ passwordManagerSampler;
292
+ /** @internal Shared passive page observation and per-operation context registration. */
293
+ getWindowObserver() {
294
+ return this.windowObserver ??= new SharedWindowObserver(
295
+ this.logger,
296
+ (low, sessionId) => this.enqueueLowEvent(low, sessionId),
297
+ () => this.lowEventFlushKeepalive()
298
+ );
299
+ }
300
+ /** Apply init overrides on every refresh; continuity and persistence only activate within a document. */
301
+ handleConfig(config) {
302
+ const next = resolveReliabilityConfig(config, this.configOverrides);
303
+ next.sessionContinuity ||= this.config.sessionContinuity;
304
+ next.durableOutbox ||= this.config.durableOutbox;
305
+ this.config = next;
306
+ this.queue.updateConfig(this.config);
307
+ }
308
+ /**
309
+ * Returns the logger used by this tracker.
310
+ * @internal
311
+ */
312
+ getLogger() {
313
+ return this.logger;
314
+ }
315
+ applicationTag() {
316
+ if (this.options.applicationId) {
317
+ return { applicationId: this.options.applicationId.toLowerCase() };
318
+ }
319
+ return {};
320
+ }
321
+ // --- Experiments ---
322
+ /**
323
+ * Register the user's variant for an experiment (e.g. `setExperiment("button_color", "blue")`).
324
+ *
325
+ * @remarks
326
+ * Corbado Observe does not assign variants — your app decides, and this records the assignment so
327
+ * Observe can attribute flows to it. The assignment persists (localStorage) across reloads and
328
+ * `resetSession`, and is attached to every event the tracker produces until you change or clear it.
329
+ * Multiple experiments can be active at once. Set it close to where the variant actually affects the
330
+ * user (or use the per-call `experiments` argument on a tracking method) to avoid over-attributing
331
+ * exposure; call {@link clearExperiment} when it no longer applies (e.g. on logout).
332
+ *
333
+ * Never throws: invalid input is ignored with a debug log.
334
+ */
335
+ setExperiment(key, variant) {
336
+ this.setExperiments({ [key]: variant });
337
+ }
338
+ /**
339
+ * Register several experiment assignments at once (experiment key → variant). Additive: existing
340
+ * assignments for other keys are kept. See {@link setExperiment}.
341
+ */
342
+ setExperiments(assignments) {
343
+ try {
344
+ if (!assignments || typeof assignments !== "object") {
345
+ return;
346
+ }
347
+ let changed = false;
348
+ for (const [key, variant] of Object.entries(assignments)) {
349
+ if (!_CorbadoTracker.isValidExperimentKey(key) || !_CorbadoTracker.isValidExperimentVariant(variant)) {
350
+ this.logger.debug(`Ignoring invalid experiment assignment "${String(key)}"`);
351
+ continue;
352
+ }
353
+ if (this.experiments[key] !== variant) {
354
+ this.experiments[key] = variant;
355
+ changed = true;
356
+ }
357
+ }
358
+ if (changed) {
359
+ this.persistExperiments();
360
+ }
361
+ } catch (error) {
362
+ this.logger.error("Failed to set experiments", error);
363
+ }
364
+ }
365
+ /** Remove a single experiment assignment. */
366
+ clearExperiment(key) {
367
+ try {
368
+ if (Object.prototype.hasOwnProperty.call(this.experiments, key)) {
369
+ delete this.experiments[key];
370
+ this.persistExperiments();
371
+ }
372
+ } catch (error) {
373
+ this.logger.error("Failed to clear experiment", error);
374
+ }
375
+ }
376
+ /** Remove all experiment assignments. */
377
+ clearExperiments() {
378
+ try {
379
+ if (Object.keys(this.experiments).length === 0) {
380
+ return;
381
+ }
382
+ this.experiments = {};
383
+ this.persistExperiments();
384
+ } catch (error) {
385
+ this.logger.error("Failed to clear experiments", error);
386
+ }
387
+ }
388
+ /** Returns a copy of the currently active experiment assignments. */
389
+ getExperiments() {
390
+ return { ...this.experiments };
391
+ }
392
+ /**
393
+ * Returns the currently active public SDK configuration, including live updates.
394
+ *
395
+ * @returns A frozen snapshot, created fresh on every call.
396
+ */
397
+ /** @internal Project-scoped localStorage used by operation helpers (e.g. email-link continuity). */
398
+ getPersistentStorage() {
399
+ return this.persistentStorage;
400
+ }
401
+ getSdkConfig() {
402
+ return Object.freeze({
403
+ version: this.config.version,
404
+ aap: this.config.aap,
405
+ rawErrors: this.config.rawErrors
406
+ });
407
+ }
408
+ // --- Data policy ---
409
+ /**
410
+ * Set the data policy code for this tracker (e.g. `setDataPolicy(1)` once the user agreed to
411
+ * extended processing; `setDataPolicy(0)` to return to the project default). Codes are
412
+ * project-scoped non-negative integers (0..255) selecting a policy configured in Corbado; they
413
+ * carry no ordering.
414
+ *
415
+ * The code is persisted (localStorage, project-scoped) and sent as `meta.dataPolicy` on every
416
+ * subsequent batch, across reloads and session rotation. It is never cleared, only overwritten.
417
+ * The server records the latest code it receives for a session (last write wins). Invalid codes
418
+ * are ignored with a debug log; never throws.
419
+ */
420
+ setDataPolicy(code) {
421
+ try {
422
+ if (!_CorbadoTracker.isValidDataPolicy(code)) {
423
+ this.logger.debug(`Ignoring invalid data policy code "${String(code)}"`);
424
+ return;
425
+ }
426
+ if (this.dataPolicy !== code) {
427
+ this.dataPolicy = code;
428
+ this.persistDataPolicy();
429
+ }
430
+ } catch (error) {
431
+ this.logger.error("Failed to set data policy", error);
432
+ }
433
+ }
434
+ /** Returns the current data policy code, or undefined when none has ever been set. */
435
+ getDataPolicy() {
436
+ return this.dataPolicy;
437
+ }
438
+ static isValidDataPolicy(code) {
439
+ return typeof code === "number" && Number.isInteger(code) && code >= 0 && code <= DATA_POLICY_MAX;
440
+ }
441
+ loadPersistedDataPolicy() {
442
+ try {
443
+ const stored = this.persistentStorage.getItem(DATA_POLICY_STORAGE_KEY);
444
+ return _CorbadoTracker.isValidDataPolicy(stored) ? stored : void 0;
445
+ } catch (error) {
446
+ this.logger.debug("Failed to load persisted data policy", error);
447
+ return void 0;
448
+ }
449
+ }
450
+ persistDataPolicy() {
451
+ try {
452
+ if (this.dataPolicy === void 0) {
453
+ return;
454
+ }
455
+ this.persistentStorage.setItem(DATA_POLICY_STORAGE_KEY, this.dataPolicy);
456
+ } catch (error) {
457
+ this.logger.debug("Failed to persist data policy", error);
458
+ }
459
+ }
460
+ static isValidExperimentKey(key) {
461
+ return typeof key === "string" && key.length > 0 && key.length <= EXPERIMENT_KEY_MAX && EXPERIMENT_KEY_RE.test(key);
462
+ }
463
+ static isValidExperimentVariant(variant) {
464
+ return typeof variant === "string" && variant.length > 0 && variant.length <= EXPERIMENT_VALUE_MAX;
465
+ }
466
+ loadPersistedExperiments() {
467
+ try {
468
+ const stored = this.persistentStorage.getItem(EXPERIMENTS_STORAGE_KEY);
469
+ if (!stored || typeof stored !== "object") {
470
+ return {};
471
+ }
472
+ const cleaned = {};
473
+ for (const [key, variant] of Object.entries(stored)) {
474
+ if (_CorbadoTracker.isValidExperimentKey(key) && _CorbadoTracker.isValidExperimentVariant(variant)) {
475
+ cleaned[key] = variant;
476
+ }
477
+ }
478
+ return cleaned;
479
+ } catch (error) {
480
+ this.logger.debug("Failed to load persisted experiments", error);
481
+ return {};
482
+ }
483
+ }
484
+ persistExperiments() {
485
+ try {
486
+ this.persistentStorage.setItem(EXPERIMENTS_STORAGE_KEY, this.experiments);
487
+ } catch (error) {
488
+ this.logger.debug("Failed to persist experiments", error);
489
+ }
490
+ }
491
+ /**
492
+ * Resolves the experiments to attach to one event: the persistent assignments with any per-call
493
+ * override merged on top. Returns undefined when there are none (so the field is omitted).
494
+ */
495
+ resolveExperiments(perCall) {
496
+ const merged = { ...this.experiments };
497
+ if (perCall && typeof perCall === "object") {
498
+ for (const [key, variant] of Object.entries(perCall)) {
499
+ if (_CorbadoTracker.isValidExperimentKey(key) && _CorbadoTracker.isValidExperimentVariant(variant)) {
500
+ merged[key] = variant;
501
+ }
502
+ }
503
+ }
504
+ return Object.keys(merged).length > 0 ? merged : void 0;
505
+ }
506
+ isTrackingBlocked() {
507
+ if (typeof navigator === "undefined") {
508
+ return false;
509
+ }
510
+ return isBlockedUserAgent(navigator.userAgent);
511
+ }
512
+ resolveSessionId() {
513
+ try {
514
+ if (this.config.sessionContinuity) {
515
+ return this.getContinuitySessionId();
516
+ }
517
+ const now = Date.now();
518
+ const stored = this.readPerTabSession();
519
+ if (stored && now - stored.lastActiveAt < this.perTabInactivityMs(stored)) {
520
+ this.persistPerTabSession(stored.id, now);
521
+ this.seq = 0;
522
+ return stored.id;
523
+ }
524
+ const newSessionId = uuidv7();
525
+ this.persistPerTabSession(newSessionId, now);
526
+ this.seq = 0;
527
+ this.logger.debug("Created session id", newSessionId);
528
+ return newSessionId;
529
+ } catch (error) {
530
+ this.logger.error("Failed to get session id", error);
531
+ this.reportTelemetry("error", "failed to get session id");
532
+ return "";
533
+ }
534
+ }
535
+ /**
536
+ * Resolve the session id from localStorage so it survives reloads and is shared across tabs of the
537
+ * same browser. Rotates after `config.sessionInactivityMs` of inactivity (server-controlled,
538
+ * evaluated at boot), resetting the seq counter.
539
+ *
540
+ * Inactivity rotation happens at boot only, here and on the per-tab path {@link resolveSessionId}
541
+ * takes without continuity. Boot is chosen on an assumption, not a guarantee: that a load
542
+ * re-announces its flow, so the fresh session id gets a classifiable flow. That holds where a
543
+ * flow is opened per load. Where one is opened once and carried across loads it does not, and a
544
+ * boot rotation splits that flow — such a project needs a window long enough to span the journey.
545
+ * Mid-page the assumption is not even available: nothing re-announces between two tracked events,
546
+ * so rotating there would leave the started flow unfinished in the old session and the remaining
547
+ * events without a flow start in the new one — one gap counted as a drop-off, one completion
548
+ * counted as nothing. `nextSeq` therefore never rotates on inactivity, on either path.
549
+ */
550
+ getContinuitySessionId() {
551
+ const now = Date.now();
552
+ const existing = this.persistentStorage.getItem(SESSION_CONTINUITY_KEY);
553
+ if (existing?.id && now - existing.lastActiveAt < this.config.sessionInactivityMs) {
554
+ this.persistContinuitySession(existing.id, now);
555
+ this.seq = this.persistentStorage.getItem(SEQ_STORAGE_KEY) ?? 0;
556
+ return existing.id;
557
+ }
558
+ if (!existing?.id) {
559
+ const perTab = this.readPerTabSession();
560
+ if (perTab && now - perTab.lastActiveAt < this.config.sessionInactivityMs) {
561
+ this.persistContinuitySession(perTab.id, now);
562
+ this.persistentStorage.setItem(SEQ_STORAGE_KEY, 0);
563
+ this.seq = 0;
564
+ this.logger.debug("Adopted per-tab session id as continuity session", perTab.id);
565
+ return perTab.id;
566
+ }
567
+ }
568
+ const newSessionId = uuidv7();
569
+ this.persistContinuitySession(newSessionId, now);
570
+ this.persistentStorage.setItem(SEQ_STORAGE_KEY, 0);
571
+ this.seq = 0;
572
+ this.logger.debug("Created continuity session id", newSessionId);
573
+ return newSessionId;
574
+ }
575
+ /**
576
+ * Persist the continuity record and remember when, so the throttled refresh in the seq critical
577
+ * section measures from the last write that actually happened — whoever made it.
578
+ */
579
+ persistContinuitySession(id, now) {
580
+ this.lastActivityWriteAt = now;
581
+ this.persistentStorage.setItem(SESSION_CONTINUITY_KEY, { id, lastActiveAt: now });
582
+ }
583
+ /**
584
+ * Read the per-tab session record. A bare string is the format that predates dating: it carries no
585
+ * `lastActiveAt`, so its window can only start on the load that reads it. That load rewrites it in
586
+ * the dated shape, so a tab converts once and ages normally from then on, and the case drains as
587
+ * the rollout replaces the bundles that write it.
588
+ */
589
+ readPerTabSession() {
590
+ const stored = this.sessionStorage.getItem(SESSION_STORAGE_KEY);
591
+ if (!stored) {
592
+ return void 0;
593
+ }
594
+ if (typeof stored === "string") {
595
+ return UUID_RE.test(stored) ? { id: stored, lastActiveAt: Date.now() } : void 0;
596
+ }
597
+ if (typeof stored.lastActiveAt !== "number") {
598
+ return void 0;
599
+ }
600
+ return UUID_RE.test(stored.id) ? stored : void 0;
601
+ }
602
+ /**
603
+ * Persist the per-tab record and remember when, sharing the throttle bookkeeping with the
604
+ * continuity record — a load resolves its session on one path or the other, never both.
605
+ *
606
+ * An explicit timeout override or server config stamps the window. Otherwise carry forward what
607
+ * the tab already knows, so a defaults boot does not replace a previously configured threshold.
608
+ */
609
+ persistPerTabSession(id, now) {
610
+ this.lastActivityWriteAt = now;
611
+ const inactivityMs = this.configOverrides.sessionInactivityMs !== void 0 || this.config.version ? this.config.sessionInactivityMs : this.readPerTabSession()?.inactivityMs;
612
+ this.sessionStorage.setItem(SESSION_STORAGE_KEY, {
613
+ id,
614
+ lastActiveAt: now,
615
+ ...inactivityMs === void 0 ? {} : { inactivityMs }
616
+ });
617
+ }
618
+ /** Explicit init timeout wins; otherwise retain the window a prior configured load recorded. */
619
+ perTabInactivityMs(stored) {
620
+ return this.configOverrides.sessionInactivityMs !== void 0 ? this.config.sessionInactivityMs : stored.inactivityMs ?? this.config.sessionInactivityMs;
621
+ }
622
+ /**
623
+ * Return the next sequence number. With session continuity the counter is persisted so ordering
624
+ * survives reloads/redirects; the read-increment-write runs under a cross-tab Web Lock
625
+ * ({@link SEQ_LOCK_NAME}) so two tabs sharing the session cannot allocate the same seq (where Web
626
+ * Locks are unavailable or exceed the configured timeout it degrades to the historical best-effort merge).
627
+ * Lock grants are FIFO, so seq order matches `track()` call order within a tab. Tracking also keeps the
628
+ * continuity session's `lastActiveAt` fresh (throttled, see {@link SESSION_ACTIVITY_WRITE_INTERVAL_MS}),
629
+ * so the window the NEXT boot evaluates measures real user
630
+ * inactivity instead of time-since-page-load (a long-lived active tab must not rotate the session
631
+ * on the next reload). It never rotates the session itself: the inactivity window is evaluated at
632
+ * boot only ({@link getContinuitySessionId} documents why). Consequence: while a page is never
633
+ * reloaded — foreground idle, bfcache restore, long-lived SPA tab — a session can outlive
634
+ * `sessionInactivityMs`. That inflates flow durations but keeps flow counts correct, which is the
635
+ * better half of the trade. An empty store while this load already has a session id means
636
+ * persistence is failing (or was cleared); the in-memory id is reused then — never re-minted — so
637
+ * a broken store degrades to one session per page load instead of one session per event.
638
+ */
639
+ nextSeq() {
640
+ if (!this.config.sessionContinuity) {
641
+ const now = Date.now();
642
+ if (this.sessionId && now - this.lastActivityWriteAt >= SESSION_ACTIVITY_WRITE_INTERVAL_MS) {
643
+ this.persistPerTabSession(this.sessionId, now);
644
+ }
645
+ return this.seq++;
646
+ }
647
+ const allocate = () => {
648
+ const now = Date.now();
649
+ const storedSession = this.persistentStorage.getItem(SESSION_CONTINUITY_KEY);
650
+ let sessionChanged = false;
651
+ if (!storedSession?.id && this.sessionId) {
652
+ this.persistContinuitySession(this.sessionId, now);
653
+ } else if (!storedSession?.id) {
654
+ const newSessionId = uuidv7();
655
+ this.persistContinuitySession(newSessionId, now);
656
+ this.persistentStorage.setItem(SEQ_STORAGE_KEY, 0);
657
+ this.sessionId = newSessionId;
658
+ this.queue.setSessionId(newSessionId);
659
+ this.seq = 0;
660
+ sessionChanged = true;
661
+ } else {
662
+ if (now - this.lastActivityWriteAt >= SESSION_ACTIVITY_WRITE_INTERVAL_MS) {
663
+ this.persistContinuitySession(storedSession.id, now);
664
+ }
665
+ if (storedSession.id !== this.sessionId) {
666
+ this.sessionId = storedSession.id;
667
+ this.queue.setSessionId(storedSession.id);
668
+ sessionChanged = true;
669
+ }
670
+ }
671
+ const stored = this.persistentStorage.getItem(SEQ_STORAGE_KEY);
672
+ if (sessionChanged) {
673
+ this.seq = typeof stored === "number" ? stored : 0;
674
+ }
675
+ const current = typeof stored === "number" && stored > this.seq ? stored : this.seq;
676
+ this.seq = current + 1;
677
+ this.persistentStorage.setItem(SEQ_STORAGE_KEY, this.seq);
678
+ return current;
679
+ };
680
+ const timeoutMs = this.config.seqLockTimeoutMs;
681
+ if (timeoutMs === 0 || typeof navigator === "undefined" || !navigator.locks?.request) {
682
+ return allocate();
683
+ }
684
+ return withWebLock(this.seqLockName, allocate, {
685
+ timeoutMs,
686
+ onTimeout: () => {
687
+ this.logger.warn(`Sequence Web Lock acquisition timed out after ${timeoutMs}ms; using fallback`);
688
+ this.reportTelemetry("info", `seq web lock timeout; using fallback, ${timeoutMs}ms`);
689
+ }
690
+ });
691
+ }
692
+ resolveTrackingSourcePath() {
693
+ if (typeof window === "undefined") {
694
+ return void 0;
695
+ }
696
+ const search = applySourcePathQuery(
697
+ window.location.search,
698
+ this.config.sourcePathQuery,
699
+ this.config.sourcePathQueryAllow
700
+ );
701
+ return `${window.location.pathname}${search}`;
702
+ }
703
+ /**
704
+ * Per-tab id for event meta, cached after the first successful resolution: the id is
705
+ * sessionStorage-backed and stable for the whole page lifetime.
706
+ */
707
+ resolveEventTabId() {
708
+ if (this.eventTabId === void 0) {
709
+ this.eventTabId = this.deviceCollector.getTabId();
710
+ }
711
+ return this.eventTabId;
712
+ }
713
+ /** @internal */
714
+ trackSubflowStarted(subflowType, data, options, eventId) {
715
+ const wrappedData = {
716
+ subflowType,
717
+ ...data
718
+ };
719
+ void this.track(
720
+ AuthEventName.SubflowStarted,
721
+ wrappedData,
722
+ options?.userReference,
723
+ void 0,
724
+ void 0,
725
+ options?.explicitTimestamp,
726
+ options?.experiments,
727
+ {
728
+ id: eventId,
729
+ subflowStartEventId: options?.subflowStartEventId,
730
+ sessionId: options?.captureSessionId,
731
+ initiator: options?.initiator
732
+ }
733
+ );
734
+ }
735
+ /** @internal */
736
+ trackSubflowTrigger(subflowType, data, options) {
737
+ const wrappedData = {
738
+ subflowType,
739
+ ...data
740
+ };
741
+ void this.track(
742
+ AuthEventName.SubflowTrigger,
743
+ wrappedData,
744
+ options?.userReference,
745
+ void 0,
746
+ void 0,
747
+ options?.explicitTimestamp,
748
+ options?.experiments,
749
+ {
750
+ subflowStartEventId: options?.subflowStartEventId,
751
+ sessionId: options?.captureSessionId,
752
+ initiator: options?.initiator
753
+ }
754
+ );
755
+ }
756
+ /** @internal */
757
+ trackSubflowStepStarted(subflowType, stepName, data, options, ignoreAsInteraction) {
758
+ const wrappedData = {
759
+ subflowType,
760
+ stepName,
761
+ stepData: data,
762
+ identifier: options?.identifier,
763
+ ignoreAsInteraction
764
+ };
765
+ void this.track(
766
+ AuthEventName.SubflowStepStarted,
767
+ wrappedData,
768
+ options?.userReference,
769
+ void 0,
770
+ void 0,
771
+ options?.explicitTimestamp,
772
+ options?.experiments,
773
+ {
774
+ subflowStartEventId: options?.subflowStartEventId,
775
+ sessionId: options?.captureSessionId,
776
+ initiator: options?.initiator
777
+ }
778
+ );
779
+ }
780
+ /** @internal */
781
+ trackSubflowStepFinished(subflowType, stepName, data, options) {
782
+ const wrappedData = {
783
+ subflowType,
784
+ stepName,
785
+ stepData: data,
786
+ identifier: options?.identifier
787
+ };
788
+ void this.track(
789
+ AuthEventName.SubflowStepFinished,
790
+ wrappedData,
791
+ options?.userReference,
792
+ void 0,
793
+ void 0,
794
+ options?.explicitTimestamp,
795
+ options?.experiments,
796
+ {
797
+ subflowStartEventId: options?.subflowStartEventId,
798
+ sessionId: options?.captureSessionId,
799
+ initiator: options?.initiator
800
+ }
801
+ );
802
+ }
803
+ /** @internal */
804
+ trackSubflowStepError(subflowType, stepName, data, options) {
805
+ const wrappedData = {
806
+ subflowType,
807
+ stepName,
808
+ stepData: data,
809
+ identifier: options?.identifier
810
+ };
811
+ void this.track(
812
+ AuthEventName.SubflowStepError,
813
+ wrappedData,
814
+ options?.userReference,
815
+ void 0,
816
+ void 0,
817
+ options?.explicitTimestamp,
818
+ options?.experiments,
819
+ {
820
+ subflowStartEventId: options?.subflowStartEventId,
821
+ sessionId: options?.captureSessionId,
822
+ initiator: options?.initiator
823
+ }
824
+ );
825
+ }
826
+ /**
827
+ * @remarks
828
+ * @internal
829
+ * Records an additional error occurrence against the subflow. Carries no outcome semantics —
830
+ * step errors are the intended channel; not part of a normal integration.
831
+ */
832
+ trackSubflowError(subflowType, data, options) {
833
+ const wrappedData = {
834
+ subflowType,
835
+ stepData: data
836
+ };
837
+ void this.track(
838
+ AuthEventName.SubflowError,
839
+ wrappedData,
840
+ options?.userReference,
841
+ void 0,
842
+ void 0,
843
+ options?.explicitTimestamp,
844
+ options?.experiments,
845
+ {
846
+ subflowStartEventId: options?.subflowStartEventId,
847
+ sessionId: options?.captureSessionId,
848
+ initiator: options?.initiator
849
+ }
850
+ );
851
+ }
852
+ /**
853
+ * Error situations:
854
+ * - Blocked user agent (see `isBlockedUserAgent`): skip with a debug log and return
855
+ * - Any error before the event is enqueued (e.g. `JSON.stringify` in debug logging, `queue.enqueue`): log `Failed to process event "<name>" before enqueue`
856
+ *
857
+ * @internal
858
+ */
859
+ async track(name, data, userReference, tags, contexts, explicitTimestamp, experiments, capture) {
860
+ try {
861
+ if (this.isTrackingBlocked()) {
862
+ this.logger.debug(`Skipping event "${name}" for blocked user agent.`);
863
+ return;
864
+ }
865
+ if (name === AuthEventName.FlowFinished || name === AuthEventName.FlowAutoFinished || name === AuthEventName.FlowReset) {
866
+ this.windowObserver?.flushInputs();
867
+ }
868
+ const timestamp = explicitTimestamp ?? Date.now();
869
+ const capturedId = capture?.id ?? uuidv7();
870
+ const capturedSessionId = capture?.sessionId ?? this.sessionId;
871
+ const nextSeq = this.nextSeq();
872
+ const seq = typeof nextSeq === "number" ? nextSeq : await nextSeq;
873
+ const trackingSourcePath = this.resolveTrackingSourcePath();
874
+ const event = {
875
+ type: "predefined",
876
+ name,
877
+ id: capturedId,
878
+ timestamp,
879
+ seq,
880
+ data,
881
+ user: userReference,
882
+ contexts,
883
+ tags,
884
+ experiments: this.resolveExperiments(experiments),
885
+ meta: {
886
+ captureVersion: 1,
887
+ pageId: getCapturePageId(),
888
+ initiator: capture?.initiator,
889
+ trackingSourcePath: trackingSourcePath ?? "",
890
+ subflowStartEventId: capture?.subflowStartEventId,
891
+ tabId: this.resolveEventTabId()
892
+ }
893
+ };
894
+ const deviceInfo = this.deviceInfoManager.getForEvent();
895
+ if (deviceInfo) {
896
+ event.deviceInfo = deviceInfo;
897
+ }
898
+ this.logger.debug(`Track: ${name}`, JSON.stringify(event, null, 2));
899
+ const passwordManagers = this.passwordManagerSampler?.takeReport(capturedSessionId);
900
+ if (passwordManagers) {
901
+ event.passwordManagers = passwordManagers;
902
+ }
903
+ this.queue.enqueue(event, capturedSessionId);
904
+ } catch (error) {
905
+ this.logger.error(`Failed to process event "${name}" before enqueue`, error);
906
+ this.reportTelemetry("error", `failed to process event "${name}" before enqueue`);
907
+ }
908
+ }
909
+ // --- Typed Tracking Methods ---
910
+ /**
911
+ * Track when an authentication flow (or flow selection screen) becomes visible.
912
+ *
913
+ * @remarks
914
+ * Call this once when the user first enters an auth touchpoint (for example account login,
915
+ * checkout login, or account recovery). If users can choose between multiple flows
916
+ * (such as login vs signup), pass `flowNames` and optionally `defaultFlowName`.
917
+ *
918
+ * The returned handle can attach identity after completion with `await flow.enrich({ userId })`.
919
+ * Enrichment requires a backend supporting explicit event targeting.
920
+ *
921
+ * @param data - Flow metadata, including one flow (`flowName`) or multiple candidates (`flowNames`).
922
+ * @param tags - Optional key-value tags for filtering and segmentation.
923
+ * @param experiments - Optional per-call experiment overrides.
924
+ * @param contexts - Optional structured event context (e.g. technical metadata that is not an
925
+ * analytical dimension). Written to the event's `contexts` field verbatim.
926
+ * @param options - Optional `explicitTimestamp` (epoch ms) when the flow became visible earlier than
927
+ * this call, e.g. because an integration confirms a screen only after it stayed up for a while.
928
+ *
929
+ * @example
930
+ * ```typescript
931
+ * tracker.flowStarted({
932
+ * flowNames: ["login", "signup"],
933
+ * defaultFlowName: "login",
934
+ * touchpoint: "account",
935
+ * });
936
+ * ```
937
+ */
938
+ flowStarted(data, tags, experiments, contexts, options) {
939
+ try {
940
+ const targetEventId = uuidv7();
941
+ const sessionId = this.sessionId;
942
+ const effectiveTags = { ...this.options.defaultTags, ...this.applicationTag(), ...tags };
943
+ void this.track(
944
+ AuthEventName.FlowStarted,
945
+ data,
946
+ void 0,
947
+ effectiveTags,
948
+ contexts,
949
+ options?.explicitTimestamp,
950
+ experiments,
951
+ {
952
+ id: targetEventId,
953
+ sessionId
954
+ }
955
+ );
956
+ return this.flowHandle(targetEventId, sessionId);
957
+ } catch (error) {
958
+ this.logger.warn("Failed to start flow", error);
959
+ return { enrich: async () => {
960
+ } };
961
+ }
962
+ }
963
+ /**
964
+ * Track when the user commits to a specific flow.
965
+ *
966
+ * @remarks
967
+ * Use this after presenting alternatives and once you know which path the user takes
968
+ * (for example when identifier checks route to login vs signup).
969
+ *
970
+ * @param data - Selected flow name.
971
+ * @param tags - Optional key-value tags for filtering and segmentation.
972
+ *
973
+ * @example
974
+ * ```typescript
975
+ * tracker.flowDecided({flowName: "signup"});
976
+ * ```
977
+ */
978
+ flowDecided(data, tags, experiments) {
979
+ void this.track(AuthEventName.FlowDecided, data, void 0, tags, void 0, void 0, experiments);
980
+ }
981
+ /**
982
+ * Track a business/marketing conversion that is not part of an authentication flow.
983
+ *
984
+ * @remarks
985
+ * Use this for outcomes you care about beyond auth, such as a completed purchase,
986
+ * a started subscription, or a newsletter signup. Provide a stable `name` for the
987
+ * conversion and (optionally) the `touchpoint` where it happened so it can be
988
+ * segmented in the same way as flows. Record identity separately with `setUser()`
989
+ * while the relevant flow is still active.
990
+ *
991
+ * Legacy identity fields in `data` remain supported but are deprecated.
992
+ *
993
+ * @param data - Conversion name, optional touchpoint, and deprecated user reference fields.
994
+ * @param tags - Optional key-value tags for filtering and segmentation.
995
+ *
996
+ * @example
997
+ * ```typescript
998
+ * tracker.conversion({
999
+ * name: "purchase",
1000
+ * touchpoint: "checkout",
1001
+ * });
1002
+ * ```
1003
+ */
1004
+ conversion(data, tags, experiments) {
1005
+ const { userId, identifier, crossEnvironmentTransactionID, ...dataOut } = data;
1006
+ const userReference = { userId, identifier, crossEnvironmentTransactionID };
1007
+ const effectiveTags = { ...this.options.defaultTags, ...this.applicationTag(), ...tags };
1008
+ void this.track(AuthEventName.Conversion, dataOut, userReference, effectiveTags, void 0, void 0, experiments);
1009
+ }
1010
+ /**
1011
+ * Track when a flow successfully completes.
1012
+ *
1013
+ * @remarks
1014
+ * Fire this when the user has finished the target flow end-to-end, such as completed login,
1015
+ * signup, or recovery. Record identity with `setUser()` while the flow is active,
1016
+ * or enrich it after completion through the handle returned by `flowStarted()` or by this call.
1017
+ *
1018
+ * The returned handle targets the flow this event finished, so identity learned only after
1019
+ * completion — for example from a profile request that follows account creation — can be
1020
+ * attached with `await finished.enrich({ userId })` even on a page that never saw the flow start.
1021
+ * Enrichment requires a backend supporting explicit flow-event targeting.
1022
+ *
1023
+ * Legacy identity fields in `data` remain supported but are deprecated.
1024
+ *
1025
+ * @param data - Finished flow name, optional explicit outcome, and deprecated user reference fields.
1026
+ * @param tags - Optional key-value tags for filtering and segmentation.
1027
+ *
1028
+ * @example
1029
+ * ```typescript
1030
+ * const finished = tracker.flowFinished({ flowName: "signup" });
1031
+ * const userId = await hashUserId(createdAccountId);
1032
+ * await finished.enrich({ userId });
1033
+ * ```
1034
+ */
1035
+ flowFinished(data, tags, experiments) {
1036
+ try {
1037
+ const { userId, identifier, ...dataOut } = data;
1038
+ const userReference = { userId, identifier };
1039
+ const targetEventId = uuidv7();
1040
+ const sessionId = this.sessionId;
1041
+ void this.track(AuthEventName.FlowFinished, dataOut, userReference, tags, void 0, void 0, experiments, {
1042
+ id: targetEventId,
1043
+ sessionId
1044
+ });
1045
+ return this.flowHandle(targetEventId, sessionId);
1046
+ } catch (error) {
1047
+ this.logger.warn("Failed to finish flow", error);
1048
+ return { enrich: async () => {
1049
+ } };
1050
+ }
1051
+ }
1052
+ /** Emits `flow_enriched` against an explicit target, in the session that emitted the target. */
1053
+ flowHandle(targetEventId, sessionId) {
1054
+ return {
1055
+ enrich: async (user) => {
1056
+ try {
1057
+ await this.track(
1058
+ AuthEventName.FlowEnriched,
1059
+ { targetEventId },
1060
+ { ...user },
1061
+ void 0,
1062
+ void 0,
1063
+ void 0,
1064
+ void 0,
1065
+ { sessionId }
1066
+ );
1067
+ } catch (error) {
1068
+ this.logger.warn("Failed to enrich flow", error);
1069
+ }
1070
+ }
1071
+ };
1072
+ }
1073
+ /**
1074
+ * Track when an in-progress flow is reset and restarted.
1075
+ *
1076
+ * @remarks
1077
+ * Use this when users abandon the current path and return to the beginning of the same flow,
1078
+ * for example after switching account/identifier and reopening the first step.
1079
+ *
1080
+ * @param data - Flow name being reset.
1081
+ * @param tags - Optional key-value tags for filtering and segmentation.
1082
+ *
1083
+ * @example
1084
+ * ```typescript
1085
+ * tracker.flowReset({flowName: "login"});
1086
+ * ```
1087
+ */
1088
+ flowReset(data, tags, experiments) {
1089
+ void this.track(AuthEventName.FlowReset, data, void 0, tags, void 0, void 0, experiments);
1090
+ }
1091
+ /**
1092
+ * Track when a flow is considered finished because another flow finished it implicitly.
1093
+ *
1094
+ * @remarks
1095
+ * Use this for automatic completions where no separate explicit finish action happens in the
1096
+ * current flow. Set `flowName` to the auto-finished flow and `finishedByFlowName` to the flow
1097
+ * that caused it. Record identity with `setUser()` while the intended flow is still
1098
+ * current, before it is auto-finished.
1099
+ *
1100
+ * Legacy identity fields in `data` remain supported but are deprecated.
1101
+ *
1102
+ * @param data - Auto-finished flow, finishing flow, and deprecated user reference fields.
1103
+ * @param tags - Optional key-value tags for filtering and segmentation.
1104
+ *
1105
+ * @example
1106
+ * ```typescript
1107
+ * tracker.flowAutoFinished({
1108
+ * flowName: "signup",
1109
+ * finishedByFlowName: "login",
1110
+ * });
1111
+ * ```
1112
+ */
1113
+ flowAutoFinished(data, tags, experiments) {
1114
+ const { userId, identifier, ...dataOut } = data;
1115
+ const userReference = { userId, identifier };
1116
+ void this.track(AuthEventName.FlowAutoFinished, dataOut, userReference, tags, void 0, void 0, experiments);
1117
+ }
1118
+ /**
1119
+ * Record a newly known user reference for the current active flow.
1120
+ *
1121
+ * @remarks
1122
+ * Emits one `flow_enriched` with `match: { flowType: "*", at: "during" }` and `expFol: true` in its data.
1123
+ * Call only after the flow starts and before it finishes. The enrichment belongs
1124
+ * to the current flow at emission time.
1125
+ * Calling after `flowFinished()` cannot enrich that finished flow. If a parent or another
1126
+ * flow is current then, the enrichment belongs to that flow instead.
1127
+ * An empty reference does not clear identity. Cross-environment transaction IDs must be UUIDs;
1128
+ * both environments must report the shared ID while their respective flows are active.
1129
+ *
1130
+ * @param user - User ID, identifier, and/or cross-environment transaction UUID.
1131
+ *
1132
+ * @example
1133
+ * ```typescript
1134
+ * tracker.flowStarted({ flowName: "login" });
1135
+ * tracker.setUser({ userId: "usr_123", identifier: "max@example.com" });
1136
+ * tracker.flowFinished({ flowName: "login" });
1137
+ * ```
1138
+ */
1139
+ setUser(user) {
1140
+ void this.track(AuthEventName.FlowEnriched, { match: { flowType: "*", at: "during" }, expFol: true }, { ...user });
1141
+ }
1142
+ /**
1143
+ * Attach tags to the flow that is open right now.
1144
+ *
1145
+ * @remarks
1146
+ * Emits one `flow_enriched` carrying `tags` and no user reference, with data
1147
+ * `{ match: { flowType, at: "during" } }` plus `expCh` / `expFol` when the options set them.
1148
+ *
1149
+ * - The tags land on the innermost flow open at call time (of `flowType` when given; default `"*"`, any type).
1150
+ * - `expandToChildren` (default `true`): child flows of that flow inherit the tags.
1151
+ * - `expandToFollowing` (default `true`): later sibling flows inherit the tags until the next
1152
+ * sibling of the same flow type as the tagged flow.
1153
+ * - Per key the last `setFlowTags()` value wins; it also overrides a same-key tag passed to that
1154
+ * flow's own tracking calls (e.g. `flowStarted()`).
1155
+ * - Call while the target flow is open. An enrichment after the flow's last container event
1156
+ * (for example after `flowFinished()`) matches no flow and is dropped with a
1157
+ * `flow_enriched/no_matching_flow` classification warning.
1158
+ * - Tag keys and values are case-sensitive. Entries with an empty key or a non-string value are
1159
+ * dropped with a warning; nothing is sent when no valid entry remains.
1160
+ *
1161
+ * @param tags - Key-value tags for filtering and segmentation.
1162
+ * @param options - Target flow type and tag expansion; omitted flags default to `true`.
1163
+ *
1164
+ * @example
1165
+ * ```typescript
1166
+ * tracker.flowStarted({ flowName: "login" });
1167
+ * tracker.setFlowTags({ entryPoint: "checkout" });
1168
+ * tracker.flowFinished({ flowName: "login" });
1169
+ * ```
1170
+ */
1171
+ setFlowTags(tags, options) {
1172
+ try {
1173
+ if (!tags || typeof tags !== "object") {
1174
+ this.logger.warn("setFlowTags: ignoring non-object tags");
1175
+ return;
1176
+ }
1177
+ const valid = {};
1178
+ for (const [key, value] of Object.entries(tags)) {
1179
+ if (key === "" || typeof value !== "string") {
1180
+ this.logger.warn(`setFlowTags: ignoring invalid tag "${key}"`);
1181
+ continue;
1182
+ }
1183
+ valid[key] = value;
1184
+ }
1185
+ if (Object.keys(valid).length === 0) {
1186
+ return;
1187
+ }
1188
+ const data = { match: { flowType: options?.flowType ?? "*", at: "during" } };
1189
+ if (options?.expandToChildren !== void 0) {
1190
+ data.expCh = options.expandToChildren;
1191
+ }
1192
+ if (options?.expandToFollowing !== void 0) {
1193
+ data.expFol = options.expandToFollowing;
1194
+ }
1195
+ void this.track(AuthEventName.FlowEnriched, data, void 0, valid);
1196
+ } catch (error) {
1197
+ this.logger.warn("Failed to set flow tags", error);
1198
+ }
1199
+ }
1200
+ /**
1201
+ * Track when you present authentication method choices to the user.
1202
+ *
1203
+ * @remarks
1204
+ * Use this whenever a decision point is shown with a concrete method set, such as
1205
+ * identifier entry options (`d1`) or post-identifier options (`d3`). Set
1206
+ * `explicitDecisionValue` when the options are re-offered after a user action and you want
1207
+ * to capture what triggered that transition.
1208
+ *
1209
+ * @param data - Decision identifier, offered options, and optional transition reason.
1210
+ * @param tags - Optional key-value tags for filtering and segmentation.
1211
+ * @param experiments - Optional per-call experiment assignments.
1212
+ * @param options - Optional explicit event timestamp.
1213
+ *
1214
+ * @example
1215
+ * ```typescript
1216
+ * tracker.authMethodsDecisionStarted({
1217
+ * decisionName: "d3",
1218
+ * options: ["passkey", "social-google", "password", "reset-flow"],
1219
+ * });
1220
+ * ```
1221
+ */
1222
+ authMethodsDecisionStarted(data, tags, experiments, options) {
1223
+ void this.track(
1224
+ AuthEventName.AuthMethodDecisionStarted,
1225
+ data,
1226
+ void 0,
1227
+ tags,
1228
+ void 0,
1229
+ options?.explicitTimestamp,
1230
+ experiments
1231
+ );
1232
+ }
1233
+ authMethodsDecisionFinished(data, tags, experiments) {
1234
+ void this.track(AuthEventName.AuthMethodDecisionFinished, data, void 0, tags, void 0, void 0, experiments);
1235
+ }
1236
+ // decisions
1237
+ /**
1238
+ * @deprecated Use {@link CorbadoTracker.authMethodsDecisionStarted} instead (its `options`
1239
+ * already accept free-form strings).
1240
+ */
1241
+ authDecisionStarted(data, tags, experiments) {
1242
+ void this.track(AuthEventName.AuthDecisionStarted, data, void 0, tags, void 0, void 0, experiments);
1243
+ }
1244
+ /**
1245
+ * @deprecated Use {@link CorbadoTracker.authMethodsDecisionFinished} instead (its `options`
1246
+ * already accept free-form strings).
1247
+ */
1248
+ authDecisionFinished(data, tags, experiments) {
1249
+ void this.track(AuthEventName.AuthDecisionFinished, data, void 0, tags, void 0, void 0, experiments);
1250
+ }
1251
+ /**
1252
+ * Enqueue a "low" event (granular DOM/window/visualviewport/PWM signal) for the current session.
1253
+ *
1254
+ * @remarks
1255
+ * Low events are sent in the `lows` array of the events endpoint payload, alongside any
1256
+ * pending typed events. Blocked user agents are skipped silently.
1257
+ *
1258
+ * @internal
1259
+ */
1260
+ enqueueLowEvent(low, capturedSessionId) {
1261
+ try {
1262
+ if (this.isTrackingBlocked()) {
1263
+ this.logger.debug(`Skipping low event "${low.lowType}" for blocked user agent.`);
1264
+ return;
1265
+ }
1266
+ this.queue.enqueueLow(
1267
+ {
1268
+ ...low,
1269
+ captureSeq: low.captureSeq ?? reserveCaptureSeq(),
1270
+ captureVersion: 1,
1271
+ pageId: low.pageId ?? getCapturePageId()
1272
+ },
1273
+ capturedSessionId
1274
+ );
1275
+ } catch (error) {
1276
+ this.logger.error(`Failed to process low event "${low.lowType}" before enqueue`, error);
1277
+ }
1278
+ }
1279
+ /**
1280
+ * Record a diagnostic telemetry entry (a `level` + free-form `message`) for the current session.
1281
+ *
1282
+ * @remarks
1283
+ * Telemetry is delivered in the `telemetry` array of the events endpoint payload, tied to the same
1284
+ * session, and is never classified as an auth-flow event. By default a telemetry entry piggybacks
1285
+ * the next normal flush (it does not trigger one on its own); this is server-controlled. Collection
1286
+ * can be turned off server-side via the `telemetry` config flag. Blocked user agents are skipped.
1287
+ *
1288
+ * The message is truncated to {@link TELEMETRY_MESSAGE_MAX} characters. Never pass credentials or
1289
+ * PII — telemetry is for diagnostics only. Like all SDK entry points, this never throws.
1290
+ */
1291
+ telemetry(level, message) {
1292
+ try {
1293
+ if (this.isTrackingBlocked()) {
1294
+ this.logger.debug("Skipping telemetry for blocked user agent.");
1295
+ return;
1296
+ }
1297
+ const text = typeof message === "string" ? message : String(message);
1298
+ if (text.length === 0) {
1299
+ return;
1300
+ }
1301
+ const entry = {
1302
+ id: uuidv7(),
1303
+ level,
1304
+ message: text.length > TELEMETRY_MESSAGE_MAX ? text.slice(0, TELEMETRY_MESSAGE_MAX) : text,
1305
+ ts: Date.now()
1306
+ };
1307
+ this.queue.enqueueTelemetry(entry);
1308
+ } catch (error) {
1309
+ this.logger.error("Failed to process telemetry before enqueue", error);
1310
+ }
1311
+ }
1312
+ /**
1313
+ * Queue a probe item (diagnostic capture produced by a Corbado autocapture bundle).
1314
+ *
1315
+ * @remarks
1316
+ * The SDK fills `id` (uuidv7), `seq` (per `pageId`), `pageId` (default: one random id per tracker
1317
+ * instance) and `ts` (default: now). Items are held in memory only (never the durable outbox) and
1318
+ * ride the regular batches; they are the lowest priority at page unload. Invalid input is dropped.
1319
+ * The payload must already be redacted. Blocked user agents are skipped. Never throws.
1320
+ *
1321
+ * @experimental Internal: used by Corbado autocapture bundles. Not part of the documented API.
1322
+ * @internal
1323
+ */
1324
+ probe(input) {
1325
+ try {
1326
+ if (this.isTrackingBlocked()) {
1327
+ this.logger.debug("Skipping probe for blocked user agent.");
1328
+ return;
1329
+ }
1330
+ if (!input || typeof input !== "object" || typeof input.kind !== "string" || !PROBE_KIND_RE.test(input.kind)) {
1331
+ this.logger.debug("Dropping probe with an invalid kind.");
1332
+ return;
1333
+ }
1334
+ const payload = input.payload;
1335
+ if (!payload || typeof payload !== "object") {
1336
+ this.logger.debug(`Dropping probe "${input.kind}" without an object payload.`);
1337
+ return;
1338
+ }
1339
+ const pageId = typeof input.pageId === "string" && PROBE_ID_RE.test(input.pageId) ? input.pageId : this.defaultProbePageId();
1340
+ const entry = {
1341
+ id: uuidv7(),
1342
+ kind: input.kind,
1343
+ ts: typeof input.ts === "number" && Number.isFinite(input.ts) ? Math.round(input.ts) : Date.now(),
1344
+ pageId,
1345
+ seq: this.nextProbeSeq(pageId),
1346
+ payload
1347
+ };
1348
+ if (typeof input.cfg === "string" && PROBE_ID_RE.test(input.cfg)) {
1349
+ entry.cfg = input.cfg;
1350
+ }
1351
+ const ctx = sanitizeProbeContext(input.ctx);
1352
+ if (ctx) {
1353
+ entry.ctx = ctx;
1354
+ }
1355
+ this.queue.enqueueProbe(entry);
1356
+ } catch (error) {
1357
+ this.logger.error("Failed to process probe before enqueue", error);
1358
+ }
1359
+ }
1360
+ defaultProbePageId() {
1361
+ return this.probePageId ??= randomProbePageId();
1362
+ }
1363
+ nextProbeSeq(pageId) {
1364
+ const seq = this.probeSeqs.get(pageId) ?? 0;
1365
+ this.probeSeqs.delete(pageId);
1366
+ this.probeSeqs.set(pageId, seq + 1);
1367
+ if (this.probeSeqs.size > PROBE_PAGE_IDS_MAX) {
1368
+ const oldest = this.probeSeqs.keys().next().value;
1369
+ if (oldest !== void 0) this.probeSeqs.delete(oldest);
1370
+ }
1371
+ return seq;
1372
+ }
1373
+ /** Convenience wrapper for {@link CorbadoTracker.telemetry} at `info` level. */
1374
+ logInfo(message) {
1375
+ this.telemetry("info", message);
1376
+ }
1377
+ /** Convenience wrapper for {@link CorbadoTracker.telemetry} at `error` level. */
1378
+ logError(message) {
1379
+ this.telemetry("error", message);
1380
+ }
1381
+ /**
1382
+ * Queue hook run right before an event batch is sent: a device info snapshot that no event carried
1383
+ * yet (typically the fast snapshot after a burst of events at page load, or the privateMode update)
1384
+ * rides the first event of this page in the batch that has none. Events of other pages (outbox
1385
+ * replays) are never touched. A snapshot no event can carry goes as the batch's request-level device
1386
+ * info instead (queue option `standaloneDeviceInfo`).
1387
+ *
1388
+ * Error situations:
1389
+ * - Any error: log "Failed to attach device info to batch" and send the batch unchanged
1390
+ */
1391
+ attachUndeliveredDeviceInfo(events) {
1392
+ try {
1393
+ const manager = this.deviceInfoManager;
1394
+ if (!manager?.hasUndelivered()) {
1395
+ return;
1396
+ }
1397
+ const pageId = getCapturePageId();
1398
+ const target = events.find((event) => event.meta?.pageId === pageId && !event.deviceInfo);
1399
+ if (target) {
1400
+ target.deviceInfo = manager.takeUndelivered();
1401
+ }
1402
+ } catch (error) {
1403
+ this.logger.error("Failed to attach device info to batch", error);
1404
+ }
1405
+ }
1406
+ /**
1407
+ * Escalate an internal SDK diagnostic to the telemetry stream (self-instrumentation). This is the
1408
+ * sink behind {@link Logger.reportTelemetry}; chosen internal failure sites route here so faults
1409
+ * that are otherwise only visible with `debug: true` become observable in the field.
1410
+ *
1411
+ * @remarks
1412
+ * Bounded and deduped per load (a persistent fault — blocked storage, a downed endpoint — cannot
1413
+ * flood the stream), re-entrancy-guarded so it can never recurse, and it never throws into the
1414
+ * host. Diagnostics raised before the queue exists (early boot) are buffered and replayed once the
1415
+ * queue is ready. Messages must be stable and low-cardinality (no raw error objects / PII).
1416
+ */
1417
+ reportTelemetry(level, message) {
1418
+ if (this.telemetryReporting) {
1419
+ return;
1420
+ }
1421
+ this.telemetryReporting = true;
1422
+ try {
1423
+ if (this.reportedTelemetryKeys.has(message) || this.reportedTelemetryKeys.size >= MAX_SELF_TELEMETRY) {
1424
+ return;
1425
+ }
1426
+ this.reportedTelemetryKeys.add(message);
1427
+ if (!this.telemetryReady) {
1428
+ this.pendingSelfTelemetry.push({ level, message });
1429
+ return;
1430
+ }
1431
+ this.telemetry(level, message);
1432
+ } catch {
1433
+ } finally {
1434
+ this.telemetryReporting = false;
1435
+ }
1436
+ }
1437
+ /**
1438
+ * Synchronously flush any pending events and low events via beacon. Use during page-unload
1439
+ * style teardown where async flushing is unreliable.
1440
+ *
1441
+ * @param reason What triggered the flush; recorded as `meta.flushReason` on the batch. Defaults
1442
+ * to a direct call by the host app.
1443
+ *
1444
+ * @internal
1445
+ */
1446
+ flushKeepalive(reason = "manual") {
1447
+ try {
1448
+ this.queue.flushKeepalive(reason);
1449
+ } catch (error) {
1450
+ this.logger.error("Failed to flush queue keepalive", error);
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Whether input trackers run the DOM password-manager scan (`pa-1`..`pa-6` lows): only an explicitly
1455
+ * set `fixedDataConfig.pmd` decides; without `fixedDataConfig` or with `pmd` omitted it always runs
1456
+ * (historical behaviour).
1457
+ *
1458
+ * @internal
1459
+ */
1460
+ isPasswordManagerDomScanEnabled() {
1461
+ return this.fixedData?.pmdExplicit ? this.fixedData.pmd : true;
1462
+ }
1463
+ /**
1464
+ * Keepalive flush requested by a low-event tracker's `pagehide` teardown. Gated by the
1465
+ * server-controlled trigger switch `tlf` (boot-snapshot, default true = historical behavior):
1466
+ * disabling it server-side makes low-event delivery rely solely on the queue's own triggers.
1467
+ *
1468
+ * @internal
1469
+ */
1470
+ lowEventFlushKeepalive() {
1471
+ if (!this.config.tlf) {
1472
+ return;
1473
+ }
1474
+ this.flushKeepalive("low-event-teardown");
1475
+ }
1476
+ passkeyLoginFullOperation(config) {
1477
+ return new PasskeyLoginOperationFull(this, config);
1478
+ }
1479
+ passkeyEnrollmentFullOperation(config) {
1480
+ return new PasskeyEnrollmentOperationFull(this, config);
1481
+ }
1482
+ /**
1483
+ * Track a user deleting one of their passkeys through userConfirmation and deletion.
1484
+ *
1485
+ * @param config - Optional configuration; the passkey-deletion subflow has no spec types.
1486
+ * @remarks
1487
+ * Create the operation when the user asks to delete a passkey (e.g. clicks "Remove"); construction
1488
+ * alone is not counted as an interaction. Start userConfirmation when the confirmation dialog opens
1489
+ * and finish it when the user confirms; report a dismissed dialog with
1490
+ * `userConfirmation.errorTyped({ code: "cancelled" })`. Skip userConfirmation when there is no dialog.
1491
+ * Start deletion when the delete request is sent and finish it only when the passkey was deleted;
1492
+ * report a rejection with `deletion.errorTyped({ code })` or a thrown error with `deletion.error(error)`.
1493
+ * Never include credential IDs or other passkey material.
1494
+ * @example
1495
+ * ```ts
1496
+ * const deletion = tracker.passkeyDeletionOperationFull();
1497
+ * deletion.userConfirmation.start({});
1498
+ * if (!(await confirmDialog())) {
1499
+ * deletion.userConfirmation.errorTyped({ code: "cancelled" });
1500
+ * return;
1501
+ * }
1502
+ * deletion.userConfirmation.finished({});
1503
+ * deletion.deletion.start({});
1504
+ * try {
1505
+ * await deletePasskey(id);
1506
+ * deletion.deletion.finished({});
1507
+ * } catch (error) {
1508
+ * deletion.deletion.error(error);
1509
+ * }
1510
+ * ```
1511
+ */
1512
+ passkeyDeletionOperationFull(config) {
1513
+ return new PasskeyDeletionOperationFull(this, config);
1514
+ }
1515
+ passwordLoginFullOperation(autoTrackConfig) {
1516
+ return new PasswordLoginOperationFull(this, autoTrackConfig);
1517
+ }
1518
+ passwordEnrollmentFullOperation(autoTrackConfig) {
1519
+ return new PasswordEnrollmentOperationFull(this, autoTrackConfig);
1520
+ }
1521
+ emailLinkOperationFull(config) {
1522
+ return new EmailLinkOperationFull(this, config);
1523
+ }
1524
+ /**
1525
+ * Continue an email link started on another page (the link's target page). In the same browser
1526
+ * and session it continues page 1's subflow directly; otherwise it emits a continuation start that
1527
+ * the backend joins to page 1 through the transaction ID. See {@link EmailLinkContinuationOperation}.
1528
+ */
1529
+ continueEmailLinkOperation(config) {
1530
+ return new EmailLinkContinuationOperation(this, config);
1531
+ }
1532
+ emailOtpOperationFull(config) {
1533
+ return new EmailOtpOperationFull(this, config);
1534
+ }
1535
+ smsOtpOperationFull(config) {
1536
+ return new SmsOtpOperationFull(this, config);
1537
+ }
1538
+ /**
1539
+ * Track a code from an authenticator app (TOTP) through its verification.
1540
+ *
1541
+ * @param config - Optional code input field(s) and spec type (`totp-login` or `totp-enrollment`).
1542
+ * @remarks
1543
+ * There is no send step: the code comes from the user's authenticator, not from the application.
1544
+ * Report the verification request on postResponse. Bind the code input to capture typing and
1545
+ * autofill; use setInputFields when the input is rendered after the operation starts.
1546
+ * @example
1547
+ * ```ts
1548
+ * const totp = tracker.totpOperationFull({ explicitSpecType: "totp-login", inputHtmlField: codeInput });
1549
+ * totp.postResponse.start({});
1550
+ * totp.postResponse.finished({});
1551
+ * ```
1552
+ */
1553
+ totpOperationFull(config) {
1554
+ return new TotpOperationFull(this, config);
1555
+ }
1556
+ provideIdentifierOperationFull(config) {
1557
+ return new OperationFullProvideIdentifierWithCUI(this, config);
1558
+ }
1559
+ provideDataOperationFull(config) {
1560
+ return new ProvideDataOperationFull(this, config);
1561
+ }
1562
+ /**
1563
+ * Track one form: a page, step or modal where the user provides one coherent set of data and submits
1564
+ * it, typically as one step of a custom process flow such as an onboarding.
1565
+ *
1566
+ * @param config - The form's stable `name` (required); optionally the names of its `fields`, the
1567
+ * `inputHtmlFields` to collect interaction signals from, and `startedAt` when the form became
1568
+ * visible earlier than this call.
1569
+ * @remarks
1570
+ * Create the operation when the form is shown; one operation per visit, so going back to a form
1571
+ * creates a new one with the same name. Report `submit` when the application accepted the form, or
1572
+ * `skip` when the user left it forward without submitting (e.g. "Do it later"). A form that ends with
1573
+ * neither counts as abandoned; one whose last submit failed counts as failed. Without a name the form
1574
+ * is not counted. Send field names only, never values. A form spread over several pages stays one
1575
+ * operation: bind each page's inputs with `setInputFields`.
1576
+ * @example
1577
+ * ```ts
1578
+ * const form = tracker.formOperationFull({ name: "personal-details", fields: ["firstName", "lastName"] });
1579
+ * form.submit.start({});
1580
+ * try {
1581
+ * await savePersonalDetails(values);
1582
+ * form.submit.finished({});
1583
+ * } catch (error) {
1584
+ * form.submit.error(error);
1585
+ * }
1586
+ * ```
1587
+ */
1588
+ formOperationFull(config) {
1589
+ return new FormOperationFull(this, config);
1590
+ }
1591
+ /**
1592
+ * Track a social login with an external provider (e.g. Google, Apple).
1593
+ *
1594
+ * @param config - Optional spec type and entry.
1595
+ * @remarks
1596
+ * Set `entry` to how the attempt began: omit it for a provider button with a full-page redirect,
1597
+ * `"popup"` when the provider ran in a popup window, `"one-tap"` when the provider showed its own
1598
+ * prompt (e.g. Google One Tap). A One Tap has no redirect: report only `exchangeCode`, never
1599
+ * `getRedirectUrl`.
1600
+ * @example
1601
+ * ```ts
1602
+ * // Provider button:
1603
+ * const social = tracker.socialLoginOperationFull({ explicitSpecType: "pre-identifier" });
1604
+ * social.getRedirectUrl.start({ provider: "google" });
1605
+ * social.getRedirectUrl.finished({});
1606
+ * // Back from the provider, on the new page load:
1607
+ * const back = tracker.socialLoginOperationFull({ explicitSpecType: "pre-identifier" });
1608
+ * back.exchangeCode.start({ provider: "google" });
1609
+ * back.exchangeCode.finished({});
1610
+ *
1611
+ * // Google One Tap, when the credential arrives in page:
1612
+ * const oneTap = tracker.socialLoginOperationFull({ explicitSpecType: "pre-identifier", entry: "one-tap" });
1613
+ * oneTap.exchangeCode.start({ provider: "google" });
1614
+ * oneTap.exchangeCode.finished({});
1615
+ * ```
1616
+ */
1617
+ socialLoginOperationFull(config) {
1618
+ return new SocialLoginOperationFull(this, config);
1619
+ }
1620
+ /**
1621
+ * Track enterprise SSO (SAML/OIDC) through the organization's identity provider.
1622
+ *
1623
+ * @param config - Optional spec type, organization email domain and actor.
1624
+ * @remarks
1625
+ * Use for policy-routed SSO, kept apart from user-chosen social login. Set `actor: "system"` when
1626
+ * the application routed to SSO without a user choice, e.g. by the email domain. Report only the
1627
+ * organization's domain, never a full address.
1628
+ * @example
1629
+ * ```ts
1630
+ * const sso = tracker.ssoLoginOperationFull({
1631
+ * explicitSpecType: "post-identifier",
1632
+ * organizationDomain: "example.com",
1633
+ * actor: "system",
1634
+ * });
1635
+ * sso.getRedirectUrl.start({});
1636
+ * sso.getRedirectUrl.finished({});
1637
+ * // After the identity provider redirects back:
1638
+ * sso.exchangeCode.start({});
1639
+ * sso.exchangeCode.finished({});
1640
+ * ```
1641
+ */
1642
+ ssoLoginOperationFull(config) {
1643
+ return new SsoLoginOperationFull(this, config);
1644
+ }
1645
+ appConfirmationOperationFull(config) {
1646
+ return new AppConfirmationOperationFull(this, config);
1647
+ }
1648
+ /**
1649
+ * Track direct key signing outside WebAuthn through prepare and sign.
1650
+ *
1651
+ * @param config - Optional configuration; explicitSpecType may only be undefined for now.
1652
+ * @remarks
1653
+ * Start prepare when resolving the eligible key and input for an actual attempt, not background
1654
+ * prefetch. Although system-driven, that work is meaningful interaction for classification.
1655
+ * Construction alone does not establish interaction. Finish sign only when the usable signed
1656
+ * artifact is ready, including assembly. Submission is outside this operation. Report failures
1657
+ * on the affected step using error(error). Never include key material, signatures or proofs.
1658
+ * WebAuthn assertions continue to use passkeyLoginFullOperation.
1659
+ * @example
1660
+ * ```ts
1661
+ * const signing = tracker.keySigningOperationFull();
1662
+ * signing.prepare.start({});
1663
+ * // Resolve an eligible key and prepare the signing input.
1664
+ * signing.prepare.finished({});
1665
+ * signing.sign.start({});
1666
+ * // Sign and assemble the artifact returned to the caller.
1667
+ * signing.sign.finished({});
1668
+ * ```
1669
+ */
1670
+ keySigningOperationFull(config) {
1671
+ return new KeySigningOperationFull(this, config);
1672
+ }
1673
+ /**
1674
+ * Track establishing a key's authority through prepare and postResponse.
1675
+ *
1676
+ * @param config - Optional configuration; explicitSpecType may only be undefined for now.
1677
+ * @remarks
1678
+ * Start prepare when building an actual registration request, not background prefetch.
1679
+ * This system-driven work is meaningful interaction; construction alone is not.
1680
+ * postResponse covers submission and acceptance. Finish only when the key is authorized and
1681
+ * usable, not merely when submission returns. Report rejection, transport, execution or timeout
1682
+ * errors on the affected step using error(error). Never include key material or proofs.
1683
+ * Credential creation and any authorizing WebAuthn assertion are separate operations.
1684
+ * @example
1685
+ * ```ts
1686
+ * const registration = tracker.keyRegistrationOperationFull();
1687
+ * registration.prepare.start({});
1688
+ * // Build the registration request.
1689
+ * registration.prepare.finished({});
1690
+ * registration.postResponse.start({});
1691
+ * // Submit and await evidence that the authority is usable.
1692
+ * registration.postResponse.finished({});
1693
+ * ```
1694
+ */
1695
+ keyRegistrationOperationFull(config) {
1696
+ return new KeyRegistrationOperationFull(this, config);
1697
+ }
1698
+ captchaOperationFull(config) {
1699
+ return new CaptchaOperationFull(this, config);
1700
+ }
1701
+ /**
1702
+ * Track a device-trust check through getOptions, ceremony and postResponse.
1703
+ *
1704
+ * @param config - Mechanism, purpose, storage and optional trust name.
1705
+ * @remarks
1706
+ * Only `trusted` completes the check. Negative results (`not-trusted`, `no-eligible-local-key`,
1707
+ * `server-key-no-local-match`) remain incomplete and may settle an early step. Key-specific
1708
+ * results require a key mechanism; the server-specific result requires explicit evidence.
1709
+ * Use `errorTyped({ code, error })` to retain an original diagnostic with a negative result,
1710
+ * or `error(error)` for an unexpected failure. An accepted result may include a stable
1711
+ * pseudonymous `bindingReference`; never send cookie values, proofs or key material.
1712
+ * @example
1713
+ * ```ts
1714
+ * const check = tracker.trustedDeviceCheckOperationFull({
1715
+ * explicitSpecType: "key", storage: "indexeddb", purpose: "additional-verification",
1716
+ * });
1717
+ * check.ceremony.start({});
1718
+ * // On a recognized local-key failure:
1719
+ * check.ceremony.errorTyped({ code: "no_eligible_local_key", error: originalError });
1720
+ * ```
1721
+ */
1722
+ trustedDeviceCheckOperationFull(config) {
1723
+ return new TrustedDeviceCheckOperationFull(this, config);
1724
+ }
1725
+ /**
1726
+ * Track device-trust enrollment through getOptions, ceremony and postResponse.
1727
+ *
1728
+ * @param config - Mechanism, purpose, storage and optional trust name.
1729
+ * @remarks
1730
+ * Report `created`, `renewed` or `unchanged` after host acceptance. Explicit opt-out is `skipped`;
1731
+ * an unanswered operation remains incomplete. User identity uses the existing `setUser` API.
1732
+ * Missing or unsupported purpose/storage logs a warning and is omitted; the operation remains usable.
1733
+ * @example
1734
+ * ```ts
1735
+ * const enrollment = tracker.trustedDeviceEnrollmentOperationFull({
1736
+ * explicitSpecType: "token", storage: "cookie", purpose: "mfa-exemption",
1737
+ * });
1738
+ * enrollment.postResponse.start({});
1739
+ * // After the host grants browser trust:
1740
+ * enrollment.postResponse.finished({ result: "created", bindingReference });
1741
+ * ```
1742
+ */
1743
+ trustedDeviceEnrollmentOperationFull(config) {
1744
+ return new TrustedDeviceEnrollmentOperationFull(this, config);
1745
+ }
1746
+ /**
1747
+ * Flush any pending events and release all resources (timers, event listeners).
1748
+ *
1749
+ * @remarks
1750
+ * After calling destroy, the tracker must not be used. Operation objects
1751
+ * returned by factory methods (e.g. `passwordLoginFullOperation`) that hold
1752
+ * DOM references must be destroyed separately by the caller.
1753
+ * The final flush is gated by the server-controlled trigger switch `td` (default true);
1754
+ * resources are released either way.
1755
+ */
1756
+ async destroy() {
1757
+ this.configManager?.destroy();
1758
+ this.passwordManagerSampler?.destroy();
1759
+ this.windowObserver?.destroy();
1760
+ this.deviceInfoManager.destroy();
1761
+ if (this.config.td) {
1762
+ try {
1763
+ await this.queue.flush("destroy");
1764
+ } catch (error) {
1765
+ this.logger.error("Failed to flush queue during destroy", error);
1766
+ }
1767
+ }
1768
+ this.queue.destroy();
1769
+ }
1770
+ // Session Management
1771
+ /**
1772
+ * Return the Observe session id this tracker is currently reporting under.
1773
+ *
1774
+ * @remarks
1775
+ * Use this to route a server-side event into the same session the browser reports to: read the id,
1776
+ * pass it to your own backend, and let the backend use it as the `sessionID` of its ingest call.
1777
+ * Reading it here is the supported path — the storage layout behind it is an implementation detail
1778
+ * that has already changed once (per-tab `sessionStorage` to the cross-tab continuity record).
1779
+ *
1780
+ * The value is **not stable for the lifetime of the page**. It changes on
1781
+ * {@link CorbadoTracker.resetSession}, when this tab adopts a continuity session another tab
1782
+ * rotated to, and when a failed bootstrap recovers. Read it at the moment you need it; never cache
1783
+ * it across a request.
1784
+ *
1785
+ * `undefined` means no session id could be established at all — an environment without Web Crypto,
1786
+ * where tracking is not working either. Skip the server-side event instead of sending an empty id:
1787
+ * the ingest API requires a UUID and would reject the batch.
1788
+ *
1789
+ * @returns The current session id, or `undefined` if none could be established.
1790
+ *
1791
+ * @example
1792
+ * ```typescript
1793
+ * const sessionId = tracker.getSessionId();
1794
+ * if (sessionId) {
1795
+ * await fetch("/api/checkout", {
1796
+ * method: "POST",
1797
+ * body: JSON.stringify({ ...payload, observeSessionId: sessionId }),
1798
+ * });
1799
+ * }
1800
+ * ```
1801
+ */
1802
+ getSessionId() {
1803
+ if (!this.sessionId) {
1804
+ const recovered = this.resolveSessionId();
1805
+ if (recovered) {
1806
+ this.sessionId = recovered;
1807
+ this.queue.setSessionId(recovered);
1808
+ }
1809
+ }
1810
+ return this.sessionId || void 0;
1811
+ }
1812
+ /** @internal */
1813
+ resetSession() {
1814
+ const newSessionId = uuidv7();
1815
+ if (this.config.sessionContinuity) {
1816
+ this.persistContinuitySession(newSessionId, Date.now());
1817
+ this.persistentStorage.setItem(SEQ_STORAGE_KEY, 0);
1818
+ } else {
1819
+ this.persistPerTabSession(newSessionId, Date.now());
1820
+ }
1821
+ this.sessionId = newSessionId;
1822
+ this.seq = 0;
1823
+ this.queue.setSessionId(newSessionId);
1824
+ this.logger.debug(`Session reset: ${newSessionId}`);
1825
+ return newSessionId;
1826
+ }
1827
+ };
1828
+ var PROBE_PAGE_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
1829
+ function randomProbePageId() {
1830
+ const bytes = new Uint8Array(16);
1831
+ try {
1832
+ globalThis.crypto.getRandomValues(bytes);
1833
+ } catch {
1834
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
1835
+ }
1836
+ let out = "";
1837
+ for (let i = 0; i < bytes.length; i++) out += PROBE_PAGE_ID_ALPHABET[bytes[i] & 63];
1838
+ return out;
1839
+ }
1840
+ function sanitizeProbeContext(ctx) {
1841
+ if (!ctx || typeof ctx !== "object") return void 0;
1842
+ const out = {};
1843
+ for (const key of ["state", "flowType", "step"]) {
1844
+ const value = ctx[key];
1845
+ if (typeof value === "string" && value.length > 0) out[key] = value.slice(0, PROBE_CTX_MAX);
1846
+ }
1847
+ return Object.keys(out).length > 0 ? out : void 0;
1848
+ }
1849
+ export {
1850
+ CorbadoTracker
1851
+ };