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