@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.
- package/dist/zest.d.ts +40 -0
- package/dist/zest.de.js +763 -51
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +763 -51
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +763 -51
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +763 -51
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +763 -51
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.d.ts +40 -0
- package/dist/zest.headless.esm.js +717 -33
- package/dist/zest.headless.esm.js.map +1 -1
- package/dist/zest.headless.esm.min.js +1 -1
- package/dist/zest.it.js +763 -51
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +763 -51
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +763 -51
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +763 -51
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +763 -51
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +763 -51
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +763 -51
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +763 -51
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +763 -51
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +1 -1
- package/src/config/defaults.js +49 -0
- package/src/core/element-interceptor.js +374 -0
- package/src/core/network-interceptor.js +289 -0
- package/src/core/pattern-matcher.js +37 -0
- package/src/core-lifecycle.js +43 -5
- package/src/index.js +46 -18
- package/src/types/zest.d.ts +40 -0
- package/src/types/zest.headless.d.ts +40 -0
- package/zest.config.schema.json +26 -0
package/dist/zest.pl.js
CHANGED
|
@@ -258,10 +258,47 @@ var Zest = (function () {
|
|
|
258
258
|
|
|
259
259
|
let patterns = { ...DEFAULT_PATTERNS };
|
|
260
260
|
|
|
261
|
+
/** Escape a string so it can be embedded in a regex literal verbatim. */
|
|
262
|
+
function escapeRegex(value) {
|
|
263
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Append patterns to a single category without replacing what's already
|
|
268
|
+
* there. Used by `essentialKeys` and `essentialPatterns` config to extend
|
|
269
|
+
* the strictly-necessary category with consumer-specific entries while
|
|
270
|
+
* keeping the built-in defaults (zest_*, csrf*, xsrf*, etc.).
|
|
271
|
+
*
|
|
272
|
+
* `keys` is an array of exact storage/cookie names; each one is
|
|
273
|
+
* compiled as a fully-anchored regex via `escapeRegex`.
|
|
274
|
+
* `patternStrings` is an array of regex source strings, each validated
|
|
275
|
+
* via `safeRegExp`. Invalid entries are dropped silently.
|
|
276
|
+
*/
|
|
277
|
+
function appendPatternsToCategory(category, { keys = [], patternStrings = [] } = {}) {
|
|
278
|
+
if (!patterns[category]) patterns[category] = [];
|
|
279
|
+
|
|
280
|
+
for (const key of keys) {
|
|
281
|
+
if (typeof key !== 'string' || !key) continue;
|
|
282
|
+
const re = safeRegExp(`^${escapeRegex(key)}$`);
|
|
283
|
+
if (re) patterns[category].push(re);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const p of patternStrings) {
|
|
287
|
+
if (typeof p !== 'string' || !p) continue;
|
|
288
|
+
const re = safeRegExp(p);
|
|
289
|
+
if (re) patterns[category].push(re);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
261
293
|
/**
|
|
262
294
|
* Set custom patterns. User-supplied strings are validated with safeRegExp,
|
|
263
295
|
* which rejects catastrophic-backtracking shapes and syntax errors.
|
|
264
296
|
* Invalid patterns are silently dropped with a console warning.
|
|
297
|
+
*
|
|
298
|
+
* Note: this REPLACES the patterns for any category present in
|
|
299
|
+
* `customPatterns`. To extend the essential category without losing the
|
|
300
|
+
* built-in defaults, use `appendPatternsToCategory()` (or pass
|
|
301
|
+
* `essentialKeys` / `essentialPatterns` to `Zest.init()`).
|
|
265
302
|
*/
|
|
266
303
|
function setPatterns(customPatterns) {
|
|
267
304
|
patterns = { ...DEFAULT_PATTERNS };
|
|
@@ -321,19 +358,19 @@ var Zest = (function () {
|
|
|
321
358
|
// Upper bound on the number of queued cookies awaiting consent replay.
|
|
322
359
|
// An unbounded queue is a memory-exhaustion DoS vector — a hostile
|
|
323
360
|
// script could flood it with document.cookie writes.
|
|
324
|
-
const MAX_QUEUE_SIZE$
|
|
361
|
+
const MAX_QUEUE_SIZE$3 = 100;
|
|
325
362
|
|
|
326
363
|
// Queue for blocked cookies
|
|
327
364
|
const cookieQueue = [];
|
|
328
365
|
|
|
329
366
|
// Reference to consent checker function
|
|
330
|
-
let checkConsent$
|
|
367
|
+
let checkConsent$5 = () => false;
|
|
331
368
|
|
|
332
369
|
/**
|
|
333
370
|
* Set the consent checker function
|
|
334
371
|
*/
|
|
335
|
-
function setConsentChecker$
|
|
336
|
-
checkConsent$
|
|
372
|
+
function setConsentChecker$4(fn) {
|
|
373
|
+
checkConsent$5 = fn;
|
|
337
374
|
}
|
|
338
375
|
|
|
339
376
|
/**
|
|
@@ -389,10 +426,10 @@ var Zest = (function () {
|
|
|
389
426
|
|
|
390
427
|
const category = getCategoryForName(name);
|
|
391
428
|
|
|
392
|
-
if (checkConsent$
|
|
429
|
+
if (checkConsent$5(category)) {
|
|
393
430
|
// Consent given - set cookie
|
|
394
431
|
originalCookieDescriptor.set.call(document, value);
|
|
395
|
-
} else if (cookieQueue.length < MAX_QUEUE_SIZE$
|
|
432
|
+
} else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
|
|
396
433
|
// No consent - queue for later (capped to prevent DoS)
|
|
397
434
|
cookieQueue.push({
|
|
398
435
|
value,
|
|
@@ -417,7 +454,7 @@ var Zest = (function () {
|
|
|
417
454
|
|
|
418
455
|
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
419
456
|
// growth would be a memory-exhaustion DoS vector.
|
|
420
|
-
const MAX_QUEUE_SIZE$
|
|
457
|
+
const MAX_QUEUE_SIZE$2 = 200;
|
|
421
458
|
|
|
422
459
|
// Store originals
|
|
423
460
|
let originalLocalStorage = null;
|
|
@@ -428,13 +465,13 @@ var Zest = (function () {
|
|
|
428
465
|
const sessionStorageQueue = [];
|
|
429
466
|
|
|
430
467
|
// Reference to consent checker function
|
|
431
|
-
let checkConsent$
|
|
468
|
+
let checkConsent$4 = () => false;
|
|
432
469
|
|
|
433
470
|
/**
|
|
434
471
|
* Set the consent checker function
|
|
435
472
|
*/
|
|
436
|
-
function setConsentChecker$
|
|
437
|
-
checkConsent$
|
|
473
|
+
function setConsentChecker$3(fn) {
|
|
474
|
+
checkConsent$4 = fn;
|
|
438
475
|
}
|
|
439
476
|
|
|
440
477
|
/**
|
|
@@ -447,9 +484,9 @@ var Zest = (function () {
|
|
|
447
484
|
return (key, value) => {
|
|
448
485
|
const category = getCategoryForName(key);
|
|
449
486
|
|
|
450
|
-
if (checkConsent$
|
|
487
|
+
if (checkConsent$4(category)) {
|
|
451
488
|
target.setItem(key, value);
|
|
452
|
-
} else if (queue.length < MAX_QUEUE_SIZE$
|
|
489
|
+
} else if (queue.length < MAX_QUEUE_SIZE$2) {
|
|
453
490
|
queue.push({
|
|
454
491
|
key,
|
|
455
492
|
value,
|
|
@@ -732,11 +769,11 @@ var Zest = (function () {
|
|
|
732
769
|
|
|
733
770
|
// Categories the author has declared blockable. A script can self-label
|
|
734
771
|
// into one of these, but not into 'essential' (a common bypass).
|
|
735
|
-
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
772
|
+
const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
|
|
736
773
|
|
|
737
774
|
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
738
775
|
// hostile page from flooding the queue with <script> nodes.
|
|
739
|
-
const MAX_QUEUE_SIZE = 500;
|
|
776
|
+
const MAX_QUEUE_SIZE$1 = 500;
|
|
740
777
|
|
|
741
778
|
// Queue for blocked scripts — the authoritative source for replay,
|
|
742
779
|
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
@@ -747,31 +784,31 @@ var Zest = (function () {
|
|
|
747
784
|
let observer = null;
|
|
748
785
|
|
|
749
786
|
// Current blocking mode
|
|
750
|
-
let blockingMode = 'safe';
|
|
787
|
+
let blockingMode$2 = 'safe';
|
|
751
788
|
|
|
752
789
|
// Custom blocked domains (user-defined)
|
|
753
|
-
let customBlockedDomains = [];
|
|
790
|
+
let customBlockedDomains$2 = [];
|
|
754
791
|
|
|
755
792
|
// Reference to consent checker function
|
|
756
|
-
let checkConsent$
|
|
793
|
+
let checkConsent$3 = () => false;
|
|
757
794
|
|
|
758
795
|
/**
|
|
759
796
|
* Set the consent checker function
|
|
760
797
|
*/
|
|
761
|
-
function setConsentChecker(fn) {
|
|
762
|
-
checkConsent$
|
|
798
|
+
function setConsentChecker$2(fn) {
|
|
799
|
+
checkConsent$3 = fn;
|
|
763
800
|
}
|
|
764
801
|
|
|
765
802
|
/**
|
|
766
803
|
* Check if script URL matches custom blocked domains
|
|
767
804
|
*/
|
|
768
|
-
function matchesCustomDomains(url) {
|
|
769
|
-
if (!url || customBlockedDomains.length === 0) return null;
|
|
805
|
+
function matchesCustomDomains$2(url) {
|
|
806
|
+
if (!url || customBlockedDomains$2.length === 0) return null;
|
|
770
807
|
|
|
771
808
|
try {
|
|
772
809
|
const hostname = new URL(url).hostname.toLowerCase();
|
|
773
810
|
|
|
774
|
-
for (const entry of customBlockedDomains) {
|
|
811
|
+
for (const entry of customBlockedDomains$2) {
|
|
775
812
|
const domain = typeof entry === 'string' ? entry : entry.domain;
|
|
776
813
|
const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
|
|
777
814
|
|
|
@@ -804,7 +841,7 @@ var Zest = (function () {
|
|
|
804
841
|
// Only honor values from the blockable set; 'essential' and unknown
|
|
805
842
|
// values fall through to the other checks.
|
|
806
843
|
const explicitCategory = script.getAttribute('data-consent-category');
|
|
807
|
-
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
844
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
|
|
808
845
|
? explicitCategory
|
|
809
846
|
: null;
|
|
810
847
|
|
|
@@ -816,17 +853,17 @@ var Zest = (function () {
|
|
|
816
853
|
}
|
|
817
854
|
|
|
818
855
|
// 2. Check custom blocked domains
|
|
819
|
-
const customCategory = matchesCustomDomains(src);
|
|
856
|
+
const customCategory = matchesCustomDomains$2(src);
|
|
820
857
|
|
|
821
858
|
// 3. Mode-based blocking
|
|
822
859
|
let modeCategory = null;
|
|
823
|
-
switch (blockingMode) {
|
|
860
|
+
switch (blockingMode$2) {
|
|
824
861
|
case 'manual':
|
|
825
862
|
break;
|
|
826
863
|
|
|
827
864
|
case 'safe':
|
|
828
865
|
case 'strict':
|
|
829
|
-
modeCategory = getCategoryForScript(src, blockingMode);
|
|
866
|
+
modeCategory = getCategoryForScript(src, blockingMode$2);
|
|
830
867
|
break;
|
|
831
868
|
|
|
832
869
|
case 'doomsday':
|
|
@@ -860,7 +897,7 @@ var Zest = (function () {
|
|
|
860
897
|
return false;
|
|
861
898
|
}
|
|
862
899
|
|
|
863
|
-
if (checkConsent$
|
|
900
|
+
if (checkConsent$3(category)) {
|
|
864
901
|
// Consent already given - allow script
|
|
865
902
|
script.setAttribute('data-zest-processed', 'allowed');
|
|
866
903
|
return false;
|
|
@@ -894,7 +931,7 @@ var Zest = (function () {
|
|
|
894
931
|
script.removeAttribute('src');
|
|
895
932
|
}
|
|
896
933
|
|
|
897
|
-
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
934
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
|
|
898
935
|
scriptQueue.push(scriptInfo);
|
|
899
936
|
}
|
|
900
937
|
return true;
|
|
@@ -975,8 +1012,8 @@ var Zest = (function () {
|
|
|
975
1012
|
* Start observing for new scripts
|
|
976
1013
|
*/
|
|
977
1014
|
function startScriptBlocking(mode = 'safe', customDomains = []) {
|
|
978
|
-
blockingMode = mode;
|
|
979
|
-
customBlockedDomains = customDomains;
|
|
1015
|
+
blockingMode$2 = mode;
|
|
1016
|
+
customBlockedDomains$2 = customDomains;
|
|
980
1017
|
|
|
981
1018
|
// Process existing scripts
|
|
982
1019
|
processExistingScripts();
|
|
@@ -992,6 +1029,568 @@ var Zest = (function () {
|
|
|
992
1029
|
return true;
|
|
993
1030
|
}
|
|
994
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
|
|
1034
|
+
*
|
|
1035
|
+
* Why this exists separately from the script blocker:
|
|
1036
|
+
*
|
|
1037
|
+
* Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
|
|
1038
|
+
* Webflow) increasingly proxy their tracker code through the site's own
|
|
1039
|
+
* origin to defeat ad-blockers. The <script> tag itself is first-party
|
|
1040
|
+
* (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
|
|
1041
|
+
* cannot match it. But at RUNTIME that script still phones home to the
|
|
1042
|
+
* vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
|
|
1043
|
+
* URL is third-party.
|
|
1044
|
+
*
|
|
1045
|
+
* This interceptor sits on the network layer and uses the same
|
|
1046
|
+
* customBlockedDomains + mode-based tracker list as the script blocker.
|
|
1047
|
+
* Whatever the user told Zest to block (typically generated by an AI
|
|
1048
|
+
* audit) gets blocked regardless of which API the tracker uses.
|
|
1049
|
+
*
|
|
1050
|
+
* No replay: network calls are one-shot and time-sensitive. Replaying a
|
|
1051
|
+
* stale beacon after consent would create confusing / duplicated data,
|
|
1052
|
+
* so blocked requests are dropped, not queued.
|
|
1053
|
+
*/
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
// Originals captured at install time. Stored for restoration tests and
|
|
1057
|
+
// for any internal Zest network calls we may add later.
|
|
1058
|
+
let originalFetch = null;
|
|
1059
|
+
let originalXhrOpen = null;
|
|
1060
|
+
let originalXhrSend = null;
|
|
1061
|
+
let originalSendBeacon = null;
|
|
1062
|
+
|
|
1063
|
+
let blockingMode$1 = 'safe';
|
|
1064
|
+
let customBlockedDomains$1 = [];
|
|
1065
|
+
let installed$1 = false;
|
|
1066
|
+
|
|
1067
|
+
let checkConsent$2 = () => false;
|
|
1068
|
+
|
|
1069
|
+
const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
|
|
1070
|
+
|
|
1071
|
+
function setConsentChecker$1(fn) {
|
|
1072
|
+
checkConsent$2 = fn;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Resolve a Request | URL | string to an absolute URL string. Returns
|
|
1077
|
+
* null if the input cannot be parsed — callers treat null as "do not
|
|
1078
|
+
* block" (we'd rather let an opaque request through than crash the page).
|
|
1079
|
+
*/
|
|
1080
|
+
function resolveUrl(input) {
|
|
1081
|
+
try {
|
|
1082
|
+
if (typeof input === 'string') {
|
|
1083
|
+
return new URL(input, location.href).href;
|
|
1084
|
+
}
|
|
1085
|
+
if (input && typeof input === 'object') {
|
|
1086
|
+
if (typeof input.url === 'string') {
|
|
1087
|
+
// Request object
|
|
1088
|
+
return new URL(input.url, location.href).href;
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof input.href === 'string') {
|
|
1091
|
+
// URL object
|
|
1092
|
+
return input.href;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
// fallthrough
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Match a URL against the user's customBlockedDomains list. Mirrors
|
|
1103
|
+
* matchesCustomDomains() in script-blocker.js — kept inline rather than
|
|
1104
|
+
* shared so each interceptor can be lifted independently.
|
|
1105
|
+
*/
|
|
1106
|
+
function matchesCustomDomains$1(hostname) {
|
|
1107
|
+
if (!hostname || customBlockedDomains$1.length === 0) return null;
|
|
1108
|
+
const host = hostname.toLowerCase();
|
|
1109
|
+
for (const entry of customBlockedDomains$1) {
|
|
1110
|
+
const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
|
|
1111
|
+
if (!domain) continue;
|
|
1112
|
+
const category = typeof entry === 'string'
|
|
1113
|
+
? 'marketing'
|
|
1114
|
+
: (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
|
|
1115
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
1116
|
+
return category;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Decide whether a URL should be blocked and return its category, or
|
|
1124
|
+
* null if it should pass through. Priority: customBlockedDomains >
|
|
1125
|
+
* mode-based tracker list (matching script-blocker priority).
|
|
1126
|
+
*/
|
|
1127
|
+
function getBlockCategory$1(url) {
|
|
1128
|
+
if (!url) return null;
|
|
1129
|
+
let hostname;
|
|
1130
|
+
try {
|
|
1131
|
+
hostname = new URL(url, location.href).hostname;
|
|
1132
|
+
} catch (e) {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const customCategory = matchesCustomDomains$1(hostname);
|
|
1137
|
+
if (customCategory) return customCategory;
|
|
1138
|
+
|
|
1139
|
+
switch (blockingMode$1) {
|
|
1140
|
+
case 'manual':
|
|
1141
|
+
return null;
|
|
1142
|
+
case 'safe':
|
|
1143
|
+
case 'strict':
|
|
1144
|
+
return getCategoryForScript(url, blockingMode$1);
|
|
1145
|
+
case 'doomsday':
|
|
1146
|
+
if (isThirdParty(url)) {
|
|
1147
|
+
return getCategoryForScript(url, 'strict') || 'marketing';
|
|
1148
|
+
}
|
|
1149
|
+
return null;
|
|
1150
|
+
default:
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Should the request be blocked right now? Returns the category that
|
|
1157
|
+
* caused the block (for logging / callbacks later) or null.
|
|
1158
|
+
*/
|
|
1159
|
+
function shouldBlock$1(url) {
|
|
1160
|
+
const category = getBlockCategory$1(url);
|
|
1161
|
+
if (!category) return null;
|
|
1162
|
+
if (checkConsent$2(category)) return null;
|
|
1163
|
+
return category;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Construct an empty, successful-looking Response for a blocked fetch.
|
|
1168
|
+
* Status 204 (No Content) is the most honest "we deliberately returned
|
|
1169
|
+
* nothing" signal. Trackers that .then(r => r.json()) will get an empty
|
|
1170
|
+
* body and typically silently move on.
|
|
1171
|
+
*/
|
|
1172
|
+
function blockedResponse() {
|
|
1173
|
+
// Some environments (older browsers, strict CSP) may not have Response
|
|
1174
|
+
// — fall back to a thenable shape the most common tracker code expects.
|
|
1175
|
+
if (typeof Response === 'function') {
|
|
1176
|
+
return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
|
|
1177
|
+
}
|
|
1178
|
+
const fake = {
|
|
1179
|
+
ok: false,
|
|
1180
|
+
status: 204,
|
|
1181
|
+
statusText: 'Blocked by Zest',
|
|
1182
|
+
json: () => Promise.resolve({}),
|
|
1183
|
+
text: () => Promise.resolve(''),
|
|
1184
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
|
1185
|
+
};
|
|
1186
|
+
return fake;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Install fetch hook. Captures the original so we can both restore it
|
|
1191
|
+
* later and use it for any internal Zest network calls.
|
|
1192
|
+
*/
|
|
1193
|
+
function patchFetch() {
|
|
1194
|
+
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
|
1195
|
+
originalFetch = window.fetch.bind(window);
|
|
1196
|
+
|
|
1197
|
+
window.fetch = function zestPatchedFetch(input, init) {
|
|
1198
|
+
const url = resolveUrl(input);
|
|
1199
|
+
if (shouldBlock$1(url)) {
|
|
1200
|
+
return Promise.resolve(blockedResponse());
|
|
1201
|
+
}
|
|
1202
|
+
return originalFetch(input, init);
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Install XMLHttpRequest hook. We patch .open() to capture the URL on
|
|
1208
|
+
* the instance, then .send() to decide whether to abort. Using a hidden
|
|
1209
|
+
* symbol on the instance avoids leaking state and survives any wrapping
|
|
1210
|
+
* code that reassigns request properties.
|
|
1211
|
+
*/
|
|
1212
|
+
const URL_KEY = Symbol('zestUrl');
|
|
1213
|
+
|
|
1214
|
+
function patchXhr() {
|
|
1215
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
1216
|
+
const proto = XMLHttpRequest.prototype;
|
|
1217
|
+
originalXhrOpen = proto.open;
|
|
1218
|
+
originalXhrSend = proto.send;
|
|
1219
|
+
|
|
1220
|
+
proto.open = function (method, url, ...rest) {
|
|
1221
|
+
this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
|
|
1222
|
+
return originalXhrOpen.call(this, method, url, ...rest);
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
proto.send = function (body) {
|
|
1226
|
+
const url = this[URL_KEY];
|
|
1227
|
+
if (shouldBlock$1(url)) {
|
|
1228
|
+
// Mimic the failure mode of a network error: queueMicrotask is
|
|
1229
|
+
// used so consumers that synchronously attach handlers after
|
|
1230
|
+
// .send() still receive the events.
|
|
1231
|
+
const xhr = this;
|
|
1232
|
+
queueMicrotask(() => {
|
|
1233
|
+
try {
|
|
1234
|
+
// Best-effort — readonly props in some environments
|
|
1235
|
+
Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
|
|
1236
|
+
Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
// ignore
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
xhr.dispatchEvent(new Event('error'));
|
|
1242
|
+
xhr.dispatchEvent(new Event('loadend'));
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
// ignore
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
return originalXhrSend.call(this, body);
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Install navigator.sendBeacon hook. Returning false matches the spec's
|
|
1255
|
+
* "data was not queued" semantics; trackers that check the return value
|
|
1256
|
+
* fall back to fetch (which we also block) or give up.
|
|
1257
|
+
*/
|
|
1258
|
+
function patchSendBeacon() {
|
|
1259
|
+
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
|
|
1260
|
+
originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
|
1261
|
+
|
|
1262
|
+
navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
|
|
1263
|
+
if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
return originalSendBeacon(url, data);
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Install all network hooks. Safe to call multiple times — subsequent
|
|
1272
|
+
* calls just refresh mode + custom domain config without re-wrapping.
|
|
1273
|
+
*/
|
|
1274
|
+
function interceptNetwork(mode = 'safe', customDomains = []) {
|
|
1275
|
+
blockingMode$1 = mode;
|
|
1276
|
+
customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
|
|
1277
|
+
|
|
1278
|
+
if (installed$1) return true;
|
|
1279
|
+
patchFetch();
|
|
1280
|
+
patchXhr();
|
|
1281
|
+
patchSendBeacon();
|
|
1282
|
+
installed$1 = true;
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
|
|
1288
|
+
*
|
|
1289
|
+
* The script-blocker uses MutationObserver. That fires asynchronously
|
|
1290
|
+
* (microtask after the DOM mutation), so by the time we can react the
|
|
1291
|
+
* browser has already kicked off the network request for the src/href.
|
|
1292
|
+
* The script may not execute (we flip type to text/plain) but the
|
|
1293
|
+
* fetch already left the building — and to ConsentTheater / a privacy
|
|
1294
|
+
* audit that fetch IS a pre-consent leak.
|
|
1295
|
+
*
|
|
1296
|
+
* This interceptor patches the prototype setters and Element.setAttribute
|
|
1297
|
+
* synchronously, so when code does:
|
|
1298
|
+
*
|
|
1299
|
+
* const s = document.createElement('script');
|
|
1300
|
+
* s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
|
|
1301
|
+
* document.head.appendChild(s); // ← src is already empty,
|
|
1302
|
+
* // no fetch ever fired
|
|
1303
|
+
*
|
|
1304
|
+
* Covers four element types and both ways to set the URL:
|
|
1305
|
+
*
|
|
1306
|
+
* - HTMLScriptElement src
|
|
1307
|
+
* - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
|
|
1308
|
+
* - HTMLImageElement src (tracking pixels)
|
|
1309
|
+
* - HTMLIFrameElement src (tracking iframes)
|
|
1310
|
+
*
|
|
1311
|
+
* Plus the global Image() constructor used by classic pixel trackers.
|
|
1312
|
+
*
|
|
1313
|
+
* What this does NOT catch: inline HTML <script src=...> / <link href=...>
|
|
1314
|
+
* tags parsed from the original HTML response. The browser starts those
|
|
1315
|
+
* fetches as soon as it encounters the tag during parsing, BEFORE any
|
|
1316
|
+
* JavaScript runs. The only complete fix for that class is server-side
|
|
1317
|
+
* CSP or template-time removal.
|
|
1318
|
+
*/
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
// Upper bound on queued blocked elements. Unbounded growth would be a
|
|
1322
|
+
// memory-exhaustion vector if a page (or a hostile script) tried to
|
|
1323
|
+
// flood us with src writes.
|
|
1324
|
+
const MAX_QUEUE_SIZE = 500;
|
|
1325
|
+
|
|
1326
|
+
// Queue of blocked element writes. Each entry remembers enough to
|
|
1327
|
+
// re-apply the original URL via the ORIGINAL setter once consent
|
|
1328
|
+
// arrives for its category. Without this queue, blocked scripts /
|
|
1329
|
+
// stylesheets / images would be lost forever and require a page
|
|
1330
|
+
// reload to come back.
|
|
1331
|
+
const elementQueue = [];
|
|
1332
|
+
|
|
1333
|
+
let blockingMode = 'safe';
|
|
1334
|
+
let customBlockedDomains = [];
|
|
1335
|
+
let installed = false;
|
|
1336
|
+
let checkConsent$1 = () => false;
|
|
1337
|
+
|
|
1338
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
1339
|
+
|
|
1340
|
+
// Map of tag name -> attribute name that carries a URL we may want to
|
|
1341
|
+
// block. Lowercased on both sides; setAttribute() gating uses this.
|
|
1342
|
+
const URL_ATTRS = {
|
|
1343
|
+
script: 'src',
|
|
1344
|
+
link: 'href',
|
|
1345
|
+
img: 'src',
|
|
1346
|
+
iframe: 'src'
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
function setConsentChecker(fn) {
|
|
1350
|
+
checkConsent$1 = fn;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function matchesCustomDomains(hostname) {
|
|
1354
|
+
if (!hostname || customBlockedDomains.length === 0) return null;
|
|
1355
|
+
const host = hostname.toLowerCase();
|
|
1356
|
+
for (const entry of customBlockedDomains) {
|
|
1357
|
+
const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
|
|
1358
|
+
if (!domain) continue;
|
|
1359
|
+
const category = typeof entry === 'string'
|
|
1360
|
+
? 'marketing'
|
|
1361
|
+
: (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
|
|
1362
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
1363
|
+
return category;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function getBlockCategory(url) {
|
|
1370
|
+
if (!url) return null;
|
|
1371
|
+
let hostname;
|
|
1372
|
+
try {
|
|
1373
|
+
hostname = new URL(url, location.href).hostname;
|
|
1374
|
+
} catch (e) {
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const customCategory = matchesCustomDomains(hostname);
|
|
1379
|
+
if (customCategory) return customCategory;
|
|
1380
|
+
|
|
1381
|
+
switch (blockingMode) {
|
|
1382
|
+
case 'manual':
|
|
1383
|
+
return null;
|
|
1384
|
+
case 'safe':
|
|
1385
|
+
case 'strict':
|
|
1386
|
+
return getCategoryForScript(url, blockingMode);
|
|
1387
|
+
case 'doomsday':
|
|
1388
|
+
if (isThirdParty(url)) {
|
|
1389
|
+
return getCategoryForScript(url, 'strict') || 'marketing';
|
|
1390
|
+
}
|
|
1391
|
+
return null;
|
|
1392
|
+
default:
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function shouldBlock(url) {
|
|
1398
|
+
const category = getBlockCategory(url);
|
|
1399
|
+
if (!category) return null;
|
|
1400
|
+
if (checkConsent$1(category)) return null;
|
|
1401
|
+
return category;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Replace the property setter for `prop` on `ProtoCtor.prototype` with
|
|
1406
|
+
* a gated version. Returns the original descriptor so we can restore.
|
|
1407
|
+
*/
|
|
1408
|
+
function patchUrlSetter(ProtoCtor, prop) {
|
|
1409
|
+
if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
|
|
1410
|
+
const proto = ProtoCtor.prototype;
|
|
1411
|
+
const desc = Object.getOwnPropertyDescriptor(proto, prop);
|
|
1412
|
+
if (!desc || typeof desc.set !== 'function') return null;
|
|
1413
|
+
|
|
1414
|
+
Object.defineProperty(proto, prop, {
|
|
1415
|
+
configurable: true,
|
|
1416
|
+
enumerable: desc.enumerable,
|
|
1417
|
+
get: desc.get,
|
|
1418
|
+
set(value) {
|
|
1419
|
+
if (typeof value === 'string') {
|
|
1420
|
+
const category = shouldBlock(value);
|
|
1421
|
+
if (category) {
|
|
1422
|
+
// Don't pass through to the original setter — the URL never
|
|
1423
|
+
// touches the element. Stash the element + URL + category
|
|
1424
|
+
// + original descriptor in the queue so replayElements()
|
|
1425
|
+
// can reinstate it once consent arrives.
|
|
1426
|
+
if (elementQueue.length < MAX_QUEUE_SIZE) {
|
|
1427
|
+
elementQueue.push({
|
|
1428
|
+
element: this,
|
|
1429
|
+
setter: desc.set,
|
|
1430
|
+
prop,
|
|
1431
|
+
value,
|
|
1432
|
+
category,
|
|
1433
|
+
method: 'property'
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
return desc.set.call(this, value);
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
return desc;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function patchSetAttribute() {
|
|
1447
|
+
if (typeof Element === 'undefined' || !Element.prototype) return null;
|
|
1448
|
+
const orig = Element.prototype.setAttribute;
|
|
1449
|
+
|
|
1450
|
+
Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
|
|
1451
|
+
// Fast path: bail out for anything not on our watchlist before doing
|
|
1452
|
+
// any string work. setAttribute is hot — keep this cheap.
|
|
1453
|
+
if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
|
|
1454
|
+
return orig.call(this, name, value);
|
|
1455
|
+
}
|
|
1456
|
+
const tag = this.tagName.toLowerCase();
|
|
1457
|
+
const watched = URL_ATTRS[tag];
|
|
1458
|
+
if (!watched) {
|
|
1459
|
+
return orig.call(this, name, value);
|
|
1460
|
+
}
|
|
1461
|
+
const attr = name.toLowerCase();
|
|
1462
|
+
if (attr !== watched) {
|
|
1463
|
+
return orig.call(this, name, value);
|
|
1464
|
+
}
|
|
1465
|
+
const category = shouldBlock(value);
|
|
1466
|
+
if (category) {
|
|
1467
|
+
// Drop silently and queue for replay. The element keeps any
|
|
1468
|
+
// other attributes you set before / after.
|
|
1469
|
+
if (elementQueue.length < MAX_QUEUE_SIZE) {
|
|
1470
|
+
elementQueue.push({
|
|
1471
|
+
element: this,
|
|
1472
|
+
setter: orig, // setAttribute itself, called like orig.call(el, name, value)
|
|
1473
|
+
prop: name,
|
|
1474
|
+
value,
|
|
1475
|
+
category,
|
|
1476
|
+
method: 'attribute'
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
return orig.call(this, name, value);
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
return orig;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function patchImageConstructor() {
|
|
1488
|
+
if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
|
|
1489
|
+
const OrigImage = window.Image;
|
|
1490
|
+
|
|
1491
|
+
function PatchedImage(width, height) {
|
|
1492
|
+
const img = arguments.length >= 2
|
|
1493
|
+
? new OrigImage(width, height)
|
|
1494
|
+
: new OrigImage();
|
|
1495
|
+
// No work needed here — the .src setter patch on HTMLImageElement
|
|
1496
|
+
// will catch any later assignment. PatchedImage exists mainly to
|
|
1497
|
+
// expose the .src patch via this path for `new Image()` users.
|
|
1498
|
+
return img;
|
|
1499
|
+
}
|
|
1500
|
+
PatchedImage.prototype = OrigImage.prototype;
|
|
1501
|
+
// Copy any static fields just in case.
|
|
1502
|
+
for (const key of Object.keys(OrigImage)) {
|
|
1503
|
+
try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
try {
|
|
1507
|
+
Object.defineProperty(window, 'Image', {
|
|
1508
|
+
configurable: true,
|
|
1509
|
+
writable: true,
|
|
1510
|
+
value: PatchedImage
|
|
1511
|
+
});
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
window.Image = PatchedImage;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
return OrigImage;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Replay blocked element writes for newly-allowed categories.
|
|
1521
|
+
*
|
|
1522
|
+
* For each queued entry whose category is in `allowedCategories`:
|
|
1523
|
+
* - If the element is still connected to the DOM, re-apply the
|
|
1524
|
+
* URL via the ORIGINAL setter / setAttribute. The browser starts
|
|
1525
|
+
* the fetch as if nothing had been intercepted.
|
|
1526
|
+
* - If the element has since been removed (no `isConnected`), drop
|
|
1527
|
+
* the entry — calling code lost its reference and we have no
|
|
1528
|
+
* parent to attach to.
|
|
1529
|
+
*
|
|
1530
|
+
* Queue ordering is preserved so that scripts/stylesheets re-execute
|
|
1531
|
+
* in the same order the page originally requested them.
|
|
1532
|
+
*/
|
|
1533
|
+
function replayElements(allowedCategories) {
|
|
1534
|
+
if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
|
|
1535
|
+
const remaining = [];
|
|
1536
|
+
|
|
1537
|
+
for (const item of elementQueue) {
|
|
1538
|
+
if (!allowedCategories.includes(item.category)) {
|
|
1539
|
+
remaining.push(item);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const el = item.element;
|
|
1544
|
+
if (!el || !el.isConnected) {
|
|
1545
|
+
// Element is detached or gone — nothing to re-apply against.
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
try {
|
|
1550
|
+
if (item.method === 'attribute') {
|
|
1551
|
+
item.setter.call(el, item.prop, item.value);
|
|
1552
|
+
} else {
|
|
1553
|
+
item.setter.call(el, item.value);
|
|
1554
|
+
}
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
// Restoration failed (rare — element might be in a weird state).
|
|
1557
|
+
// Don't requeue; one failure is enough.
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
elementQueue.length = 0;
|
|
1562
|
+
elementQueue.push(...remaining);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Install all element-level interceptors. Idempotent — second call
|
|
1567
|
+
* just refreshes mode + customDomains without rewrapping.
|
|
1568
|
+
*/
|
|
1569
|
+
function interceptElements(mode = 'safe', customDomains = []) {
|
|
1570
|
+
blockingMode = mode;
|
|
1571
|
+
customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
|
|
1572
|
+
|
|
1573
|
+
if (installed) return true;
|
|
1574
|
+
|
|
1575
|
+
if (typeof HTMLScriptElement !== 'undefined') {
|
|
1576
|
+
patchUrlSetter(HTMLScriptElement, 'src');
|
|
1577
|
+
}
|
|
1578
|
+
if (typeof HTMLLinkElement !== 'undefined') {
|
|
1579
|
+
patchUrlSetter(HTMLLinkElement, 'href');
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof HTMLImageElement !== 'undefined') {
|
|
1582
|
+
patchUrlSetter(HTMLImageElement, 'src');
|
|
1583
|
+
}
|
|
1584
|
+
if (typeof HTMLIFrameElement !== 'undefined') {
|
|
1585
|
+
patchUrlSetter(HTMLIFrameElement, 'src');
|
|
1586
|
+
}
|
|
1587
|
+
patchSetAttribute();
|
|
1588
|
+
patchImageConstructor();
|
|
1589
|
+
|
|
1590
|
+
installed = true;
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
995
1594
|
/**
|
|
996
1595
|
* Default consent categories
|
|
997
1596
|
*/
|
|
@@ -1292,6 +1891,33 @@ var Zest = (function () {
|
|
|
1292
1891
|
// Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
|
|
1293
1892
|
mode: 'safe',
|
|
1294
1893
|
|
|
1894
|
+
// Interceptor toggles. By default Zest installs cookie + storage
|
|
1895
|
+
// interceptors that route writes through the consent layer. Consumers
|
|
1896
|
+
// who manage gating themselves (typically headless mode with custom
|
|
1897
|
+
// analytics integrations) can opt out per channel.
|
|
1898
|
+
intercept: {
|
|
1899
|
+
cookies: true,
|
|
1900
|
+
storage: true,
|
|
1901
|
+
scripts: true,
|
|
1902
|
+
network: true
|
|
1903
|
+
},
|
|
1904
|
+
|
|
1905
|
+
// Strictly-necessary declarations. Both fields *append* to whatever
|
|
1906
|
+
// the essential category already matches via the pattern matcher
|
|
1907
|
+
// defaults — they do not replace.
|
|
1908
|
+
//
|
|
1909
|
+
// - essentialKeys: array of exact storage / cookie names to treat
|
|
1910
|
+
// as strictly-necessary. Easiest case.
|
|
1911
|
+
// - essentialPatterns: array of regex source strings, validated via
|
|
1912
|
+
// safeRegExp. For prefix or family matches.
|
|
1913
|
+
//
|
|
1914
|
+
// Use these instead of `patterns.essential` when you only want to
|
|
1915
|
+
// ADD entries to the essential category without replacing the
|
|
1916
|
+
// built-in patterns (zest_*, csrf*, xsrf*, session*, __host-*,
|
|
1917
|
+
// __secure-*).
|
|
1918
|
+
essentialKeys: [],
|
|
1919
|
+
essentialPatterns: [],
|
|
1920
|
+
|
|
1295
1921
|
// Custom domains to block (in addition to mode-based blocking)
|
|
1296
1922
|
blockedDomains: [], // days
|
|
1297
1923
|
|
|
@@ -1374,6 +2000,28 @@ var Zest = (function () {
|
|
|
1374
2000
|
config.patterns = userConfig.patterns;
|
|
1375
2001
|
}
|
|
1376
2002
|
|
|
2003
|
+
// Interceptor toggles — shallow-merge so consumers can pass partial
|
|
2004
|
+
// overrides like `intercept: { storage: false }` without losing the
|
|
2005
|
+
// other defaults.
|
|
2006
|
+
if (userConfig.intercept && typeof userConfig.intercept === 'object') {
|
|
2007
|
+
config.intercept = {
|
|
2008
|
+
...DEFAULTS.intercept,
|
|
2009
|
+
...userConfig.intercept
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Strictly-necessary declarations
|
|
2014
|
+
if (Array.isArray(userConfig.essentialKeys)) {
|
|
2015
|
+
config.essentialKeys = userConfig.essentialKeys.filter(
|
|
2016
|
+
(k) => typeof k === 'string' && k.length > 0 && k.length <= 200
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
if (Array.isArray(userConfig.essentialPatterns)) {
|
|
2020
|
+
config.essentialPatterns = userConfig.essentialPatterns.filter(
|
|
2021
|
+
(p) => typeof p === 'string' && p.length > 0 && p.length <= 500
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1377
2025
|
return config;
|
|
1378
2026
|
}
|
|
1379
2027
|
|
|
@@ -1785,6 +2433,11 @@ var Zest = (function () {
|
|
|
1785
2433
|
replayCookies(categories);
|
|
1786
2434
|
replayStorage(categories);
|
|
1787
2435
|
replayScripts(categories);
|
|
2436
|
+
// Element-level replays (script/link/img/iframe URLs that were
|
|
2437
|
+
// dropped at the prototype-setter / setAttribute layer). Network
|
|
2438
|
+
// interceptor (fetch/XHR/sendBeacon) intentionally has no replay
|
|
2439
|
+
// — beacons are one-shot and resending would duplicate analytics.
|
|
2440
|
+
replayElements(categories);
|
|
1788
2441
|
}
|
|
1789
2442
|
|
|
1790
2443
|
/**
|
|
@@ -1816,13 +2469,44 @@ var Zest = (function () {
|
|
|
1816
2469
|
setPatterns(currentConfig.patterns);
|
|
1817
2470
|
}
|
|
1818
2471
|
|
|
2472
|
+
// Append consumer-declared strictly-necessary entries on top of
|
|
2473
|
+
// whatever's already in the essential category. This is the friendly
|
|
2474
|
+
// alternative to overriding via `patterns.essential` directly.
|
|
2475
|
+
if (
|
|
2476
|
+
(Array.isArray(currentConfig.essentialKeys) && currentConfig.essentialKeys.length > 0) ||
|
|
2477
|
+
(Array.isArray(currentConfig.essentialPatterns) && currentConfig.essentialPatterns.length > 0)
|
|
2478
|
+
) {
|
|
2479
|
+
appendPatternsToCategory('essential', {
|
|
2480
|
+
keys: currentConfig.essentialKeys,
|
|
2481
|
+
patternStrings: currentConfig.essentialPatterns
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
setConsentChecker$4(checkConsent);
|
|
2486
|
+
setConsentChecker$3(checkConsent);
|
|
1819
2487
|
setConsentChecker$2(checkConsent);
|
|
1820
2488
|
setConsentChecker$1(checkConsent);
|
|
1821
2489
|
setConsentChecker(checkConsent);
|
|
1822
2490
|
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2491
|
+
// Interceptor toggles. By default everything is intercepted (back-compat
|
|
2492
|
+
// with v2.0 / v2.1). Consumers that gate scripts and storage themselves
|
|
2493
|
+
// can opt out per channel via `intercept: { storage: false, … }`.
|
|
2494
|
+
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
|
|
2495
|
+
if (intercept.cookies !== false) interceptCookies();
|
|
2496
|
+
if (intercept.storage !== false) interceptStorage();
|
|
2497
|
+
if (intercept.scripts !== false) {
|
|
2498
|
+
// Element-level synchronous interception (prototype setters +
|
|
2499
|
+
// setAttribute) installs BEFORE startScriptBlocking so that the
|
|
2500
|
+
// moment any later script does `el.src = "https://tracker..."`,
|
|
2501
|
+
// we drop the URL before the browser fetches. The MutationObserver
|
|
2502
|
+
// inside startScriptBlocking remains as a defence-in-depth net for
|
|
2503
|
+
// anything that slips past (e.g. nodes constructed via cloneNode).
|
|
2504
|
+
interceptElements(currentConfig.mode, currentConfig.blockedDomains);
|
|
2505
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
2506
|
+
}
|
|
2507
|
+
if (intercept.network !== false) {
|
|
2508
|
+
interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
|
|
2509
|
+
}
|
|
1826
2510
|
|
|
1827
2511
|
const consent = loadConsent();
|
|
1828
2512
|
initialized = true;
|
|
@@ -2961,18 +3645,29 @@ ${customCss}
|
|
|
2961
3645
|
}
|
|
2962
3646
|
|
|
2963
3647
|
/**
|
|
2964
|
-
*
|
|
3648
|
+
* UI mount guard. We split UI mounting (which needs `<body>` and a parsed
|
|
3649
|
+
* DOM) from interceptor installation (which must happen on script eval to
|
|
3650
|
+
* gate any later `defer` / `async` tracker scripts). `coreInit()` is
|
|
3651
|
+
* idempotent so calling init() before the DOM is ready is safe — the UI
|
|
3652
|
+
* portion just gets queued.
|
|
2965
3653
|
*/
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
3654
|
+
let uiMounted = false;
|
|
3655
|
+
|
|
3656
|
+
function mountUI() {
|
|
3657
|
+
if (uiMounted) return;
|
|
3658
|
+
|
|
3659
|
+
// Banner needs document.body to mount its host element. If body isn't
|
|
3660
|
+
// there yet, requeue on DOMContentLoaded.
|
|
3661
|
+
if (!document || !document.body) {
|
|
3662
|
+
document.addEventListener('DOMContentLoaded', mountUI, { once: true });
|
|
3663
|
+
return;
|
|
2971
3664
|
}
|
|
2972
3665
|
|
|
3666
|
+
uiMounted = true;
|
|
2973
3667
|
const config = getActiveConfig();
|
|
3668
|
+
const decision = hasConsentDecision();
|
|
2974
3669
|
|
|
2975
|
-
if (!
|
|
3670
|
+
if (!decision) {
|
|
2976
3671
|
showBanner({
|
|
2977
3672
|
onAcceptAll: handleAcceptAll,
|
|
2978
3673
|
onRejectAll: handleRejectAll,
|
|
@@ -2982,7 +3677,27 @@ ${customCss}
|
|
|
2982
3677
|
} else if (config?.showWidget) {
|
|
2983
3678
|
showWidget({ onClick: handleShowSettings });
|
|
2984
3679
|
}
|
|
3680
|
+
}
|
|
2985
3681
|
|
|
3682
|
+
/**
|
|
3683
|
+
* Initialize Zest with UI.
|
|
3684
|
+
*
|
|
3685
|
+
* Splits into two phases:
|
|
3686
|
+
*
|
|
3687
|
+
* 1. `coreInit()` runs synchronously: interceptors install on the
|
|
3688
|
+
* cookie / storage / script / network channels immediately so any
|
|
3689
|
+
* `defer` or `async` script that fires later is already gated.
|
|
3690
|
+
* Critical — DOMContentLoaded fires AFTER `defer` scripts execute,
|
|
3691
|
+
* so deferring interceptor install means trackers fire first.
|
|
3692
|
+
*
|
|
3693
|
+
* 2. UI mount (banner / widget) is queued until `<body>` exists. If
|
|
3694
|
+
* this script runs in `<head>` while the document is still
|
|
3695
|
+
* parsing, that means waiting for DOMContentLoaded; if it runs
|
|
3696
|
+
* after, mount happens immediately.
|
|
3697
|
+
*/
|
|
3698
|
+
function init(userConfig = {}) {
|
|
3699
|
+
coreInit(userConfig);
|
|
3700
|
+
mountUI();
|
|
2986
3701
|
return Zest;
|
|
2987
3702
|
}
|
|
2988
3703
|
|
|
@@ -3091,17 +3806,14 @@ ${customCss}
|
|
|
3091
3806
|
window.Zest = Zest;
|
|
3092
3807
|
}
|
|
3093
3808
|
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
document.addEventListener('DOMContentLoaded', autoInit);
|
|
3103
|
-
} else {
|
|
3104
|
-
autoInit();
|
|
3809
|
+
// Run init() synchronously on script eval. init() itself splits the
|
|
3810
|
+
// work — interceptors install now, UI mount waits for <body> if
|
|
3811
|
+
// needed. No DOMContentLoaded wait at this layer: deferring init()
|
|
3812
|
+
// would let any `defer` / `async` tracker script fire its network
|
|
3813
|
+
// calls before our interceptors are in place.
|
|
3814
|
+
const cfg = getConfig();
|
|
3815
|
+
if (cfg.autoInit !== false) {
|
|
3816
|
+
init(window.ZestConfig);
|
|
3105
3817
|
}
|
|
3106
3818
|
}
|
|
3107
3819
|
|