@datalyr/web 1.7.5 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/datalyr.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.5
2
+ * @datalyr/web v1.7.6
3
3
  * Datalyr Web SDK - Modern attribution tracking for web applications
4
4
  * (c) 2026 Datalyr Inc.
5
5
  * Released under the MIT License
@@ -794,6 +794,125 @@ var Datalyr = (function (exports) {
794
794
  }
795
795
  return data;
796
796
  }
797
+ // 9.A.4: query-param NAMES whose VALUES are secrets/PII — password-reset tokens,
798
+ // OAuth codes, magic-link sessions, emails/phones prefilled into URLs. Events ship
799
+ // `url` / `referrer` / `landingPage` with the FULL query string (sanitizeEventData
800
+ // only strips property KEYS, not URL param values), so /reset?token=…&email=… leaked
801
+ // verbatim into events and onward to ad platforms. Matched case-insensitively.
802
+ const REDACTED_URL_PARAMS = new Set([
803
+ 'token', 'access_token', 'refresh_token', 'id_token', 'auth', 'authorization',
804
+ 'password', 'pass', 'pwd', 'secret', 'api_key', 'apikey', 'key',
805
+ 'session', 'session_id', 'sid', 'email', 'e', 'phone', 'tel',
806
+ 'signature', 'sig', 'otp', 'reset', 'hash'
807
+ ]);
808
+ // BATCH-2(e): `code` is CONDITIONAL, not in the always-redact set above. An OAuth
809
+ // authorization code (account-takeover-grade if it leaks to ad platforms) ALWAYS travels
810
+ // with an OAuth-flow marker in the same query/fragment — the response carries `code`+`state`
811
+ // (+`scope`/`session_state`/`iss`), the authorize request carries `client_id`/`redirect_uri`/
812
+ // `response_type`/`code_challenge`. A coupon/referral/product code (`?code=SUMMER20`) — a real
813
+ // attribution signal for the DTC/Shopify ICP — never does. So we redact `code` ONLY when a
814
+ // marker co-occurs in the same k=v string, preserving marketing codes. (A bare `code=` with no
815
+ // marker is syntactically indistinguishable from a coupon code and is kept; state-less OAuth
816
+ // flows are rare and CSRF-unsafe.) Markers are deliberately OAuth-specific to avoid redacting
817
+ // legitimate codes — `prompt`/`authuser` are excluded as too generic.
818
+ const OAUTH_CODE_CONTEXT_MARKERS = new Set([
819
+ 'state', 'client_id', 'redirect_uri', 'response_type', 'grant_type',
820
+ 'code_challenge', 'session_state', 'scope', 'iss',
821
+ // Token family: the presence of any bearer/OAuth token means a bare `code` in the same
822
+ // string is almost certainly an auth code, not a coupon — a marketing URL never carries
823
+ // these. (These values are independently redacted too; this only governs `code`.)
824
+ 'access_token', 'id_token', 'refresh_token', 'token'
825
+ ]);
826
+ const REDACTED_URL_VALUE = '__redacted__';
827
+ // Redact denylisted keys in one `k=v&k=v` string (query OR fragment). Returns the
828
+ // re-encoded string and whether anything was rewritten (so the caller can preserve the
829
+ // exact original bytes when nothing matched).
830
+ function redactKvPairs(s) {
831
+ const params = new URLSearchParams(s);
832
+ let mutated = false;
833
+ const keysLower = Array.from(new Set(params.keys())).map(k => k.toLowerCase());
834
+ // `code` co-occurrence check (BATCH-2e) — evaluated per-string, since an OAuth flow puts
835
+ // `code` and its marker in the SAME query (or the SAME fragment).
836
+ const hasOAuthMarker = keysLower.some(k => OAUTH_CODE_CONTEXT_MARKERS.has(k));
837
+ // Snapshot the keys first — set() collapses duplicate keys mid-iteration.
838
+ for (const key of Array.from(new Set(params.keys()))) {
839
+ const lower = key.toLowerCase();
840
+ const sensitive = lower === 'code'
841
+ ? hasOAuthMarker
842
+ : REDACTED_URL_PARAMS.has(lower);
843
+ if (sensitive) {
844
+ params.set(key, REDACTED_URL_VALUE);
845
+ mutated = true;
846
+ }
847
+ }
848
+ return { out: params.toString(), mutated };
849
+ }
850
+ /**
851
+ * Redact secret/PII param VALUES in a URL (9.A.4). The param NAME is kept
852
+ * (value → `__redacted__`) so funnel steps that match on the param's presence still
853
+ * work; every other param — click IDs (fbclid/gclid/…) and utm_* — survives untouched.
854
+ * Both the query string AND a k=v fragment are rewritten: TR-04 — OAuth-implicit /
855
+ * Supabase magic links carry the session in the FRAGMENT (`/welcome#access_token=eyJ…`),
856
+ * with no query string at all; the old code returned early on a missing `?` and reattached
857
+ * the fragment verbatim, leaking the full JWT into `url` / `landingPage` / `dl_first_touch`
858
+ * (90d) and onward to ad platforms. Non-k=v fragments (`#section`, `#/spa-route`) keep their
859
+ * shape. Returns the input unchanged when nothing sensitive matched or on any parse
860
+ * failure — redaction must never break tracking.
861
+ */
862
+ function redactUrl(url) {
863
+ if (!url || typeof url !== 'string')
864
+ return url;
865
+ const hashStart = url.indexOf('#');
866
+ const qIdx = url.indexOf('?');
867
+ // A query string exists only when '?' appears BEFORE the fragment — a '?' after '#' is
868
+ // part of the fragment per the URL grammar (e.g. a SPA hash-route `#/reset?token=…`).
869
+ const queryStart = (qIdx !== -1 && (hashStart === -1 || qIdx < hashStart)) ? qIdx : -1;
870
+ if (queryStart === -1 && hashStart === -1)
871
+ return url; // nothing to redact
872
+ try {
873
+ const base = url.slice(0, queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length));
874
+ const query = queryStart === -1 ? '' : url.slice(queryStart + 1, hashStart === -1 ? url.length : hashStart);
875
+ const rawFragment = hashStart === -1 ? '' : url.slice(hashStart + 1);
876
+ let mutated = false;
877
+ let queryOut = query;
878
+ if (query) {
879
+ const r = redactKvPairs(query);
880
+ if (r.mutated) {
881
+ queryOut = r.out;
882
+ mutated = true;
883
+ }
884
+ }
885
+ // TR-04: redact the fragment when it carries k=v pairs. Two shapes: a pure implicit-flow
886
+ // fragment (`#access_token=…&refresh_token=…`) and a SPA hash-route with its own query
887
+ // (`#/reset?access_token=…`). Leave non-k=v fragments (`#section`, `#/route`) untouched.
888
+ let fragmentOut = rawFragment;
889
+ if (rawFragment) {
890
+ const fragQ = rawFragment.indexOf('?');
891
+ if (fragQ !== -1) {
892
+ const r = redactKvPairs(rawFragment.slice(fragQ + 1));
893
+ if (r.mutated) {
894
+ fragmentOut = rawFragment.slice(0, fragQ + 1) + r.out;
895
+ mutated = true;
896
+ }
897
+ }
898
+ else if (/[\w-]+=/.test(rawFragment)) {
899
+ const r = redactKvPairs(rawFragment);
900
+ if (r.mutated) {
901
+ fragmentOut = r.out;
902
+ mutated = true;
903
+ }
904
+ }
905
+ }
906
+ if (!mutated)
907
+ return url; // nothing sensitive matched → ship the exact original bytes
908
+ return base
909
+ + (queryStart === -1 ? '' : '?' + queryOut)
910
+ + (hashStart === -1 ? '' : '#' + fragmentOut);
911
+ }
912
+ catch (_a) {
913
+ return url; // malformed URL → ship as-is rather than drop/break the event
914
+ }
915
+ }
797
916
  /**
798
917
  * Deep merge objects
799
918
  */
@@ -924,16 +1043,18 @@ var Datalyr = (function (exports) {
924
1043
  return {};
925
1044
  try {
926
1045
  const url = new URL(referrer);
1046
+ // 9.A.4: the referrer can be our own /reset?token=… or an OAuth callback —
1047
+ // redact secret/PII param values before they're stamped onto the event.
927
1048
  return {
928
- referrer,
1049
+ referrer: redactUrl(referrer),
929
1050
  referrer_host: url.hostname,
930
1051
  referrer_path: url.pathname,
931
- referrer_search: url.search,
1052
+ referrer_search: redactUrl(url.search),
932
1053
  referrer_source: detectReferrerSource(url.hostname)
933
1054
  };
934
1055
  }
935
1056
  catch (_a) {
936
- return { referrer };
1057
+ return { referrer: redactUrl(referrer) };
937
1058
  }
938
1059
  }
939
1060
  /**
@@ -994,17 +1115,13 @@ var Datalyr = (function (exports) {
994
1115
  this.setRootDomainCookie('__dl_visitor_id', anonymousId);
995
1116
  return anonymousId;
996
1117
  }
997
- // 2. Fresh visitor (no persisted id): accept a cross-domain bridge id from the URL,
998
- // but ONLY a well-formed anon_<uuid> reject arbitrary / oversized values (a
999
- // multi-KB or attacker-crafted _dl_vid was previously persisted verbatim). Strip
1000
- // it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
1118
+ // 2. Raw URL visitor IDs are never identity authority. A syntactically
1119
+ // valid ID is still replayable/attacker-chosen and can merge two people.
1120
+ // Strip the legacy parameter and generate a fresh local identity below.
1001
1121
  try {
1002
1122
  const urlParams = new URLSearchParams(window.location.search);
1003
- const urlVisitorId = urlParams.get('_dl_vid');
1004
- if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
1123
+ if (urlParams.has('_dl_vid')) {
1005
1124
  this.stripUrlParam('_dl_vid');
1006
- this.persistAnonymousId(urlVisitorId);
1007
- return urlVisitorId;
1008
1125
  }
1009
1126
  }
1010
1127
  catch (e) {
@@ -1028,12 +1145,17 @@ var Datalyr = (function (exports) {
1028
1145
  storage.set('dl_anonymous_id', id);
1029
1146
  }
1030
1147
  /**
1031
- * Whether a `_dl_vid` value is a well-formed Datalyr anonymous id (anon_ + UUID).
1032
- * The length is bounded by the pattern, so an oversized/garbage value is rejected.
1033
- * (FSR-50)
1148
+ * TR-15 / FSR-107: tracking became allowed mid-session (optIn / consent grant). Start
1149
+ * persisting AND flush the current in-memory anon id to the cookie + localStorage now.
1150
+ * Without this, a visitor declined at init keeps a memory-only id, so that session's
1151
+ * events land under a visitor_id that vanishes on the next page load (attribution
1152
+ * fragmentation). Idempotent — a no-op once already persisting.
1034
1153
  */
1035
- isValidAnonymousId(id) {
1036
- return /^anon_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
1154
+ enablePersistence() {
1155
+ if (this.persistNewId)
1156
+ return;
1157
+ this.persistNewId = true;
1158
+ this.persistAnonymousId(this.anonymousId);
1037
1159
  }
1038
1160
  /**
1039
1161
  * Remove a query param from the current URL via history.replaceState (best-effort).
@@ -1524,6 +1646,10 @@ var Datalyr = (function (exports) {
1524
1646
  this.CLICK_ID_ALIASES = {
1525
1647
  ScCid: 'sclid',
1526
1648
  sccid: 'sclid',
1649
+ // Impact's real landing param is `irclickid`; capture it and normalize to the canonical
1650
+ // `irclid` that ingest / the MV / the source map (`irclid→impact`) all key on. Without
1651
+ // this, real Impact affiliate clicks were dark on web (only bare `irclid` was captured).
1652
+ irclickid: 'irclid',
1527
1653
  };
1528
1654
  // Default tracked params matching dl.js
1529
1655
  this.DEFAULT_TRACKED_PARAMS = [
@@ -1534,9 +1660,18 @@ var Datalyr = (function (exports) {
1534
1660
  'medium', // Generic medium (non-UTM)
1535
1661
  'gad_source' // Google Ads source parameter
1536
1662
  ];
1663
+ // TR-03: ad-cookie fields (Meta/Google Ads/TikTok/Snap) that are MARKETING-scoped. When
1664
+ // marketing consent is declined these are neither synthesized/written nor shipped. Google
1665
+ // Analytics cookies (_ga/_gid) and utm_* stay — they're analytics-scoped.
1666
+ this.MARKETING_COOKIES = ['_fbp', '_fbc', '_gcl_aw', '_gcl_dc', '_gcl_gb', '_gcl_ha', '_gac', '_ttp', '_ttc', '_scid'];
1537
1667
  this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
1538
1668
  // Merge default tracked params with user-provided ones
1539
1669
  this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
1670
+ this.marketingAllowedFn = options.marketingAllowed;
1671
+ }
1672
+ // TR-03: default (no predicate / no signal) = allowed → byte-identical to prior behavior.
1673
+ isMarketingAllowed() {
1674
+ return this.marketingAllowedFn ? this.marketingAllowedFn() !== false : true;
1540
1675
  }
1541
1676
  /**
1542
1677
  * Clear query params cache (called on page navigation)
@@ -1607,13 +1742,15 @@ var Datalyr = (function (exports) {
1607
1742
  attribution[param] = value;
1608
1743
  }
1609
1744
  }
1610
- // Capture referrer
1745
+ // Capture referrer. 9.A.4: redact secret/PII query-param values (reset tokens,
1746
+ // OAuth codes, emails) — this attribution object is stamped onto every event AND
1747
+ // persisted into dl_first_touch/dl_last_touch, so it must be clean at capture.
1611
1748
  if (document.referrer) {
1612
- attribution.referrer = document.referrer;
1749
+ attribution.referrer = redactUrl(document.referrer);
1613
1750
  attribution.referrerHost = this.extractHostname(document.referrer);
1614
1751
  }
1615
- // Capture landing page
1616
- attribution.landingPage = window.location.href;
1752
+ // Capture landing page (redacted — see referrer note above)
1753
+ attribution.landingPage = redactUrl(window.location.href);
1617
1754
  attribution.landingPath = window.location.pathname;
1618
1755
  // Determine source if not explicitly set
1619
1756
  if (!attribution.source) {
@@ -1709,6 +1846,10 @@ var Datalyr = (function (exports) {
1709
1846
  captureAdCookies() {
1710
1847
  var _a, _b;
1711
1848
  const adCookies = {};
1849
+ // TR-03: when marketing consent is declined, do NOT synthesize/write the Meta/Google ad
1850
+ // cookies below (writing _fbp/_fbc is active marketing use). Existing cookies are still
1851
+ // read here but stripped from the event payload in getAttributionData().
1852
+ const marketingAllowed = this.isMarketingAllowed();
1712
1853
  // Facebook/Meta cookies
1713
1854
  adCookies._fbp = cookies.get('_fbp');
1714
1855
  adCookies._fbc = cookies.get('_fbc');
@@ -1727,8 +1868,8 @@ var Datalyr = (function (exports) {
1727
1868
  // Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
1728
1869
  // cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
1729
1870
  adCookies._scid = cookies.get('_scid');
1730
- // Generate _fbp if missing (Facebook browser ID)
1731
- if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1871
+ // Generate _fbp if missing (Facebook browser ID). TR-03: only when marketing is allowed.
1872
+ if (marketingAllowed && !adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1732
1873
  const timestamp = Date.now();
1733
1874
  // Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
1734
1875
  // segment MUST be a decimal integer. The old base36 string was non-conformant —
@@ -1752,7 +1893,8 @@ var Datalyr = (function (exports) {
1752
1893
  // Shopify cart / server-side rebuild forward it as a time, so its format must not
1753
1894
  // change.
1754
1895
  const fbclid = this.getCurrentFbclid();
1755
- if (fbclid) {
1896
+ // TR-03: skip _fbc synthesis/refresh (cookie write) when marketing is declined.
1897
+ if (marketingAllowed && fbclid) {
1756
1898
  const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
1757
1899
  const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
1758
1900
  if (!existingFbc) {
@@ -1782,7 +1924,7 @@ var Datalyr = (function (exports) {
1782
1924
  // (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
1783
1925
  // validate a creationTime window the way Meta does, so this stays once-only.)
1784
1926
  const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
1785
- if (gclid && !cookies.get('_dl_gclid_at')) {
1927
+ if (marketingAllowed && gclid && !cookies.get('_dl_gclid_at')) {
1786
1928
  cookies.set('_dl_gclid_at', String(Date.now()), 90);
1787
1929
  }
1788
1930
  // Filter out null values for cleaner data
@@ -1860,7 +2002,7 @@ var Datalyr = (function (exports) {
1860
2002
  if (hasRealAttribution) {
1861
2003
  this.storeLastTouch(current);
1862
2004
  }
1863
- return Object.assign(Object.assign(Object.assign({}, current), adCookies), {
2005
+ const result = Object.assign(Object.assign(Object.assign({}, current), adCookies), {
1864
2006
  // First touch (with snake_case aliases)
1865
2007
  first_touch_source: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, first_touch_medium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, first_touch_campaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign, first_touch_timestamp: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp, firstTouchSource: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.source, firstTouchMedium: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.medium, firstTouchCampaign: firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.campaign,
1866
2008
  // Last touch (with snake_case aliases)
@@ -1871,6 +2013,21 @@ var Datalyr = (function (exports) {
1871
2013
  : 0, daysSinceFirstTouch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp)
1872
2014
  ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
1873
2015
  : 0 });
2016
+ // TR-03: strip MARKETING-scoped signals (click IDs + ad cookies) from the event payload
2017
+ // when marketing consent is declined — analytics-scoped fields (utm_*, source/medium/
2018
+ // campaign, first/last touch, _ga/_gid) stay. Live predicate → a later grant restores
2019
+ // them on the next event. Synthesis of _fbp/_fbc was already skipped in captureAdCookies.
2020
+ if (!this.isMarketingAllowed()) {
2021
+ const marketingKeys = [
2022
+ ...this.CLICK_IDS,
2023
+ ...Object.values(this.CLICK_ID_ALIASES),
2024
+ 'clickId', 'clickIdType',
2025
+ ...this.MARKETING_COOKIES,
2026
+ ];
2027
+ for (const k of marketingKeys)
2028
+ delete result[k];
2029
+ }
2030
+ return result;
1874
2031
  }
1875
2032
  /**
1876
2033
  * Determine source from attribution data
@@ -1881,13 +2038,19 @@ var Datalyr = (function (exports) {
1881
2038
  const clickIdSources = {
1882
2039
  fbclid: 'facebook',
1883
2040
  gclid: 'google',
2041
+ gbraid: 'google', // 9.A.6: Google Ads (iOS) — was falling through to 'paid'
2042
+ wbraid: 'google', // 9.A.6: Google Ads (web)
1884
2043
  ttclid: 'tiktok',
1885
2044
  msclkid: 'bing',
1886
2045
  twclid: 'twitter',
1887
2046
  li_fat_id: 'linkedin',
1888
2047
  sclid: 'snapchat',
1889
2048
  dclid: 'doubleclick',
1890
- epik: 'pinterest'
2049
+ epik: 'pinterest',
2050
+ rdt_cid: 'reddit', // 9.A.6
2051
+ obclid: 'outbrain', // 9.A.6
2052
+ irclid: 'impact', // 9.A.6: Impact Radius
2053
+ ko_click_id: 'klaviyo' // 9.A.6
1891
2054
  };
1892
2055
  return clickIdSources[attribution.clickIdType] || 'paid';
1893
2056
  }
@@ -2513,6 +2676,16 @@ var Datalyr = (function (exports) {
2513
2676
  // two of them could splice the same offlineQueue concurrently and double-send.
2514
2677
  if (!this.enabled)
2515
2678
  return;
2679
+ // TR-15: re-check consent at DRAIN time. A returning DECLINED visitor whose consent
2680
+ // signal loads asynchronously (Shopify customerPrivacy) fires no visitorConsentCollected
2681
+ // event, so `enabled` stayed at its fail-open init value. If consent now says no, latch
2682
+ // off and PURGE the persisted backlog instead of draining it (a later grant re-enables
2683
+ // via setEnabled). Mirrors the withdrawal purge in optOut()/setConsent().
2684
+ if (this.consentCheck && this.consentCheck() === false) {
2685
+ this.enabled = false;
2686
+ this.clearOffline();
2687
+ return;
2688
+ }
2516
2689
  if (Date.now() < this.rateLimitedUntil)
2517
2690
  return; // FIXED (429): honor Retry-After window
2518
2691
  if (this.offlineProcessing)
@@ -2604,10 +2777,13 @@ var Datalyr = (function (exports) {
2604
2777
  * - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
2605
2778
  * fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
2606
2779
  * neither erases the backlog on failure. The destructive beacon path is not used.
2607
- * - Terminal (unload): beacon live+offline best-effort, but PERSIST everything first
2608
- * and NEVER storage.remove() on a mere beacon enqueue. The next page load's
2609
- * response-checked drain (normal fetch, no 64KB cap) is the source of truth and
2610
- * clears the copy; server event_id dedup makes the potential double-send safe.
2780
+ * - Terminal (unload): PERSIST everything first (live + the already-persisted offline
2781
+ * backlog) and NEVER storage.remove() on a mere beacon enqueue. Beacon ONLY the LIVE
2782
+ * queue best-effort NOT the offline backlog (TR-13): the backlog is already durable and
2783
+ * the next-load drain delivers it exactly once, so beaconing it here too risked a
2784
+ * delivered-now-AND-re-drained-next-load double-send that, when >6h apart, outlives
2785
+ * ingest's dedup window → a duplicate purchase. The next page load's response-checked
2786
+ * drain (normal fetch, no 64KB cap) is the source of truth and clears the copy.
2611
2787
  * - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
2612
2788
  *
2613
2789
  * Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
@@ -2649,6 +2825,14 @@ var Datalyr = (function (exports) {
2649
2825
  }
2650
2826
  storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
2651
2827
  }
2828
+ // TR-13: beacon ONLY the LIVE queue (never-persisted events from THIS session). The
2829
+ // offline backlog is already durable and the next-load drain delivers it once, so
2830
+ // beaconing it here would double-send it — and a stale backlog event (e.g. a purchase
2831
+ // parked during an outage) re-drained on a later day lands >6h from the beacon, past
2832
+ // ingest's dedup window. The live queue's beacon + its own next-load drain both land THIS
2833
+ // session (<6h), so dedup absorbs that pair.
2834
+ if (live.length === 0)
2835
+ return; // only the persisted backlog remained → it drains next load
2652
2836
  // Within the 429 window, don't beacon into it — the persisted copy drains next load.
2653
2837
  if (Date.now() < this.rateLimitedUntil)
2654
2838
  return;
@@ -2659,7 +2843,7 @@ var Datalyr = (function (exports) {
2659
2843
  const chunks = [];
2660
2844
  let current = [];
2661
2845
  let currentBytes = 2; // approx for the JSON array/object wrapper
2662
- for (const ev of pending) {
2846
+ for (const ev of live) {
2663
2847
  // Byte length (not UTF-16 .length) so multibyte product names / emoji can't
2664
2848
  // under-count and overflow the cap.
2665
2849
  const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
@@ -2685,7 +2869,7 @@ var Datalyr = (function (exports) {
2685
2869
  break;
2686
2870
  beaconed += chunk.length;
2687
2871
  }
2688
- this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
2872
+ this.log(`forceFlush(terminal): beaconed ${beaconed}/${live.length} live events; backlog (${pending.length - live.length}) persisted for next-load drain`);
2689
2873
  });
2690
2874
  }
2691
2875
  /**
@@ -2706,6 +2890,15 @@ var Datalyr = (function (exports) {
2706
2890
  setEnabled(enabled) {
2707
2891
  this.enabled = enabled;
2708
2892
  }
2893
+ /**
2894
+ * TR-15: register a live consent predicate the offline drain re-evaluates at drain time.
2895
+ * `enabled` is latched at init to a fail-open value (Shopify's customerPrivacy loads
2896
+ * async), and a returning DECLINED visitor fires no visitorConsentCollected event — so
2897
+ * without this, their persisted backlog would drain before the decline is known.
2898
+ */
2899
+ setConsentCheck(fn) {
2900
+ this.consentCheck = fn;
2901
+ }
2709
2902
  /**
2710
2903
  * Clear the offline queue and its persisted copy (used by opt-out to purge any
2711
2904
  * PII-bearing events that were parked before the user opted out).
@@ -4298,7 +4491,11 @@ var Datalyr = (function (exports) {
4298
4491
  this.session = new SessionManager(this.config.sessionTimeout);
4299
4492
  this.attribution = new AttributionManager({
4300
4493
  attributionWindow: this.config.attributionWindow,
4301
- trackedParams: this.config.trackedParams
4494
+ trackedParams: this.config.trackedParams,
4495
+ // TR-03: gate ad-signal synthesis + shipping on LIVE marketing consent. Returns true by
4496
+ // default (no consent signal) so behavior is unchanged for the common case; false only on
4497
+ // an explicit decline (Shopify marketing:false / setConsent marketing|sale=false).
4498
+ marketingAllowed: () => this.consentAllowsMarketing()
4302
4499
  });
4303
4500
  this.queue = new EventQueue(this.config);
4304
4501
  this.fingerprint = new FingerprintCollector({
@@ -4311,11 +4508,18 @@ var Datalyr = (function (exports) {
4311
4508
  // Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
4312
4509
  // GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
4313
4510
  this.queue.setEnabled(this.shouldTrack());
4511
+ // TR-15: also give the offline drain a LIVE consent gate. setEnabled above is latched to
4512
+ // a fail-open value while Shopify's customerPrivacy loads async; a returning declined
4513
+ // visitor fires no visitorConsentCollected event, so the drain must re-check per run.
4514
+ this.queue.setConsentCheck(() => this.shouldTrack());
4314
4515
  // FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
4315
4516
  // This allows encryption to initialize before any events are tracked
4316
4517
  this.initializeAsync();
4317
4518
  // Setup page unload handler
4318
4519
  this.setupUnloadHandler();
4520
+ // Shopify Customer Privacy (9.A.1): react to the native consent banner's
4521
+ // decision mid-session (grant AND revoke), not just at the next page load.
4522
+ this.setupShopifyConsentListener();
4319
4523
  // Initialize plugins
4320
4524
  if (this.config.plugins) {
4321
4525
  for (const plugin of this.config.plugins) {
@@ -4347,6 +4551,27 @@ var Datalyr = (function (exports) {
4347
4551
  this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
4348
4552
  var _a, _b;
4349
4553
  try {
4554
+ // TR-22: capture the LANDING attribution NOW, before any await. The first read used to
4555
+ // sit inside this.page() AFTER `await container.init()` (up to a ~3s budget), so a
4556
+ // router / consent tool that strips fbclid via history.replaceState within that window
4557
+ // — or a fast bounce — lost the click id entirely (nothing persisted to first/last
4558
+ // touch until the first event). getAttributionData() warms queryParamsCache (freezing
4559
+ // the landing params) AND persists first/last touch immediately. Wrapped so a failure
4560
+ // never blocks init.
4561
+ // CONSENT (Track-3 review P1): gate the eager capture behind shouldTrack(). This call
4562
+ // synthesizes _fbp/_fbc and persists first/last touch — which must NOT happen at page
4563
+ // load for an opted-out / DNT / GPC visitor (shouldTrack()=false), or it undoes the
4564
+ // id-less-at-init privacy invariant. For a TRACKED visitor with marketing declined,
4565
+ // getAttributionData already strips the click-ids / marketing cookies (TR-03), so TR-22's
4566
+ // early-capture benefit is preserved for consenting/default visitors.
4567
+ if (this.shouldTrack()) {
4568
+ try {
4569
+ this.attribution.getAttributionData();
4570
+ }
4571
+ catch (e) {
4572
+ this.log('Eager attribution capture failed:', e);
4573
+ }
4574
+ }
4350
4575
  // SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
4351
4576
  // pageviews, or pixels. On a non-secure context (http://) or an old browser,
4352
4577
  // crypto.subtle is absent and initialize() throws; isolate it so the rest of init
@@ -4429,7 +4654,10 @@ var Datalyr = (function (exports) {
4429
4654
  // Initialize auto-identify when enabled (explicit or remote) AND
4430
4655
  // tracking is allowed. The shouldTrack() gate keeps capture from even
4431
4656
  // setting up its form/API interceptors for opted-out / DNT / GPC users.
4432
- if (this.config.autoIdentify === true && this.shouldTrack()) {
4657
+ // 9.A.1: the captured email feeds Meta advanced matching / CAPI, so a
4658
+ // declined Shopify marketing consent also blocks setup (incl. the
4659
+ // /account.json polling); null = no Shopify signal → unchanged.
4660
+ if (this.config.autoIdentify === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
4433
4661
  this.autoIdentify = new AutoIdentifyManager({
4434
4662
  enabled: true,
4435
4663
  captureFromForms: this.config.autoIdentifyForms,
@@ -4454,8 +4682,10 @@ var Datalyr = (function (exports) {
4454
4682
  // Stamp attribution signals into the Shopify cart (OPT-IN, default off).
4455
4683
  // Lets server-side order webhooks recover the browser visitor + Meta click
4456
4684
  // signals (the postback webhook reads these as note_attributes). Inert unless
4457
- // enabled; best-effort and never blocks init.
4458
- if (this.config.shopifyCartAttributes === true && this.shouldTrack()) {
4685
+ // enabled; best-effort and never blocks init. 9.A.1: the stamped visitor_id +
4686
+ // fbc/fbp/fbclid are marketing signals, so a declined Shopify marketing
4687
+ // consent blocks stamping too (null = no signal → unchanged).
4688
+ if (this.config.shopifyCartAttributes === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
4459
4689
  this.syncShopifyCartAttributes().catch((error) => {
4460
4690
  this.log('Shopify cart attribute sync failed:', error);
4461
4691
  });
@@ -4625,6 +4855,13 @@ var Datalyr = (function (exports) {
4625
4855
  return;
4626
4856
  }
4627
4857
  try {
4858
+ // A different authenticated user on the same device is a privacy
4859
+ // boundary. Rotate the anonymous/session identities and clear the prior
4860
+ // user's traits/attribution before creating the new link, even when the
4861
+ // integrator forgot to call reset() on logout.
4862
+ const currentUserId = this.identity.getUserId();
4863
+ if (currentUserId && currentUserId !== userId)
4864
+ this.reset();
4628
4865
  // FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
4629
4866
  // auth-credential concept; a client-generated analytics session_id is not one.
4630
4867
  // Rotating split every identified visit (and every auto-identify form submit) into
@@ -4687,7 +4924,9 @@ var Datalyr = (function (exports) {
4687
4924
  if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
4688
4925
  this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
4689
4926
  }
4690
- const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
4927
+ // 9.A.4: url/search/referrer are redacted (secret/PII query-param values
4928
+ // __redacted__) before they ever leave the browser — see redactUrl in utils.
4929
+ const pageData = Object.assign({ title: document.title, url: redactUrl(window.location.href), path: window.location.pathname, search: redactUrl(window.location.search), referrer: redactUrl(document.referrer) }, properties);
4691
4930
  // Add referrer data
4692
4931
  const referrerData = getReferrerData();
4693
4932
  Object.assign(pageData, referrerData);
@@ -4757,6 +4996,13 @@ var Datalyr = (function (exports) {
4757
4996
  // Gate before mutating persisted identity (identity.alias writes dl_user_id).
4758
4997
  if (!this.shouldTrack())
4759
4998
  return;
4999
+ if (previousId && previousId !== this.identity.getAnonymousId()) {
5000
+ console.warn('[Datalyr] alias() only accepts the current anonymous ID as previousId');
5001
+ return;
5002
+ }
5003
+ const currentUserId = this.identity.getUserId();
5004
+ if (currentUserId && currentUserId !== userId)
5005
+ this.reset();
4760
5006
  const aliasData = this.identity.alias(userId, previousId);
4761
5007
  this.track('$alias', aliasData);
4762
5008
  }
@@ -4912,6 +5158,27 @@ var Datalyr = (function (exports) {
4912
5158
  /**
4913
5159
  * Opt out of tracking
4914
5160
  */
5161
+ /**
5162
+ * X-1 (consent): stop the Stripe Payment Link + CheckoutChamp outbound-link decorators from
5163
+ * stamping `client_reference_id`/`prefilled_email` onto <a> hrefs once consent/marketing is
5164
+ * withdrawn. Their MutationObservers otherwise keep rewriting links after a CMP or Shopify
5165
+ * decline — and the server still reads the stamped `client_reference_id` — so attribution
5166
+ * continues post-withdrawal. Idempotent; decorators re-init on the next load if consent returns
5167
+ * (same convention as pixels/auto-identify).
5168
+ */
5169
+ disposeMarketingLinkDecorators() {
5170
+ var _a, _b;
5171
+ try {
5172
+ (_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
5173
+ }
5174
+ catch ( /* best-effort */_c) { /* best-effort */ }
5175
+ this.stripeLinksDisposer = undefined;
5176
+ try {
5177
+ (_b = this.outboundDisposer) === null || _b === void 0 ? void 0 : _b.call(this);
5178
+ }
5179
+ catch ( /* best-effort */_d) { /* best-effort */ }
5180
+ this.outboundDisposer = undefined;
5181
+ }
4915
5182
  optOut() {
4916
5183
  var _a;
4917
5184
  if (!this.initialized) {
@@ -4933,6 +5200,8 @@ var Datalyr = (function (exports) {
4933
5200
  this.container.cleanupAllIframes();
4934
5201
  this.container = undefined;
4935
5202
  }
5203
+ // X-1: stop stamping Stripe/CC outbound links with the visitor id / email.
5204
+ this.disposeMarketingLinkDecorators();
4936
5205
  // Purge PII at rest.
4937
5206
  this.userProperties = {};
4938
5207
  storage.remove('dl_user_traits');
@@ -4954,6 +5223,9 @@ var Datalyr = (function (exports) {
4954
5223
  // merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
4955
5224
  // events would start draining again. (Pixels / auto-identify resume on next load.)
4956
5225
  this.queue.setEnabled(this.shouldTrack());
5226
+ // TR-15 (P3): opt-in → persist the in-memory anon id now (see onShopifyConsentChanged).
5227
+ if (this.shouldTrack())
5228
+ this.identity.enablePersistence();
4957
5229
  this.log('User opted in');
4958
5230
  }
4959
5231
  /**
@@ -4966,6 +5238,7 @@ var Datalyr = (function (exports) {
4966
5238
  * Set consent preferences
4967
5239
  */
4968
5240
  setConsent(consent) {
5241
+ var _a;
4969
5242
  // Always record + persist consent FIRST, even before init(). FSR-51: a consent-
4970
5243
  // management platform commonly calls setConsent() before init() so tracking is gated
4971
5244
  // from the very first event. The old code then dereferenced this.queue/this.config
@@ -4986,6 +5259,22 @@ var Datalyr = (function (exports) {
4986
5259
  if (!allowed) {
4987
5260
  this.queue.clear();
4988
5261
  this.queue.clearOffline();
5262
+ // TR-14: mirror optOut() on analytics withdrawal. Previously setConsent tore down the
5263
+ // queue + container but left autoIdentify alive — its form/fetch interceptors and
5264
+ // /account.json polling kept running and triggerIdentify persisted the captured email
5265
+ // to storage AFTER withdrawal (PII newly written at rest → GDPR/consent-audit failure
5266
+ // on non-Shopify CMP installs). Destroy it and purge PII at rest. (Auto-identify
5267
+ // resumes on the next page load if consent is re-granted, matching optOut/optIn.)
5268
+ (_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
5269
+ this.autoIdentify = undefined;
5270
+ this.userProperties = {};
5271
+ storage.remove('dl_user_traits');
5272
+ storage.remove('dl_auto_identified_email');
5273
+ storage.remove('dl_journey');
5274
+ }
5275
+ else {
5276
+ // TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
5277
+ this.identity.enablePersistence();
4989
5278
  }
4990
5279
  // Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
4991
5280
  // prevent them from being initialized on the next page load. NOTE: an
@@ -4995,6 +5284,12 @@ var Datalyr = (function (exports) {
4995
5284
  this.container.cleanupAllIframes();
4996
5285
  this.container = undefined;
4997
5286
  }
5287
+ // X-1: on marketing/sale withdrawal, stop the Stripe/CC decorators from stamping the
5288
+ // visitor id / email onto outbound links (the observers otherwise keep rewriting hrefs
5289
+ // after withdrawal, and the server still reads the stamped client_reference_id).
5290
+ if (!this.consentAllowsMarketing()) {
5291
+ this.disposeMarketingLinkDecorators();
5292
+ }
4998
5293
  this.log('Consent updated:', consent);
4999
5294
  }
5000
5295
  /**
@@ -5079,91 +5374,27 @@ var Datalyr = (function (exports) {
5079
5374
  }
5080
5375
  });
5081
5376
  }
5082
- /**
5083
- * Restore _dl_* URL bridge params on a Checkout Champ funnel page.
5084
- * Runs BEFORE IdentityManager so the storefront's visitor_id wins over a
5085
- * freshly auto-generated one. Also restores _fbc / _fbp cookies and the
5086
- * fbclid click time so server-side rebuilds carry the real click moment.
5087
- *
5088
- * Strategy: stamp matching cookies (without clobbering pre-existing values),
5089
- * then rewrite the URL so `_dl_fbclid` becomes `fbclid` — that way the rest
5090
- * of the SDK's attribution layer (which reads `params.fbclid`) works
5091
- * unchanged, and any merchant analytics also see the canonical click ID.
5092
- */
5377
+ /** Strip the retired unsigned Checkout Champ bridge parameters. */
5093
5378
  restoreFromURL() {
5094
5379
  var _a;
5095
5380
  if (typeof window === "undefined" || typeof document === "undefined")
5096
5381
  return;
5097
5382
  try {
5098
5383
  const params = new URLSearchParams(window.location.search);
5099
- const get = (k) => params.get(k);
5100
- const vid = get("_dl_vid");
5101
- const fbc = get("_dl_fbc");
5102
- const fbp = get("_dl_fbp");
5103
- const fbclid = get("_dl_fbclid");
5104
- const fbclidAt = get("_dl_fbclid_at");
5105
- const gclid = get("_dl_gclid");
5106
- const gclidAt = get("_dl_gclid_at");
5107
- // visitor_id: bridge wins. The whole point of `_dl_vid` is to unify the
5108
- // storefront's session with the CC funnel session. A pre-existing local
5109
- // cookie from a prior direct CC visit would silently sink the integration
5110
- // (CC events stay on the local id; storefront events use the bridged id;
5111
- // the two never link). Overwrite — orphaned local events are fine.
5112
- if (vid)
5113
- this.cookies.set("__dl_visitor_id", vid, 365);
5114
- // Meta cookies + click-time cookies: existing wins. _fbc / _fbp may have
5115
- // been written by Meta Pixel on the CC funnel page itself (more recent
5116
- // than the bridged value); _dl_fbclid_at should record first-touch click
5117
- // time per device, not get reset by a bridge from a new campaign.
5118
- const setIfMissing = (name, value) => {
5119
- if (!value)
5120
- return;
5121
- if (this.cookies.get(name))
5122
- return;
5123
- this.cookies.set(name, value, 365);
5124
- };
5125
- setIfMissing("_fbc", fbc);
5126
- setIfMissing("_fbp", fbp);
5127
- setIfMissing("_dl_fbclid_at", fbclidAt);
5128
- setIfMissing("_dl_gclid_at", gclidAt);
5129
- // Rewrite URL: _dl_fbclid → fbclid (etc.) so captureAttribution() picks
5130
- // them up via its existing `params.fbclid` path. Strip the _dl_* params
5131
- // either way so they don't leak into downstream analytics URLs.
5132
- let rewrote = false;
5133
- const mappings = [
5134
- ["fbclid", fbclid],
5135
- ["gclid", gclid]
5136
- ];
5137
- for (const [canonical, value] of mappings) {
5138
- if (value && !params.get(canonical)) {
5139
- params.set(canonical, value);
5140
- rewrote = true;
5141
- }
5142
- }
5384
+ let changed = false;
5143
5385
  for (const k of ["_dl_vid", "_dl_fbc", "_dl_fbp", "_dl_fbclid", "_dl_fbclid_at", "_dl_gclid", "_dl_gclid_at"]) {
5144
5386
  if (params.has(k)) {
5145
5387
  params.delete(k);
5146
- rewrote = true;
5388
+ changed = true;
5147
5389
  }
5148
5390
  }
5149
- if (rewrote && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
5391
+ if (changed && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
5150
5392
  const newSearch = params.toString();
5151
- const newUrl = window.location.pathname +
5152
- (newSearch ? "?" + newSearch : "") +
5153
- window.location.hash;
5393
+ const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
5154
5394
  window.history.replaceState(window.history.state, "", newUrl);
5155
5395
  }
5156
- this.log("Checkout Champ bridge restored:", {
5157
- had_vid: !!vid,
5158
- had_fbc: !!fbc,
5159
- had_fbp: !!fbp,
5160
- had_fbclid: !!fbclid,
5161
- had_gclid: !!gclid
5162
- });
5163
5396
  }
5164
5397
  catch (error) {
5165
- // Don't let bridge restoration block init — fall through to normal SDK
5166
- // behavior (a fresh visitor_id, no restored click signals).
5167
5398
  this.log("restoreFromURL failed:", error);
5168
5399
  }
5169
5400
  }
@@ -5264,138 +5495,9 @@ var Datalyr = (function (exports) {
5264
5495
  this.log("fireCheckoutChampPurchasePixel failed:", error);
5265
5496
  }
5266
5497
  }
5267
- /**
5268
- * Storefront → Checkout Champ link stamping. Finds every `<a href>` whose host
5269
- * matches the configured CC domain list and appends `?_dl_vid=…&_dl_fbc=…&
5270
- * _dl_fbp=…&_dl_fbclid=…&_dl_fbclid_at=…&_dl_gclid=…` so the user's
5271
- * visitor_id + Meta click signals cross the domain. MutationObserver watches
5272
- * for dynamically-injected links. On click, force-flush the event queue so
5273
- * any pending track() events land before the browser navigates away.
5274
- */
5498
+ /** Checkout Champ identity bridging remains disabled pending signed tokens. */
5275
5499
  syncOutboundLinkParams(domains) {
5276
- if (typeof window === "undefined" || typeof document === "undefined")
5277
- return;
5278
- const lowerDomains = domains.map((d) => d.toLowerCase());
5279
- const matchesCcDomain = (href) => {
5280
- try {
5281
- const host = new URL(href, window.location.href).hostname.toLowerCase();
5282
- return lowerDomains.some((d) => host === d || host.endsWith("." + d));
5283
- }
5284
- catch (_a) {
5285
- return false;
5286
- }
5287
- };
5288
- const buildBridgeParams = () => {
5289
- var _a, _b;
5290
- const attribution = this.attribution.getAttributionData();
5291
- const out = {};
5292
- const vid = this.identity.getAnonymousId();
5293
- const fbc = this.cookies.get("_fbc") || attribution._fbc;
5294
- const fbp = this.cookies.get("_fbp") || attribution._fbp;
5295
- // FSR-104: read the named click-id fields directly (captureAttribution stores every
5296
- // present click id under its own key) so a landing URL carrying BOTH fbclid and
5297
- // gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
5298
- // gclid whenever fbclid was the primary, silently losing Google attribution on the
5299
- // cross-domain CC path. Fall back to the primary clickId for older stored shapes.
5300
- const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
5301
- const fbclidAt = this.cookies.get("_dl_fbclid_at");
5302
- const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
5303
- const gclidAt = this.cookies.get("_dl_gclid_at");
5304
- if (vid)
5305
- out._dl_vid = vid;
5306
- if (fbc)
5307
- out._dl_fbc = String(fbc);
5308
- if (fbp)
5309
- out._dl_fbp = String(fbp);
5310
- if (fbclid)
5311
- out._dl_fbclid = String(fbclid);
5312
- if (fbclidAt)
5313
- out._dl_fbclid_at = String(fbclidAt);
5314
- if (gclid)
5315
- out._dl_gclid = String(gclid);
5316
- if (gclidAt)
5317
- out._dl_gclid_at = String(gclidAt);
5318
- return out;
5319
- };
5320
- const stampLink = (anchor) => {
5321
- if (!anchor.href || !matchesCcDomain(anchor.href))
5322
- return;
5323
- try {
5324
- const u = new URL(anchor.href, window.location.href);
5325
- const bridge = buildBridgeParams();
5326
- let mutated = false;
5327
- for (const [k, v] of Object.entries(bridge)) {
5328
- if (!u.searchParams.get(k)) {
5329
- u.searchParams.set(k, v);
5330
- mutated = true;
5331
- }
5332
- }
5333
- if (mutated)
5334
- anchor.href = u.toString();
5335
- }
5336
- catch (_a) {
5337
- // Ignore malformed URLs — don't break the page.
5338
- }
5339
- };
5340
- const stampAll = () => {
5341
- document.querySelectorAll("a[href]").forEach((el) => stampLink(el));
5342
- };
5343
- const onClick = (e) => {
5344
- var _a, _b;
5345
- const target = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest("a[href]");
5346
- if (!target)
5347
- return;
5348
- const anchor = target;
5349
- if (!matchesCcDomain(anchor.href))
5350
- return;
5351
- stampLink(anchor); // re-stamp in case attribution changed since DOMReady
5352
- try {
5353
- (_b = this.queue) === null || _b === void 0 ? void 0 : _b.flush();
5354
- }
5355
- catch (_c) {
5356
- // Best-effort — never block navigation.
5357
- }
5358
- };
5359
- stampAll();
5360
- document.addEventListener("click", onClick, true);
5361
- try {
5362
- // Debounce re-stamping. A busy SPA (infinite scroll, animations,
5363
- // React/Vue updates) can fire thousands of mutations per second; a naive
5364
- // re-stamp on every notification would burn CPU pointlessly when
5365
- // outbound link sets only change occasionally. 150ms is small enough to
5366
- // catch links before the user can click them, large enough to coalesce
5367
- // bursts.
5368
- let restampTimer = null;
5369
- const scheduleRestamp = () => {
5370
- if (restampTimer != null)
5371
- return;
5372
- restampTimer = setTimeout(() => {
5373
- restampTimer = null;
5374
- stampAll();
5375
- }, 150);
5376
- };
5377
- const observer = new MutationObserver(scheduleRestamp);
5378
- observer.observe(document.documentElement, { childList: true, subtree: true });
5379
- // Observer + click listener live for the session; pagehide cleans up on full-page
5380
- // unload, and destroy() can tear them down early via this disposer (H2 — otherwise
5381
- // a destroy()+re-init() leaks the observer + capturing click listener).
5382
- this.outboundDisposer = () => {
5383
- try {
5384
- observer.disconnect();
5385
- }
5386
- catch ( /* idempotent */_a) { /* idempotent */ }
5387
- if (restampTimer != null) {
5388
- clearTimeout(restampTimer);
5389
- restampTimer = null;
5390
- }
5391
- document.removeEventListener("click", onClick, true);
5392
- };
5393
- window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
5394
- }
5395
- catch (error) {
5396
- this.log("MutationObserver setup failed (CC link sync):", error);
5397
- }
5398
- this.log("Checkout Champ outbound-link sync active for:", lowerDomains);
5500
+ this.log("Checkout Champ outbound identity bridge disabled (signed token required)", domains);
5399
5501
  }
5400
5502
  /**
5401
5503
  * Stripe Payment Link auto-decoration (D1). Structurally mirrors
@@ -5554,6 +5656,7 @@ var Datalyr = (function (exports) {
5554
5656
  * Create event payload
5555
5657
  */
5556
5658
  createEventPayload(eventName, properties, eventIdArg) {
5659
+ var _a;
5557
5660
  // NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
5558
5661
  // The session id changes on session timeout (and previously on identify(), removed in
5559
5662
  // FSR-53), but identity only synced it at init/start — so the top-level
@@ -5590,17 +5693,30 @@ var Datalyr = (function (exports) {
5590
5693
  device_fingerprint: fingerprintData // Use snake_case only (matches backend)
5591
5694
  });
5592
5695
  }
5593
- // Add browser context (caller wins on collisions)
5696
+ // Add browser context (caller wins on collisions). 9.A.4: url/referrer are
5697
+ // redacted — secret/PII query-param values must not ship on events.
5594
5698
  assignMissing(eventData, {
5595
- url: window.location.href,
5699
+ url: redactUrl(window.location.href),
5596
5700
  path: window.location.pathname,
5597
- referrer: document.referrer,
5701
+ referrer: redactUrl(document.referrer),
5598
5702
  title: document.title,
5599
5703
  screen_width: screen.width,
5600
5704
  screen_height: screen.height,
5601
5705
  viewport_width: window.innerWidth,
5602
5706
  viewport_height: window.innerHeight
5603
5707
  });
5708
+ // Consent travels with the event so downstream profile enrichment and ad
5709
+ // postbacks enforce the decision that applied at occurrence time. The SDK
5710
+ // snapshot is authoritative over caller properties.
5711
+ const consentSnapshot = Object.assign({}, ((_a = this.consent) !== null && _a !== void 0 ? _a : {}));
5712
+ const shopifyAnalytics = this.shopifyAnalyticsConsent();
5713
+ const shopifyMarketing = this.shopifyMarketingConsent();
5714
+ if (typeof shopifyAnalytics === 'boolean')
5715
+ consentSnapshot.analytics = shopifyAnalytics;
5716
+ if (typeof shopifyMarketing === 'boolean')
5717
+ consentSnapshot.marketing = shopifyMarketing;
5718
+ if (Object.keys(consentSnapshot).length > 0)
5719
+ eventData.consent = consentSnapshot;
5604
5720
  // Create payload using snake_case only (matches backend API and production script)
5605
5721
  const identityFields = this.identity.getIdentityFields();
5606
5722
  // Use the caller-provided event_id (shared with the Meta Pixel co-fire for
@@ -5629,11 +5745,17 @@ var Datalyr = (function (exports) {
5629
5745
  // Resolution metadata
5630
5746
  resolution_method: 'browser_sdk',
5631
5747
  resolution_confidence: 1.0,
5632
- // SDK metadata. MUST stay in sync with package.json "version" the build guard
5633
- // (scripts/check-bundle.js, run by build:check) fails the build if the bundle's
5634
- // sdk_version doesn't match package.json. (FSR-103)
5635
- sdk_version: '1.7.4',
5636
- sdk_name: 'datalyr-web-sdk'
5748
+ // SDK metadata. The placeholder is replaced with package.json "version" at build
5749
+ // time (rollup.config.js injectSdkVersion 9.A.3), so the literal can never
5750
+ // drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
5751
+ // build guard (scripts/check-bundle.js, run by build:check) still verifies the
5752
+ // deployable bundles carry the package.json version. (FSR-103)
5753
+ sdk_version: '1.7.6',
5754
+ sdk_name: 'datalyr-web-sdk',
5755
+ // A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
5756
+ // layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
5757
+ // keys, canonical click-ids, clamped client timestamp — all now converged across SDKs).
5758
+ schema_version: 1
5637
5759
  };
5638
5760
  return payload;
5639
5761
  }
@@ -5649,6 +5771,14 @@ var Datalyr = (function (exports) {
5649
5771
  if (this.consent && this.consent.analytics === false) {
5650
5772
  return false;
5651
5773
  }
5774
+ // Shopify Customer Privacy (9.A.1 — LEGAL): on a Shopify storefront, a shopper who
5775
+ // declined the native consent banner must not be tracked, even when the merchant
5776
+ // never wired setConsent(). null = API absent (non-Plus store not using it, or
5777
+ // script loaded off-Shopify) → fall through. A detected Shopify storefront
5778
+ // fails closed while the asynchronous Customer Privacy API is unresolved.
5779
+ if (this.shopifyAnalyticsConsent() === false) {
5780
+ return false;
5781
+ }
5652
5782
  // Check Do Not Track
5653
5783
  if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
5654
5784
  return false;
@@ -5665,8 +5795,185 @@ var Datalyr = (function (exports) {
5665
5795
  * pixels (which share data). No consent set = allowed (default).
5666
5796
  */
5667
5797
  consentAllowsMarketing() {
5798
+ // Shopify Customer Privacy (9.A.1): a declined native banner blocks marketing use
5799
+ // (pixel loads, click-id/email forwarding) even with no setConsent() call.
5800
+ // null = no signal → defer to the setConsent-based policy below, unchanged.
5801
+ if (this.shopifyMarketingConsent() === false) {
5802
+ return false;
5803
+ }
5668
5804
  return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
5669
5805
  }
5806
+ /**
5807
+ * Shopify Customer Privacy API handle (9.A.1), or null when it doesn't apply.
5808
+ *
5809
+ * X-2 (LEGAL): gated on RUNTIME detection of `window.Shopify.customerPrivacy`, NOT on
5810
+ * `config.platform === 'shopify'`. A Shopify merchant who installs via a plain <script>
5811
+ * snippet or a headless storefront (no `platform:'shopify'`) still exposes customerPrivacy;
5812
+ * gating on config.platform meant a shopper who DECLINED the native banner (with no
5813
+ * setConsent() wired) was fully tracked — nullifying the consent feature for a whole install
5814
+ * class, and unfixable server-side (platform is install-time only). Every access is wrapped —
5815
+ * a Shopify API shape change (or a non-Shopify site) must NEVER break tracking (→ null = fail open).
5816
+ */
5817
+ getShopifyCustomerPrivacy() {
5818
+ var _a, _b;
5819
+ if (typeof window === 'undefined')
5820
+ return null;
5821
+ try {
5822
+ return (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.customerPrivacy) !== null && _b !== void 0 ? _b : null;
5823
+ }
5824
+ catch (_c) {
5825
+ return null;
5826
+ }
5827
+ }
5828
+ isShopifyStorefront() {
5829
+ var _a;
5830
+ if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.platform) === 'shopify')
5831
+ return true;
5832
+ if (typeof window === 'undefined')
5833
+ return false;
5834
+ try {
5835
+ return Boolean(window.Shopify) ||
5836
+ window.location.hostname.toLowerCase().endsWith('.myshopify.com');
5837
+ }
5838
+ catch (_b) {
5839
+ return false;
5840
+ }
5841
+ }
5842
+ /**
5843
+ * Shopify's analytics-consent signal: true/false when the Customer Privacy API is
5844
+ * present and answers, null when there's no signal (API absent / unexpected shape)
5845
+ * — null means "no restriction from Shopify", i.e. today's behavior.
5846
+ */
5847
+ shopifyAnalyticsConsent() {
5848
+ try {
5849
+ const cp = this.getShopifyCustomerPrivacy();
5850
+ if (!cp)
5851
+ return this.isShopifyStorefront() ? false : null;
5852
+ if (typeof cp.analyticsProcessingAllowed === 'function') {
5853
+ const allowed = cp.analyticsProcessingAllowed();
5854
+ return typeof allowed === 'boolean' ? allowed : null;
5855
+ }
5856
+ // Older API surface: the aggregate "can this visitor be tracked" signal.
5857
+ if (typeof cp.userCanBeTracked === 'function') {
5858
+ const allowed = cp.userCanBeTracked();
5859
+ return typeof allowed === 'boolean' ? allowed : null;
5860
+ }
5861
+ return this.isShopifyStorefront() ? false : null;
5862
+ }
5863
+ catch (_a) {
5864
+ return this.isShopifyStorefront() ? false : null;
5865
+ }
5866
+ }
5867
+ /**
5868
+ * Shopify's marketing-consent signal (gates pixels, click-id/email → CAPI, cart
5869
+ * attribute stamping, auto-identify email capture). Same null semantics as
5870
+ * shopifyAnalyticsConsent().
5871
+ */
5872
+ shopifyMarketingConsent() {
5873
+ try {
5874
+ const cp = this.getShopifyCustomerPrivacy();
5875
+ if (!cp || typeof cp.marketingAllowed !== 'function') {
5876
+ return this.isShopifyStorefront() ? false : null;
5877
+ }
5878
+ const allowed = cp.marketingAllowed();
5879
+ return typeof allowed === 'boolean'
5880
+ ? allowed
5881
+ : (this.isShopifyStorefront() ? false : null);
5882
+ }
5883
+ catch (_a) {
5884
+ return this.isShopifyStorefront() ? false : null;
5885
+ }
5886
+ }
5887
+ /**
5888
+ * Listen for Shopify's `visitorConsentCollected` document event (9.A.1) so a
5889
+ * consent decision made mid-session takes effect without a reload. Revocation is
5890
+ * enforced immediately, mirroring setConsent(): queue gated + purged, pixels torn
5891
+ * down, auto-identify (email capture + /account.json polling) destroyed. On grant
5892
+ * the queue re-enables (track() re-checks shouldTrack() per event anyway) and cart
5893
+ * attribute stamping runs; pixels / auto-identify resume on the next page load —
5894
+ * the same convention as optIn().
5895
+ */
5896
+ setupShopifyConsentListener() {
5897
+ // X-2: NOT gated on config.platform — a plain-snippet/headless Shopify install must also honor
5898
+ // a mid-session consent decision. The `visitorConsentCollected` event only fires on Shopify
5899
+ // storefronts, and the handler + loadFeatures are fully guarded, so this is a safe no-op
5900
+ // everywhere else.
5901
+ if (typeof document === 'undefined')
5902
+ return;
5903
+ try {
5904
+ this.shopifyConsentHandler = () => {
5905
+ try {
5906
+ this.onShopifyConsentChanged();
5907
+ }
5908
+ catch (error) {
5909
+ this.log('Shopify consent change handling failed:', error);
5910
+ }
5911
+ };
5912
+ document.addEventListener('visitorConsentCollected', this.shopifyConsentHandler);
5913
+ // TR-15: customerPrivacy loads ASYNCHRONOUSLY. Until it's present shopifyAnalyticsConsent()
5914
+ // returns null (fail-open → events send in the pre-load window). Force the feature to load
5915
+ // and re-run the full consent evaluation in the callback so a declined visitor is gated
5916
+ // (queue disabled + purged) as soon as the API answers, without waiting for a reload or a
5917
+ // banner interaction. Guarded — a missing/changed loadFeatures must never break tracking.
5918
+ const shopify = window.Shopify;
5919
+ if (shopify && typeof shopify.loadFeatures === 'function') {
5920
+ shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
5921
+ if (error) {
5922
+ this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
5923
+ return;
5924
+ }
5925
+ try {
5926
+ this.onShopifyConsentChanged();
5927
+ }
5928
+ catch (e) {
5929
+ this.log('Shopify post-load consent eval failed:', e);
5930
+ }
5931
+ });
5932
+ }
5933
+ }
5934
+ catch (error) {
5935
+ this.log('Shopify consent listener setup failed:', error);
5936
+ }
5937
+ }
5938
+ onShopifyConsentChanged() {
5939
+ const allowed = this.shouldTrack();
5940
+ this.queue.setEnabled(allowed);
5941
+ // TR-15 (P3): a mid-session grant must persist the in-memory anon id NOW — otherwise a
5942
+ // visitor declined at init keeps a memory-only id and this session's events land under a
5943
+ // visitor_id that vanishes on the next page load. Idempotent.
5944
+ if (allowed)
5945
+ this.identity.enablePersistence();
5946
+ if (!allowed) {
5947
+ // Mirror setConsent() withdrawal: purge buffered events so events captured
5948
+ // before the decline can't drain if consent is later re-granted.
5949
+ this.queue.clear();
5950
+ this.queue.clearOffline();
5951
+ }
5952
+ const marketingBlocked = this.shopifyMarketingConsent() === false;
5953
+ // Stop email capture (form interceptors + /account.json polling) immediately.
5954
+ if ((!allowed || marketingBlocked) && this.autoIdentify) {
5955
+ this.autoIdentify.destroy();
5956
+ this.autoIdentify = undefined;
5957
+ }
5958
+ // Same caveat as setConsent(): an already-injected pixel global (fbq/gtag/ttq)
5959
+ // keeps running in the page; full removal is on reload.
5960
+ if (!this.consentAllowsMarketing() && this.container) {
5961
+ this.container.cleanupAllIframes();
5962
+ this.container = undefined;
5963
+ }
5964
+ // X-1: marketing withdrawn (Shopify decline) → stop stamping Stripe/CC outbound links.
5965
+ if (!this.consentAllowsMarketing()) {
5966
+ this.disposeMarketingLinkDecorators();
5967
+ }
5968
+ // Grant direction: cart-attribute stamping is cheap and idempotent (merges via
5969
+ // /cart/update.js), so run it now instead of waiting for the next page load.
5970
+ if (allowed && !marketingBlocked && this.config.shopifyCartAttributes === true) {
5971
+ this.syncShopifyCartAttributes().catch((error) => {
5972
+ this.log('Shopify cart attribute sync failed:', error);
5973
+ });
5974
+ }
5975
+ this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
5976
+ }
5670
5977
  /**
5671
5978
  * Setup SPA tracking
5672
5979
  * Fixed Issue #15: Store original methods for cleanup
@@ -5767,7 +6074,7 @@ var Datalyr = (function (exports) {
5767
6074
  stack: error.stack,
5768
6075
  context,
5769
6076
  timestamp: new Date().toISOString(),
5770
- url: window.location.href
6077
+ url: redactUrl(window.location.href) // 9.A.4: no secret query values in error logs
5771
6078
  };
5772
6079
  this.errors.push(errorInfo);
5773
6080
  // Keep only recent errors
@@ -5849,6 +6156,10 @@ var Datalyr = (function (exports) {
5849
6156
  window.removeEventListener('visibilitychange', this.visibilityHandler);
5850
6157
  this.visibilityHandler = undefined;
5851
6158
  }
6159
+ if (this.shopifyConsentHandler) {
6160
+ document.removeEventListener('visitorConsentCollected', this.shopifyConsentHandler);
6161
+ this.shopifyConsentHandler = undefined;
6162
+ }
5852
6163
  if (this.outboundDisposer) {
5853
6164
  this.outboundDisposer();
5854
6165
  this.outboundDisposer = undefined;
@@ -5890,8 +6201,14 @@ var Datalyr = (function (exports) {
5890
6201
  this.log('SDK destroyed');
5891
6202
  }
5892
6203
  }
5893
- // Create singleton instance
5894
- const datalyr = new Datalyr();
6204
+ // Create singleton instance. TR-23: reuse an existing window.datalyr (a prior tag's instance —
6205
+ // Shopify App Embed + a manual snippet coexisting, or a double-included bundle) instead of
6206
+ // constructing a SECOND one. Two instances each patch history.pushState and fire their own
6207
+ // pageviews (distinct event_ids → no server-side dedup). A plain truthy check (NOT instanceof):
6208
+ // two <script> tags run two separate IIFEs whose Datalyr classes are structurally identical but
6209
+ // distinct objects, so instanceof would fail across them. With one shared instance, the second
6210
+ // tag's bootstrap init() hits the instance-level `initialized` guard and no-ops.
6211
+ const datalyr = (typeof window !== 'undefined' && window.datalyr) || new Datalyr();
5895
6212
  // Expose global API
5896
6213
  if (typeof window !== 'undefined') {
5897
6214
  window.datalyr = datalyr;