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