@formo/analytics 1.33.1 → 1.34.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.
Files changed (45) hide show
  1. package/README.md +3 -0
  2. package/dist/cjs/src/FormoAnalytics.d.ts +65 -19
  3. package/dist/cjs/src/FormoAnalytics.js +188 -94
  4. package/dist/cjs/src/event/EventFactory.d.ts +1 -1
  5. package/dist/cjs/src/event/EventFactory.js +32 -16
  6. package/dist/cjs/src/event/sanitize.d.ts +13 -0
  7. package/dist/cjs/src/event/sanitize.js +94 -0
  8. package/dist/cjs/src/privy/index.d.ts +8 -2
  9. package/dist/cjs/src/privy/index.js +8 -2
  10. package/dist/cjs/src/privy/types.d.ts +25 -2
  11. package/dist/cjs/src/privy/utils.d.ts +100 -0
  12. package/dist/cjs/src/privy/utils.js +375 -16
  13. package/dist/cjs/src/session/index.d.ts +73 -6
  14. package/dist/cjs/src/session/index.js +309 -12
  15. package/dist/cjs/src/solana/SolanaManager.d.ts +1 -1
  16. package/dist/cjs/src/solana/SolanaManager.js +1 -1
  17. package/dist/cjs/src/solana/storeTypes.d.ts +1 -1
  18. package/dist/cjs/src/solana/storeTypes.js +1 -1
  19. package/dist/cjs/src/solana/types.d.ts +2 -2
  20. package/dist/cjs/src/types/base.d.ts +17 -1
  21. package/dist/cjs/src/version.d.ts +1 -1
  22. package/dist/cjs/src/version.js +1 -1
  23. package/dist/esm/src/FormoAnalytics.d.ts +65 -19
  24. package/dist/esm/src/FormoAnalytics.js +188 -94
  25. package/dist/esm/src/event/EventFactory.d.ts +1 -1
  26. package/dist/esm/src/event/EventFactory.js +32 -16
  27. package/dist/esm/src/event/sanitize.d.ts +13 -0
  28. package/dist/esm/src/event/sanitize.js +88 -0
  29. package/dist/esm/src/privy/index.d.ts +8 -2
  30. package/dist/esm/src/privy/index.js +8 -2
  31. package/dist/esm/src/privy/types.d.ts +25 -2
  32. package/dist/esm/src/privy/utils.d.ts +100 -0
  33. package/dist/esm/src/privy/utils.js +374 -16
  34. package/dist/esm/src/session/index.d.ts +73 -6
  35. package/dist/esm/src/session/index.js +309 -12
  36. package/dist/esm/src/solana/SolanaManager.d.ts +1 -1
  37. package/dist/esm/src/solana/SolanaManager.js +1 -1
  38. package/dist/esm/src/solana/storeTypes.d.ts +1 -1
  39. package/dist/esm/src/solana/storeTypes.js +1 -1
  40. package/dist/esm/src/solana/types.d.ts +2 -2
  41. package/dist/esm/src/types/base.d.ts +17 -1
  42. package/dist/esm/src/version.d.ts +1 -1
  43. package/dist/esm/src/version.js +1 -1
  44. package/dist/index.umd.min.js +1 -1
  45. package/package.json +7 -7
@@ -72,6 +72,8 @@ import { parseChainId } from "./utils/chain";
72
72
  import { WagmiEventHandler } from "./wagmi";
73
73
  import { isSolanaChainId } from "./solana";
74
74
  import { SolanaManager } from "./solana/SolanaManager";
75
+ // Internal: the Privy identify is reached through identify(user), not exported.
76
+ import { identifyPrivyUser } from "./privy/utils";
75
77
  /**
76
78
  * Constants for provider switching reasons
77
79
  */
@@ -87,7 +89,7 @@ var FormoAnalytics = /** @class */ (function () {
87
89
  var _a, _b, _c;
88
90
  this.writeKey = writeKey;
89
91
  this.options = options;
90
- // Per-chain namespace state isolates EVM and Solana connection state
92
+ // Per-chain namespace state - isolates EVM and Solana connection state
91
93
  this._chainState = {
92
94
  evm: {},
93
95
  solana: {},
@@ -203,7 +205,7 @@ var FormoAnalytics = /** @class */ (function () {
203
205
  this.trackPageHits();
204
206
  }
205
207
  Object.defineProperty(FormoAnalytics.prototype, "_provider", {
206
- // EVM state accessors EVM listener paths must use these instead of
208
+ // EVM state accessors - EVM listener paths must use these instead of
207
209
  // currentAddress/currentChainId to avoid cross-namespace reads.
208
210
  get: function () {
209
211
  return this._chainState.evm.provider;
@@ -393,7 +395,7 @@ var FormoAnalytics = /** @class */ (function () {
393
395
  return [2 /*return*/];
394
396
  }
395
397
  // connect() persists wallet/chain state (active-wallet cookie,
396
- // currentAddress/currentChainId) before trackEvent's consent check
398
+ // currentAddress/currentChainId) before trackEvent's consent check -
397
399
  // gate the whole method so a suppressed visitor or excluded environment
398
400
  // (opt-out / timezone / host / path) leaves no session state.
399
401
  if (this.isTrackingSuppressed()) {
@@ -550,79 +552,85 @@ var FormoAnalytics = /** @class */ (function () {
550
552
  });
551
553
  });
552
554
  };
553
- /**
554
- * Emits an identify event with current wallet address and provider info.
555
- *
556
- * @param {string} params.address - Wallet address
557
- * @param {string} params.userId - External user ID
558
- * @param {string} params.rdns - Provider reverse domain name
559
- * @param {string} params.providerName - Provider display name
560
- * @param {IFormoEventProperties} properties - Additional properties to include with the identify event
561
- * @param {IFormoEventContext} context
562
- * @param {(...args: unknown[]) => void} callback
563
- * @returns {Promise<void>}
564
- *
565
- * @example
566
- * ```ts
567
- * // Basic identify
568
- * formo.identify({ address: '0x...', userId: 'user123' });
569
- *
570
- * // With Privy user
571
- * import { parsePrivyProperties } from '@formo/analytics';
572
- * const { user } = usePrivy();
573
- * if (user) {
574
- * const { properties, wallets } = parsePrivyProperties(user);
575
- * for (const wallet of wallets) {
576
- * formo.identify({ address: wallet.address, userId: user.id }, properties);
577
- * }
578
- * }
579
- * ```
580
- */
581
- FormoAnalytics.prototype.identify = function (params, properties, context, callback) {
555
+ FormoAnalytics.prototype.identify = function (paramsOrUser, propertiesOrOptions, context, callback) {
582
556
  return __awaiter(this, void 0, void 0, function () {
583
- var _i, _a, providerDetail, provider, address_1, validAddress_1, err_1, address, providerName, userId, rdns, validAddress, domain, isAlreadyIdentified, e_1;
557
+ var maybeUser, opts, params, properties, _i, _a, providerDetail, provider, address_1, validAddress_1, err_1, address, providerName, userId, rdns, setActive, validAddress, domain, isAlreadyIdentified, e_1;
584
558
  var _b, _c;
585
559
  return __generator(this, function (_d) {
586
560
  switch (_d.label) {
587
561
  case 0:
588
- _d.trys.push([0, 12, , 13]);
562
+ _d.trys.push([0, 14, , 15]);
563
+ maybeUser = paramsOrUser;
564
+ if (!(maybeUser &&
565
+ typeof maybeUser.id === "string" &&
566
+ maybeUser.address === undefined)) return [3 /*break*/, 2];
567
+ opts = (propertiesOrOptions !== null && propertiesOrOptions !== void 0 ? propertiesOrOptions : {});
568
+ // identifyPrivyUser records every linked wallet for clustering WITHOUT
569
+ // touching active state (internal setActive:false), promotes only the
570
+ // resolved active wallet, and reconciles the chain id with that wallet's
571
+ // namespace before emitting. It reads this.currentAddress itself to
572
+ // preserve an already-connected wallet, so this dispatch is a thin
573
+ // pass-through and both entry points behave identically.
574
+ return [4 /*yield*/, identifyPrivyUser(this, maybeUser, {
575
+ activeAddress: opts.activeAddress,
576
+ properties: opts.properties,
577
+ })];
578
+ case 1:
579
+ // identifyPrivyUser records every linked wallet for clustering WITHOUT
580
+ // touching active state (internal setActive:false), promotes only the
581
+ // resolved active wallet, and reconciles the chain id with that wallet's
582
+ // namespace before emitting. It reads this.currentAddress itself to
583
+ // preserve an already-connected wallet, so this dispatch is a thin
584
+ // pass-through and both entry points behave identically.
585
+ _d.sent();
586
+ return [2 /*return*/];
587
+ case 2:
588
+ params = paramsOrUser;
589
+ properties = propertiesOrOptions;
589
590
  // identify() writes the user-id cookie and marks wallet
590
- // identification before trackEvent's consent check gate the whole
591
+ // identification before trackEvent's consent check - gate the whole
591
592
  // method so a suppressed visitor or excluded environment (opt-out /
592
593
  // timezone / host / path) gets no identity persistence.
593
- if (this.isTrackingSuppressed()) {
594
- logger.info("identify() skipped: tracking is suppressed for this visitor or environment");
594
+ //
595
+ // The chain check belongs here and NOT in isTrackingSuppressed(), which
596
+ // identifyPrivyUser calls before it reconciles the chain. Folding it in
597
+ // there would block a Privy sync that reconciliation was about to make
598
+ // valid (activating a Solana wallet while an excluded EVM chain id is
599
+ // still current). By the time the Privy path reaches this guard it has
600
+ // already reconciled, so each inner identify is judged on the right chain.
601
+ if (this.isTrackingSuppressed() || this.isCurrentChainExcluded()) {
602
+ logger.info("identify() skipped: tracking is suppressed for this visitor, environment, or chain");
595
603
  return [2 /*return*/];
596
604
  }
597
- if (!!params) return [3 /*break*/, 10];
605
+ if (!!params) return [3 /*break*/, 12];
598
606
  // If no params provided, auto-identify
599
607
  logger.info("Auto-identifying with providers:", this._providers.map(function (p) { return p.info.name; }));
600
608
  _i = 0, _a = this._providers;
601
- _d.label = 1;
602
- case 1:
603
- if (!(_i < _a.length)) return [3 /*break*/, 9];
609
+ _d.label = 3;
610
+ case 3:
611
+ if (!(_i < _a.length)) return [3 /*break*/, 11];
604
612
  providerDetail = _a[_i];
605
613
  provider = providerDetail.provider;
606
614
  if (!provider)
607
- return [3 /*break*/, 8];
608
- _d.label = 2;
609
- case 2:
610
- _d.trys.push([2, 7, , 8]);
615
+ return [3 /*break*/, 10];
616
+ _d.label = 4;
617
+ case 4:
618
+ _d.trys.push([4, 9, , 10]);
611
619
  return [4 /*yield*/, this.getAddress(provider)];
612
- case 3:
620
+ case 5:
613
621
  address_1 = _d.sent();
614
- if (!address_1) return [3 /*break*/, 6];
622
+ if (!address_1) return [3 /*break*/, 8];
615
623
  validAddress_1 = validateAndChecksumAddress(address_1);
616
624
  logger.info("Auto-identify: Checking deduplication", {
617
625
  validAddress: validAddress_1,
618
626
  rdns: providerDetail.info.rdns,
619
627
  providerName: providerDetail.info.name,
620
628
  isAlreadyIdentified: validAddress_1
621
- ? this.session.isWalletIdentified(validAddress_1, providerDetail.info.rdns)
629
+ ? this.session.isWalletIdentified(validAddress_1, providerDetail.info.rdns, undefined, properties)
622
630
  : false,
623
631
  });
624
632
  if (!(validAddress_1 &&
625
- !this.session.isWalletIdentified(validAddress_1, providerDetail.info.rdns))) return [3 /*break*/, 5];
633
+ !this.session.isWalletIdentified(validAddress_1, providerDetail.info.rdns, undefined, properties))) return [3 /*break*/, 7];
626
634
  logger.info("Auto-identifying", validAddress_1, providerDetail.info.name, providerDetail.info.rdns);
627
635
  // NOTE: do not set this.currentAddress without explicit connect or identify
628
636
  return [4 /*yield*/, this.identify({
@@ -630,26 +638,26 @@ var FormoAnalytics = /** @class */ (function () {
630
638
  providerName: providerDetail.info.name,
631
639
  rdns: providerDetail.info.rdns,
632
640
  }, properties, context, callback)];
633
- case 4:
641
+ case 6:
634
642
  // NOTE: do not set this.currentAddress without explicit connect or identify
635
643
  _d.sent();
636
- return [3 /*break*/, 6];
637
- case 5:
644
+ return [3 /*break*/, 8];
645
+ case 7:
638
646
  if (validAddress_1) {
639
647
  logger.info("Auto-identify: Skipping already identified wallet", validAddress_1, providerDetail.info.name, providerDetail.info.rdns);
640
648
  }
641
- _d.label = 6;
642
- case 6: return [3 /*break*/, 8];
643
- case 7:
649
+ _d.label = 8;
650
+ case 8: return [3 /*break*/, 10];
651
+ case 9:
644
652
  err_1 = _d.sent();
645
653
  logger.error("Failed to identify provider ".concat(providerDetail.info.name, ":"), err_1);
646
- return [3 /*break*/, 8];
647
- case 8:
648
- _i++;
649
- return [3 /*break*/, 1];
650
- case 9: return [2 /*return*/];
654
+ return [3 /*break*/, 10];
651
655
  case 10:
652
- address = params.address, providerName = params.providerName, userId = params.userId, rdns = params.rdns;
656
+ _i++;
657
+ return [3 /*break*/, 3];
658
+ case 11: return [2 /*return*/];
659
+ case 12:
660
+ address = params.address, providerName = params.providerName, userId = params.userId, rdns = params.rdns, setActive = params.setActive;
653
661
  // Runtime validation: address is required
654
662
  if (!address) {
655
663
  (_b = logger.warn) === null || _b === void 0 ? void 0 : _b.call(logger, "identify() called without address - address is required");
@@ -658,20 +666,26 @@ var FormoAnalytics = /** @class */ (function () {
658
666
  // Explicit identify
659
667
  logger.info("Identify", address, userId, providerName, rdns);
660
668
  validAddress = validateAddress(address);
661
- if (validAddress) {
662
- this.currentAddress = validAddress;
663
- this.persistActiveWallet();
664
- }
665
- else {
669
+ if (!validAddress) {
666
670
  (_c = logger.warn) === null || _c === void 0 ? void 0 : _c.call(logger, "Invalid address provided to identify:", address);
667
671
  return [2 /*return*/];
668
672
  }
669
- if (userId) {
670
- this.currentUserId = userId;
671
- domain = getIdentityCookieDomain(this.crossSubdomainCookies);
672
- cookie().set(SESSION_USER_ID_KEY, userId, __assign(__assign({ path: "/" }, getIdentityCookieSecurity()), (domain ? { domain: domain } : {})));
673
+ // Promote this wallet to the SDK's active identity - the (currentAddress,
674
+ // currentUserId) pair later events are attributed to - unless the caller
675
+ // opts out with setActive:false. A non-active identify still emits its
676
+ // event and marks dedup below (for clustering), it just doesn't repoint
677
+ // attribution. Gating address and userId together prevents leaving the
678
+ // active address paired with a different wallet's user id.
679
+ if (setActive !== false) {
680
+ this.currentAddress = validAddress;
681
+ this.persistActiveWallet();
682
+ if (userId) {
683
+ this.currentUserId = userId;
684
+ domain = getIdentityCookieDomain(this.crossSubdomainCookies);
685
+ cookie().set(SESSION_USER_ID_KEY, userId, __assign(__assign({ path: "/" }, getIdentityCookieSecurity()), (domain ? { domain: domain } : {})));
686
+ }
673
687
  }
674
- isAlreadyIdentified = this.session.isWalletIdentified(validAddress, rdns || "");
688
+ isAlreadyIdentified = this.session.isWalletIdentified(validAddress, rdns || "", userId, properties);
675
689
  logger.debug("Identify: Checking deduplication", {
676
690
  validAddress: validAddress,
677
691
  rdns: rdns,
@@ -684,25 +698,77 @@ var FormoAnalytics = /** @class */ (function () {
684
698
  return [2 /*return*/];
685
699
  }
686
700
  // Mark as identified before emitting the event
687
- this.session.markWalletIdentified(validAddress, rdns || "");
701
+ this.session.markWalletIdentified(validAddress, rdns || "", userId, properties);
688
702
  return [4 /*yield*/, this.trackEvent(EventType.IDENTIFY, {
689
703
  address: validAddress,
690
704
  providerName: providerName,
691
705
  userId: userId,
692
706
  rdns: rdns,
693
707
  }, properties, context, callback)];
694
- case 11:
708
+ case 13:
695
709
  _d.sent();
696
- return [3 /*break*/, 13];
697
- case 12:
710
+ return [3 /*break*/, 15];
711
+ case 14:
698
712
  e_1 = _d.sent();
699
713
  logger.log("identify error", e_1);
700
- return [3 /*break*/, 13];
701
- case 13: return [2 /*return*/];
714
+ return [3 /*break*/, 15];
715
+ case 15: return [2 /*return*/];
702
716
  }
703
717
  });
704
718
  });
705
719
  };
720
+ /**
721
+ * Reconcile currentChainId with a newly-activated Privy wallet's chain
722
+ * namespace. identify() sets currentAddress but never touches the chain id
723
+ * (that comes from connect()/chain()/wagmi), so activating e.g. a Solana
724
+ * wallet while an EVM chain id is current would leave the address paired with
725
+ * a mismatched chain in events, excludeChains, and the active-wallet cookie.
726
+ *
727
+ * We can't infer the wallet's specific chain id from Privy's chainType, so on
728
+ * a namespace mismatch we clear the chain id rather than assert a wrong one; a
729
+ * real wallet connect will set the correct chain. Same-namespace activations
730
+ * (and wallets whose namespace can't be determined) leave the chain id alone.
731
+ *
732
+ * Privy doesn't always supply `chainType`: a `smart_wallet` entry is
733
+ * `{ type, address, smartWalletType }`, and `cross_app` wallets are bare
734
+ * `{ address }`. A `0x`-prefixed 20-byte address is unambiguously EVM though,
735
+ * so fall back to the address shape - otherwise activating an EVM smart
736
+ * wallet while a Solana chain id is current would leave the address paired
737
+ * with the wrong chain, and an `excludeChains` gate could drop the identify
738
+ * after it was already dedup-marked.
739
+ *
740
+ * @internal Not part of the public IFormoAnalytics contract - invoked by
741
+ * `identifyPrivyUser` (via a structural cast) before it emits, so both the
742
+ * `identify(user,{privy:true})` and direct `identifyPrivyUser()` paths
743
+ * reconcile the chain.
744
+ */
745
+ FormoAnalytics.prototype.syncPrivyActiveChain = function (chainType, address) {
746
+ if (this.currentChainId === undefined || this.currentChainId === null)
747
+ return;
748
+ var walletIsSolana;
749
+ var namespace = chainType === null || chainType === void 0 ? void 0 : chainType.toLowerCase();
750
+ // Only recognized namespaces decide. An unknown or future chainType
751
+ // ("bitcoin", "cosmos", …) must stay undecided rather than defaulting to
752
+ // EVM, which would wrongly clear a valid Solana chain id.
753
+ if (namespace === "solana") {
754
+ walletIsSolana = true;
755
+ }
756
+ else if (namespace === "ethereum") {
757
+ walletIsSolana = false;
758
+ }
759
+ else if (!namespace && address && /^0x[0-9a-f]{40}$/i.test(address)) {
760
+ // Only the EVM shape is inferable: a non-0x address could be Solana,
761
+ // Bitcoin, Cosmos, … so absence of 0x proves nothing.
762
+ walletIsSolana = false;
763
+ }
764
+ if (walletIsSolana === undefined)
765
+ return;
766
+ var currentIsSolana = isSolanaChainId(this.currentChainId);
767
+ if (walletIsSolana !== currentIsSolana) {
768
+ this.currentChainId = undefined;
769
+ this.persistActiveWallet();
770
+ }
771
+ };
706
772
  /**
707
773
  * Emits a detect wallet event with current wallet provider info.
708
774
  * @param {string} params.providerName
@@ -719,7 +785,7 @@ var FormoAnalytics = /** @class */ (function () {
719
785
  switch (_c.label) {
720
786
  case 0:
721
787
  // detect() marks wallet detection (a cookie write) before
722
- // trackEvent's consent check gate it for a suppressed visitor or
788
+ // trackEvent's consent check - gate it for a suppressed visitor or
723
789
  // excluded environment (opt-out / timezone / host / path).
724
790
  if (this.isTrackingSuppressed()) {
725
791
  logger.info("detect() skipped: tracking is suppressed for this visitor or environment");
@@ -1088,7 +1154,7 @@ var FormoAnalytics = /** @class */ (function () {
1088
1154
  wasDisconnected = !this._evmAddress;
1089
1155
  // Update state regardless of whether connect *event* tracking is enabled,
1090
1156
  // so disconnect events keep valid address/chainId values. (excludeChains is
1091
- // NOT suppression it still updates state so currentChainId can gate
1157
+ // NOT suppression - it still updates state so currentChainId can gate
1092
1158
  // events.)
1093
1159
  if (this.isTrackingSuppressed()) {
1094
1160
  this.clearStaleEvmWalletOnSwitchWhileSuppressed(address);
@@ -1269,7 +1335,7 @@ var FormoAnalytics = /** @class */ (function () {
1269
1335
  }
1270
1336
  isActiveProvider = this._provider === provider;
1271
1337
  // Update state from active provider so disconnect events keep valid
1272
- // address/chainId values except while suppressed, where we must not
1338
+ // address/chainId values - except while suppressed, where we must not
1273
1339
  // LEARN identity (only drop a stale EVM wallet on a switch).
1274
1340
  if (isActiveProvider) {
1275
1341
  if (this.isTrackingSuppressed()) {
@@ -1540,7 +1606,7 @@ var FormoAnalytics = /** @class */ (function () {
1540
1606
  var _this = this;
1541
1607
  // Install a single, instance-agnostic wrapper around history.pushState /
1542
1608
  // replaceState so concurrent SDK instances (React Strict Mode, HMR) don't
1543
- // each stack their own wrapper which would dispatch N synthetic events
1609
+ // each stack their own wrapper - which would dispatch N synthetic events
1544
1610
  // per navigation and produce O(N^2) onLocationChange calls. The wrapper
1545
1611
  // dispatches once; per-instance bookkeeping is done by per-instance
1546
1612
  // listeners that each register/unregister themselves.
@@ -1653,22 +1719,50 @@ var FormoAnalytics = /** @class */ (function () {
1653
1719
  * Visitor-level tracking suppression.
1654
1720
  *
1655
1721
  * Returns true when the SDK must not persist any identity/session/chain
1656
- * state or send any events for this visitor i.e. an explicit opt-out or a
1722
+ * state or send any events for this visitor - i.e. an explicit opt-out or a
1657
1723
  * jurisdiction/timezone exclusion. Public entry points that write state
1658
1724
  * before reaching the `shouldTrack()` event gate (identify/connect/detect)
1659
1725
  * check this first so suppressed visitors leave no cookies or session state.
1660
1726
  * @returns {boolean} True if all tracking and persistence must be suppressed
1727
+ * @internal Also read by `identifyPrivyUser` (via a structural cast) so the
1728
+ * Privy sync skips chain reconciliation and emission for suppressed visitors.
1661
1729
  */
1662
1730
  FormoAnalytics.prototype.isTrackingSuppressed = function () {
1663
1731
  return this.hasOptedOutTracking() || this.isCurrentEnvironmentExcluded();
1664
1732
  };
1665
1733
  /**
1666
- * Whether the current environment is excluded from tracking — the visitor's
1734
+ * Whether the current chain id is in `tracking.excludeChains`.
1735
+ *
1736
+ * Split out from `shouldTrack()` so `identify()` can check it *before*
1737
+ * mutating identity state. `trackEvent()` drops an excluded event silently
1738
+ * and returns void, but `identify()` marks the wallet as identified first, so
1739
+ * without this guard an identify on an excluded chain is dedup-marked and
1740
+ * then discarded, and the wallet never re-emits for the rest of the session
1741
+ * even after switching to an allowed chain. On the Privy path that loses the
1742
+ * user's whole cluster at once rather than a single wallet.
1743
+ *
1744
+ * Mirrors the chain rule in `shouldTrack()`: only applies when `tracking` is
1745
+ * an options object with `excludeChains` set, and only once a chain id is
1746
+ * known.
1747
+ */
1748
+ FormoAnalytics.prototype.isCurrentChainExcluded = function () {
1749
+ if (this.options.tracking === null ||
1750
+ typeof this.options.tracking !== "object" ||
1751
+ Array.isArray(this.options.tracking)) {
1752
+ return false;
1753
+ }
1754
+ var _a = this.options.tracking.excludeChains, excludeChains = _a === void 0 ? [] : _a;
1755
+ return (excludeChains.length > 0 &&
1756
+ !!this.currentChainId &&
1757
+ excludeChains.includes(this.currentChainId));
1758
+ };
1759
+ /**
1760
+ * Whether the current environment is excluded from tracking - the visitor's
1667
1761
  * timezone, the current hostname, or the current pathname matches a
1668
1762
  * configured exclusion.
1669
1763
  *
1670
1764
  * Timezone is visitor/session-level (stable for the session); host/path are
1671
- * current-page-level and transient if a SPA navigates to an allowed path,
1765
+ * current-page-level and transient - if a SPA navigates to an allowed path,
1672
1766
  * tracking resumes for future actions. Used as the "do not write identity or
1673
1767
  * send events" gate at every entry point that would persist state before the
1674
1768
  * `shouldTrack()` event gate.
@@ -1681,7 +1775,7 @@ var FormoAnalytics = /** @class */ (function () {
1681
1775
  };
1682
1776
  /**
1683
1777
  * Whether the current hostname matches a configured `tracking.excludeHosts`
1684
- * entry (exact match). Current-page-level see isCurrentEnvironmentExcluded.
1778
+ * entry (exact match). Current-page-level - see isCurrentEnvironmentExcluded.
1685
1779
  * @returns {boolean} True if the current hostname is excluded
1686
1780
  */
1687
1781
  FormoAnalytics.prototype.isHostExcluded = function () {
@@ -1699,7 +1793,7 @@ var FormoAnalytics = /** @class */ (function () {
1699
1793
  };
1700
1794
  /**
1701
1795
  * Whether the current pathname matches a configured `tracking.excludePaths`
1702
- * entry (exact match). Current-page-level see isCurrentEnvironmentExcluded.
1796
+ * entry (exact match). Current-page-level - see isCurrentEnvironmentExcluded.
1703
1797
  * @returns {boolean} True if the current pathname is excluded
1704
1798
  */
1705
1799
  FormoAnalytics.prototype.isPathExcluded = function () {
@@ -1716,8 +1810,8 @@ var FormoAnalytics = /** @class */ (function () {
1716
1810
  return excludePaths.includes(window.location.pathname);
1717
1811
  };
1718
1812
  /**
1719
- * Whether the current call is in a visitor-level suppression state opt-out
1720
- * or excluded timezone for which any persisted identity cookie should be
1813
+ * Whether the current call is in a visitor-level suppression state - opt-out
1814
+ * or excluded timezone - for which any persisted identity cookie should be
1721
1815
  * actively purged (not merely skipped). Host/path exclusions are
1722
1816
  * deliberately excluded here: they are transient current-page states, so a
1723
1817
  * cookie legitimately written on an allowed page must survive a visit to an
@@ -1730,7 +1824,7 @@ var FormoAnalytics = /** @class */ (function () {
1730
1824
  /**
1731
1825
  * Whether the visitor's browser-resolved timezone matches a configured
1732
1826
  * `tracking.excludeTimezones` entry (case-insensitive). Client-side and
1733
- * best-effort see TrackingOptions.excludeTimezones.
1827
+ * best-effort - see TrackingOptions.excludeTimezones.
1734
1828
  * @returns {boolean} True if the current timezone is excluded
1735
1829
  */
1736
1830
  FormoAnalytics.prototype.isTimezoneExcluded = function () {
@@ -1769,7 +1863,7 @@ var FormoAnalytics = /** @class */ (function () {
1769
1863
  typeof this.options.tracking === "object" &&
1770
1864
  !Array.isArray(this.options.tracking)) {
1771
1865
  var _a = this.options.tracking.excludeChains, excludeChains = _a === void 0 ? [] : _a;
1772
- // Environment exclusions (timezone / host / path) no identify / connect
1866
+ // Environment exclusions (timezone / host / path) - no identify / connect
1773
1867
  // / track events while excluded. Host/path are exact-match.
1774
1868
  if (this.isCurrentEnvironmentExcluded()) {
1775
1869
  return false;
@@ -2382,7 +2476,7 @@ var FormoAnalytics = /** @class */ (function () {
2382
2476
  * WITHOUT emitting an event.
2383
2477
  *
2384
2478
  * Integrations (e.g. the wagmi handler) must call this on every
2385
- * connect / chain-change / disconnect even when the corresponding
2479
+ * connect / chain-change / disconnect - even when the corresponding
2386
2480
  * autocapture event is disabled. Otherwise `currentChainId` stays
2387
2481
  * stale/undefined and `shouldTrack()`'s `tracking.excludeChains`
2388
2482
  * check (which keys off `currentChainId`, not the event payload) can
@@ -2396,7 +2490,7 @@ var FormoAnalytics = /** @class */ (function () {
2396
2490
  var chainId = params.chainId, address = params.address;
2397
2491
  if (this.isTrackingSuppressed()) {
2398
2492
  // While suppressed (opt-out / timezone / excluded host or path) we must
2399
- // never LEARN a new wallet but we must still CLEAR stale identity.
2493
+ // never LEARN a new wallet - but we must still CLEAR stale identity.
2400
2494
  // Otherwise a disconnect or wallet switch observed on a suppressed route
2401
2495
  // would leave the previously-learned address in memory and in the
2402
2496
  // active-wallet cookie, attaching it to later allowed-page events.
@@ -2478,14 +2572,14 @@ var FormoAnalytics = /** @class */ (function () {
2478
2572
  /**
2479
2573
  * Persist (or clear) the current wallet snapshot in a cookie so that the
2480
2574
  * SDK can repopulate `currentAddress`/`currentChainId` at init on the next
2481
- * page load closing the gap between page-show and wagmi/EIP-1193
2575
+ * page load - closing the gap between page-show and wagmi/EIP-1193
2482
2576
  * reconnection during which track()/page() events would otherwise ship
2483
2577
  * with an empty address.
2484
2578
  */
2485
2579
  FormoAnalytics.prototype.persistActiveWallet = function () {
2486
2580
  try {
2487
2581
  // Visitor-level suppression (opt-out or excluded timezone): purge any
2488
- // prior snapshot these are stable for the session, so deletion is safe.
2582
+ // prior snapshot - these are stable for the session, so deletion is safe.
2489
2583
  if (this.isPersistedIdentityPurgeRequired()) {
2490
2584
  cookie().remove(ACTIVE_WALLET_KEY);
2491
2585
  return;
@@ -41,7 +41,7 @@ declare class EventFactory implements IEventFactory {
41
41
  /**
42
42
  * Returns the document referrer with same-host referrers filtered out.
43
43
  * Internal navigation populates `document.referrer` with the previous page
44
- * on the same site, which is not an attribution signal treating it as
44
+ * on the same site, which is not an attribution signal - treating it as
45
45
  * "external" would otherwise let an internal URL become the session's
46
46
  * first-touch referrer after a direct landing.
47
47
  */
@@ -64,6 +64,7 @@ import mergeDeepRight from "../ramda/mergeDeepRight";
64
64
  import { session } from "../storage";
65
65
  import { version } from "../version";
66
66
  import { CHANNEL, CLICK_ID_PARAMS, DEFAULT_EXCLUDED_QUERY_PARAMS, DEFAULT_REFERRAL_PARAMS, PAGE_PROPERTIES_EXCLUDED_FIELDS, VERSION, } from "./constants";
67
+ import { sanitizeTrafficSources } from "./sanitize";
67
68
  import { generateAnonymousId } from "./utils";
68
69
  import { detectBrowser } from "../browser/browsers";
69
70
  var ISO_3166_ALPHA_2_REGEX = /^[A-Z]{2}$/;
@@ -132,7 +133,7 @@ var EventFactory = /** @class */ (function () {
132
133
  /**
133
134
  * Returns the document referrer with same-host referrers filtered out.
134
135
  * Internal navigation populates `document.referrer` with the previous page
135
- * on the same site, which is not an attribution signal treating it as
136
+ * on the same site, which is not an attribution signal - treating it as
136
137
  * "external" would otherwise let an internal URL become the session's
137
138
  * first-touch referrer after a direct landing.
138
139
  */
@@ -151,12 +152,16 @@ var EventFactory = /** @class */ (function () {
151
152
  };
152
153
  this.getTrafficSources = function (url) {
153
154
  var urlObj = new URL(url);
154
- var contextTrafficSources = __assign(__assign(__assign({}, _this.extractUTMParameters(url)), _this.extractClickIdParameters(urlObj)), { ref: _this.extractReferralParameter(urlObj), referrer: _this.getExternalReferrer() });
155
+ // Sanitize at the source so scanner-injected garbage (XSS probes in
156
+ // utm_*/click-id/ref query params) never wins the context-over-stored
157
+ // merge below, never gets persisted, and never reaches an event.
158
+ var contextTrafficSources = sanitizeTrafficSources(__assign(__assign(__assign({}, _this.extractUTMParameters(url)), _this.extractClickIdParameters(urlObj)), { ref: _this.extractReferralParameter(urlObj), referrer: _this.getExternalReferrer() }));
155
159
  // Sticky traffic sources may have been persisted by an older SDK version or
156
160
  // a looser config, before the current excludeQueryParams was in effect.
157
161
  // Honor the current denylist on the way out so excluded values can never
158
- // resurface from session storage (or get re-persisted below).
159
- var storedTrafficSources = _this.redactStoredTrafficSources(session().get(SESSION_TRAFFIC_SOURCE_KEY) || {});
162
+ // resurface from session storage (or get re-persisted below). Sanitizing
163
+ // here too flushes poisoned values persisted by a pre-sanitization SDK.
164
+ var storedTrafficSources = sanitizeTrafficSources(_this.redactStoredTrafficSources(session().get(SESSION_TRAFFIC_SOURCE_KEY) || {}));
160
165
  var mergedClickIds = {};
161
166
  for (var _i = 0, CLICK_ID_PARAMS_2 = CLICK_ID_PARAMS; _i < CLICK_ID_PARAMS_2.length; _i++) {
162
167
  var p = CLICK_ID_PARAMS_2[_i];
@@ -587,8 +592,9 @@ var EventFactory = /** @class */ (function () {
587
592
  EventFactory.prototype.create = function (event, address, userId) {
588
593
  return __awaiter(this, void 0, void 0, function () {
589
594
  var formoEvent, _a, chainId;
590
- return __generator(this, function (_b) {
591
- switch (_b.label) {
595
+ var _b, _c;
596
+ return __generator(this, function (_d) {
597
+ switch (_d.label) {
592
598
  case 0:
593
599
  formoEvent = {};
594
600
  _a = event.type;
@@ -606,39 +612,39 @@ var EventFactory = /** @class */ (function () {
606
612
  return [3 /*break*/, 17];
607
613
  case 1: return [4 /*yield*/, this.generatePageEvent(event.category, event.name, event.properties, event.context)];
608
614
  case 2:
609
- formoEvent = _b.sent();
615
+ formoEvent = _d.sent();
610
616
  return [3 /*break*/, 19];
611
617
  case 3: return [4 /*yield*/, this.generateDetectWalletEvent(event.providerName, event.rdns, event.properties, event.context)];
612
618
  case 4:
613
- formoEvent = _b.sent();
619
+ formoEvent = _d.sent();
614
620
  return [3 /*break*/, 19];
615
621
  case 5: return [4 /*yield*/, this.generateIdentifyEvent(event.providerName, event.rdns, event.address, event.userId, event.properties, event.context)];
616
622
  case 6:
617
- formoEvent = _b.sent();
623
+ formoEvent = _d.sent();
618
624
  return [3 /*break*/, 19];
619
625
  case 7: return [4 /*yield*/, this.generateChainChangedEvent(event.chainId, event.address, event.properties, event.context)];
620
626
  case 8:
621
- formoEvent = _b.sent();
627
+ formoEvent = _d.sent();
622
628
  return [3 /*break*/, 19];
623
629
  case 9: return [4 /*yield*/, this.generateConnectEvent(event.chainId, event.address, event.properties, event.context)];
624
630
  case 10:
625
- formoEvent = _b.sent();
631
+ formoEvent = _d.sent();
626
632
  return [3 /*break*/, 19];
627
633
  case 11: return [4 /*yield*/, this.generateDisconnectEvent(event.chainId, event.address, event.properties, event.context)];
628
634
  case 12:
629
- formoEvent = _b.sent();
635
+ formoEvent = _d.sent();
630
636
  return [3 /*break*/, 19];
631
637
  case 13: return [4 /*yield*/, this.generateSignatureEvent(event.status, event.chainId, event.address, event.message, event.properties, event.context)];
632
638
  case 14:
633
- formoEvent = _b.sent();
639
+ formoEvent = _d.sent();
634
640
  return [3 /*break*/, 19];
635
641
  case 15: return [4 /*yield*/, this.generateTransactionEvent(event.status, event.chainId, event.address, event.data, event.to, event.value, event.transactionHash, event.function_name, event.function_args, event.properties, event.context)];
636
642
  case 16:
637
- formoEvent = _b.sent();
643
+ formoEvent = _d.sent();
638
644
  return [3 /*break*/, 19];
639
645
  case 17: return [4 /*yield*/, this.generateTrackEvent(event.event, event.properties, event.context)];
640
646
  case 18:
641
- formoEvent = _b.sent();
647
+ formoEvent = _d.sent();
642
648
  return [3 /*break*/, 19];
643
649
  case 19:
644
650
  // Set address if not already set by the specific event generator
@@ -649,7 +655,17 @@ var EventFactory = /** @class */ (function () {
649
655
  chainId = 'chainId' in event ? event.chainId : undefined;
650
656
  formoEvent.address = this.validateEventAddress(address, chainId);
651
657
  }
652
- formoEvent.user_id = userId || null;
658
+ // An identify event asserts an explicit identity in its own payload (e.g. a
659
+ // Privy DID for each wallet being clustered). Keep that payload user_id
660
+ // rather than overwriting it with the active-session user id - otherwise a
661
+ // clustering identify that intentionally leaves the active user unchanged
662
+ // (setActive:false) would be stripped of its DID, defeating server-side
663
+ // wallet clustering. Fall back to the active-session user id when the
664
+ // identify payload carries none; all other events use the session user id.
665
+ formoEvent.user_id =
666
+ event.type === "identify"
667
+ ? (_c = (_b = formoEvent.user_id) !== null && _b !== void 0 ? _b : userId) !== null && _c !== void 0 ? _c : null
668
+ : userId || null;
653
669
  return [2 /*return*/, formoEvent];
654
670
  }
655
671
  });
@@ -0,0 +1,13 @@
1
+ import { ITrafficSource } from "../types";
2
+ declare const sanitizeClickId: (value: string) => string;
3
+ declare const sanitizeRef: (value: string) => string;
4
+ declare const sanitizeUtm: (value: string) => string;
5
+ /**
6
+ * Sanitize every traffic-source field of a (possibly sparse) traffic-source
7
+ * object. `referrer` is left untouched: it is a browser-set URL already
8
+ * handled by redactUrl, not an attacker-controlled query parameter. Unknown
9
+ * keys fall through to the UTM rule, the most permissive one.
10
+ */
11
+ declare const sanitizeTrafficSources: <T extends Partial<ITrafficSource>>(trafficSources: T) => T;
12
+ export { sanitizeClickId, sanitizeRef, sanitizeUtm, sanitizeTrafficSources };
13
+ //# sourceMappingURL=sanitize.d.ts.map