@freshjuice/zest 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/zest.d.ts +40 -0
  2. package/dist/zest.de.js +763 -51
  3. package/dist/zest.de.js.map +1 -1
  4. package/dist/zest.de.min.js +1 -1
  5. package/dist/zest.en.js +763 -51
  6. package/dist/zest.en.js.map +1 -1
  7. package/dist/zest.en.min.js +1 -1
  8. package/dist/zest.es.js +763 -51
  9. package/dist/zest.es.js.map +1 -1
  10. package/dist/zest.es.min.js +1 -1
  11. package/dist/zest.esm.js +763 -51
  12. package/dist/zest.esm.js.map +1 -1
  13. package/dist/zest.esm.min.js +1 -1
  14. package/dist/zest.fr.js +763 -51
  15. package/dist/zest.fr.js.map +1 -1
  16. package/dist/zest.fr.min.js +1 -1
  17. package/dist/zest.headless.d.ts +40 -0
  18. package/dist/zest.headless.esm.js +717 -33
  19. package/dist/zest.headless.esm.js.map +1 -1
  20. package/dist/zest.headless.esm.min.js +1 -1
  21. package/dist/zest.it.js +763 -51
  22. package/dist/zest.it.js.map +1 -1
  23. package/dist/zest.it.min.js +1 -1
  24. package/dist/zest.ja.js +763 -51
  25. package/dist/zest.ja.js.map +1 -1
  26. package/dist/zest.ja.min.js +1 -1
  27. package/dist/zest.js +763 -51
  28. package/dist/zest.js.map +1 -1
  29. package/dist/zest.min.js +1 -1
  30. package/dist/zest.nl.js +763 -51
  31. package/dist/zest.nl.js.map +1 -1
  32. package/dist/zest.nl.min.js +1 -1
  33. package/dist/zest.pl.js +763 -51
  34. package/dist/zest.pl.js.map +1 -1
  35. package/dist/zest.pl.min.js +1 -1
  36. package/dist/zest.pt.js +763 -51
  37. package/dist/zest.pt.js.map +1 -1
  38. package/dist/zest.pt.min.js +1 -1
  39. package/dist/zest.ru.js +763 -51
  40. package/dist/zest.ru.js.map +1 -1
  41. package/dist/zest.ru.min.js +1 -1
  42. package/dist/zest.uk.js +763 -51
  43. package/dist/zest.uk.js.map +1 -1
  44. package/dist/zest.uk.min.js +1 -1
  45. package/dist/zest.zh.js +763 -51
  46. package/dist/zest.zh.js.map +1 -1
  47. package/dist/zest.zh.min.js +1 -1
  48. package/package.json +1 -1
  49. package/src/config/defaults.js +49 -0
  50. package/src/core/element-interceptor.js +374 -0
  51. package/src/core/network-interceptor.js +289 -0
  52. package/src/core/pattern-matcher.js +37 -0
  53. package/src/core-lifecycle.js +43 -5
  54. package/src/index.js +46 -18
  55. package/src/types/zest.d.ts +40 -0
  56. package/src/types/zest.headless.d.ts +40 -0
  57. package/zest.config.schema.json +26 -0
@@ -141,10 +141,47 @@ const DEFAULT_PATTERNS = {
141
141
 
142
142
  let patterns = { ...DEFAULT_PATTERNS };
143
143
 
144
+ /** Escape a string so it can be embedded in a regex literal verbatim. */
145
+ function escapeRegex(value) {
146
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
147
+ }
148
+
149
+ /**
150
+ * Append patterns to a single category without replacing what's already
151
+ * there. Used by `essentialKeys` and `essentialPatterns` config to extend
152
+ * the strictly-necessary category with consumer-specific entries while
153
+ * keeping the built-in defaults (zest_*, csrf*, xsrf*, etc.).
154
+ *
155
+ * `keys` is an array of exact storage/cookie names; each one is
156
+ * compiled as a fully-anchored regex via `escapeRegex`.
157
+ * `patternStrings` is an array of regex source strings, each validated
158
+ * via `safeRegExp`. Invalid entries are dropped silently.
159
+ */
160
+ function appendPatternsToCategory(category, { keys = [], patternStrings = [] } = {}) {
161
+ if (!patterns[category]) patterns[category] = [];
162
+
163
+ for (const key of keys) {
164
+ if (typeof key !== 'string' || !key) continue;
165
+ const re = safeRegExp(`^${escapeRegex(key)}$`);
166
+ if (re) patterns[category].push(re);
167
+ }
168
+
169
+ for (const p of patternStrings) {
170
+ if (typeof p !== 'string' || !p) continue;
171
+ const re = safeRegExp(p);
172
+ if (re) patterns[category].push(re);
173
+ }
174
+ }
175
+
144
176
  /**
145
177
  * Set custom patterns. User-supplied strings are validated with safeRegExp,
146
178
  * which rejects catastrophic-backtracking shapes and syntax errors.
147
179
  * Invalid patterns are silently dropped with a console warning.
180
+ *
181
+ * Note: this REPLACES the patterns for any category present in
182
+ * `customPatterns`. To extend the essential category without losing the
183
+ * built-in defaults, use `appendPatternsToCategory()` (or pass
184
+ * `essentialKeys` / `essentialPatterns` to `Zest.init()`).
148
185
  */
149
186
  function setPatterns(customPatterns) {
150
187
  patterns = { ...DEFAULT_PATTERNS };
@@ -204,19 +241,19 @@ let originalCookieDescriptor = null;
204
241
  // Upper bound on the number of queued cookies awaiting consent replay.
205
242
  // An unbounded queue is a memory-exhaustion DoS vector — a hostile
206
243
  // script could flood it with document.cookie writes.
207
- const MAX_QUEUE_SIZE$2 = 100;
244
+ const MAX_QUEUE_SIZE$3 = 100;
208
245
 
209
246
  // Queue for blocked cookies
210
247
  const cookieQueue = [];
211
248
 
212
249
  // Reference to consent checker function
213
- let checkConsent$3 = () => false;
250
+ let checkConsent$5 = () => false;
214
251
 
215
252
  /**
216
253
  * Set the consent checker function
217
254
  */
218
- function setConsentChecker$2(fn) {
219
- checkConsent$3 = fn;
255
+ function setConsentChecker$4(fn) {
256
+ checkConsent$5 = fn;
220
257
  }
221
258
 
222
259
  /**
@@ -272,10 +309,10 @@ function interceptCookies() {
272
309
 
273
310
  const category = getCategoryForName(name);
274
311
 
275
- if (checkConsent$3(category)) {
312
+ if (checkConsent$5(category)) {
276
313
  // Consent given - set cookie
277
314
  originalCookieDescriptor.set.call(document, value);
278
- } else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
315
+ } else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
279
316
  // No consent - queue for later (capped to prevent DoS)
280
317
  cookieQueue.push({
281
318
  value,
@@ -300,7 +337,7 @@ function interceptCookies() {
300
337
 
301
338
  // Upper bound on queued operations awaiting consent replay — unbounded
302
339
  // growth would be a memory-exhaustion DoS vector.
303
- const MAX_QUEUE_SIZE$1 = 200;
340
+ const MAX_QUEUE_SIZE$2 = 200;
304
341
 
305
342
  // Store originals
306
343
  let originalLocalStorage = null;
@@ -311,13 +348,13 @@ const localStorageQueue = [];
311
348
  const sessionStorageQueue = [];
312
349
 
313
350
  // Reference to consent checker function
314
- let checkConsent$2 = () => false;
351
+ let checkConsent$4 = () => false;
315
352
 
316
353
  /**
317
354
  * Set the consent checker function
318
355
  */
319
- function setConsentChecker$1(fn) {
320
- checkConsent$2 = fn;
356
+ function setConsentChecker$3(fn) {
357
+ checkConsent$4 = fn;
321
358
  }
322
359
 
323
360
  /**
@@ -330,9 +367,9 @@ function createStorageProxy(storage, queue, storageName) {
330
367
  return (key, value) => {
331
368
  const category = getCategoryForName(key);
332
369
 
333
- if (checkConsent$2(category)) {
370
+ if (checkConsent$4(category)) {
334
371
  target.setItem(key, value);
335
- } else if (queue.length < MAX_QUEUE_SIZE$1) {
372
+ } else if (queue.length < MAX_QUEUE_SIZE$2) {
336
373
  queue.push({
337
374
  key,
338
375
  value,
@@ -615,11 +652,11 @@ function isThirdParty(url) {
615
652
 
616
653
  // Categories the author has declared blockable. A script can self-label
617
654
  // into one of these, but not into 'essential' (a common bypass).
618
- const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
655
+ const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
619
656
 
620
657
  // Upper bound on queued scripts awaiting consent replay — prevents a
621
658
  // hostile page from flooding the queue with <script> nodes.
622
- const MAX_QUEUE_SIZE = 500;
659
+ const MAX_QUEUE_SIZE$1 = 500;
623
660
 
624
661
  // Queue for blocked scripts — the authoritative source for replay,
625
662
  // snapshotting src/inline BEFORE any DOM mutation so later tampering
@@ -630,31 +667,31 @@ const scriptQueue = [];
630
667
  let observer = null;
631
668
 
632
669
  // Current blocking mode
633
- let blockingMode = 'safe';
670
+ let blockingMode$2 = 'safe';
634
671
 
635
672
  // Custom blocked domains (user-defined)
636
- let customBlockedDomains = [];
673
+ let customBlockedDomains$2 = [];
637
674
 
638
675
  // Reference to consent checker function
639
- let checkConsent$1 = () => false;
676
+ let checkConsent$3 = () => false;
640
677
 
641
678
  /**
642
679
  * Set the consent checker function
643
680
  */
644
- function setConsentChecker(fn) {
645
- checkConsent$1 = fn;
681
+ function setConsentChecker$2(fn) {
682
+ checkConsent$3 = fn;
646
683
  }
647
684
 
648
685
  /**
649
686
  * Check if script URL matches custom blocked domains
650
687
  */
651
- function matchesCustomDomains(url) {
652
- if (!url || customBlockedDomains.length === 0) return null;
688
+ function matchesCustomDomains$2(url) {
689
+ if (!url || customBlockedDomains$2.length === 0) return null;
653
690
 
654
691
  try {
655
692
  const hostname = new URL(url).hostname.toLowerCase();
656
693
 
657
- for (const entry of customBlockedDomains) {
694
+ for (const entry of customBlockedDomains$2) {
658
695
  const domain = typeof entry === 'string' ? entry : entry.domain;
659
696
  const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
660
697
 
@@ -687,7 +724,7 @@ function getScriptBlockCategory(script) {
687
724
  // Only honor values from the blockable set; 'essential' and unknown
688
725
  // values fall through to the other checks.
689
726
  const explicitCategory = script.getAttribute('data-consent-category');
690
- const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
727
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
691
728
  ? explicitCategory
692
729
  : null;
693
730
 
@@ -699,17 +736,17 @@ function getScriptBlockCategory(script) {
699
736
  }
700
737
 
701
738
  // 2. Check custom blocked domains
702
- const customCategory = matchesCustomDomains(src);
739
+ const customCategory = matchesCustomDomains$2(src);
703
740
 
704
741
  // 3. Mode-based blocking
705
742
  let modeCategory = null;
706
- switch (blockingMode) {
743
+ switch (blockingMode$2) {
707
744
  case 'manual':
708
745
  break;
709
746
 
710
747
  case 'safe':
711
748
  case 'strict':
712
- modeCategory = getCategoryForScript(src, blockingMode);
749
+ modeCategory = getCategoryForScript(src, blockingMode$2);
713
750
  break;
714
751
 
715
752
  case 'doomsday':
@@ -743,7 +780,7 @@ function blockScript(script) {
743
780
  return false;
744
781
  }
745
782
 
746
- if (checkConsent$1(category)) {
783
+ if (checkConsent$3(category)) {
747
784
  // Consent already given - allow script
748
785
  script.setAttribute('data-zest-processed', 'allowed');
749
786
  return false;
@@ -777,7 +814,7 @@ function blockScript(script) {
777
814
  script.removeAttribute('src');
778
815
  }
779
816
 
780
- if (scriptQueue.length < MAX_QUEUE_SIZE) {
817
+ if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
781
818
  scriptQueue.push(scriptInfo);
782
819
  }
783
820
  return true;
@@ -858,8 +895,8 @@ function handleMutations(mutations) {
858
895
  * Start observing for new scripts
859
896
  */
860
897
  function startScriptBlocking(mode = 'safe', customDomains = []) {
861
- blockingMode = mode;
862
- customBlockedDomains = customDomains;
898
+ blockingMode$2 = mode;
899
+ customBlockedDomains$2 = customDomains;
863
900
 
864
901
  // Process existing scripts
865
902
  processExistingScripts();
@@ -875,6 +912,568 @@ function startScriptBlocking(mode = 'safe', customDomains = []) {
875
912
  return true;
876
913
  }
877
914
 
915
+ /**
916
+ * Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
917
+ *
918
+ * Why this exists separately from the script blocker:
919
+ *
920
+ * Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
921
+ * Webflow) increasingly proxy their tracker code through the site's own
922
+ * origin to defeat ad-blockers. The <script> tag itself is first-party
923
+ * (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
924
+ * cannot match it. But at RUNTIME that script still phones home to the
925
+ * vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
926
+ * URL is third-party.
927
+ *
928
+ * This interceptor sits on the network layer and uses the same
929
+ * customBlockedDomains + mode-based tracker list as the script blocker.
930
+ * Whatever the user told Zest to block (typically generated by an AI
931
+ * audit) gets blocked regardless of which API the tracker uses.
932
+ *
933
+ * No replay: network calls are one-shot and time-sensitive. Replaying a
934
+ * stale beacon after consent would create confusing / duplicated data,
935
+ * so blocked requests are dropped, not queued.
936
+ */
937
+
938
+
939
+ // Originals captured at install time. Stored for restoration tests and
940
+ // for any internal Zest network calls we may add later.
941
+ let originalFetch = null;
942
+ let originalXhrOpen = null;
943
+ let originalXhrSend = null;
944
+ let originalSendBeacon = null;
945
+
946
+ let blockingMode$1 = 'safe';
947
+ let customBlockedDomains$1 = [];
948
+ let installed$1 = false;
949
+
950
+ let checkConsent$2 = () => false;
951
+
952
+ const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
953
+
954
+ function setConsentChecker$1(fn) {
955
+ checkConsent$2 = fn;
956
+ }
957
+
958
+ /**
959
+ * Resolve a Request | URL | string to an absolute URL string. Returns
960
+ * null if the input cannot be parsed — callers treat null as "do not
961
+ * block" (we'd rather let an opaque request through than crash the page).
962
+ */
963
+ function resolveUrl(input) {
964
+ try {
965
+ if (typeof input === 'string') {
966
+ return new URL(input, location.href).href;
967
+ }
968
+ if (input && typeof input === 'object') {
969
+ if (typeof input.url === 'string') {
970
+ // Request object
971
+ return new URL(input.url, location.href).href;
972
+ }
973
+ if (typeof input.href === 'string') {
974
+ // URL object
975
+ return input.href;
976
+ }
977
+ }
978
+ } catch (e) {
979
+ // fallthrough
980
+ }
981
+ return null;
982
+ }
983
+
984
+ /**
985
+ * Match a URL against the user's customBlockedDomains list. Mirrors
986
+ * matchesCustomDomains() in script-blocker.js — kept inline rather than
987
+ * shared so each interceptor can be lifted independently.
988
+ */
989
+ function matchesCustomDomains$1(hostname) {
990
+ if (!hostname || customBlockedDomains$1.length === 0) return null;
991
+ const host = hostname.toLowerCase();
992
+ for (const entry of customBlockedDomains$1) {
993
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
994
+ if (!domain) continue;
995
+ const category = typeof entry === 'string'
996
+ ? 'marketing'
997
+ : (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
998
+ if (host === domain || host.endsWith('.' + domain)) {
999
+ return category;
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+
1005
+ /**
1006
+ * Decide whether a URL should be blocked and return its category, or
1007
+ * null if it should pass through. Priority: customBlockedDomains >
1008
+ * mode-based tracker list (matching script-blocker priority).
1009
+ */
1010
+ function getBlockCategory$1(url) {
1011
+ if (!url) return null;
1012
+ let hostname;
1013
+ try {
1014
+ hostname = new URL(url, location.href).hostname;
1015
+ } catch (e) {
1016
+ return null;
1017
+ }
1018
+
1019
+ const customCategory = matchesCustomDomains$1(hostname);
1020
+ if (customCategory) return customCategory;
1021
+
1022
+ switch (blockingMode$1) {
1023
+ case 'manual':
1024
+ return null;
1025
+ case 'safe':
1026
+ case 'strict':
1027
+ return getCategoryForScript(url, blockingMode$1);
1028
+ case 'doomsday':
1029
+ if (isThirdParty(url)) {
1030
+ return getCategoryForScript(url, 'strict') || 'marketing';
1031
+ }
1032
+ return null;
1033
+ default:
1034
+ return null;
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Should the request be blocked right now? Returns the category that
1040
+ * caused the block (for logging / callbacks later) or null.
1041
+ */
1042
+ function shouldBlock$1(url) {
1043
+ const category = getBlockCategory$1(url);
1044
+ if (!category) return null;
1045
+ if (checkConsent$2(category)) return null;
1046
+ return category;
1047
+ }
1048
+
1049
+ /**
1050
+ * Construct an empty, successful-looking Response for a blocked fetch.
1051
+ * Status 204 (No Content) is the most honest "we deliberately returned
1052
+ * nothing" signal. Trackers that .then(r => r.json()) will get an empty
1053
+ * body and typically silently move on.
1054
+ */
1055
+ function blockedResponse() {
1056
+ // Some environments (older browsers, strict CSP) may not have Response
1057
+ // — fall back to a thenable shape the most common tracker code expects.
1058
+ if (typeof Response === 'function') {
1059
+ return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
1060
+ }
1061
+ const fake = {
1062
+ ok: false,
1063
+ status: 204,
1064
+ statusText: 'Blocked by Zest',
1065
+ json: () => Promise.resolve({}),
1066
+ text: () => Promise.resolve(''),
1067
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
1068
+ };
1069
+ return fake;
1070
+ }
1071
+
1072
+ /**
1073
+ * Install fetch hook. Captures the original so we can both restore it
1074
+ * later and use it for any internal Zest network calls.
1075
+ */
1076
+ function patchFetch() {
1077
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
1078
+ originalFetch = window.fetch.bind(window);
1079
+
1080
+ window.fetch = function zestPatchedFetch(input, init) {
1081
+ const url = resolveUrl(input);
1082
+ if (shouldBlock$1(url)) {
1083
+ return Promise.resolve(blockedResponse());
1084
+ }
1085
+ return originalFetch(input, init);
1086
+ };
1087
+ }
1088
+
1089
+ /**
1090
+ * Install XMLHttpRequest hook. We patch .open() to capture the URL on
1091
+ * the instance, then .send() to decide whether to abort. Using a hidden
1092
+ * symbol on the instance avoids leaking state and survives any wrapping
1093
+ * code that reassigns request properties.
1094
+ */
1095
+ const URL_KEY = Symbol('zestUrl');
1096
+
1097
+ function patchXhr() {
1098
+ if (typeof XMLHttpRequest === 'undefined') return;
1099
+ const proto = XMLHttpRequest.prototype;
1100
+ originalXhrOpen = proto.open;
1101
+ originalXhrSend = proto.send;
1102
+
1103
+ proto.open = function (method, url, ...rest) {
1104
+ this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
1105
+ return originalXhrOpen.call(this, method, url, ...rest);
1106
+ };
1107
+
1108
+ proto.send = function (body) {
1109
+ const url = this[URL_KEY];
1110
+ if (shouldBlock$1(url)) {
1111
+ // Mimic the failure mode of a network error: queueMicrotask is
1112
+ // used so consumers that synchronously attach handlers after
1113
+ // .send() still receive the events.
1114
+ const xhr = this;
1115
+ queueMicrotask(() => {
1116
+ try {
1117
+ // Best-effort — readonly props in some environments
1118
+ Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
1119
+ Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
1120
+ } catch (e) {
1121
+ // ignore
1122
+ }
1123
+ try {
1124
+ xhr.dispatchEvent(new Event('error'));
1125
+ xhr.dispatchEvent(new Event('loadend'));
1126
+ } catch (e) {
1127
+ // ignore
1128
+ }
1129
+ });
1130
+ return;
1131
+ }
1132
+ return originalXhrSend.call(this, body);
1133
+ };
1134
+ }
1135
+
1136
+ /**
1137
+ * Install navigator.sendBeacon hook. Returning false matches the spec's
1138
+ * "data was not queued" semantics; trackers that check the return value
1139
+ * fall back to fetch (which we also block) or give up.
1140
+ */
1141
+ function patchSendBeacon() {
1142
+ if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
1143
+ originalSendBeacon = navigator.sendBeacon.bind(navigator);
1144
+
1145
+ navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
1146
+ if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
1147
+ return false;
1148
+ }
1149
+ return originalSendBeacon(url, data);
1150
+ };
1151
+ }
1152
+
1153
+ /**
1154
+ * Install all network hooks. Safe to call multiple times — subsequent
1155
+ * calls just refresh mode + custom domain config without re-wrapping.
1156
+ */
1157
+ function interceptNetwork(mode = 'safe', customDomains = []) {
1158
+ blockingMode$1 = mode;
1159
+ customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
1160
+
1161
+ if (installed$1) return true;
1162
+ patchFetch();
1163
+ patchXhr();
1164
+ patchSendBeacon();
1165
+ installed$1 = true;
1166
+ return true;
1167
+ }
1168
+
1169
+ /**
1170
+ * Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
1171
+ *
1172
+ * The script-blocker uses MutationObserver. That fires asynchronously
1173
+ * (microtask after the DOM mutation), so by the time we can react the
1174
+ * browser has already kicked off the network request for the src/href.
1175
+ * The script may not execute (we flip type to text/plain) but the
1176
+ * fetch already left the building — and to ConsentTheater / a privacy
1177
+ * audit that fetch IS a pre-consent leak.
1178
+ *
1179
+ * This interceptor patches the prototype setters and Element.setAttribute
1180
+ * synchronously, so when code does:
1181
+ *
1182
+ * const s = document.createElement('script');
1183
+ * s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
1184
+ * document.head.appendChild(s); // ← src is already empty,
1185
+ * // no fetch ever fired
1186
+ *
1187
+ * Covers four element types and both ways to set the URL:
1188
+ *
1189
+ * - HTMLScriptElement src
1190
+ * - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
1191
+ * - HTMLImageElement src (tracking pixels)
1192
+ * - HTMLIFrameElement src (tracking iframes)
1193
+ *
1194
+ * Plus the global Image() constructor used by classic pixel trackers.
1195
+ *
1196
+ * What this does NOT catch: inline HTML <script src=...> / <link href=...>
1197
+ * tags parsed from the original HTML response. The browser starts those
1198
+ * fetches as soon as it encounters the tag during parsing, BEFORE any
1199
+ * JavaScript runs. The only complete fix for that class is server-side
1200
+ * CSP or template-time removal.
1201
+ */
1202
+
1203
+
1204
+ // Upper bound on queued blocked elements. Unbounded growth would be a
1205
+ // memory-exhaustion vector if a page (or a hostile script) tried to
1206
+ // flood us with src writes.
1207
+ const MAX_QUEUE_SIZE = 500;
1208
+
1209
+ // Queue of blocked element writes. Each entry remembers enough to
1210
+ // re-apply the original URL via the ORIGINAL setter once consent
1211
+ // arrives for its category. Without this queue, blocked scripts /
1212
+ // stylesheets / images would be lost forever and require a page
1213
+ // reload to come back.
1214
+ const elementQueue = [];
1215
+
1216
+ let blockingMode = 'safe';
1217
+ let customBlockedDomains = [];
1218
+ let installed = false;
1219
+ let checkConsent$1 = () => false;
1220
+
1221
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
1222
+
1223
+ // Map of tag name -> attribute name that carries a URL we may want to
1224
+ // block. Lowercased on both sides; setAttribute() gating uses this.
1225
+ const URL_ATTRS = {
1226
+ script: 'src',
1227
+ link: 'href',
1228
+ img: 'src',
1229
+ iframe: 'src'
1230
+ };
1231
+
1232
+ function setConsentChecker(fn) {
1233
+ checkConsent$1 = fn;
1234
+ }
1235
+
1236
+ function matchesCustomDomains(hostname) {
1237
+ if (!hostname || customBlockedDomains.length === 0) return null;
1238
+ const host = hostname.toLowerCase();
1239
+ for (const entry of customBlockedDomains) {
1240
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
1241
+ if (!domain) continue;
1242
+ const category = typeof entry === 'string'
1243
+ ? 'marketing'
1244
+ : (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
1245
+ if (host === domain || host.endsWith('.' + domain)) {
1246
+ return category;
1247
+ }
1248
+ }
1249
+ return null;
1250
+ }
1251
+
1252
+ function getBlockCategory(url) {
1253
+ if (!url) return null;
1254
+ let hostname;
1255
+ try {
1256
+ hostname = new URL(url, location.href).hostname;
1257
+ } catch (e) {
1258
+ return null;
1259
+ }
1260
+
1261
+ const customCategory = matchesCustomDomains(hostname);
1262
+ if (customCategory) return customCategory;
1263
+
1264
+ switch (blockingMode) {
1265
+ case 'manual':
1266
+ return null;
1267
+ case 'safe':
1268
+ case 'strict':
1269
+ return getCategoryForScript(url, blockingMode);
1270
+ case 'doomsday':
1271
+ if (isThirdParty(url)) {
1272
+ return getCategoryForScript(url, 'strict') || 'marketing';
1273
+ }
1274
+ return null;
1275
+ default:
1276
+ return null;
1277
+ }
1278
+ }
1279
+
1280
+ function shouldBlock(url) {
1281
+ const category = getBlockCategory(url);
1282
+ if (!category) return null;
1283
+ if (checkConsent$1(category)) return null;
1284
+ return category;
1285
+ }
1286
+
1287
+ /**
1288
+ * Replace the property setter for `prop` on `ProtoCtor.prototype` with
1289
+ * a gated version. Returns the original descriptor so we can restore.
1290
+ */
1291
+ function patchUrlSetter(ProtoCtor, prop) {
1292
+ if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
1293
+ const proto = ProtoCtor.prototype;
1294
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
1295
+ if (!desc || typeof desc.set !== 'function') return null;
1296
+
1297
+ Object.defineProperty(proto, prop, {
1298
+ configurable: true,
1299
+ enumerable: desc.enumerable,
1300
+ get: desc.get,
1301
+ set(value) {
1302
+ if (typeof value === 'string') {
1303
+ const category = shouldBlock(value);
1304
+ if (category) {
1305
+ // Don't pass through to the original setter — the URL never
1306
+ // touches the element. Stash the element + URL + category
1307
+ // + original descriptor in the queue so replayElements()
1308
+ // can reinstate it once consent arrives.
1309
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1310
+ elementQueue.push({
1311
+ element: this,
1312
+ setter: desc.set,
1313
+ prop,
1314
+ value,
1315
+ category,
1316
+ method: 'property'
1317
+ });
1318
+ }
1319
+ return;
1320
+ }
1321
+ }
1322
+ return desc.set.call(this, value);
1323
+ }
1324
+ });
1325
+
1326
+ return desc;
1327
+ }
1328
+
1329
+ function patchSetAttribute() {
1330
+ if (typeof Element === 'undefined' || !Element.prototype) return null;
1331
+ const orig = Element.prototype.setAttribute;
1332
+
1333
+ Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
1334
+ // Fast path: bail out for anything not on our watchlist before doing
1335
+ // any string work. setAttribute is hot — keep this cheap.
1336
+ if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
1337
+ return orig.call(this, name, value);
1338
+ }
1339
+ const tag = this.tagName.toLowerCase();
1340
+ const watched = URL_ATTRS[tag];
1341
+ if (!watched) {
1342
+ return orig.call(this, name, value);
1343
+ }
1344
+ const attr = name.toLowerCase();
1345
+ if (attr !== watched) {
1346
+ return orig.call(this, name, value);
1347
+ }
1348
+ const category = shouldBlock(value);
1349
+ if (category) {
1350
+ // Drop silently and queue for replay. The element keeps any
1351
+ // other attributes you set before / after.
1352
+ if (elementQueue.length < MAX_QUEUE_SIZE) {
1353
+ elementQueue.push({
1354
+ element: this,
1355
+ setter: orig, // setAttribute itself, called like orig.call(el, name, value)
1356
+ prop: name,
1357
+ value,
1358
+ category,
1359
+ method: 'attribute'
1360
+ });
1361
+ }
1362
+ return;
1363
+ }
1364
+ return orig.call(this, name, value);
1365
+ };
1366
+
1367
+ return orig;
1368
+ }
1369
+
1370
+ function patchImageConstructor() {
1371
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
1372
+ const OrigImage = window.Image;
1373
+
1374
+ function PatchedImage(width, height) {
1375
+ const img = arguments.length >= 2
1376
+ ? new OrigImage(width, height)
1377
+ : new OrigImage();
1378
+ // No work needed here — the .src setter patch on HTMLImageElement
1379
+ // will catch any later assignment. PatchedImage exists mainly to
1380
+ // expose the .src patch via this path for `new Image()` users.
1381
+ return img;
1382
+ }
1383
+ PatchedImage.prototype = OrigImage.prototype;
1384
+ // Copy any static fields just in case.
1385
+ for (const key of Object.keys(OrigImage)) {
1386
+ try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
1387
+ }
1388
+
1389
+ try {
1390
+ Object.defineProperty(window, 'Image', {
1391
+ configurable: true,
1392
+ writable: true,
1393
+ value: PatchedImage
1394
+ });
1395
+ } catch (e) {
1396
+ window.Image = PatchedImage;
1397
+ }
1398
+
1399
+ return OrigImage;
1400
+ }
1401
+
1402
+ /**
1403
+ * Replay blocked element writes for newly-allowed categories.
1404
+ *
1405
+ * For each queued entry whose category is in `allowedCategories`:
1406
+ * - If the element is still connected to the DOM, re-apply the
1407
+ * URL via the ORIGINAL setter / setAttribute. The browser starts
1408
+ * the fetch as if nothing had been intercepted.
1409
+ * - If the element has since been removed (no `isConnected`), drop
1410
+ * the entry — calling code lost its reference and we have no
1411
+ * parent to attach to.
1412
+ *
1413
+ * Queue ordering is preserved so that scripts/stylesheets re-execute
1414
+ * in the same order the page originally requested them.
1415
+ */
1416
+ function replayElements(allowedCategories) {
1417
+ if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
1418
+ const remaining = [];
1419
+
1420
+ for (const item of elementQueue) {
1421
+ if (!allowedCategories.includes(item.category)) {
1422
+ remaining.push(item);
1423
+ continue;
1424
+ }
1425
+
1426
+ const el = item.element;
1427
+ if (!el || !el.isConnected) {
1428
+ // Element is detached or gone — nothing to re-apply against.
1429
+ continue;
1430
+ }
1431
+
1432
+ try {
1433
+ if (item.method === 'attribute') {
1434
+ item.setter.call(el, item.prop, item.value);
1435
+ } else {
1436
+ item.setter.call(el, item.value);
1437
+ }
1438
+ } catch (e) {
1439
+ // Restoration failed (rare — element might be in a weird state).
1440
+ // Don't requeue; one failure is enough.
1441
+ }
1442
+ }
1443
+
1444
+ elementQueue.length = 0;
1445
+ elementQueue.push(...remaining);
1446
+ }
1447
+
1448
+ /**
1449
+ * Install all element-level interceptors. Idempotent — second call
1450
+ * just refreshes mode + customDomains without rewrapping.
1451
+ */
1452
+ function interceptElements(mode = 'safe', customDomains = []) {
1453
+ blockingMode = mode;
1454
+ customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
1455
+
1456
+ if (installed) return true;
1457
+
1458
+ if (typeof HTMLScriptElement !== 'undefined') {
1459
+ patchUrlSetter(HTMLScriptElement, 'src');
1460
+ }
1461
+ if (typeof HTMLLinkElement !== 'undefined') {
1462
+ patchUrlSetter(HTMLLinkElement, 'href');
1463
+ }
1464
+ if (typeof HTMLImageElement !== 'undefined') {
1465
+ patchUrlSetter(HTMLImageElement, 'src');
1466
+ }
1467
+ if (typeof HTMLIFrameElement !== 'undefined') {
1468
+ patchUrlSetter(HTMLIFrameElement, 'src');
1469
+ }
1470
+ patchSetAttribute();
1471
+ patchImageConstructor();
1472
+
1473
+ installed = true;
1474
+ return true;
1475
+ }
1476
+
878
1477
  /**
879
1478
  * Default consent categories
880
1479
  */
@@ -1663,6 +2262,33 @@ const DEFAULTS = {
1663
2262
  // Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
1664
2263
  mode: 'safe',
1665
2264
 
2265
+ // Interceptor toggles. By default Zest installs cookie + storage
2266
+ // interceptors that route writes through the consent layer. Consumers
2267
+ // who manage gating themselves (typically headless mode with custom
2268
+ // analytics integrations) can opt out per channel.
2269
+ intercept: {
2270
+ cookies: true,
2271
+ storage: true,
2272
+ scripts: true,
2273
+ network: true
2274
+ },
2275
+
2276
+ // Strictly-necessary declarations. Both fields *append* to whatever
2277
+ // the essential category already matches via the pattern matcher
2278
+ // defaults — they do not replace.
2279
+ //
2280
+ // - essentialKeys: array of exact storage / cookie names to treat
2281
+ // as strictly-necessary. Easiest case.
2282
+ // - essentialPatterns: array of regex source strings, validated via
2283
+ // safeRegExp. For prefix or family matches.
2284
+ //
2285
+ // Use these instead of `patterns.essential` when you only want to
2286
+ // ADD entries to the essential category without replacing the
2287
+ // built-in patterns (zest_*, csrf*, xsrf*, session*, __host-*,
2288
+ // __secure-*).
2289
+ essentialKeys: [],
2290
+ essentialPatterns: [],
2291
+
1666
2292
  // Custom domains to block (in addition to mode-based blocking)
1667
2293
  blockedDomains: [], // days
1668
2294
 
@@ -1745,6 +2371,28 @@ function mergeConfig(userConfig) {
1745
2371
  config.patterns = userConfig.patterns;
1746
2372
  }
1747
2373
 
2374
+ // Interceptor toggles — shallow-merge so consumers can pass partial
2375
+ // overrides like `intercept: { storage: false }` without losing the
2376
+ // other defaults.
2377
+ if (userConfig.intercept && typeof userConfig.intercept === 'object') {
2378
+ config.intercept = {
2379
+ ...DEFAULTS.intercept,
2380
+ ...userConfig.intercept
2381
+ };
2382
+ }
2383
+
2384
+ // Strictly-necessary declarations
2385
+ if (Array.isArray(userConfig.essentialKeys)) {
2386
+ config.essentialKeys = userConfig.essentialKeys.filter(
2387
+ (k) => typeof k === 'string' && k.length > 0 && k.length <= 200
2388
+ );
2389
+ }
2390
+ if (Array.isArray(userConfig.essentialPatterns)) {
2391
+ config.essentialPatterns = userConfig.essentialPatterns.filter(
2392
+ (p) => typeof p === 'string' && p.length > 0 && p.length <= 500
2393
+ );
2394
+ }
2395
+
1748
2396
  return config;
1749
2397
  }
1750
2398
 
@@ -2053,6 +2701,11 @@ function replayAll(categories) {
2053
2701
  replayCookies(categories);
2054
2702
  replayStorage(categories);
2055
2703
  replayScripts(categories);
2704
+ // Element-level replays (script/link/img/iframe URLs that were
2705
+ // dropped at the prototype-setter / setAttribute layer). Network
2706
+ // interceptor (fetch/XHR/sendBeacon) intentionally has no replay
2707
+ // — beacons are one-shot and resending would duplicate analytics.
2708
+ replayElements(categories);
2056
2709
  }
2057
2710
 
2058
2711
  /**
@@ -2084,13 +2737,44 @@ function coreInit(userConfig = {}) {
2084
2737
  setPatterns(currentConfig.patterns);
2085
2738
  }
2086
2739
 
2740
+ // Append consumer-declared strictly-necessary entries on top of
2741
+ // whatever's already in the essential category. This is the friendly
2742
+ // alternative to overriding via `patterns.essential` directly.
2743
+ if (
2744
+ (Array.isArray(currentConfig.essentialKeys) && currentConfig.essentialKeys.length > 0) ||
2745
+ (Array.isArray(currentConfig.essentialPatterns) && currentConfig.essentialPatterns.length > 0)
2746
+ ) {
2747
+ appendPatternsToCategory('essential', {
2748
+ keys: currentConfig.essentialKeys,
2749
+ patternStrings: currentConfig.essentialPatterns
2750
+ });
2751
+ }
2752
+
2753
+ setConsentChecker$4(checkConsent);
2754
+ setConsentChecker$3(checkConsent);
2087
2755
  setConsentChecker$2(checkConsent);
2088
2756
  setConsentChecker$1(checkConsent);
2089
2757
  setConsentChecker(checkConsent);
2090
2758
 
2091
- interceptCookies();
2092
- interceptStorage();
2093
- startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2759
+ // Interceptor toggles. By default everything is intercepted (back-compat
2760
+ // with v2.0 / v2.1). Consumers that gate scripts and storage themselves
2761
+ // can opt out per channel via `intercept: { storage: false, … }`.
2762
+ const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
2763
+ if (intercept.cookies !== false) interceptCookies();
2764
+ if (intercept.storage !== false) interceptStorage();
2765
+ if (intercept.scripts !== false) {
2766
+ // Element-level synchronous interception (prototype setters +
2767
+ // setAttribute) installs BEFORE startScriptBlocking so that the
2768
+ // moment any later script does `el.src = "https://tracker..."`,
2769
+ // we drop the URL before the browser fetches. The MutationObserver
2770
+ // inside startScriptBlocking remains as a defence-in-depth net for
2771
+ // anything that slips past (e.g. nodes constructed via cloneNode).
2772
+ interceptElements(currentConfig.mode, currentConfig.blockedDomains);
2773
+ startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
2774
+ }
2775
+ if (intercept.network !== false) {
2776
+ interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
2777
+ }
2094
2778
 
2095
2779
  const consent = loadConsent();
2096
2780
  initialized = true;