@datalyr/web 1.7.4 → 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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @datalyr/web v1.7.4
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
@@ -791,6 +791,125 @@ function sanitizeEventData(data, maxDepth = 5, currentDepth = 0) {
791
791
  }
792
792
  return data;
793
793
  }
794
+ // 9.A.4: query-param NAMES whose VALUES are secrets/PII — password-reset tokens,
795
+ // OAuth codes, magic-link sessions, emails/phones prefilled into URLs. Events ship
796
+ // `url` / `referrer` / `landingPage` with the FULL query string (sanitizeEventData
797
+ // only strips property KEYS, not URL param values), so /reset?token=…&email=… leaked
798
+ // verbatim into events and onward to ad platforms. Matched case-insensitively.
799
+ const REDACTED_URL_PARAMS = new Set([
800
+ 'token', 'access_token', 'refresh_token', 'id_token', 'auth', 'authorization',
801
+ 'password', 'pass', 'pwd', 'secret', 'api_key', 'apikey', 'key',
802
+ 'session', 'session_id', 'sid', 'email', 'e', 'phone', 'tel',
803
+ 'signature', 'sig', 'otp', 'reset', 'hash'
804
+ ]);
805
+ // BATCH-2(e): `code` is CONDITIONAL, not in the always-redact set above. An OAuth
806
+ // authorization code (account-takeover-grade if it leaks to ad platforms) ALWAYS travels
807
+ // with an OAuth-flow marker in the same query/fragment — the response carries `code`+`state`
808
+ // (+`scope`/`session_state`/`iss`), the authorize request carries `client_id`/`redirect_uri`/
809
+ // `response_type`/`code_challenge`. A coupon/referral/product code (`?code=SUMMER20`) — a real
810
+ // attribution signal for the DTC/Shopify ICP — never does. So we redact `code` ONLY when a
811
+ // marker co-occurs in the same k=v string, preserving marketing codes. (A bare `code=` with no
812
+ // marker is syntactically indistinguishable from a coupon code and is kept; state-less OAuth
813
+ // flows are rare and CSRF-unsafe.) Markers are deliberately OAuth-specific to avoid redacting
814
+ // legitimate codes — `prompt`/`authuser` are excluded as too generic.
815
+ const OAUTH_CODE_CONTEXT_MARKERS = new Set([
816
+ 'state', 'client_id', 'redirect_uri', 'response_type', 'grant_type',
817
+ 'code_challenge', 'session_state', 'scope', 'iss',
818
+ // Token family: the presence of any bearer/OAuth token means a bare `code` in the same
819
+ // string is almost certainly an auth code, not a coupon — a marketing URL never carries
820
+ // these. (These values are independently redacted too; this only governs `code`.)
821
+ 'access_token', 'id_token', 'refresh_token', 'token'
822
+ ]);
823
+ const REDACTED_URL_VALUE = '__redacted__';
824
+ // Redact denylisted keys in one `k=v&k=v` string (query OR fragment). Returns the
825
+ // re-encoded string and whether anything was rewritten (so the caller can preserve the
826
+ // exact original bytes when nothing matched).
827
+ function redactKvPairs(s) {
828
+ const params = new URLSearchParams(s);
829
+ let mutated = false;
830
+ const keysLower = Array.from(new Set(params.keys())).map(k => k.toLowerCase());
831
+ // `code` co-occurrence check (BATCH-2e) — evaluated per-string, since an OAuth flow puts
832
+ // `code` and its marker in the SAME query (or the SAME fragment).
833
+ const hasOAuthMarker = keysLower.some(k => OAUTH_CODE_CONTEXT_MARKERS.has(k));
834
+ // Snapshot the keys first — set() collapses duplicate keys mid-iteration.
835
+ for (const key of Array.from(new Set(params.keys()))) {
836
+ const lower = key.toLowerCase();
837
+ const sensitive = lower === 'code'
838
+ ? hasOAuthMarker
839
+ : REDACTED_URL_PARAMS.has(lower);
840
+ if (sensitive) {
841
+ params.set(key, REDACTED_URL_VALUE);
842
+ mutated = true;
843
+ }
844
+ }
845
+ return { out: params.toString(), mutated };
846
+ }
847
+ /**
848
+ * Redact secret/PII param VALUES in a URL (9.A.4). The param NAME is kept
849
+ * (value → `__redacted__`) so funnel steps that match on the param's presence still
850
+ * work; every other param — click IDs (fbclid/gclid/…) and utm_* — survives untouched.
851
+ * Both the query string AND a k=v fragment are rewritten: TR-04 — OAuth-implicit /
852
+ * Supabase magic links carry the session in the FRAGMENT (`/welcome#access_token=eyJ…`),
853
+ * with no query string at all; the old code returned early on a missing `?` and reattached
854
+ * the fragment verbatim, leaking the full JWT into `url` / `landingPage` / `dl_first_touch`
855
+ * (90d) and onward to ad platforms. Non-k=v fragments (`#section`, `#/spa-route`) keep their
856
+ * shape. Returns the input unchanged when nothing sensitive matched or on any parse
857
+ * failure — redaction must never break tracking.
858
+ */
859
+ function redactUrl(url) {
860
+ if (!url || typeof url !== 'string')
861
+ return url;
862
+ const hashStart = url.indexOf('#');
863
+ const qIdx = url.indexOf('?');
864
+ // A query string exists only when '?' appears BEFORE the fragment — a '?' after '#' is
865
+ // part of the fragment per the URL grammar (e.g. a SPA hash-route `#/reset?token=…`).
866
+ const queryStart = (qIdx !== -1 && (hashStart === -1 || qIdx < hashStart)) ? qIdx : -1;
867
+ if (queryStart === -1 && hashStart === -1)
868
+ return url; // nothing to redact
869
+ try {
870
+ const base = url.slice(0, queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length));
871
+ const query = queryStart === -1 ? '' : url.slice(queryStart + 1, hashStart === -1 ? url.length : hashStart);
872
+ const rawFragment = hashStart === -1 ? '' : url.slice(hashStart + 1);
873
+ let mutated = false;
874
+ let queryOut = query;
875
+ if (query) {
876
+ const r = redactKvPairs(query);
877
+ if (r.mutated) {
878
+ queryOut = r.out;
879
+ mutated = true;
880
+ }
881
+ }
882
+ // TR-04: redact the fragment when it carries k=v pairs. Two shapes: a pure implicit-flow
883
+ // fragment (`#access_token=…&refresh_token=…`) and a SPA hash-route with its own query
884
+ // (`#/reset?access_token=…`). Leave non-k=v fragments (`#section`, `#/route`) untouched.
885
+ let fragmentOut = rawFragment;
886
+ if (rawFragment) {
887
+ const fragQ = rawFragment.indexOf('?');
888
+ if (fragQ !== -1) {
889
+ const r = redactKvPairs(rawFragment.slice(fragQ + 1));
890
+ if (r.mutated) {
891
+ fragmentOut = rawFragment.slice(0, fragQ + 1) + r.out;
892
+ mutated = true;
893
+ }
894
+ }
895
+ else if (/[\w-]+=/.test(rawFragment)) {
896
+ const r = redactKvPairs(rawFragment);
897
+ if (r.mutated) {
898
+ fragmentOut = r.out;
899
+ mutated = true;
900
+ }
901
+ }
902
+ }
903
+ if (!mutated)
904
+ return url; // nothing sensitive matched → ship the exact original bytes
905
+ return base
906
+ + (queryStart === -1 ? '' : '?' + queryOut)
907
+ + (hashStart === -1 ? '' : '#' + fragmentOut);
908
+ }
909
+ catch (_a) {
910
+ return url; // malformed URL → ship as-is rather than drop/break the event
911
+ }
912
+ }
794
913
  /**
795
914
  * Deep merge objects
796
915
  */
@@ -921,16 +1040,18 @@ function getReferrerData() {
921
1040
  return {};
922
1041
  try {
923
1042
  const url = new URL(referrer);
1043
+ // 9.A.4: the referrer can be our own /reset?token=… or an OAuth callback —
1044
+ // redact secret/PII param values before they're stamped onto the event.
924
1045
  return {
925
- referrer,
1046
+ referrer: redactUrl(referrer),
926
1047
  referrer_host: url.hostname,
927
1048
  referrer_path: url.pathname,
928
- referrer_search: url.search,
1049
+ referrer_search: redactUrl(url.search),
929
1050
  referrer_source: detectReferrerSource(url.hostname)
930
1051
  };
931
1052
  }
932
1053
  catch (_a) {
933
- return { referrer };
1054
+ return { referrer: redactUrl(referrer) };
934
1055
  }
935
1056
  }
936
1057
  /**
@@ -991,17 +1112,13 @@ class IdentityManager {
991
1112
  this.setRootDomainCookie('__dl_visitor_id', anonymousId);
992
1113
  return anonymousId;
993
1114
  }
994
- // 2. Fresh visitor (no persisted id): accept a cross-domain bridge id from the URL,
995
- // but ONLY a well-formed anon_<uuid> reject arbitrary / oversized values (a
996
- // multi-KB or attacker-crafted _dl_vid was previously persisted verbatim). Strip
997
- // it from the address bar so a re-shared URL can't merge the next visitor. (FSR-50)
1115
+ // 2. Raw URL visitor IDs are never identity authority. A syntactically
1116
+ // valid ID is still replayable/attacker-chosen and can merge two people.
1117
+ // Strip the legacy parameter and generate a fresh local identity below.
998
1118
  try {
999
1119
  const urlParams = new URLSearchParams(window.location.search);
1000
- const urlVisitorId = urlParams.get('_dl_vid');
1001
- if (urlVisitorId && this.isValidAnonymousId(urlVisitorId)) {
1120
+ if (urlParams.has('_dl_vid')) {
1002
1121
  this.stripUrlParam('_dl_vid');
1003
- this.persistAnonymousId(urlVisitorId);
1004
- return urlVisitorId;
1005
1122
  }
1006
1123
  }
1007
1124
  catch (e) {
@@ -1025,12 +1142,17 @@ class IdentityManager {
1025
1142
  storage.set('dl_anonymous_id', id);
1026
1143
  }
1027
1144
  /**
1028
- * Whether a `_dl_vid` value is a well-formed Datalyr anonymous id (anon_ + UUID).
1029
- * The length is bounded by the pattern, so an oversized/garbage value is rejected.
1030
- * (FSR-50)
1145
+ * TR-15 / FSR-107: tracking became allowed mid-session (optIn / consent grant). Start
1146
+ * persisting AND flush the current in-memory anon id to the cookie + localStorage now.
1147
+ * Without this, a visitor declined at init keeps a memory-only id, so that session's
1148
+ * events land under a visitor_id that vanishes on the next page load (attribution
1149
+ * fragmentation). Idempotent — a no-op once already persisting.
1031
1150
  */
1032
- isValidAnonymousId(id) {
1033
- 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);
1151
+ enablePersistence() {
1152
+ if (this.persistNewId)
1153
+ return;
1154
+ this.persistNewId = true;
1155
+ this.persistAnonymousId(this.anonymousId);
1034
1156
  }
1035
1157
  /**
1036
1158
  * Remove a query param from the current URL via history.replaceState (best-effort).
@@ -1521,6 +1643,10 @@ class AttributionManager {
1521
1643
  this.CLICK_ID_ALIASES = {
1522
1644
  ScCid: 'sclid',
1523
1645
  sccid: 'sclid',
1646
+ // Impact's real landing param is `irclickid`; capture it and normalize to the canonical
1647
+ // `irclid` that ingest / the MV / the source map (`irclid→impact`) all key on. Without
1648
+ // this, real Impact affiliate clicks were dark on web (only bare `irclid` was captured).
1649
+ irclickid: 'irclid',
1524
1650
  };
1525
1651
  // Default tracked params matching dl.js
1526
1652
  this.DEFAULT_TRACKED_PARAMS = [
@@ -1531,9 +1657,18 @@ class AttributionManager {
1531
1657
  'medium', // Generic medium (non-UTM)
1532
1658
  'gad_source' // Google Ads source parameter
1533
1659
  ];
1660
+ // TR-03: ad-cookie fields (Meta/Google Ads/TikTok/Snap) that are MARKETING-scoped. When
1661
+ // marketing consent is declined these are neither synthesized/written nor shipped. Google
1662
+ // Analytics cookies (_ga/_gid) and utm_* stay — they're analytics-scoped.
1663
+ this.MARKETING_COOKIES = ['_fbp', '_fbc', '_gcl_aw', '_gcl_dc', '_gcl_gb', '_gcl_ha', '_gac', '_ttp', '_ttc', '_scid'];
1534
1664
  this.attributionWindow = options.attributionWindow || 90 * 24 * 60 * 60 * 1000; // 90 days (increased from 30 for B2B sales cycles)
1535
1665
  // Merge default tracked params with user-provided ones
1536
1666
  this.trackedParams = [...this.DEFAULT_TRACKED_PARAMS, ...(options.trackedParams || [])];
1667
+ this.marketingAllowedFn = options.marketingAllowed;
1668
+ }
1669
+ // TR-03: default (no predicate / no signal) = allowed → byte-identical to prior behavior.
1670
+ isMarketingAllowed() {
1671
+ return this.marketingAllowedFn ? this.marketingAllowedFn() !== false : true;
1537
1672
  }
1538
1673
  /**
1539
1674
  * Clear query params cache (called on page navigation)
@@ -1604,13 +1739,15 @@ class AttributionManager {
1604
1739
  attribution[param] = value;
1605
1740
  }
1606
1741
  }
1607
- // Capture referrer
1742
+ // Capture referrer. 9.A.4: redact secret/PII query-param values (reset tokens,
1743
+ // OAuth codes, emails) — this attribution object is stamped onto every event AND
1744
+ // persisted into dl_first_touch/dl_last_touch, so it must be clean at capture.
1608
1745
  if (document.referrer) {
1609
- attribution.referrer = document.referrer;
1746
+ attribution.referrer = redactUrl(document.referrer);
1610
1747
  attribution.referrerHost = this.extractHostname(document.referrer);
1611
1748
  }
1612
- // Capture landing page
1613
- attribution.landingPage = window.location.href;
1749
+ // Capture landing page (redacted — see referrer note above)
1750
+ attribution.landingPage = redactUrl(window.location.href);
1614
1751
  attribution.landingPath = window.location.pathname;
1615
1752
  // Determine source if not explicitly set
1616
1753
  if (!attribution.source) {
@@ -1706,6 +1843,10 @@ class AttributionManager {
1706
1843
  captureAdCookies() {
1707
1844
  var _a, _b;
1708
1845
  const adCookies = {};
1846
+ // TR-03: when marketing consent is declined, do NOT synthesize/write the Meta/Google ad
1847
+ // cookies below (writing _fbp/_fbc is active marketing use). Existing cookies are still
1848
+ // read here but stripped from the event payload in getAttributionData().
1849
+ const marketingAllowed = this.isMarketingAllowed();
1709
1850
  // Facebook/Meta cookies
1710
1851
  adCookies._fbp = cookies.get('_fbp');
1711
1852
  adCookies._fbc = cookies.get('_fbc');
@@ -1724,8 +1865,8 @@ class AttributionManager {
1724
1865
  // Snapchat first-party cookie (Snap Pixel sets _scid). Flows to the server as
1725
1866
  // cookies._scid and is sent on the Snap CAPI event as sc_cookie1 (raw).
1726
1867
  adCookies._scid = cookies.get('_scid');
1727
- // Generate _fbp if missing (Facebook browser ID)
1728
- if (!adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1868
+ // Generate _fbp if missing (Facebook browser ID). TR-03: only when marketing is allowed.
1869
+ if (marketingAllowed && !adCookies._fbp && (this.hasClickId('fbclid') || adCookies._fbc)) {
1729
1870
  const timestamp = Date.now();
1730
1871
  // Meta's _fbp format is fb.1.<creationTimeMs>.<randomNumber> where the last
1731
1872
  // segment MUST be a decimal integer. The old base36 string was non-conformant —
@@ -1749,7 +1890,8 @@ class AttributionManager {
1749
1890
  // Shopify cart / server-side rebuild forward it as a time, so its format must not
1750
1891
  // change.
1751
1892
  const fbclid = this.getCurrentFbclid();
1752
- if (fbclid) {
1893
+ // TR-03: skip _fbc synthesis/refresh (cookie write) when marketing is declined.
1894
+ if (marketingAllowed && fbclid) {
1753
1895
  const existingFbc = adCookies._fbc; // captured above (cookies.get('_fbc'))
1754
1896
  const embeddedFbclid = this.extractFbclidFromFbc(existingFbc);
1755
1897
  if (!existingFbc) {
@@ -1779,7 +1921,7 @@ class AttributionManager {
1779
1921
  // (gclid has no synthesized-and-going-stale artifact like _fbc, and Google does not
1780
1922
  // validate a creationTime window the way Meta does, so this stays once-only.)
1781
1923
  const gclid = this.hasClickId('gclid') ? ((_b = (_a = this.queryParamsCache) === null || _a === void 0 ? void 0 : _a.gclid) !== null && _b !== void 0 ? _b : null) : null;
1782
- if (gclid && !cookies.get('_dl_gclid_at')) {
1924
+ if (marketingAllowed && gclid && !cookies.get('_dl_gclid_at')) {
1783
1925
  cookies.set('_dl_gclid_at', String(Date.now()), 90);
1784
1926
  }
1785
1927
  // Filter out null values for cleaner data
@@ -1857,7 +1999,7 @@ class AttributionManager {
1857
1999
  if (hasRealAttribution) {
1858
2000
  this.storeLastTouch(current);
1859
2001
  }
1860
- return Object.assign(Object.assign(Object.assign({}, current), adCookies), {
2002
+ const result = Object.assign(Object.assign(Object.assign({}, current), adCookies), {
1861
2003
  // First touch (with snake_case aliases)
1862
2004
  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,
1863
2005
  // Last touch (with snake_case aliases)
@@ -1868,6 +2010,21 @@ class AttributionManager {
1868
2010
  : 0, daysSinceFirstTouch: (firstTouch === null || firstTouch === void 0 ? void 0 : firstTouch.timestamp)
1869
2011
  ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
1870
2012
  : 0 });
2013
+ // TR-03: strip MARKETING-scoped signals (click IDs + ad cookies) from the event payload
2014
+ // when marketing consent is declined — analytics-scoped fields (utm_*, source/medium/
2015
+ // campaign, first/last touch, _ga/_gid) stay. Live predicate → a later grant restores
2016
+ // them on the next event. Synthesis of _fbp/_fbc was already skipped in captureAdCookies.
2017
+ if (!this.isMarketingAllowed()) {
2018
+ const marketingKeys = [
2019
+ ...this.CLICK_IDS,
2020
+ ...Object.values(this.CLICK_ID_ALIASES),
2021
+ 'clickId', 'clickIdType',
2022
+ ...this.MARKETING_COOKIES,
2023
+ ];
2024
+ for (const k of marketingKeys)
2025
+ delete result[k];
2026
+ }
2027
+ return result;
1871
2028
  }
1872
2029
  /**
1873
2030
  * Determine source from attribution data
@@ -1878,13 +2035,19 @@ class AttributionManager {
1878
2035
  const clickIdSources = {
1879
2036
  fbclid: 'facebook',
1880
2037
  gclid: 'google',
2038
+ gbraid: 'google', // 9.A.6: Google Ads (iOS) — was falling through to 'paid'
2039
+ wbraid: 'google', // 9.A.6: Google Ads (web)
1881
2040
  ttclid: 'tiktok',
1882
2041
  msclkid: 'bing',
1883
2042
  twclid: 'twitter',
1884
2043
  li_fat_id: 'linkedin',
1885
2044
  sclid: 'snapchat',
1886
2045
  dclid: 'doubleclick',
1887
- epik: 'pinterest'
2046
+ epik: 'pinterest',
2047
+ rdt_cid: 'reddit', // 9.A.6
2048
+ obclid: 'outbrain', // 9.A.6
2049
+ irclid: 'impact', // 9.A.6: Impact Radius
2050
+ ko_click_id: 'klaviyo' // 9.A.6
1888
2051
  };
1889
2052
  return clickIdSources[attribution.clickIdType] || 'paid';
1890
2053
  }
@@ -2298,16 +2461,22 @@ class EventQueue {
2298
2461
  // so only request it when the body is safely under the cap; oversized batches go via
2299
2462
  // a normal fetch (no cap) and succeed.
2300
2463
  const useKeepalive = new Blob([body]).size <= 60000;
2464
+ // First-party endpoint (the customer's own tracking host, never *.datalyr.com — set
2465
+ // by the script-origin auto-derive): send the cookie cross-subdomain so the edge can
2466
+ // read/refresh the server-set, ITP-durable visitor_id. Legacy datalyr.com endpoints
2467
+ // stay credential-less — unchanged behavior for every existing install.
2468
+ let firstPartyEndpoint = false;
2301
2469
  try {
2302
- const response = yield fetch(currentEndpoint, {
2303
- method: 'POST',
2304
- headers: {
2470
+ firstPartyEndpoint = !/(^|\.)datalyr\.com$/i.test(new URL(currentEndpoint).hostname);
2471
+ }
2472
+ catch (_a) {
2473
+ /* malformed endpoint — leave false */
2474
+ }
2475
+ try {
2476
+ const response = yield fetch(currentEndpoint, Object.assign({ method: 'POST', headers: {
2305
2477
  'Content-Type': 'application/json',
2306
2478
  'X-Batch-Size': events.length.toString()
2307
- },
2308
- body,
2309
- keepalive: useKeepalive
2310
- });
2479
+ }, body, keepalive: useKeepalive }, (firstPartyEndpoint ? { credentials: 'include' } : {})));
2311
2480
  if (!response.ok) {
2312
2481
  // Handle rate limiting
2313
2482
  if (response.status === 429) {
@@ -2504,6 +2673,16 @@ class EventQueue {
2504
2673
  // two of them could splice the same offlineQueue concurrently and double-send.
2505
2674
  if (!this.enabled)
2506
2675
  return;
2676
+ // TR-15: re-check consent at DRAIN time. A returning DECLINED visitor whose consent
2677
+ // signal loads asynchronously (Shopify customerPrivacy) fires no visitorConsentCollected
2678
+ // event, so `enabled` stayed at its fail-open init value. If consent now says no, latch
2679
+ // off and PURGE the persisted backlog instead of draining it (a later grant re-enables
2680
+ // via setEnabled). Mirrors the withdrawal purge in optOut()/setConsent().
2681
+ if (this.consentCheck && this.consentCheck() === false) {
2682
+ this.enabled = false;
2683
+ this.clearOffline();
2684
+ return;
2685
+ }
2507
2686
  if (Date.now() < this.rateLimitedUntil)
2508
2687
  return; // FIXED (429): honor Retry-After window
2509
2688
  if (this.offlineProcessing)
@@ -2595,10 +2774,13 @@ class EventQueue {
2595
2774
  * - Non-terminal (tab switch): deliver the LIVE queue via a response-checked keepalive
2596
2775
  * fetch (flush) and response-check-drain the offline backlog (processOfflineQueue) —
2597
2776
  * neither erases the backlog on failure. The destructive beacon path is not used.
2598
- * - Terminal (unload): beacon live+offline best-effort, but PERSIST everything first
2599
- * and NEVER storage.remove() on a mere beacon enqueue. The next page load's
2600
- * response-checked drain (normal fetch, no 64KB cap) is the source of truth and
2601
- * clears the copy; server event_id dedup makes the potential double-send safe.
2777
+ * - Terminal (unload): PERSIST everything first (live + the already-persisted offline
2778
+ * backlog) and NEVER storage.remove() on a mere beacon enqueue. Beacon ONLY the LIVE
2779
+ * queue best-effort NOT the offline backlog (TR-13): the backlog is already durable and
2780
+ * the next-load drain delivers it exactly once, so beaconing it here too risked a
2781
+ * delivered-now-AND-re-drained-next-load double-send that, when >6h apart, outlives
2782
+ * ingest's dedup window → a duplicate purchase. The next page load's response-checked
2783
+ * drain (normal fetch, no 64KB cap) is the source of truth and clears the copy.
2602
2784
  * - Both paths honor rateLimitedUntil (don't beacon/flush into the backoff window).
2603
2785
  *
2604
2786
  * Still excludes the in-flight _flush batch (its keepalive fetch already carries it,
@@ -2640,6 +2822,14 @@ class EventQueue {
2640
2822
  }
2641
2823
  storage.set(this.OFFLINE_QUEUE_KEY, toPersist);
2642
2824
  }
2825
+ // TR-13: beacon ONLY the LIVE queue (never-persisted events from THIS session). The
2826
+ // offline backlog is already durable and the next-load drain delivers it once, so
2827
+ // beaconing it here would double-send it — and a stale backlog event (e.g. a purchase
2828
+ // parked during an outage) re-drained on a later day lands >6h from the beacon, past
2829
+ // ingest's dedup window. The live queue's beacon + its own next-load drain both land THIS
2830
+ // session (<6h), so dedup absorbs that pair.
2831
+ if (live.length === 0)
2832
+ return; // only the persisted backlog remained → it drains next load
2643
2833
  // Within the 429 window, don't beacon into it — the persisted copy drains next load.
2644
2834
  if (Date.now() < this.rateLimitedUntil)
2645
2835
  return;
@@ -2650,7 +2840,7 @@ class EventQueue {
2650
2840
  const chunks = [];
2651
2841
  let current = [];
2652
2842
  let currentBytes = 2; // approx for the JSON array/object wrapper
2653
- for (const ev of pending) {
2843
+ for (const ev of live) {
2654
2844
  // Byte length (not UTF-16 .length) so multibyte product names / emoji can't
2655
2845
  // under-count and overflow the cap.
2656
2846
  const evBytes = new Blob([JSON.stringify(ev)]).size + 1;
@@ -2676,7 +2866,7 @@ class EventQueue {
2676
2866
  break;
2677
2867
  beaconed += chunk.length;
2678
2868
  }
2679
- this.log(`forceFlush(terminal): beaconed ${beaconed}/${pending.length} events; persisted copy retained for next-load drain`);
2869
+ this.log(`forceFlush(terminal): beaconed ${beaconed}/${live.length} live events; backlog (${pending.length - live.length}) persisted for next-load drain`);
2680
2870
  });
2681
2871
  }
2682
2872
  /**
@@ -2697,6 +2887,15 @@ class EventQueue {
2697
2887
  setEnabled(enabled) {
2698
2888
  this.enabled = enabled;
2699
2889
  }
2890
+ /**
2891
+ * TR-15: register a live consent predicate the offline drain re-evaluates at drain time.
2892
+ * `enabled` is latched at init to a fail-open value (Shopify's customerPrivacy loads
2893
+ * async), and a returning DECLINED visitor fires no visitorConsentCollected event — so
2894
+ * without this, their persisted backlog would drain before the decline is known.
2895
+ */
2896
+ setConsentCheck(fn) {
2897
+ this.consentCheck = fn;
2898
+ }
2700
2899
  /**
2701
2900
  * Clear the offline queue and its persisted copy (used by opt-out to purge any
2702
2901
  * PII-bearing events that were parked before the user opted out).
@@ -4289,7 +4488,11 @@ class Datalyr {
4289
4488
  this.session = new SessionManager(this.config.sessionTimeout);
4290
4489
  this.attribution = new AttributionManager({
4291
4490
  attributionWindow: this.config.attributionWindow,
4292
- trackedParams: this.config.trackedParams
4491
+ trackedParams: this.config.trackedParams,
4492
+ // TR-03: gate ad-signal synthesis + shipping on LIVE marketing consent. Returns true by
4493
+ // default (no consent signal) so behavior is unchanged for the common case; false only on
4494
+ // an explicit decline (Shopify marketing:false / setConsent marketing|sale=false).
4495
+ marketingAllowed: () => this.consentAllowsMarketing()
4293
4496
  });
4294
4497
  this.queue = new EventQueue(this.config);
4295
4498
  this.fingerprint = new FingerprintCollector({
@@ -4302,11 +4505,18 @@ class Datalyr {
4302
4505
  // Gate the queue by the FULL tracking policy (opt-out + analytics consent + DNT +
4303
4506
  // GPC) so a returning opted-out / DNT / GPC visitor's persisted events don't drain.
4304
4507
  this.queue.setEnabled(this.shouldTrack());
4508
+ // TR-15: also give the offline drain a LIVE consent gate. setEnabled above is latched to
4509
+ // a fail-open value while Shopify's customerPrivacy loads async; a returning declined
4510
+ // visitor fires no visitorConsentCollected event, so the drain must re-check per run.
4511
+ this.queue.setConsentCheck(() => this.shouldTrack());
4305
4512
  // FIXED (ISSUE-01): Start async initialization immediately but don't block constructor
4306
4513
  // This allows encryption to initialize before any events are tracked
4307
4514
  this.initializeAsync();
4308
4515
  // Setup page unload handler
4309
4516
  this.setupUnloadHandler();
4517
+ // Shopify Customer Privacy (9.A.1): react to the native consent banner's
4518
+ // decision mid-session (grant AND revoke), not just at the next page load.
4519
+ this.setupShopifyConsentListener();
4310
4520
  // Initialize plugins
4311
4521
  if (this.config.plugins) {
4312
4522
  for (const plugin of this.config.plugins) {
@@ -4338,6 +4548,27 @@ class Datalyr {
4338
4548
  this.initializationPromise = (() => __awaiter(this, void 0, void 0, function* () {
4339
4549
  var _a, _b;
4340
4550
  try {
4551
+ // TR-22: capture the LANDING attribution NOW, before any await. The first read used to
4552
+ // sit inside this.page() AFTER `await container.init()` (up to a ~3s budget), so a
4553
+ // router / consent tool that strips fbclid via history.replaceState within that window
4554
+ // — or a fast bounce — lost the click id entirely (nothing persisted to first/last
4555
+ // touch until the first event). getAttributionData() warms queryParamsCache (freezing
4556
+ // the landing params) AND persists first/last touch immediately. Wrapped so a failure
4557
+ // never blocks init.
4558
+ // CONSENT (Track-3 review P1): gate the eager capture behind shouldTrack(). This call
4559
+ // synthesizes _fbp/_fbc and persists first/last touch — which must NOT happen at page
4560
+ // load for an opted-out / DNT / GPC visitor (shouldTrack()=false), or it undoes the
4561
+ // id-less-at-init privacy invariant. For a TRACKED visitor with marketing declined,
4562
+ // getAttributionData already strips the click-ids / marketing cookies (TR-03), so TR-22's
4563
+ // early-capture benefit is preserved for consenting/default visitors.
4564
+ if (this.shouldTrack()) {
4565
+ try {
4566
+ this.attribution.getAttributionData();
4567
+ }
4568
+ catch (e) {
4569
+ this.log('Eager attribution capture failed:', e);
4570
+ }
4571
+ }
4341
4572
  // SEC-03: encryption is for PII-AT-REST only — it must NOT gate event delivery,
4342
4573
  // pageviews, or pixels. On a non-secure context (http://) or an old browser,
4343
4574
  // crypto.subtle is absent and initialize() throws; isolate it so the rest of init
@@ -4420,7 +4651,10 @@ class Datalyr {
4420
4651
  // Initialize auto-identify when enabled (explicit or remote) AND
4421
4652
  // tracking is allowed. The shouldTrack() gate keeps capture from even
4422
4653
  // setting up its form/API interceptors for opted-out / DNT / GPC users.
4423
- if (this.config.autoIdentify === true && this.shouldTrack()) {
4654
+ // 9.A.1: the captured email feeds Meta advanced matching / CAPI, so a
4655
+ // declined Shopify marketing consent also blocks setup (incl. the
4656
+ // /account.json polling); null = no Shopify signal → unchanged.
4657
+ if (this.config.autoIdentify === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
4424
4658
  this.autoIdentify = new AutoIdentifyManager({
4425
4659
  enabled: true,
4426
4660
  captureFromForms: this.config.autoIdentifyForms,
@@ -4445,8 +4679,10 @@ class Datalyr {
4445
4679
  // Stamp attribution signals into the Shopify cart (OPT-IN, default off).
4446
4680
  // Lets server-side order webhooks recover the browser visitor + Meta click
4447
4681
  // signals (the postback webhook reads these as note_attributes). Inert unless
4448
- // enabled; best-effort and never blocks init.
4449
- if (this.config.shopifyCartAttributes === true && this.shouldTrack()) {
4682
+ // enabled; best-effort and never blocks init. 9.A.1: the stamped visitor_id +
4683
+ // fbc/fbp/fbclid are marketing signals, so a declined Shopify marketing
4684
+ // consent blocks stamping too (null = no signal → unchanged).
4685
+ if (this.config.shopifyCartAttributes === true && this.shouldTrack() && this.shopifyMarketingConsent() !== false) {
4450
4686
  this.syncShopifyCartAttributes().catch((error) => {
4451
4687
  this.log('Shopify cart attribute sync failed:', error);
4452
4688
  });
@@ -4616,6 +4852,13 @@ class Datalyr {
4616
4852
  return;
4617
4853
  }
4618
4854
  try {
4855
+ // A different authenticated user on the same device is a privacy
4856
+ // boundary. Rotate the anonymous/session identities and clear the prior
4857
+ // user's traits/attribution before creating the new link, even when the
4858
+ // integrator forgot to call reset() on logout.
4859
+ const currentUserId = this.identity.getUserId();
4860
+ if (currentUserId && currentUserId !== userId)
4861
+ this.reset();
4619
4862
  // FSR-53: do NOT rotate the session id on identify(). 'Session fixation' is an
4620
4863
  // auth-credential concept; a client-generated analytics session_id is not one.
4621
4864
  // Rotating split every identified visit (and every auto-identify form submit) into
@@ -4678,7 +4921,9 @@ class Datalyr {
4678
4921
  if (!lastTouchpoint || lastTouchpoint.sessionId !== sid) {
4679
4922
  this.attribution.addTouchpoint(sid, this.attribution.captureAttribution());
4680
4923
  }
4681
- const pageData = Object.assign({ title: document.title, url: window.location.href, path: window.location.pathname, search: window.location.search, referrer: document.referrer }, properties);
4924
+ // 9.A.4: url/search/referrer are redacted (secret/PII query-param values
4925
+ // __redacted__) before they ever leave the browser — see redactUrl in utils.
4926
+ 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);
4682
4927
  // Add referrer data
4683
4928
  const referrerData = getReferrerData();
4684
4929
  Object.assign(pageData, referrerData);
@@ -4748,6 +4993,13 @@ class Datalyr {
4748
4993
  // Gate before mutating persisted identity (identity.alias writes dl_user_id).
4749
4994
  if (!this.shouldTrack())
4750
4995
  return;
4996
+ if (previousId && previousId !== this.identity.getAnonymousId()) {
4997
+ console.warn('[Datalyr] alias() only accepts the current anonymous ID as previousId');
4998
+ return;
4999
+ }
5000
+ const currentUserId = this.identity.getUserId();
5001
+ if (currentUserId && currentUserId !== userId)
5002
+ this.reset();
4751
5003
  const aliasData = this.identity.alias(userId, previousId);
4752
5004
  this.track('$alias', aliasData);
4753
5005
  }
@@ -4903,6 +5155,27 @@ class Datalyr {
4903
5155
  /**
4904
5156
  * Opt out of tracking
4905
5157
  */
5158
+ /**
5159
+ * X-1 (consent): stop the Stripe Payment Link + CheckoutChamp outbound-link decorators from
5160
+ * stamping `client_reference_id`/`prefilled_email` onto <a> hrefs once consent/marketing is
5161
+ * withdrawn. Their MutationObservers otherwise keep rewriting links after a CMP or Shopify
5162
+ * decline — and the server still reads the stamped `client_reference_id` — so attribution
5163
+ * continues post-withdrawal. Idempotent; decorators re-init on the next load if consent returns
5164
+ * (same convention as pixels/auto-identify).
5165
+ */
5166
+ disposeMarketingLinkDecorators() {
5167
+ var _a, _b;
5168
+ try {
5169
+ (_a = this.stripeLinksDisposer) === null || _a === void 0 ? void 0 : _a.call(this);
5170
+ }
5171
+ catch ( /* best-effort */_c) { /* best-effort */ }
5172
+ this.stripeLinksDisposer = undefined;
5173
+ try {
5174
+ (_b = this.outboundDisposer) === null || _b === void 0 ? void 0 : _b.call(this);
5175
+ }
5176
+ catch ( /* best-effort */_d) { /* best-effort */ }
5177
+ this.outboundDisposer = undefined;
5178
+ }
4906
5179
  optOut() {
4907
5180
  var _a;
4908
5181
  if (!this.initialized) {
@@ -4924,6 +5197,8 @@ class Datalyr {
4924
5197
  this.container.cleanupAllIframes();
4925
5198
  this.container = undefined;
4926
5199
  }
5200
+ // X-1: stop stamping Stripe/CC outbound links with the visitor id / email.
5201
+ this.disposeMarketingLinkDecorators();
4927
5202
  // Purge PII at rest.
4928
5203
  this.userProperties = {};
4929
5204
  storage.remove('dl_user_traits');
@@ -4945,6 +5220,9 @@ class Datalyr {
4945
5220
  // merely because opt-out was lifted — otherwise a GPC/DNT visitor's persisted
4946
5221
  // events would start draining again. (Pixels / auto-identify resume on next load.)
4947
5222
  this.queue.setEnabled(this.shouldTrack());
5223
+ // TR-15 (P3): opt-in → persist the in-memory anon id now (see onShopifyConsentChanged).
5224
+ if (this.shouldTrack())
5225
+ this.identity.enablePersistence();
4948
5226
  this.log('User opted in');
4949
5227
  }
4950
5228
  /**
@@ -4957,6 +5235,7 @@ class Datalyr {
4957
5235
  * Set consent preferences
4958
5236
  */
4959
5237
  setConsent(consent) {
5238
+ var _a;
4960
5239
  // Always record + persist consent FIRST, even before init(). FSR-51: a consent-
4961
5240
  // management platform commonly calls setConsent() before init() so tracking is gated
4962
5241
  // from the very first event. The old code then dereferenced this.queue/this.config
@@ -4977,6 +5256,22 @@ class Datalyr {
4977
5256
  if (!allowed) {
4978
5257
  this.queue.clear();
4979
5258
  this.queue.clearOffline();
5259
+ // TR-14: mirror optOut() on analytics withdrawal. Previously setConsent tore down the
5260
+ // queue + container but left autoIdentify alive — its form/fetch interceptors and
5261
+ // /account.json polling kept running and triggerIdentify persisted the captured email
5262
+ // to storage AFTER withdrawal (PII newly written at rest → GDPR/consent-audit failure
5263
+ // on non-Shopify CMP installs). Destroy it and purge PII at rest. (Auto-identify
5264
+ // resumes on the next page load if consent is re-granted, matching optOut/optIn.)
5265
+ (_a = this.autoIdentify) === null || _a === void 0 ? void 0 : _a.destroy();
5266
+ this.autoIdentify = undefined;
5267
+ this.userProperties = {};
5268
+ storage.remove('dl_user_traits');
5269
+ storage.remove('dl_auto_identified_email');
5270
+ storage.remove('dl_journey');
5271
+ }
5272
+ else {
5273
+ // TR-15 (P3): grant → persist the in-memory anon id now (see onShopifyConsentChanged).
5274
+ this.identity.enablePersistence();
4980
5275
  }
4981
5276
  // Marketing / "do not sell" withdrawal: stop FEEDING the third-party pixels and
4982
5277
  // prevent them from being initialized on the next page load. NOTE: an
@@ -4986,6 +5281,12 @@ class Datalyr {
4986
5281
  this.container.cleanupAllIframes();
4987
5282
  this.container = undefined;
4988
5283
  }
5284
+ // X-1: on marketing/sale withdrawal, stop the Stripe/CC decorators from stamping the
5285
+ // visitor id / email onto outbound links (the observers otherwise keep rewriting hrefs
5286
+ // after withdrawal, and the server still reads the stamped client_reference_id).
5287
+ if (!this.consentAllowsMarketing()) {
5288
+ this.disposeMarketingLinkDecorators();
5289
+ }
4989
5290
  this.log('Consent updated:', consent);
4990
5291
  }
4991
5292
  /**
@@ -5070,91 +5371,27 @@ class Datalyr {
5070
5371
  }
5071
5372
  });
5072
5373
  }
5073
- /**
5074
- * Restore _dl_* URL bridge params on a Checkout Champ funnel page.
5075
- * Runs BEFORE IdentityManager so the storefront's visitor_id wins over a
5076
- * freshly auto-generated one. Also restores _fbc / _fbp cookies and the
5077
- * fbclid click time so server-side rebuilds carry the real click moment.
5078
- *
5079
- * Strategy: stamp matching cookies (without clobbering pre-existing values),
5080
- * then rewrite the URL so `_dl_fbclid` becomes `fbclid` — that way the rest
5081
- * of the SDK's attribution layer (which reads `params.fbclid`) works
5082
- * unchanged, and any merchant analytics also see the canonical click ID.
5083
- */
5374
+ /** Strip the retired unsigned Checkout Champ bridge parameters. */
5084
5375
  restoreFromURL() {
5085
5376
  var _a;
5086
5377
  if (typeof window === "undefined" || typeof document === "undefined")
5087
5378
  return;
5088
5379
  try {
5089
5380
  const params = new URLSearchParams(window.location.search);
5090
- const get = (k) => params.get(k);
5091
- const vid = get("_dl_vid");
5092
- const fbc = get("_dl_fbc");
5093
- const fbp = get("_dl_fbp");
5094
- const fbclid = get("_dl_fbclid");
5095
- const fbclidAt = get("_dl_fbclid_at");
5096
- const gclid = get("_dl_gclid");
5097
- const gclidAt = get("_dl_gclid_at");
5098
- // visitor_id: bridge wins. The whole point of `_dl_vid` is to unify the
5099
- // storefront's session with the CC funnel session. A pre-existing local
5100
- // cookie from a prior direct CC visit would silently sink the integration
5101
- // (CC events stay on the local id; storefront events use the bridged id;
5102
- // the two never link). Overwrite — orphaned local events are fine.
5103
- if (vid)
5104
- this.cookies.set("__dl_visitor_id", vid, 365);
5105
- // Meta cookies + click-time cookies: existing wins. _fbc / _fbp may have
5106
- // been written by Meta Pixel on the CC funnel page itself (more recent
5107
- // than the bridged value); _dl_fbclid_at should record first-touch click
5108
- // time per device, not get reset by a bridge from a new campaign.
5109
- const setIfMissing = (name, value) => {
5110
- if (!value)
5111
- return;
5112
- if (this.cookies.get(name))
5113
- return;
5114
- this.cookies.set(name, value, 365);
5115
- };
5116
- setIfMissing("_fbc", fbc);
5117
- setIfMissing("_fbp", fbp);
5118
- setIfMissing("_dl_fbclid_at", fbclidAt);
5119
- setIfMissing("_dl_gclid_at", gclidAt);
5120
- // Rewrite URL: _dl_fbclid → fbclid (etc.) so captureAttribution() picks
5121
- // them up via its existing `params.fbclid` path. Strip the _dl_* params
5122
- // either way so they don't leak into downstream analytics URLs.
5123
- let rewrote = false;
5124
- const mappings = [
5125
- ["fbclid", fbclid],
5126
- ["gclid", gclid]
5127
- ];
5128
- for (const [canonical, value] of mappings) {
5129
- if (value && !params.get(canonical)) {
5130
- params.set(canonical, value);
5131
- rewrote = true;
5132
- }
5133
- }
5381
+ let changed = false;
5134
5382
  for (const k of ["_dl_vid", "_dl_fbc", "_dl_fbp", "_dl_fbclid", "_dl_fbclid_at", "_dl_gclid", "_dl_gclid_at"]) {
5135
5383
  if (params.has(k)) {
5136
5384
  params.delete(k);
5137
- rewrote = true;
5385
+ changed = true;
5138
5386
  }
5139
5387
  }
5140
- if (rewrote && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
5388
+ if (changed && typeof ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState) === "function") {
5141
5389
  const newSearch = params.toString();
5142
- const newUrl = window.location.pathname +
5143
- (newSearch ? "?" + newSearch : "") +
5144
- window.location.hash;
5390
+ const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
5145
5391
  window.history.replaceState(window.history.state, "", newUrl);
5146
5392
  }
5147
- this.log("Checkout Champ bridge restored:", {
5148
- had_vid: !!vid,
5149
- had_fbc: !!fbc,
5150
- had_fbp: !!fbp,
5151
- had_fbclid: !!fbclid,
5152
- had_gclid: !!gclid
5153
- });
5154
5393
  }
5155
5394
  catch (error) {
5156
- // Don't let bridge restoration block init — fall through to normal SDK
5157
- // behavior (a fresh visitor_id, no restored click signals).
5158
5395
  this.log("restoreFromURL failed:", error);
5159
5396
  }
5160
5397
  }
@@ -5255,138 +5492,9 @@ class Datalyr {
5255
5492
  this.log("fireCheckoutChampPurchasePixel failed:", error);
5256
5493
  }
5257
5494
  }
5258
- /**
5259
- * Storefront → Checkout Champ link stamping. Finds every `<a href>` whose host
5260
- * matches the configured CC domain list and appends `?_dl_vid=…&_dl_fbc=…&
5261
- * _dl_fbp=…&_dl_fbclid=…&_dl_fbclid_at=…&_dl_gclid=…` so the user's
5262
- * visitor_id + Meta click signals cross the domain. MutationObserver watches
5263
- * for dynamically-injected links. On click, force-flush the event queue so
5264
- * any pending track() events land before the browser navigates away.
5265
- */
5495
+ /** Checkout Champ identity bridging remains disabled pending signed tokens. */
5266
5496
  syncOutboundLinkParams(domains) {
5267
- if (typeof window === "undefined" || typeof document === "undefined")
5268
- return;
5269
- const lowerDomains = domains.map((d) => d.toLowerCase());
5270
- const matchesCcDomain = (href) => {
5271
- try {
5272
- const host = new URL(href, window.location.href).hostname.toLowerCase();
5273
- return lowerDomains.some((d) => host === d || host.endsWith("." + d));
5274
- }
5275
- catch (_a) {
5276
- return false;
5277
- }
5278
- };
5279
- const buildBridgeParams = () => {
5280
- var _a, _b;
5281
- const attribution = this.attribution.getAttributionData();
5282
- const out = {};
5283
- const vid = this.identity.getAnonymousId();
5284
- const fbc = this.cookies.get("_fbc") || attribution._fbc;
5285
- const fbp = this.cookies.get("_fbp") || attribution._fbp;
5286
- // FSR-104: read the named click-id fields directly (captureAttribution stores every
5287
- // present click id under its own key) so a landing URL carrying BOTH fbclid and
5288
- // gclid bridges both — the old `clickIdType === 'fbclid' ? clickId : null` dropped
5289
- // gclid whenever fbclid was the primary, silently losing Google attribution on the
5290
- // cross-domain CC path. Fall back to the primary clickId for older stored shapes.
5291
- const fbclid = (_a = attribution.fbclid) !== null && _a !== void 0 ? _a : (attribution.clickIdType === "fbclid" ? attribution.clickId : null);
5292
- const fbclidAt = this.cookies.get("_dl_fbclid_at");
5293
- const gclid = (_b = attribution.gclid) !== null && _b !== void 0 ? _b : (attribution.clickIdType === "gclid" ? attribution.clickId : null);
5294
- const gclidAt = this.cookies.get("_dl_gclid_at");
5295
- if (vid)
5296
- out._dl_vid = vid;
5297
- if (fbc)
5298
- out._dl_fbc = String(fbc);
5299
- if (fbp)
5300
- out._dl_fbp = String(fbp);
5301
- if (fbclid)
5302
- out._dl_fbclid = String(fbclid);
5303
- if (fbclidAt)
5304
- out._dl_fbclid_at = String(fbclidAt);
5305
- if (gclid)
5306
- out._dl_gclid = String(gclid);
5307
- if (gclidAt)
5308
- out._dl_gclid_at = String(gclidAt);
5309
- return out;
5310
- };
5311
- const stampLink = (anchor) => {
5312
- if (!anchor.href || !matchesCcDomain(anchor.href))
5313
- return;
5314
- try {
5315
- const u = new URL(anchor.href, window.location.href);
5316
- const bridge = buildBridgeParams();
5317
- let mutated = false;
5318
- for (const [k, v] of Object.entries(bridge)) {
5319
- if (!u.searchParams.get(k)) {
5320
- u.searchParams.set(k, v);
5321
- mutated = true;
5322
- }
5323
- }
5324
- if (mutated)
5325
- anchor.href = u.toString();
5326
- }
5327
- catch (_a) {
5328
- // Ignore malformed URLs — don't break the page.
5329
- }
5330
- };
5331
- const stampAll = () => {
5332
- document.querySelectorAll("a[href]").forEach((el) => stampLink(el));
5333
- };
5334
- const onClick = (e) => {
5335
- var _a, _b;
5336
- const target = (_a = e.target) === null || _a === void 0 ? void 0 : _a.closest("a[href]");
5337
- if (!target)
5338
- return;
5339
- const anchor = target;
5340
- if (!matchesCcDomain(anchor.href))
5341
- return;
5342
- stampLink(anchor); // re-stamp in case attribution changed since DOMReady
5343
- try {
5344
- (_b = this.queue) === null || _b === void 0 ? void 0 : _b.flush();
5345
- }
5346
- catch (_c) {
5347
- // Best-effort — never block navigation.
5348
- }
5349
- };
5350
- stampAll();
5351
- document.addEventListener("click", onClick, true);
5352
- try {
5353
- // Debounce re-stamping. A busy SPA (infinite scroll, animations,
5354
- // React/Vue updates) can fire thousands of mutations per second; a naive
5355
- // re-stamp on every notification would burn CPU pointlessly when
5356
- // outbound link sets only change occasionally. 150ms is small enough to
5357
- // catch links before the user can click them, large enough to coalesce
5358
- // bursts.
5359
- let restampTimer = null;
5360
- const scheduleRestamp = () => {
5361
- if (restampTimer != null)
5362
- return;
5363
- restampTimer = setTimeout(() => {
5364
- restampTimer = null;
5365
- stampAll();
5366
- }, 150);
5367
- };
5368
- const observer = new MutationObserver(scheduleRestamp);
5369
- observer.observe(document.documentElement, { childList: true, subtree: true });
5370
- // Observer + click listener live for the session; pagehide cleans up on full-page
5371
- // unload, and destroy() can tear them down early via this disposer (H2 — otherwise
5372
- // a destroy()+re-init() leaks the observer + capturing click listener).
5373
- this.outboundDisposer = () => {
5374
- try {
5375
- observer.disconnect();
5376
- }
5377
- catch ( /* idempotent */_a) { /* idempotent */ }
5378
- if (restampTimer != null) {
5379
- clearTimeout(restampTimer);
5380
- restampTimer = null;
5381
- }
5382
- document.removeEventListener("click", onClick, true);
5383
- };
5384
- window.addEventListener("pagehide", () => { var _a; return (_a = this.outboundDisposer) === null || _a === void 0 ? void 0 : _a.call(this); }, { once: true });
5385
- }
5386
- catch (error) {
5387
- this.log("MutationObserver setup failed (CC link sync):", error);
5388
- }
5389
- this.log("Checkout Champ outbound-link sync active for:", lowerDomains);
5497
+ this.log("Checkout Champ outbound identity bridge disabled (signed token required)", domains);
5390
5498
  }
5391
5499
  /**
5392
5500
  * Stripe Payment Link auto-decoration (D1). Structurally mirrors
@@ -5545,6 +5653,7 @@ class Datalyr {
5545
5653
  * Create event payload
5546
5654
  */
5547
5655
  createEventPayload(eventName, properties, eventIdArg) {
5656
+ var _a;
5548
5657
  // NEW-2: keep the identity's cached session id in lockstep with the LIVE session.
5549
5658
  // The session id changes on session timeout (and previously on identify(), removed in
5550
5659
  // FSR-53), but identity only synced it at init/start — so the top-level
@@ -5581,17 +5690,30 @@ class Datalyr {
5581
5690
  device_fingerprint: fingerprintData // Use snake_case only (matches backend)
5582
5691
  });
5583
5692
  }
5584
- // Add browser context (caller wins on collisions)
5693
+ // Add browser context (caller wins on collisions). 9.A.4: url/referrer are
5694
+ // redacted — secret/PII query-param values must not ship on events.
5585
5695
  assignMissing(eventData, {
5586
- url: window.location.href,
5696
+ url: redactUrl(window.location.href),
5587
5697
  path: window.location.pathname,
5588
- referrer: document.referrer,
5698
+ referrer: redactUrl(document.referrer),
5589
5699
  title: document.title,
5590
5700
  screen_width: screen.width,
5591
5701
  screen_height: screen.height,
5592
5702
  viewport_width: window.innerWidth,
5593
5703
  viewport_height: window.innerHeight
5594
5704
  });
5705
+ // Consent travels with the event so downstream profile enrichment and ad
5706
+ // postbacks enforce the decision that applied at occurrence time. The SDK
5707
+ // snapshot is authoritative over caller properties.
5708
+ const consentSnapshot = Object.assign({}, ((_a = this.consent) !== null && _a !== void 0 ? _a : {}));
5709
+ const shopifyAnalytics = this.shopifyAnalyticsConsent();
5710
+ const shopifyMarketing = this.shopifyMarketingConsent();
5711
+ if (typeof shopifyAnalytics === 'boolean')
5712
+ consentSnapshot.analytics = shopifyAnalytics;
5713
+ if (typeof shopifyMarketing === 'boolean')
5714
+ consentSnapshot.marketing = shopifyMarketing;
5715
+ if (Object.keys(consentSnapshot).length > 0)
5716
+ eventData.consent = consentSnapshot;
5595
5717
  // Create payload using snake_case only (matches backend API and production script)
5596
5718
  const identityFields = this.identity.getIdentityFields();
5597
5719
  // Use the caller-provided event_id (shared with the Meta Pixel co-fire for
@@ -5620,11 +5742,17 @@ class Datalyr {
5620
5742
  // Resolution metadata
5621
5743
  resolution_method: 'browser_sdk',
5622
5744
  resolution_confidence: 1.0,
5623
- // SDK metadata. MUST stay in sync with package.json "version" the build guard
5624
- // (scripts/check-bundle.js, run by build:check) fails the build if the bundle's
5625
- // sdk_version doesn't match package.json. (FSR-103)
5626
- sdk_version: '1.7.4',
5627
- sdk_name: 'datalyr-web-sdk'
5745
+ // SDK metadata. The placeholder is replaced with package.json "version" at build
5746
+ // time (rollup.config.js injectSdkVersion 9.A.3), so the literal can never
5747
+ // drift from the package again (it sat at 1.7.4 across the 1.7.5 release). The
5748
+ // build guard (scripts/check-bundle.js, run by build:check) still verifies the
5749
+ // deployable bundles carry the package.json version. (FSR-103)
5750
+ sdk_version: '1.7.6',
5751
+ sdk_name: 'datalyr-web-sdk',
5752
+ // A3-25: versioned-envelope stamp. Every SDK emits schema_version so the ingest/contract
5753
+ // layer can key on ONE canonical envelope version (snake_case fields, event_name/event_data
5754
+ // keys, canonical click-ids, clamped client timestamp — all now converged across SDKs).
5755
+ schema_version: 1
5628
5756
  };
5629
5757
  return payload;
5630
5758
  }
@@ -5640,6 +5768,14 @@ class Datalyr {
5640
5768
  if (this.consent && this.consent.analytics === false) {
5641
5769
  return false;
5642
5770
  }
5771
+ // Shopify Customer Privacy (9.A.1 — LEGAL): on a Shopify storefront, a shopper who
5772
+ // declined the native consent banner must not be tracked, even when the merchant
5773
+ // never wired setConsent(). null = API absent (non-Plus store not using it, or
5774
+ // script loaded off-Shopify) → fall through. A detected Shopify storefront
5775
+ // fails closed while the asynchronous Customer Privacy API is unresolved.
5776
+ if (this.shopifyAnalyticsConsent() === false) {
5777
+ return false;
5778
+ }
5643
5779
  // Check Do Not Track
5644
5780
  if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) {
5645
5781
  return false;
@@ -5656,8 +5792,185 @@ class Datalyr {
5656
5792
  * pixels (which share data). No consent set = allowed (default).
5657
5793
  */
5658
5794
  consentAllowsMarketing() {
5795
+ // Shopify Customer Privacy (9.A.1): a declined native banner blocks marketing use
5796
+ // (pixel loads, click-id/email forwarding) even with no setConsent() call.
5797
+ // null = no signal → defer to the setConsent-based policy below, unchanged.
5798
+ if (this.shopifyMarketingConsent() === false) {
5799
+ return false;
5800
+ }
5659
5801
  return !this.consent || (this.consent.marketing !== false && this.consent.sale !== false);
5660
5802
  }
5803
+ /**
5804
+ * Shopify Customer Privacy API handle (9.A.1), or null when it doesn't apply.
5805
+ *
5806
+ * X-2 (LEGAL): gated on RUNTIME detection of `window.Shopify.customerPrivacy`, NOT on
5807
+ * `config.platform === 'shopify'`. A Shopify merchant who installs via a plain <script>
5808
+ * snippet or a headless storefront (no `platform:'shopify'`) still exposes customerPrivacy;
5809
+ * gating on config.platform meant a shopper who DECLINED the native banner (with no
5810
+ * setConsent() wired) was fully tracked — nullifying the consent feature for a whole install
5811
+ * class, and unfixable server-side (platform is install-time only). Every access is wrapped —
5812
+ * a Shopify API shape change (or a non-Shopify site) must NEVER break tracking (→ null = fail open).
5813
+ */
5814
+ getShopifyCustomerPrivacy() {
5815
+ var _a, _b;
5816
+ if (typeof window === 'undefined')
5817
+ return null;
5818
+ try {
5819
+ return (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.customerPrivacy) !== null && _b !== void 0 ? _b : null;
5820
+ }
5821
+ catch (_c) {
5822
+ return null;
5823
+ }
5824
+ }
5825
+ isShopifyStorefront() {
5826
+ var _a;
5827
+ if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.platform) === 'shopify')
5828
+ return true;
5829
+ if (typeof window === 'undefined')
5830
+ return false;
5831
+ try {
5832
+ return Boolean(window.Shopify) ||
5833
+ window.location.hostname.toLowerCase().endsWith('.myshopify.com');
5834
+ }
5835
+ catch (_b) {
5836
+ return false;
5837
+ }
5838
+ }
5839
+ /**
5840
+ * Shopify's analytics-consent signal: true/false when the Customer Privacy API is
5841
+ * present and answers, null when there's no signal (API absent / unexpected shape)
5842
+ * — null means "no restriction from Shopify", i.e. today's behavior.
5843
+ */
5844
+ shopifyAnalyticsConsent() {
5845
+ try {
5846
+ const cp = this.getShopifyCustomerPrivacy();
5847
+ if (!cp)
5848
+ return this.isShopifyStorefront() ? false : null;
5849
+ if (typeof cp.analyticsProcessingAllowed === 'function') {
5850
+ const allowed = cp.analyticsProcessingAllowed();
5851
+ return typeof allowed === 'boolean' ? allowed : null;
5852
+ }
5853
+ // Older API surface: the aggregate "can this visitor be tracked" signal.
5854
+ if (typeof cp.userCanBeTracked === 'function') {
5855
+ const allowed = cp.userCanBeTracked();
5856
+ return typeof allowed === 'boolean' ? allowed : null;
5857
+ }
5858
+ return this.isShopifyStorefront() ? false : null;
5859
+ }
5860
+ catch (_a) {
5861
+ return this.isShopifyStorefront() ? false : null;
5862
+ }
5863
+ }
5864
+ /**
5865
+ * Shopify's marketing-consent signal (gates pixels, click-id/email → CAPI, cart
5866
+ * attribute stamping, auto-identify email capture). Same null semantics as
5867
+ * shopifyAnalyticsConsent().
5868
+ */
5869
+ shopifyMarketingConsent() {
5870
+ try {
5871
+ const cp = this.getShopifyCustomerPrivacy();
5872
+ if (!cp || typeof cp.marketingAllowed !== 'function') {
5873
+ return this.isShopifyStorefront() ? false : null;
5874
+ }
5875
+ const allowed = cp.marketingAllowed();
5876
+ return typeof allowed === 'boolean'
5877
+ ? allowed
5878
+ : (this.isShopifyStorefront() ? false : null);
5879
+ }
5880
+ catch (_a) {
5881
+ return this.isShopifyStorefront() ? false : null;
5882
+ }
5883
+ }
5884
+ /**
5885
+ * Listen for Shopify's `visitorConsentCollected` document event (9.A.1) so a
5886
+ * consent decision made mid-session takes effect without a reload. Revocation is
5887
+ * enforced immediately, mirroring setConsent(): queue gated + purged, pixels torn
5888
+ * down, auto-identify (email capture + /account.json polling) destroyed. On grant
5889
+ * the queue re-enables (track() re-checks shouldTrack() per event anyway) and cart
5890
+ * attribute stamping runs; pixels / auto-identify resume on the next page load —
5891
+ * the same convention as optIn().
5892
+ */
5893
+ setupShopifyConsentListener() {
5894
+ // X-2: NOT gated on config.platform — a plain-snippet/headless Shopify install must also honor
5895
+ // a mid-session consent decision. The `visitorConsentCollected` event only fires on Shopify
5896
+ // storefronts, and the handler + loadFeatures are fully guarded, so this is a safe no-op
5897
+ // everywhere else.
5898
+ if (typeof document === 'undefined')
5899
+ return;
5900
+ try {
5901
+ this.shopifyConsentHandler = () => {
5902
+ try {
5903
+ this.onShopifyConsentChanged();
5904
+ }
5905
+ catch (error) {
5906
+ this.log('Shopify consent change handling failed:', error);
5907
+ }
5908
+ };
5909
+ document.addEventListener('visitorConsentCollected', this.shopifyConsentHandler);
5910
+ // TR-15: customerPrivacy loads ASYNCHRONOUSLY. Until it's present shopifyAnalyticsConsent()
5911
+ // returns null (fail-open → events send in the pre-load window). Force the feature to load
5912
+ // and re-run the full consent evaluation in the callback so a declined visitor is gated
5913
+ // (queue disabled + purged) as soon as the API answers, without waiting for a reload or a
5914
+ // banner interaction. Guarded — a missing/changed loadFeatures must never break tracking.
5915
+ const shopify = window.Shopify;
5916
+ if (shopify && typeof shopify.loadFeatures === 'function') {
5917
+ shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], (error) => {
5918
+ if (error) {
5919
+ this.log('Shopify loadFeatures(consent-tracking-api) failed:', error);
5920
+ return;
5921
+ }
5922
+ try {
5923
+ this.onShopifyConsentChanged();
5924
+ }
5925
+ catch (e) {
5926
+ this.log('Shopify post-load consent eval failed:', e);
5927
+ }
5928
+ });
5929
+ }
5930
+ }
5931
+ catch (error) {
5932
+ this.log('Shopify consent listener setup failed:', error);
5933
+ }
5934
+ }
5935
+ onShopifyConsentChanged() {
5936
+ const allowed = this.shouldTrack();
5937
+ this.queue.setEnabled(allowed);
5938
+ // TR-15 (P3): a mid-session grant must persist the in-memory anon id NOW — otherwise a
5939
+ // visitor declined at init keeps a memory-only id and this session's events land under a
5940
+ // visitor_id that vanishes on the next page load. Idempotent.
5941
+ if (allowed)
5942
+ this.identity.enablePersistence();
5943
+ if (!allowed) {
5944
+ // Mirror setConsent() withdrawal: purge buffered events so events captured
5945
+ // before the decline can't drain if consent is later re-granted.
5946
+ this.queue.clear();
5947
+ this.queue.clearOffline();
5948
+ }
5949
+ const marketingBlocked = this.shopifyMarketingConsent() === false;
5950
+ // Stop email capture (form interceptors + /account.json polling) immediately.
5951
+ if ((!allowed || marketingBlocked) && this.autoIdentify) {
5952
+ this.autoIdentify.destroy();
5953
+ this.autoIdentify = undefined;
5954
+ }
5955
+ // Same caveat as setConsent(): an already-injected pixel global (fbq/gtag/ttq)
5956
+ // keeps running in the page; full removal is on reload.
5957
+ if (!this.consentAllowsMarketing() && this.container) {
5958
+ this.container.cleanupAllIframes();
5959
+ this.container = undefined;
5960
+ }
5961
+ // X-1: marketing withdrawn (Shopify decline) → stop stamping Stripe/CC outbound links.
5962
+ if (!this.consentAllowsMarketing()) {
5963
+ this.disposeMarketingLinkDecorators();
5964
+ }
5965
+ // Grant direction: cart-attribute stamping is cheap and idempotent (merges via
5966
+ // /cart/update.js), so run it now instead of waiting for the next page load.
5967
+ if (allowed && !marketingBlocked && this.config.shopifyCartAttributes === true) {
5968
+ this.syncShopifyCartAttributes().catch((error) => {
5969
+ this.log('Shopify cart attribute sync failed:', error);
5970
+ });
5971
+ }
5972
+ this.log('Shopify consent collected — analytics allowed:', allowed, '— marketing blocked:', marketingBlocked);
5973
+ }
5661
5974
  /**
5662
5975
  * Setup SPA tracking
5663
5976
  * Fixed Issue #15: Store original methods for cleanup
@@ -5758,7 +6071,7 @@ class Datalyr {
5758
6071
  stack: error.stack,
5759
6072
  context,
5760
6073
  timestamp: new Date().toISOString(),
5761
- url: window.location.href
6074
+ url: redactUrl(window.location.href) // 9.A.4: no secret query values in error logs
5762
6075
  };
5763
6076
  this.errors.push(errorInfo);
5764
6077
  // Keep only recent errors
@@ -5840,6 +6153,10 @@ class Datalyr {
5840
6153
  window.removeEventListener('visibilitychange', this.visibilityHandler);
5841
6154
  this.visibilityHandler = undefined;
5842
6155
  }
6156
+ if (this.shopifyConsentHandler) {
6157
+ document.removeEventListener('visitorConsentCollected', this.shopifyConsentHandler);
6158
+ this.shopifyConsentHandler = undefined;
6159
+ }
5843
6160
  if (this.outboundDisposer) {
5844
6161
  this.outboundDisposer();
5845
6162
  this.outboundDisposer = undefined;
@@ -5881,8 +6198,14 @@ class Datalyr {
5881
6198
  this.log('SDK destroyed');
5882
6199
  }
5883
6200
  }
5884
- // Create singleton instance
5885
- const datalyr = new Datalyr();
6201
+ // Create singleton instance. TR-23: reuse an existing window.datalyr (a prior tag's instance —
6202
+ // Shopify App Embed + a manual snippet coexisting, or a double-included bundle) instead of
6203
+ // constructing a SECOND one. Two instances each patch history.pushState and fire their own
6204
+ // pageviews (distinct event_ids → no server-side dedup). A plain truthy check (NOT instanceof):
6205
+ // two <script> tags run two separate IIFEs whose Datalyr classes are structurally identical but
6206
+ // distinct objects, so instanceof would fail across them. With one shared instance, the second
6207
+ // tag's bootstrap init() hits the instance-level `initialized` guard and no-ops.
6208
+ const datalyr = (typeof window !== 'undefined' && window.datalyr) || new Datalyr();
5886
6209
  // Expose global API
5887
6210
  if (typeof window !== 'undefined') {
5888
6211
  window.datalyr = datalyr;