@corbado/observe 1.14.1 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @corbado/observe
2
2
 
3
+ ## 1.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a2cb17d: Collection manifest v2 descriptors (tools-only subpaths `@corbado/observe/manifest`, `@corbado/autocapture/manifest`;
8
+ data only, nothing changes in the bundles):
9
+ - Contract (additive, optional): `Conditional` (`when`, now also `"!<switch>"`; `requires` = never-ship item id) on
10
+ side effects, gateway fields, generated and stored entries; `SideEffect`; stored `deleteOnly`, `note` and
11
+ `otherwise` (`StoredVariant`: what the entry becomes when its `when` does not hold).
12
+ - Side effects: `ac:request@1` wraps fetch and XMLHttpRequest; `ac:ceremony@1` wraps navigator.credentials (proxy
13
+ with `aap`); `sdk:input@1` / `ac:element@1` watch the document for added elements.
14
+ - Own storage: the private-mode library's temporary test storage is no longer a stored entry (only the
15
+ `sdk:private-mode@1` side effect); config cache `when: "!static"` (absent from static-config builds); legacy debug keys
16
+ `deleteOnly` (project-scoped keys); low outbox `when: "durableLowOutbox"` with a not-written variant; cross-origin
17
+ session cookie and email-link starts carry `requires`.
18
+ - Texts: password managers ("whether the person is signed in to them", "behavior"), transport IP address and
19
+ client hints.
20
+
21
+ ## 1.15.0
22
+
23
+ ### Minor Changes
24
+
25
+ - 41434cb: Collection manifest v2 descriptors: new subpath `@corbado/observe/manifest` (Node ESM: `@corbado/observe/dist/manifest/index.mjs`) with the descriptor contract (`CapabilityDescriptor`, `GatewayDescriptor`), the v2 vocabulary (collection levels, categories, kind ladders) and the descriptors of `sdk:input@1`, `sdk:window@1`, `sdk:password-managers@1`, `sdk:private-mode@1` and the device and transport gateways. Every `never` promise names a proof test in this package. Runtime code never imports the descriptors: the npm, CDN and analysis builds contain none of it (checked by tests and `test:data-guards`). No runtime change.
26
+ - b8ef6fb: Own storage and generated ids in the collection manifest v2 (gaps G2, G3), dark: the build define `__CBO_DATA_RETENTION__ = true` deletes queued events and low events older than 7 days from the outboxes when the tracker starts (never sent) and deletes expired email-link start notes on read instead of only ignoring them. The new tracker plugin `deviceHandleMaxAge(ms)` (a plugin option, not a top-level `init()` option; the autocapture runtime passes it from the static config) replaces an older device handle with a new one. Without the define and the plugin (npm, CDN) nothing changes. `@corbado/observe/manifest` adds the descriptors `sdk:own-storage` (every `cbo_` key with area, scope, ISO 8601 retention, owner and typed contents), `sdk:generated` (device handle, session, tab, page, event ids, counters, passkey stand-ins with lifetime and `exposedTo`, incl. `getSessionId`), `sdk:page` (today's page path, declared honestly) and `sdk:api`, and the device fields of private mode and password managers.
27
+ - 4c509f6: Strict page gateway masking (collection manifest v2, gap G8), dark behind the build define `__CBO_DATA_PAGE_STRICT__`: the SDK's own source path masks every path segment (page templates come from an integration's `fixedSourcePath` policy), masks query parameters without `=` and keys that are not plain names unless allowlisted, and treats `sourcePathQuery: "all"` as `"allowlist"`. Without the define (npm, CDN) nothing changes.
28
+ - 1760522: Static SDK config for builds that fix every switch at build time (collection manifest v2): when a bundler defines `__CBO_SDK_CONFIG__` (the complete reliability config as JSON text), the tracker runs exactly that config, makes no config request, neither reads nor writes the config cache, ignores init `sdkConfig` overrides, and `ConfigManager` is removed from the bundle (about 3.6 KB minified). A fixed `fixedDataConfig.pmd` still wins. Malformed text runs the built-in defaults and never throws. The npm and CDN builds never define it: unchanged.
29
+ - c5fd614: WebAuthn allowlist `ac:ceremony@1` (collection manifest v2, gap G1), dark behind a build define: `ceremonyRequestOptions`, `ceremonyCreationOptions`, `ceremonyAssertionResponse` and `ceremonyCreationResponse` keep only the allowlisted fields in the existing payload format, so the backend parses them unchanged: the challenge becomes zeros of the same length (options and `clientDataJSON`), the signature, `getPublicKey()`, user name / display name / id, PRF results and unknown fields never leave, a present user handle becomes a placeholder (`userHandlePresent` becomes accurate), and credential ids are replaced by page-scoped random stand-ins of the same length unless the per-call option `passkeyReference` is set (`sanitizeRequestOptions` takes an optional second argument for it). Builds that define `__CBO_DATA_CEREMONY__ = true` route the existing `sanitize*` functions to the allowlist and drop the blocklist. Without the define (npm, CDN) the sanitizers are unchanged. The `@corbado/observe/manifest` subpath adds the `ac:ceremony@1` descriptor.
30
+
31
+ ### Patch Changes
32
+
33
+ - 087d2ad: Debug mode no longer writes `cbo_visibility_change_debug` / `cbo_pagehide_debug` (they held the full, unmasked page
34
+ address; nothing read them); the script deletes what older versions left there. Manifest descriptors (data only, never
35
+ in a bundle): the sequence counters are a technical field of each record (`record:sdk.event#seq`, new source kind
36
+ `record`) instead of a generated id; `cbo_storage_schema` declared in local and session storage, both
37
+ `cbo_low_outbox_v1` key forms, the private-mode library's localStorage `test` key and its test databases per area;
38
+ `ac:ceremony@1` names page attributes as a second payload entry point next to request bodies.
39
+
3
40
  ## 1.14.1
4
41
 
5
42
  ### Patch Changes
@@ -177,6 +177,23 @@ function resolveReliabilityConfig(serverConfig, overrides = {}) {
177
177
  resolved.retry.maxDelayMs = Math.max(resolved.retry.baseDelayMs, resolved.retry.maxDelayMs);
178
178
  return resolved;
179
179
  }
180
+ var STATIC_CONFIG_VERSION = "static";
181
+ function staticReliabilityConfig(json, pmd) {
182
+ let value;
183
+ try {
184
+ value = JSON.parse(json);
185
+ } catch {
186
+ value = void 0;
187
+ }
188
+ const valid = !!value && typeof value === "object" && !Array.isArray(value);
189
+ const fields = valid ? value : {};
190
+ const config = resolveReliabilityConfig(void 0, {
191
+ ...fields,
192
+ version: typeof fields.version === "string" && fields.version !== "" ? fields.version : STATIC_CONFIG_VERSION,
193
+ ...pmd !== void 0 && { pmd }
194
+ });
195
+ return { config, valid };
196
+ }
180
197
  function resolveFixedDataConfig(raw) {
181
198
  try {
182
199
  if (!raw || typeof raw !== "object") {
@@ -215,10 +232,12 @@ export {
215
232
  DEFAULT_RELIABILITY_CONFIG,
216
233
  DEFAULT_SEQ_LOCK_TIMEOUT_MS,
217
234
  DEFAULT_SESSION_INACTIVITY_MS,
235
+ STATIC_CONFIG_VERSION,
218
236
  cacheConfigRecord,
219
237
  loadCachedConfig,
220
238
  loadConfigRecord,
221
239
  parseReliabilityConfig,
222
240
  resolveFixedDataConfig,
223
- resolveReliabilityConfig
241
+ resolveReliabilityConfig,
242
+ staticReliabilityConfig
224
243
  };
@@ -59,7 +59,7 @@ async function settlesWithin(promise, timeoutMs) {
59
59
  }
60
60
  }
61
61
  var DeviceCollector = class {
62
- constructor(logger, storage, storageSource = "localstorage", sessionStorage, settings = () => DEFAULT_SETTINGS, emitTelemetry, privateModeEnabled = true) {
62
+ constructor(logger, storage, storageSource = "localstorage", sessionStorage, settings = () => DEFAULT_SETTINGS, emitTelemetry, privateModeEnabled = true, deviceHandleMaxAgeMs) {
63
63
  this.logger = logger;
64
64
  this.storage = storage;
65
65
  this.storageSource = storageSource;
@@ -67,6 +67,7 @@ var DeviceCollector = class {
67
67
  this.settings = settings;
68
68
  this.emitTelemetry = emitTelemetry;
69
69
  this.privateModeEnabled = privateModeEnabled;
70
+ this.deviceHandleMaxAgeMs = deviceHandleMaxAgeMs;
70
71
  }
71
72
  /** privateMode known on this page (from the cache or a detection), reused by every refresh. */
72
73
  pagePrivateMode;
@@ -525,7 +526,8 @@ var DeviceCollector = class {
525
526
  }
526
527
  getClientEnvHandle() {
527
528
  const existingClientEnvHandle = this.storage.getItem("cbo_client_env_handle");
528
- if (existingClientEnvHandle) {
529
+ const maxAgeMs = this.deviceHandleMaxAgeMs;
530
+ if (existingClientEnvHandle && !(maxAgeMs !== void 0 && !(Date.now() - existingClientEnvHandle.timestamp <= maxAgeMs))) {
529
531
  return existingClientEnvHandle;
530
532
  }
531
533
  const newClientEnvHandleValue = uuidv7();
@@ -13,6 +13,7 @@ export * from "./plugins/raw-errors.mjs";
13
13
  export * from "./plugins/probes.mjs";
14
14
  export * from "./plugins/fixed-source-path.mjs";
15
15
  export * from "./plugins/event-time.mjs";
16
+ export * from "./plugins/device-handle-max-age.mjs";
16
17
  export * from "./probe-lane.mjs";
17
18
  export * from "./transport.mjs";
18
19
  export * from "./queue.mjs";
@@ -53,6 +54,12 @@ import {
53
54
  sanitizeRequestOptions,
54
55
  sanitizeAssertionResponse
55
56
  } from "./utils/webauthn-payloads.mjs";
57
+ import {
58
+ ceremonyCreationOptions,
59
+ ceremonyRequestOptions,
60
+ ceremonyCreationResponse,
61
+ ceremonyAssertionResponse
62
+ } from "./utils/webauthn-ceremony.mjs";
56
63
  function init(options) {
57
64
  const tracker = new CorbadoTracker(options);
58
65
  setDefaultTracker(tracker);
@@ -93,6 +100,10 @@ export {
93
100
  READER_EXPOSURES,
94
101
  SDK_NAME,
95
102
  SDK_VERSION,
103
+ ceremonyAssertionResponse,
104
+ ceremonyCreationOptions,
105
+ ceremonyCreationResponse,
106
+ ceremonyRequestOptions,
96
107
  destroy,
97
108
  detectPasswordManagerExtensions,
98
109
  getExperiments,
@@ -0,0 +1,8 @@
1
+ // src/plugins/device-handle-max-age.ts
2
+ function deviceHandleMaxAge(maxAgeMs) {
3
+ if (typeof maxAgeMs !== "number" || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) return {};
4
+ return { deviceHandleMaxAgeMs: maxAgeMs };
5
+ }
6
+ export {
7
+ deviceHandleMaxAge
8
+ };
@@ -3,7 +3,7 @@ import { v7 as uuidv7 } from "uuid";
3
3
  import { AuthEventName } from "./types.mjs";
4
4
  import { CONFIG_REQUEST_HEADER, DEFAULT_RELIABILITY_CONFIG } from "./config.mjs";
5
5
  import { EventStore } from "./store/event-store.mjs";
6
- import { listenWindow, readDocumentHidden, readPageUrl } from "./readers/page.mjs";
6
+ import { listenWindow, readDocumentHidden } from "./readers/page.mjs";
7
7
  var UNLOAD_BUDGET_BYTES = 6e4;
8
8
  var UNLOAD_PROBE_BYTES = 32e3;
9
9
  function utf8Length(text) {
@@ -19,6 +19,7 @@ function utf8Length(text) {
19
19
  }
20
20
  return bytes;
21
21
  }
22
+ var LEGACY_DEBUG_KEYS = ["cbo_visibility_change_debug", "cbo_pagehide_debug"];
22
23
  var RequestQueue = class {
23
24
  constructor(logger, transport, sessionId, sdk, options = {}, storage) {
24
25
  this.logger = logger;
@@ -44,6 +45,7 @@ var RequestQueue = class {
44
45
  this.pending = this.store.all();
45
46
  }
46
47
  if (this.storage) {
48
+ for (const key of LEGACY_DEBUG_KEYS) this.storage.removeItem(key);
47
49
  this.lowStore = new EventStore(
48
50
  this.storage,
49
51
  this.logger,
@@ -636,7 +638,6 @@ var RequestQueue = class {
636
638
  if (typeof window === "undefined") return;
637
639
  this.onVisibilityChange = () => {
638
640
  try {
639
- this.writeLifecycleDebugOutput("cbo_visibility_change_debug");
640
641
  if (readDocumentHidden({
641
642
  key: "page:lifecycle.visibility",
642
643
  title: "Page visibility",
@@ -661,7 +662,6 @@ var RequestQueue = class {
661
662
  };
662
663
  this.onPageHide = () => {
663
664
  try {
664
- this.writeLifecycleDebugOutput("cbo_pagehide_debug");
665
665
  if (this.config.tph) {
666
666
  this.flushSync("pagehide");
667
667
  }
@@ -710,31 +710,6 @@ var RequestQueue = class {
710
710
  this.onPageHide
711
711
  );
712
712
  }
713
- writeLifecycleDebugOutput(key) {
714
- if (!this.debug || !this.storage) {
715
- return;
716
- }
717
- const output = {
718
- last_executed: Date.now(),
719
- url: readPageUrl({
720
- key: "page:location.url",
721
- title: "Page address (debug output only)",
722
- accessNote: "location.href, only written to the debug log",
723
- group: "Page",
724
- origin: "observe-external",
725
- fields: [
726
- {
727
- path: "url",
728
- label: "Full page address",
729
- category: "technical",
730
- exposure: "raw",
731
- transform: { note: "debug mode only; written to own localStorage, never sent" }
732
- }
733
- ]
734
- })
735
- };
736
- this.storage.setItem(key, output);
737
- }
738
713
  /**
739
714
  * Synchronously flush pending events (and best-effort lows) during page unload.
740
715
  *
@@ -1,5 +1,5 @@
1
1
  // src/readers/page.ts
2
- import { applySourcePathQuery } from "../utils/source-path.mjs";
2
+ import { applySourcePathQuery, applyStrictSourcePathQuery, maskPathSegments } from "../utils/source-path.mjs";
3
3
  function listenWindow(_meta, type, handler, options) {
4
4
  if (options === void 0) {
5
5
  window.addEventListener(type, handler);
@@ -18,6 +18,9 @@ function readPageUrl(_meta) {
18
18
  return window.location.href;
19
19
  }
20
20
  function readSourcePath(_meta, mode, allow) {
21
+ if (typeof __CBO_DATA_PAGE_STRICT__ === "boolean" && __CBO_DATA_PAGE_STRICT__) {
22
+ return `${maskPathSegments(window.location.pathname)}${applyStrictSourcePathQuery(window.location.search, allow)}`;
23
+ }
21
24
  const search = applySourcePathQuery(window.location.search, mode, allow);
22
25
  return `${window.location.pathname}${search}`;
23
26
  }
@@ -4,6 +4,14 @@ var OUTBOX_STORAGE_KEY = "cbo_outbox";
4
4
  var OUTBOX_LOCK_NAME = "cbo_outbox";
5
5
  var DEFAULT_MAX_ENTRIES = 500;
6
6
  var DEFAULT_MAX_SERIALIZED_LENGTH = 512 * 1024;
7
+ var OUTBOX_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
8
+ function outboxEntryTime(entry) {
9
+ const event = entry.event;
10
+ const time = typeof event.timestamp === "number" ? event.timestamp : event.ts;
11
+ if (typeof time === "number" && Number.isFinite(time)) return time;
12
+ const hex = /^([0-9a-f]{8})-?([0-9a-f]{4})-?7/i.exec(entry.id);
13
+ return hex ? parseInt(hex[1] + hex[2], 16) : void 0;
14
+ }
7
15
  function isOutboxEntry(value) {
8
16
  if (!value || typeof value !== "object") {
9
17
  return false;
@@ -20,10 +28,23 @@ var EventStore = class {
20
28
  this.lockName = lockName;
21
29
  this.storageKey = storageKey;
22
30
  this.entries = this.read();
31
+ if (typeof __CBO_DATA_RETENTION__ === "boolean" && __CBO_DATA_RETENTION__) this.purgeExpired(Date.now());
23
32
  }
24
33
  entries = [];
25
34
  /** Malformed entries are dropped on every read; report only once per instance to avoid log spam. */
26
35
  reportedMalformed = false;
36
+ /** Deletes entries older than {@link OUTBOX_MAX_AGE_MS} (they are never sent). Never throws. */
37
+ purgeExpired(now) {
38
+ const expired = (entry) => {
39
+ const time = outboxEntryTime(entry);
40
+ return time !== void 0 && now - time > OUTBOX_MAX_AGE_MS;
41
+ };
42
+ if (!this.entries.some(expired)) return;
43
+ const count = this.entries.filter(expired).length;
44
+ this.entries = this.entries.filter((entry) => !expired(entry));
45
+ this.logger.debug(`EventStore deleted ${count} entries past the retention limit`);
46
+ void this.mutate((current) => current.filter((entry) => !expired(entry)));
47
+ }
27
48
  read() {
28
49
  const raw = this.storage.getItem(this.storageKey);
29
50
  if (!Array.isArray(raw)) {
@@ -129,5 +150,7 @@ export {
129
150
  DEFAULT_MAX_SERIALIZED_LENGTH,
130
151
  EventStore,
131
152
  OUTBOX_LOCK_NAME,
132
- OUTBOX_STORAGE_KEY
153
+ OUTBOX_MAX_AGE_MS,
154
+ OUTBOX_STORAGE_KEY,
155
+ outboxEntryTime
133
156
  };
@@ -16,6 +16,7 @@ import { migrateLegacyStorage } from "./storage/migration.mjs";
16
16
  import {
17
17
  DEFAULT_RELIABILITY_CONFIG,
18
18
  resolveFixedDataConfig,
19
+ staticReliabilityConfig,
19
20
  resolveReliabilityConfig
20
21
  } from "./config.mjs";
21
22
  import { ConfigManager } from "./config-manager.mjs";
@@ -91,25 +92,31 @@ var CorbadoTrackerCore = class _CorbadoTrackerCore {
91
92
  if (this.fixedData) {
92
93
  this.configOverrides.pmd = this.fixedData.pmd;
93
94
  }
94
- const completeOverride = Object.keys(DEFAULT_RELIABILITY_CONFIG).every(
95
- (key) => this.configOverrides[key] !== void 0
96
- ) && Object.keys(DEFAULT_RELIABILITY_CONFIG.retry).every(
97
- (key) => this.configOverrides.retry?.[key] !== void 0
98
- );
99
- if (!completeOverride) {
100
- this.configManager = new ConfigManager(
101
- options.apiBaseUrl + (options.apiConfigPath ?? "/v1/observe/config") + "/" + options.projectId,
102
- this.persistentStorage,
103
- this.logger
95
+ if (typeof __CBO_SDK_CONFIG__ === "string") {
96
+ const fixed = staticReliabilityConfig(__CBO_SDK_CONFIG__, this.fixedData?.pmd);
97
+ this.config = fixed.config;
98
+ if (!fixed.valid) this.logger.error("Invalid static SDK config: running the built-in defaults");
99
+ } else {
100
+ const completeOverride = Object.keys(DEFAULT_RELIABILITY_CONFIG).every(
101
+ (key) => this.configOverrides[key] !== void 0
102
+ ) && Object.keys(DEFAULT_RELIABILITY_CONFIG.retry).every(
103
+ (key) => this.configOverrides.retry?.[key] !== void 0
104
104
  );
105
- }
106
- const cachedConfig = this.configManager?.initialConfig;
107
- this.config = resolveReliabilityConfig(cachedConfig, this.configOverrides);
108
- if (!completeOverride && !cachedConfig && options.flushInterval != null && this.configOverrides.flushIntervalMs === void 0) {
109
- this.config = { ...this.config, flushIntervalMs: options.flushInterval };
110
- }
111
- if (!completeOverride && !cachedConfig && options.deviceInfoCollectorTimeout != null && this.configOverrides.deviceInfoCollectorTimeoutMs === void 0) {
112
- this.config = { ...this.config, deviceInfoCollectorTimeoutMs: options.deviceInfoCollectorTimeout };
105
+ if (!completeOverride) {
106
+ this.configManager = new ConfigManager(
107
+ options.apiBaseUrl + (options.apiConfigPath ?? "/v1/observe/config") + "/" + options.projectId,
108
+ this.persistentStorage,
109
+ this.logger
110
+ );
111
+ }
112
+ const cachedConfig = this.configManager?.initialConfig;
113
+ this.config = resolveReliabilityConfig(cachedConfig, this.configOverrides);
114
+ if (!completeOverride && !cachedConfig && options.flushInterval != null && this.configOverrides.flushIntervalMs === void 0) {
115
+ this.config = { ...this.config, flushIntervalMs: options.flushInterval };
116
+ }
117
+ if (!completeOverride && !cachedConfig && options.deviceInfoCollectorTimeout != null && this.configOverrides.deviceInfoCollectorTimeoutMs === void 0) {
118
+ this.config = { ...this.config, deviceInfoCollectorTimeoutMs: options.deviceInfoCollectorTimeout };
119
+ }
113
120
  }
114
121
  if (options.apiBaseUrl !== "") {
115
122
  if (!/^https?:\/\//.test(options.apiBaseUrl)) {
@@ -134,6 +141,7 @@ var CorbadoTrackerCore = class _CorbadoTrackerCore {
134
141
  if (plugin.rawErrors) this.rawErrors = plugin.rawErrors;
135
142
  if (typeof plugin.sourcePath === "function") this.fixedSourcePath = plugin.sourcePath;
136
143
  if (plugin.eventTime) this.eventClock = plugin.eventTime;
144
+ if (plugin.deviceHandleMaxAgeMs !== void 0) this.deviceHandleMaxAgeMs = plugin.deviceHandleMaxAgeMs;
137
145
  });
138
146
  if (options.fixedDataConfig?.crossOriginSession && !this.bridge) {
139
147
  this.logger.warn("fixedDataConfig.crossOriginSession is ignored without the crossOriginSession() plugin");
@@ -196,7 +204,8 @@ var CorbadoTrackerCore = class _CorbadoTrackerCore {
196
204
  // rather than reportTelemetry: the latter dedupes by exact message and is capped per load,
197
205
  // where ever-changing messages would crowd out real error diagnostics.
198
206
  (level, message) => this.telemetry(level, message),
199
- this.fixedData?.privateMode ?? true
207
+ this.fixedData?.privateMode ?? true,
208
+ this.deviceHandleMaxAgeMs
200
209
  );
201
210
  this.deviceCollector = deviceInfoCollector;
202
211
  const deviceInfoDebounceTime = options.deviceInfoDebounceTime ?? 3e4;
@@ -276,6 +285,8 @@ var CorbadoTrackerCore = class _CorbadoTrackerCore {
276
285
  fixedSourcePath;
277
286
  /** Event clock of a plugin (never throws); undefined or an undefined result = `explicitTimestamp ?? Date.now()`. */
278
287
  eventClock;
288
+ /** Device-handle maximum of a plugin; undefined = no maximum. */
289
+ deviceHandleMaxAgeMs;
279
290
  /** @internal Shared passive page observation and per-operation context registration. */
280
291
  getWindowObserver() {
281
292
  return this.windowObserver ??= new SharedWindowObserver(
@@ -5,9 +5,14 @@ var MAX_ENTRIES = 10;
5
5
  function read(storage, now) {
6
6
  const raw = storage.getItem(EMAIL_LINK_STARTS_STORAGE_KEY);
7
7
  if (!Array.isArray(raw)) return [];
8
- return raw.filter(
8
+ const valid = raw.filter(
9
9
  (entry) => !!entry && typeof entry === "object" && typeof entry.transactionId === "string" && typeof entry.anchor === "string" && typeof entry.sessionId === "string" && typeof entry.startedAt === "number" && now - entry.startedAt < EMAIL_LINK_START_TTL_MS
10
10
  );
11
+ if (typeof __CBO_DATA_RETENTION__ === "boolean" && __CBO_DATA_RETENTION__ && valid.length < raw.length) {
12
+ if (valid.length === 0) storage.removeItem(EMAIL_LINK_STARTS_STORAGE_KEY);
13
+ else storage.setItem(EMAIL_LINK_STARTS_STORAGE_KEY, valid);
14
+ }
15
+ return valid;
11
16
  }
12
17
  function rememberEmailLinkStart(storage, entry) {
13
18
  if (!storage) return;
@@ -33,7 +33,29 @@ function applySourcePathQuery(search, mode, allow) {
33
33
  });
34
34
  return `?${masked.join("&")}`;
35
35
  }
36
+ var PLAIN_KEY = /^[A-Za-z][A-Za-z0-9_.\-[\]]{0,63}$/;
37
+ function applyStrictSourcePathQuery(search, allow) {
38
+ if (search === "" || search === "?") return search;
39
+ const query = search.startsWith("?") ? search.slice(1) : search;
40
+ const masked = query.split("&").map((pair) => {
41
+ if (pair === "") return pair;
42
+ const separator = pair.indexOf("=");
43
+ const rawKey = separator === -1 ? pair : pair.slice(0, separator);
44
+ const key = decodeKey(rawKey);
45
+ if (isAllowed(key, allow)) return pair;
46
+ if (separator === -1) return MASKED_QUERY_VALUE;
47
+ const name = PLAIN_KEY.test(key) ? rawKey : MASKED_QUERY_VALUE;
48
+ return pair.length === separator + 1 ? `${name}=` : `${name}=${MASKED_QUERY_VALUE}`;
49
+ });
50
+ return `?${masked.join("&")}`;
51
+ }
52
+ function maskPathSegments(path) {
53
+ const segments = String(path ?? "").split("/").filter((segment) => segment !== "");
54
+ return segments.length === 0 ? "/" : "/" + segments.map(() => MASKED_QUERY_VALUE).join("/");
55
+ }
36
56
  export {
37
57
  MASKED_QUERY_VALUE,
38
- applySourcePathQuery
58
+ applySourcePathQuery,
59
+ applyStrictSourcePathQuery,
60
+ maskPathSegments
39
61
  };