@formo/analytics 1.31.0 → 1.33.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.
@@ -46,6 +46,15 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
46
46
  if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
47
47
  }
48
48
  };
49
+ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
50
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
51
+ if (ar || !(i in from)) {
52
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
53
+ ar[i] = from[i];
54
+ }
55
+ }
56
+ return to.concat(ar || Array.prototype.slice.call(from));
57
+ };
49
58
  var __importDefault = (this && this.__importDefault) || function (mod) {
50
59
  return (mod && mod.__esModule) ? mod : { "default": mod };
51
60
  };
@@ -67,7 +76,7 @@ var ISO_3166_ALPHA_2_REGEX = /^[A-Z]{2}$/;
67
76
  var EventFactory = /** @class */ (function () {
68
77
  function EventFactory(options) {
69
78
  var _this = this;
70
- var _a;
79
+ var _a, _b;
71
80
  this.extractUTMParameters = function (url) {
72
81
  var result = {
73
82
  utm_campaign: "",
@@ -145,12 +154,16 @@ var EventFactory = /** @class */ (function () {
145
154
  return "";
146
155
  }
147
156
  catch (_b) { }
148
- return ref;
157
+ return _this.redactUrl(ref);
149
158
  };
150
159
  this.getTrafficSources = function (url) {
151
160
  var urlObj = new URL(url);
152
161
  var contextTrafficSources = __assign(__assign(__assign({}, _this.extractUTMParameters(url)), _this.extractClickIdParameters(urlObj)), { ref: _this.extractReferralParameter(urlObj), referrer: _this.getExternalReferrer() });
153
- var storedTrafficSources = (0, storage_1.session)().get(constants_1.SESSION_TRAFFIC_SOURCE_KEY) || {};
162
+ // Sticky traffic sources may have been persisted by an older SDK version or
163
+ // a looser config, before the current excludeQueryParams was in effect.
164
+ // Honor the current denylist on the way out so excluded values can never
165
+ // resurface from session storage (or get re-persisted below).
166
+ var storedTrafficSources = _this.redactStoredTrafficSources((0, storage_1.session)().get(constants_1.SESSION_TRAFFIC_SOURCE_KEY) || {});
154
167
  var mergedClickIds = {};
155
168
  for (var _i = 0, CLICK_ID_PARAMS_2 = constants_2.CLICK_ID_PARAMS; _i < CLICK_ID_PARAMS_2.length; _i++) {
156
169
  var p = CLICK_ID_PARAMS_2[_i];
@@ -191,8 +204,17 @@ var EventFactory = /** @class */ (function () {
191
204
  this.getPageProperties = function (properties) {
192
205
  // Create a copy to avoid mutating the original properties object
193
206
  var pageProps = __assign({}, properties);
207
+ // Parse the current URL once and strip any excluded (sensitive) query
208
+ // params up front, so nothing sensitive is forwarded via url, query, or the
209
+ // per-param explosion below. The hash/fragment is intentionally untouched.
210
+ var urlObj = null;
211
+ try {
212
+ urlObj = new URL(globalThis.location.href);
213
+ _this.redactQueryParams(urlObj);
214
+ }
215
+ catch (_a) { }
194
216
  if ((0, validators_1.isUndefined)(pageProps.url)) {
195
- pageProps.url = new URL(globalThis.location.href).href;
217
+ pageProps.url = urlObj ? urlObj.href : globalThis.location.href;
196
218
  }
197
219
  if ((0, validators_1.isUndefined)(pageProps.path)) {
198
220
  pageProps.path = globalThis.location.pathname;
@@ -202,18 +224,22 @@ var EventFactory = /** @class */ (function () {
202
224
  }
203
225
  // Add query string without the '?' prefix
204
226
  if ((0, validators_1.isUndefined)(pageProps.query)) {
205
- pageProps.query = globalThis.location.search.slice(1);
227
+ pageProps.query = urlObj
228
+ ? urlObj.search.slice(1)
229
+ : globalThis.location.search.slice(1);
206
230
  }
207
231
  // Parse query parameters and add as individual properties (don't overwrite existing)
208
- // Skip fields that are already captured in context or are semantic event properties
232
+ // Skip fields that are already captured in context or are semantic event properties.
233
+ // Excluded params were already removed from urlObj above.
209
234
  try {
210
- var urlObj = new URL(globalThis.location.href);
211
- urlObj.searchParams.forEach(function (value, key) {
212
- // Only add if the property doesn't already exist and is not excluded
213
- if ((0, validators_1.isUndefined)(pageProps[key]) && !constants_2.PAGE_PROPERTIES_EXCLUDED_FIELDS.has(key)) {
214
- pageProps[key] = value;
215
- }
216
- });
235
+ if (urlObj) {
236
+ urlObj.searchParams.forEach(function (value, key) {
237
+ // Only add if the property doesn't already exist and is not excluded
238
+ if ((0, validators_1.isUndefined)(pageProps[key]) && !constants_2.PAGE_PROPERTIES_EXCLUDED_FIELDS.has(key)) {
239
+ pageProps[key] = value;
240
+ }
241
+ });
242
+ }
217
243
  }
218
244
  catch (error) {
219
245
  logger_1.logger.error("Error parsing query parameters for page properties:", error);
@@ -221,8 +247,13 @@ var EventFactory = /** @class */ (function () {
221
247
  return pageProps;
222
248
  };
223
249
  this.options = options;
250
+ var tracking = options === null || options === void 0 ? void 0 : options.tracking;
251
+ var configuredExcludes = typeof tracking === "object" ? (_a = tracking.excludeQueryParams) !== null && _a !== void 0 ? _a : [] : [];
252
+ this.excludedQueryParams = new Set(__spreadArray(__spreadArray([], constants_2.DEFAULT_EXCLUDED_QUERY_PARAMS, true), configuredExcludes, true).map(function (key) {
253
+ return key.toLowerCase();
254
+ }));
224
255
  // Compile regex pattern once for better performance
225
- if ((_a = options === null || options === void 0 ? void 0 : options.referral) === null || _a === void 0 ? void 0 : _a.pathPattern) {
256
+ if ((_b = options === null || options === void 0 ? void 0 : options.referral) === null || _b === void 0 ? void 0 : _b.pathPattern) {
226
257
  try {
227
258
  this.compiledPathPattern = new RegExp(options.referral.pathPattern);
228
259
  }
@@ -245,13 +276,7 @@ var EventFactory = /** @class */ (function () {
245
276
  return (0, address_1.validateAddress)(address, chainId) || null;
246
277
  };
247
278
  EventFactory.prototype.getTimezone = function () {
248
- try {
249
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
250
- }
251
- catch (error) {
252
- logger_1.logger.error("Error resolving timezone:", error);
253
- return "";
254
- }
279
+ return (0, utils_1.getTimezone)();
255
280
  };
256
281
  EventFactory.prototype.getLocation = function () {
257
282
  try {
@@ -284,6 +309,66 @@ var EventFactory = /** @class */ (function () {
284
309
  EventFactory.prototype.getLibraryVersion = function () {
285
310
  return version_1.version;
286
311
  };
312
+ EventFactory.prototype.isExcludedQueryParam = function (key) {
313
+ return this.excludedQueryParams.has(key.toLowerCase());
314
+ };
315
+ /**
316
+ * Strip excluded (sensitive) query parameters from a URL in place. Only the
317
+ * query string is touched; the path and hash/fragment are left as-is.
318
+ */
319
+ EventFactory.prototype.redactQueryParams = function (url) {
320
+ var _this = this;
321
+ // Collect first, then delete: mutating searchParams while iterating is
322
+ // unsafe, and deleting a key removes all of its values at once.
323
+ var keysToDelete = new Set();
324
+ url.searchParams.forEach(function (_value, key) {
325
+ if (_this.isExcludedQueryParam(key)) {
326
+ keysToDelete.add(key);
327
+ }
328
+ });
329
+ keysToDelete.forEach(function (key) { return url.searchParams.delete(key); });
330
+ };
331
+ /**
332
+ * Return the given absolute URL with excluded query parameters removed. The
333
+ * input is returned unchanged when it is empty or cannot be parsed (e.g. an
334
+ * empty referrer).
335
+ */
336
+ EventFactory.prototype.redactUrl = function (href) {
337
+ if (!href)
338
+ return href;
339
+ try {
340
+ var url = new URL(href);
341
+ this.redactQueryParams(url);
342
+ return url.href;
343
+ }
344
+ catch (_a) {
345
+ return href;
346
+ }
347
+ };
348
+ /**
349
+ * Apply the current query-param denylist to a previously-persisted traffic
350
+ * source object. Traffic-source keys (utm_*, click ids, ref) are themselves
351
+ * query-parameter names, so an excluded key's stored value is dropped; the
352
+ * referrer is a URL and is re-redacted. Guards against a stored value
353
+ * outliving the config (or SDK version) under which it was first captured.
354
+ */
355
+ EventFactory.prototype.redactStoredTrafficSources = function (stored) {
356
+ var result = {};
357
+ for (var _i = 0, _a = Object.keys(stored); _i < _a.length; _i++) {
358
+ var key = _a[_i];
359
+ var value = stored[key];
360
+ if (key === "referrer") {
361
+ result[key] = this.redactUrl(value || "");
362
+ }
363
+ else if (this.isExcludedQueryParam(key)) {
364
+ result[key] = "";
365
+ }
366
+ else {
367
+ result[key] = value;
368
+ }
369
+ }
370
+ return result;
371
+ };
287
372
  // Get screen dimensions and pixel density
288
373
  // Returns safe defaults if any error occurs to ensure event creation continues
289
374
  EventFactory.prototype.getScreen = function () {
@@ -312,7 +397,7 @@ var EventFactory = /** @class */ (function () {
312
397
  // Contextual fields that are automatically collected and populated by the Formo SDK
313
398
  EventFactory.prototype.generateContext = function (context) {
314
399
  return __awaiter(this, void 0, void 0, function () {
315
- var browserName, language, timezone, location, library_version, defaultContext, mergedContext;
400
+ var browserName, language, timezone, location, library_version, redactedHref, defaultContext, mergedContext;
316
401
  return __generator(this, function (_a) {
317
402
  switch (_a.label) {
318
403
  case 0: return [4 /*yield*/, (0, browsers_1.detectBrowser)()];
@@ -322,7 +407,8 @@ var EventFactory = /** @class */ (function () {
322
407
  timezone = this.getTimezone();
323
408
  location = this.getLocation();
324
409
  library_version = this.getLibraryVersion();
325
- defaultContext = __assign(__assign(__assign({ user_agent: globalThis.navigator.userAgent, locale: language, timezone: timezone, location: location }, this.getTrafficSources(globalThis.location.href)), { page_title: document.title, page_url: globalThis.location.href, library_name: "Formo Web SDK", library_version: library_version, browser: browserName }), this.getScreen());
410
+ redactedHref = this.redactUrl(globalThis.location.href);
411
+ defaultContext = __assign(__assign(__assign({ user_agent: globalThis.navigator.userAgent, locale: language, timezone: timezone, location: location }, this.getTrafficSources(redactedHref)), { page_title: document.title, page_url: redactedHref, library_name: "Formo Web SDK", library_version: library_version, browser: browserName }), this.getScreen());
326
412
  mergedContext = (0, mergeDeepRight_1.default)(defaultContext, context || {});
327
413
  return [2 /*return*/, mergedContext];
328
414
  }
@@ -6,6 +6,18 @@ declare const VERSION = "0";
6
6
  * in src/types/events.ts derived from this array.
7
7
  */
8
8
  declare const CLICK_ID_PARAMS: readonly ["gclid", "gad_source", "fbclid", "msclkid", "twclid", "li_fat_id", "rdt_cid", "ttclid"];
9
+ /**
10
+ * Query parameters that are ALWAYS stripped from forwarded and stored URLs,
11
+ * regardless of consumer configuration, because they carry high-sensitivity
12
+ * secrets that must never reach Formo:
13
+ * - privy_oauth_code: Privy OAuth authorization code
14
+ * - privy_oauth_state: Privy OAuth CSRF state token
15
+ * - privy_oauth_provider: Privy OAuth provider identifier
16
+ *
17
+ * Consumers can extend the denylist via `tracking.excludeQueryParams` but
18
+ * cannot remove these built-ins. Matched case-insensitively.
19
+ */
20
+ declare const DEFAULT_EXCLUDED_QUERY_PARAMS: readonly ["privy_oauth_code", "privy_oauth_state", "privy_oauth_provider"];
9
21
  /**
10
22
  * Fields that should be excluded from page event properties parsing
11
23
  * These are either:
@@ -13,5 +25,5 @@ declare const CLICK_ID_PARAMS: readonly ["gclid", "gad_source", "fbclid", "msclk
13
25
  * - Semantic event properties that should not be overridden by URL params
14
26
  */
15
27
  declare const PAGE_PROPERTIES_EXCLUDED_FIELDS: Set<string>;
16
- export { CHANNEL, VERSION, CLICK_ID_PARAMS, PAGE_PROPERTIES_EXCLUDED_FIELDS };
28
+ export { CHANNEL, VERSION, CLICK_ID_PARAMS, DEFAULT_EXCLUDED_QUERY_PARAMS, PAGE_PROPERTIES_EXCLUDED_FIELDS, };
17
29
  //# sourceMappingURL=constants.d.ts.map
@@ -9,7 +9,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
9
9
  return to.concat(ar || Array.prototype.slice.call(from));
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.PAGE_PROPERTIES_EXCLUDED_FIELDS = exports.CLICK_ID_PARAMS = exports.VERSION = exports.CHANNEL = void 0;
12
+ exports.PAGE_PROPERTIES_EXCLUDED_FIELDS = exports.DEFAULT_EXCLUDED_QUERY_PARAMS = exports.CLICK_ID_PARAMS = exports.VERSION = exports.CHANNEL = void 0;
13
13
  var CHANNEL = "web";
14
14
  exports.CHANNEL = CHANNEL;
15
15
  var VERSION = "0";
@@ -30,6 +30,23 @@ var CLICK_ID_PARAMS = [
30
30
  "ttclid", // TikTok Ads
31
31
  ];
32
32
  exports.CLICK_ID_PARAMS = CLICK_ID_PARAMS;
33
+ /**
34
+ * Query parameters that are ALWAYS stripped from forwarded and stored URLs,
35
+ * regardless of consumer configuration, because they carry high-sensitivity
36
+ * secrets that must never reach Formo:
37
+ * - privy_oauth_code: Privy OAuth authorization code
38
+ * - privy_oauth_state: Privy OAuth CSRF state token
39
+ * - privy_oauth_provider: Privy OAuth provider identifier
40
+ *
41
+ * Consumers can extend the denylist via `tracking.excludeQueryParams` but
42
+ * cannot remove these built-ins. Matched case-insensitively.
43
+ */
44
+ var DEFAULT_EXCLUDED_QUERY_PARAMS = [
45
+ "privy_oauth_code",
46
+ "privy_oauth_state",
47
+ "privy_oauth_provider",
48
+ ];
49
+ exports.DEFAULT_EXCLUDED_QUERY_PARAMS = DEFAULT_EXCLUDED_QUERY_PARAMS;
33
50
  /**
34
51
  * Fields that should be excluded from page event properties parsing
35
52
  * These are either:
@@ -72,6 +72,34 @@ export interface TrackingOptions {
72
72
  excludeHosts?: string[];
73
73
  excludePaths?: string[];
74
74
  excludeChains?: ChainID[];
75
+ /**
76
+ * IANA timezone names to opt out of tracking entirely. When the visitor's
77
+ * resolved timezone (via `Intl.DateTimeFormat().resolvedOptions().timeZone`)
78
+ * matches one of these, no events are enqueued or sent — including `identify`
79
+ * and `connect`. Matched case-insensitively against the full timezone string.
80
+ *
81
+ * Note: this is client-side, timezone-derived geolocation. It is best-effort
82
+ * and can be bypassed (a VPN does not change the browser timezone, and users
83
+ * can change their OS timezone). For authoritative jurisdiction blocking, use
84
+ * server-side IP geolocation at your ingest endpoint instead.
85
+ *
86
+ * @example ["Europe/London", "America/New_York"]
87
+ */
88
+ excludeTimezones?: string[];
89
+ /**
90
+ * Additional query parameter names to strip from forwarded and stored URLs,
91
+ * on top of a built-in always-on denylist (currently `privy_oauth_code`,
92
+ * `privy_oauth_state`, and `privy_oauth_provider`) that cannot be disabled.
93
+ * Matched case-insensitively. Excluded params are stripped from the captured
94
+ * page URL, query string, per-parameter page properties, and referrer before
95
+ * any event is sent. The URL hash/fragment is intentionally left untouched.
96
+ *
97
+ * Mirrors Mixpanel's `property_blacklist` and PostHog's `property_denylist`,
98
+ * scoped here to URL query parameters.
99
+ *
100
+ * @example ["token", "access_token", "email", "signature"]
101
+ */
102
+ excludeQueryParams?: string[];
75
103
  }
76
104
  /**
77
105
  * Configuration options for controlling wallet event autocapture
@@ -3,4 +3,5 @@ export * from "./base";
3
3
  export * from "./converter";
4
4
  export * from "./generate";
5
5
  export * from "./hash";
6
+ export * from "./timezone";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -19,4 +19,5 @@ __exportStar(require("./base"), exports);
19
19
  __exportStar(require("./converter"), exports);
20
20
  __exportStar(require("./generate"), exports);
21
21
  __exportStar(require("./hash"), exports);
22
+ __exportStar(require("./timezone"), exports);
22
23
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolve the current IANA timezone (e.g. "Europe/London") via the Intl API.
3
+ * Returns "" when the timezone cannot be resolved (e.g. Intl unavailable).
4
+ */
5
+ declare const getTimezone: () => string;
6
+ export { getTimezone };
7
+ //# sourceMappingURL=timezone.d.ts.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getTimezone = void 0;
4
+ var logger_1 = require("../logger");
5
+ /**
6
+ * Resolve the current IANA timezone (e.g. "Europe/London") via the Intl API.
7
+ * Returns "" when the timezone cannot be resolved (e.g. Intl unavailable).
8
+ */
9
+ var getTimezone = function () {
10
+ try {
11
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
12
+ }
13
+ catch (error) {
14
+ logger_1.logger.error("Error resolving timezone:", error);
15
+ return "";
16
+ }
17
+ };
18
+ exports.getTimezone = getTimezone;
19
+ //# sourceMappingURL=timezone.js.map
@@ -282,6 +282,59 @@ export declare class FormoAnalytics implements IFormoAnalytics {
282
282
  private static installHistoryHooksOnce;
283
283
  private trackPageHit;
284
284
  private trackEvent;
285
+ /**
286
+ * Visitor-level tracking suppression.
287
+ *
288
+ * Returns true when the SDK must not persist any identity/session/chain
289
+ * state or send any events for this visitor — i.e. an explicit opt-out or a
290
+ * jurisdiction/timezone exclusion. Public entry points that write state
291
+ * before reaching the `shouldTrack()` event gate (identify/connect/detect)
292
+ * check this first so suppressed visitors leave no cookies or session state.
293
+ * @returns {boolean} True if all tracking and persistence must be suppressed
294
+ */
295
+ private isTrackingSuppressed;
296
+ /**
297
+ * Whether the current environment is excluded from tracking — the visitor's
298
+ * timezone, the current hostname, or the current pathname matches a
299
+ * configured exclusion.
300
+ *
301
+ * Timezone is visitor/session-level (stable for the session); host/path are
302
+ * current-page-level and transient — if a SPA navigates to an allowed path,
303
+ * tracking resumes for future actions. Used as the "do not write identity or
304
+ * send events" gate at every entry point that would persist state before the
305
+ * `shouldTrack()` event gate.
306
+ * @returns {boolean} True if the current environment is excluded
307
+ */
308
+ private isCurrentEnvironmentExcluded;
309
+ /**
310
+ * Whether the current hostname matches a configured `tracking.excludeHosts`
311
+ * entry (exact match). Current-page-level — see isCurrentEnvironmentExcluded.
312
+ * @returns {boolean} True if the current hostname is excluded
313
+ */
314
+ private isHostExcluded;
315
+ /**
316
+ * Whether the current pathname matches a configured `tracking.excludePaths`
317
+ * entry (exact match). Current-page-level — see isCurrentEnvironmentExcluded.
318
+ * @returns {boolean} True if the current pathname is excluded
319
+ */
320
+ private isPathExcluded;
321
+ /**
322
+ * Whether the current call is in a visitor-level suppression state — opt-out
323
+ * or excluded timezone — for which any persisted identity cookie should be
324
+ * actively purged (not merely skipped). Host/path exclusions are
325
+ * deliberately excluded here: they are transient current-page states, so a
326
+ * cookie legitimately written on an allowed page must survive a visit to an
327
+ * excluded route.
328
+ * @returns {boolean} True if persisted identity must be purged
329
+ */
330
+ private isPersistedIdentityPurgeRequired;
331
+ /**
332
+ * Whether the visitor's browser-resolved timezone matches a configured
333
+ * `tracking.excludeTimezones` entry (case-insensitive). Client-side and
334
+ * best-effort — see TrackingOptions.excludeTimezones.
335
+ * @returns {boolean} True if the current timezone is excluded
336
+ */
337
+ private isTimezoneExcluded;
285
338
  /**
286
339
  * Determines if tracking should be enabled based on configuration and consent
287
340
  * @returns {boolean} True if tracking should be enabled
@@ -329,6 +382,14 @@ export declare class FormoAnalytics implements IFormoAnalytics {
329
382
  * value in the normal way; existing connections are never clobbered.
330
383
  */
331
384
  private backfillActiveWallet;
385
+ /**
386
+ * Apply an EVM autocapture connect/switch while tracking is suppressed
387
+ * (opt-out / timezone / excluded host or path): never LEARN the wallet, but
388
+ * if it is a switch away from an already-learned EVM wallet, drop the stale
389
+ * one (which also clears the active-wallet cookie) so it can't attach to a
390
+ * later allowed-page event.
391
+ */
392
+ private clearStaleEvmWalletOnSwitchWhileSuppressed;
332
393
  /**
333
394
  * Polls for transaction receipt and emits tx.status = CONFIRMED or REVERTED.
334
395
  */