@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
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
3
+ *
4
+ * Why this exists separately from the script blocker:
5
+ *
6
+ * Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
7
+ * Webflow) increasingly proxy their tracker code through the site's own
8
+ * origin to defeat ad-blockers. The <script> tag itself is first-party
9
+ * (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
10
+ * cannot match it. But at RUNTIME that script still phones home to the
11
+ * vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
12
+ * URL is third-party.
13
+ *
14
+ * This interceptor sits on the network layer and uses the same
15
+ * customBlockedDomains + mode-based tracker list as the script blocker.
16
+ * Whatever the user told Zest to block (typically generated by an AI
17
+ * audit) gets blocked regardless of which API the tracker uses.
18
+ *
19
+ * No replay: network calls are one-shot and time-sensitive. Replaying a
20
+ * stale beacon after consent would create confusing / duplicated data,
21
+ * so blocked requests are dropped, not queued.
22
+ */
23
+
24
+ import { getCategoryForScript, isThirdParty } from './known-trackers.js';
25
+
26
+ // Originals captured at install time. Stored for restoration tests and
27
+ // for any internal Zest network calls we may add later.
28
+ let originalFetch = null;
29
+ let originalXhrOpen = null;
30
+ let originalXhrSend = null;
31
+ let originalSendBeacon = null;
32
+
33
+ let blockingMode = 'safe';
34
+ let customBlockedDomains = [];
35
+ let installed = false;
36
+
37
+ let checkConsent = () => false;
38
+
39
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
40
+
41
+ export function setConsentChecker(fn) {
42
+ checkConsent = fn;
43
+ }
44
+
45
+ export function setBlockingMode(mode) {
46
+ blockingMode = mode;
47
+ }
48
+
49
+ export function setCustomBlockedDomains(domains) {
50
+ customBlockedDomains = Array.isArray(domains) ? domains : [];
51
+ }
52
+
53
+ /**
54
+ * Resolve a Request | URL | string to an absolute URL string. Returns
55
+ * null if the input cannot be parsed — callers treat null as "do not
56
+ * block" (we'd rather let an opaque request through than crash the page).
57
+ */
58
+ function resolveUrl(input) {
59
+ try {
60
+ if (typeof input === 'string') {
61
+ return new URL(input, location.href).href;
62
+ }
63
+ if (input && typeof input === 'object') {
64
+ if (typeof input.url === 'string') {
65
+ // Request object
66
+ return new URL(input.url, location.href).href;
67
+ }
68
+ if (typeof input.href === 'string') {
69
+ // URL object
70
+ return input.href;
71
+ }
72
+ }
73
+ } catch (e) {
74
+ // fallthrough
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Match a URL against the user's customBlockedDomains list. Mirrors
81
+ * matchesCustomDomains() in script-blocker.js — kept inline rather than
82
+ * shared so each interceptor can be lifted independently.
83
+ */
84
+ function matchesCustomDomains(hostname) {
85
+ if (!hostname || customBlockedDomains.length === 0) return null;
86
+ const host = hostname.toLowerCase();
87
+ for (const entry of customBlockedDomains) {
88
+ const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
89
+ if (!domain) continue;
90
+ const category = typeof entry === 'string'
91
+ ? 'marketing'
92
+ : (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
93
+ if (host === domain || host.endsWith('.' + domain)) {
94
+ return category;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Decide whether a URL should be blocked and return its category, or
102
+ * null if it should pass through. Priority: customBlockedDomains >
103
+ * mode-based tracker list (matching script-blocker priority).
104
+ */
105
+ function getBlockCategory(url) {
106
+ if (!url) return null;
107
+ let hostname;
108
+ try {
109
+ hostname = new URL(url, location.href).hostname;
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+
114
+ const customCategory = matchesCustomDomains(hostname);
115
+ if (customCategory) return customCategory;
116
+
117
+ switch (blockingMode) {
118
+ case 'manual':
119
+ return null;
120
+ case 'safe':
121
+ case 'strict':
122
+ return getCategoryForScript(url, blockingMode);
123
+ case 'doomsday':
124
+ if (isThirdParty(url)) {
125
+ return getCategoryForScript(url, 'strict') || 'marketing';
126
+ }
127
+ return null;
128
+ default:
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Should the request be blocked right now? Returns the category that
135
+ * caused the block (for logging / callbacks later) or null.
136
+ */
137
+ function shouldBlock(url) {
138
+ const category = getBlockCategory(url);
139
+ if (!category) return null;
140
+ if (checkConsent(category)) return null;
141
+ return category;
142
+ }
143
+
144
+ /**
145
+ * Construct an empty, successful-looking Response for a blocked fetch.
146
+ * Status 204 (No Content) is the most honest "we deliberately returned
147
+ * nothing" signal. Trackers that .then(r => r.json()) will get an empty
148
+ * body and typically silently move on.
149
+ */
150
+ function blockedResponse() {
151
+ // Some environments (older browsers, strict CSP) may not have Response
152
+ // — fall back to a thenable shape the most common tracker code expects.
153
+ if (typeof Response === 'function') {
154
+ return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
155
+ }
156
+ const fake = {
157
+ ok: false,
158
+ status: 204,
159
+ statusText: 'Blocked by Zest',
160
+ json: () => Promise.resolve({}),
161
+ text: () => Promise.resolve(''),
162
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
163
+ };
164
+ return fake;
165
+ }
166
+
167
+ /**
168
+ * Install fetch hook. Captures the original so we can both restore it
169
+ * later and use it for any internal Zest network calls.
170
+ */
171
+ function patchFetch() {
172
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
173
+ originalFetch = window.fetch.bind(window);
174
+
175
+ window.fetch = function zestPatchedFetch(input, init) {
176
+ const url = resolveUrl(input);
177
+ if (shouldBlock(url)) {
178
+ return Promise.resolve(blockedResponse());
179
+ }
180
+ return originalFetch(input, init);
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Install XMLHttpRequest hook. We patch .open() to capture the URL on
186
+ * the instance, then .send() to decide whether to abort. Using a hidden
187
+ * symbol on the instance avoids leaking state and survives any wrapping
188
+ * code that reassigns request properties.
189
+ */
190
+ const URL_KEY = Symbol('zestUrl');
191
+
192
+ function patchXhr() {
193
+ if (typeof XMLHttpRequest === 'undefined') return;
194
+ const proto = XMLHttpRequest.prototype;
195
+ originalXhrOpen = proto.open;
196
+ originalXhrSend = proto.send;
197
+
198
+ proto.open = function (method, url, ...rest) {
199
+ this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
200
+ return originalXhrOpen.call(this, method, url, ...rest);
201
+ };
202
+
203
+ proto.send = function (body) {
204
+ const url = this[URL_KEY];
205
+ if (shouldBlock(url)) {
206
+ // Mimic the failure mode of a network error: queueMicrotask is
207
+ // used so consumers that synchronously attach handlers after
208
+ // .send() still receive the events.
209
+ const xhr = this;
210
+ queueMicrotask(() => {
211
+ try {
212
+ // Best-effort — readonly props in some environments
213
+ Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
214
+ Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
215
+ } catch (e) {
216
+ // ignore
217
+ }
218
+ try {
219
+ xhr.dispatchEvent(new Event('error'));
220
+ xhr.dispatchEvent(new Event('loadend'));
221
+ } catch (e) {
222
+ // ignore
223
+ }
224
+ });
225
+ return;
226
+ }
227
+ return originalXhrSend.call(this, body);
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Install navigator.sendBeacon hook. Returning false matches the spec's
233
+ * "data was not queued" semantics; trackers that check the return value
234
+ * fall back to fetch (which we also block) or give up.
235
+ */
236
+ function patchSendBeacon() {
237
+ if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
238
+ originalSendBeacon = navigator.sendBeacon.bind(navigator);
239
+
240
+ navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
241
+ if (shouldBlock(typeof url === 'string' ? url : (url && url.href) || '')) {
242
+ return false;
243
+ }
244
+ return originalSendBeacon(url, data);
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Install all network hooks. Safe to call multiple times — subsequent
250
+ * calls just refresh mode + custom domain config without re-wrapping.
251
+ */
252
+ export function interceptNetwork(mode = 'safe', customDomains = []) {
253
+ blockingMode = mode;
254
+ customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
255
+
256
+ if (installed) return true;
257
+ patchFetch();
258
+ patchXhr();
259
+ patchSendBeacon();
260
+ installed = true;
261
+ return true;
262
+ }
263
+
264
+ /**
265
+ * Test helper / opt-out. Restores the original APIs. Not called by
266
+ * coreInit — exposed for unit tests and headless consumers that need
267
+ * to tear down their environment between consent flows.
268
+ */
269
+ export function restoreNetwork() {
270
+ if (!installed) return;
271
+ if (originalFetch && typeof window !== 'undefined') window.fetch = originalFetch;
272
+ if (originalXhrOpen && typeof XMLHttpRequest !== 'undefined') {
273
+ XMLHttpRequest.prototype.open = originalXhrOpen;
274
+ }
275
+ if (originalXhrSend && typeof XMLHttpRequest !== 'undefined') {
276
+ XMLHttpRequest.prototype.send = originalXhrSend;
277
+ }
278
+ if (originalSendBeacon && typeof navigator !== 'undefined') {
279
+ navigator.sendBeacon = originalSendBeacon;
280
+ }
281
+ installed = false;
282
+ }
283
+
284
+ /**
285
+ * For tests: query install state.
286
+ */
287
+ export function isInstalled() {
288
+ return installed;
289
+ }
@@ -51,10 +51,47 @@ export const DEFAULT_PATTERNS = {
51
51
 
52
52
  let patterns = { ...DEFAULT_PATTERNS };
53
53
 
54
+ /** Escape a string so it can be embedded in a regex literal verbatim. */
55
+ function escapeRegex(value) {
56
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+
59
+ /**
60
+ * Append patterns to a single category without replacing what's already
61
+ * there. Used by `essentialKeys` and `essentialPatterns` config to extend
62
+ * the strictly-necessary category with consumer-specific entries while
63
+ * keeping the built-in defaults (zest_*, csrf*, xsrf*, etc.).
64
+ *
65
+ * `keys` is an array of exact storage/cookie names; each one is
66
+ * compiled as a fully-anchored regex via `escapeRegex`.
67
+ * `patternStrings` is an array of regex source strings, each validated
68
+ * via `safeRegExp`. Invalid entries are dropped silently.
69
+ */
70
+ export function appendPatternsToCategory(category, { keys = [], patternStrings = [] } = {}) {
71
+ if (!patterns[category]) patterns[category] = [];
72
+
73
+ for (const key of keys) {
74
+ if (typeof key !== 'string' || !key) continue;
75
+ const re = safeRegExp(`^${escapeRegex(key)}$`);
76
+ if (re) patterns[category].push(re);
77
+ }
78
+
79
+ for (const p of patternStrings) {
80
+ if (typeof p !== 'string' || !p) continue;
81
+ const re = safeRegExp(p);
82
+ if (re) patterns[category].push(re);
83
+ }
84
+ }
85
+
54
86
  /**
55
87
  * Set custom patterns. User-supplied strings are validated with safeRegExp,
56
88
  * which rejects catastrophic-backtracking shapes and syntax errors.
57
89
  * Invalid patterns are silently dropped with a console warning.
90
+ *
91
+ * Note: this REPLACES the patterns for any category present in
92
+ * `customPatterns`. To extend the essential category without losing the
93
+ * built-in defaults, use `appendPatternsToCategory()` (or pass
94
+ * `essentialKeys` / `essentialPatterns` to `Zest.init()`).
58
95
  */
59
96
  export function setPatterns(customPatterns) {
60
97
  patterns = { ...DEFAULT_PATTERNS };
@@ -11,7 +11,9 @@
11
11
  import { interceptCookies, setConsentChecker as setCookieChecker, replayCookies } from './core/cookie-interceptor.js';
12
12
  import { interceptStorage, setConsentChecker as setStorageChecker, replayStorage } from './core/storage-interceptor.js';
13
13
  import { startScriptBlocking, setConsentChecker as setScriptChecker, replayScripts } from './core/script-blocker.js';
14
- import { setPatterns } from './core/pattern-matcher.js';
14
+ import { interceptNetwork, setConsentChecker as setNetworkChecker } from './core/network-interceptor.js';
15
+ import { interceptElements, setConsentChecker as setElementChecker, replayElements } from './core/element-interceptor.js';
16
+ import { setPatterns, appendPatternsToCategory } from './core/pattern-matcher.js';
15
17
  import { getCategoryIds } from './core/categories.js';
16
18
  import { isDoNotTrackEnabled } from './core/dnt.js';
17
19
  import { safeInvoke } from './core/security.js';
@@ -42,6 +44,11 @@ function replayAll(categories) {
42
44
  replayCookies(categories);
43
45
  replayStorage(categories);
44
46
  replayScripts(categories);
47
+ // Element-level replays (script/link/img/iframe URLs that were
48
+ // dropped at the prototype-setter / setAttribute layer). Network
49
+ // interceptor (fetch/XHR/sendBeacon) intentionally has no replay
50
+ // — beacons are one-shot and resending would duplicate analytics.
51
+ replayElements(categories);
45
52
  }
46
53
 
47
54
  /**
@@ -73,13 +80,44 @@ export function coreInit(userConfig = {}) {
73
80
  setPatterns(currentConfig.patterns);
74
81
  }
75
82
 
83
+ // Append consumer-declared strictly-necessary entries on top of
84
+ // whatever's already in the essential category. This is the friendly
85
+ // alternative to overriding via `patterns.essential` directly.
86
+ if (
87
+ (Array.isArray(currentConfig.essentialKeys) && currentConfig.essentialKeys.length > 0) ||
88
+ (Array.isArray(currentConfig.essentialPatterns) && currentConfig.essentialPatterns.length > 0)
89
+ ) {
90
+ appendPatternsToCategory('essential', {
91
+ keys: currentConfig.essentialKeys,
92
+ patternStrings: currentConfig.essentialPatterns
93
+ });
94
+ }
95
+
76
96
  setCookieChecker(checkConsent);
77
97
  setStorageChecker(checkConsent);
78
98
  setScriptChecker(checkConsent);
79
-
80
- interceptCookies();
81
- interceptStorage();
82
- startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
99
+ setNetworkChecker(checkConsent);
100
+ setElementChecker(checkConsent);
101
+
102
+ // Interceptor toggles. By default everything is intercepted (back-compat
103
+ // with v2.0 / v2.1). Consumers that gate scripts and storage themselves
104
+ // can opt out per channel via `intercept: { storage: false, … }`.
105
+ const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
106
+ if (intercept.cookies !== false) interceptCookies();
107
+ if (intercept.storage !== false) interceptStorage();
108
+ if (intercept.scripts !== false) {
109
+ // Element-level synchronous interception (prototype setters +
110
+ // setAttribute) installs BEFORE startScriptBlocking so that the
111
+ // moment any later script does `el.src = "https://tracker..."`,
112
+ // we drop the URL before the browser fetches. The MutationObserver
113
+ // inside startScriptBlocking remains as a defence-in-depth net for
114
+ // anything that slips past (e.g. nodes constructed via cloneNode).
115
+ interceptElements(currentConfig.mode, currentConfig.blockedDomains);
116
+ startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
117
+ }
118
+ if (intercept.network !== false) {
119
+ interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
120
+ }
83
121
 
84
122
  const consent = loadConsent();
85
123
  initialized = true;
package/src/index.js CHANGED
@@ -119,18 +119,29 @@ function handleCloseModal() {
119
119
  }
120
120
 
121
121
  /**
122
- * Initialize Zest with UI.
122
+ * UI mount guard. We split UI mounting (which needs `<body>` and a parsed
123
+ * DOM) from interceptor installation (which must happen on script eval to
124
+ * gate any later `defer` / `async` tracker scripts). `coreInit()` is
125
+ * idempotent so calling init() before the DOM is ready is safe — the UI
126
+ * portion just gets queued.
123
127
  */
124
- function init(userConfig = {}) {
125
- const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
126
- if (alreadyInitialized) {
127
- console.warn('[Zest] Already initialized');
128
- return Zest;
128
+ let uiMounted = false;
129
+
130
+ function mountUI() {
131
+ if (uiMounted) return;
132
+
133
+ // Banner needs document.body to mount its host element. If body isn't
134
+ // there yet, requeue on DOMContentLoaded.
135
+ if (!document || !document.body) {
136
+ document.addEventListener('DOMContentLoaded', mountUI, { once: true });
137
+ return;
129
138
  }
130
139
 
140
+ uiMounted = true;
131
141
  const config = getActiveConfig();
142
+ const decision = hasConsentDecision();
132
143
 
133
- if (!hasDecision && !dntApplied) {
144
+ if (!decision) {
134
145
  showBanner({
135
146
  onAcceptAll: handleAcceptAll,
136
147
  onRejectAll: handleRejectAll,
@@ -140,7 +151,27 @@ function init(userConfig = {}) {
140
151
  } else if (config?.showWidget) {
141
152
  showWidget({ onClick: handleShowSettings });
142
153
  }
154
+ }
143
155
 
156
+ /**
157
+ * Initialize Zest with UI.
158
+ *
159
+ * Splits into two phases:
160
+ *
161
+ * 1. `coreInit()` runs synchronously: interceptors install on the
162
+ * cookie / storage / script / network channels immediately so any
163
+ * `defer` or `async` script that fires later is already gated.
164
+ * Critical — DOMContentLoaded fires AFTER `defer` scripts execute,
165
+ * so deferring interceptor install means trackers fire first.
166
+ *
167
+ * 2. UI mount (banner / widget) is queued until `<body>` exists. If
168
+ * this script runs in `<head>` while the document is still
169
+ * parsing, that means waiting for DOMContentLoaded; if it runs
170
+ * after, mount happens immediately.
171
+ */
172
+ function init(userConfig = {}) {
173
+ coreInit(userConfig);
174
+ mountUI();
144
175
  return Zest;
145
176
  }
146
177
 
@@ -249,17 +280,14 @@ if (typeof window !== 'undefined') {
249
280
  window.Zest = Zest;
250
281
  }
251
282
 
252
- const autoInit = () => {
253
- const cfg = getConfig();
254
- if (cfg.autoInit !== false) {
255
- init(window.ZestConfig);
256
- }
257
- };
258
-
259
- if (document.readyState === 'loading') {
260
- document.addEventListener('DOMContentLoaded', autoInit);
261
- } else {
262
- autoInit();
283
+ // Run init() synchronously on script eval. init() itself splits the
284
+ // work interceptors install now, UI mount waits for <body> if
285
+ // needed. No DOMContentLoaded wait at this layer: deferring init()
286
+ // would let any `defer` / `async` tracker script fire its network
287
+ // calls before our interceptors are in place.
288
+ const cfg = getConfig();
289
+ if (cfg.autoInit !== false) {
290
+ init(window.ZestConfig);
263
291
  }
264
292
  }
265
293
 
@@ -83,6 +83,27 @@ export interface ZestCallbacks {
83
83
  onReady?: (consent: ConsentState) => void;
84
84
  }
85
85
 
86
+ /**
87
+ * Granular toggles for Zest's interceptor layer. Default is `true` on
88
+ * every channel — back-compat with previous versions.
89
+ *
90
+ * Consumers that gate optional scripts and storage themselves can
91
+ * disable interception per channel and use Zest as a pure consent-state
92
+ * engine.
93
+ */
94
+ export interface InterceptToggles {
95
+ cookies?: boolean;
96
+ storage?: boolean;
97
+ scripts?: boolean;
98
+ /**
99
+ * fetch / XMLHttpRequest / navigator.sendBeacon interception. Catches
100
+ * trackers that ship via CMS first-party proxies (HubSpot, Cloudflare
101
+ * Zaraz, server-side GTM) where the <script> tag is same-origin but
102
+ * the runtime beacon is third-party.
103
+ */
104
+ network?: boolean;
105
+ }
106
+
86
107
  /** Configuration accepted by `init()` and `window.ZestConfig`. */
87
108
  export interface InitOptions {
88
109
  /** Display language. `'auto'` detects from `<html lang>` / browser. */
@@ -110,6 +131,25 @@ export interface InitOptions {
110
131
  respectDNT?: boolean;
111
132
  /** What to do when DNT/GPC is on. Default `'reject'`. */
112
133
  dntBehavior?: DNTBehavior;
134
+ /** Disable individual interceptors. Default: all on. */
135
+ intercept?: InterceptToggles;
136
+ /**
137
+ * Exact storage / cookie names to treat as strictly-necessary. Each
138
+ * is appended to the essential category as a fully-anchored regex,
139
+ * so the built-in essential patterns (zest_*, csrf*, …) stay intact.
140
+ */
141
+ essentialKeys?: string[];
142
+ /**
143
+ * Regex source strings to treat as strictly-necessary. Validated via
144
+ * safeRegExp, appended (not replaced) to the essential category.
145
+ */
146
+ essentialPatterns?: string[];
147
+ /**
148
+ * Override patterns per category. Note: this REPLACES the category's
149
+ * built-in patterns. Prefer `essentialKeys` / `essentialPatterns` if
150
+ * you only want to add to the essential category.
151
+ */
152
+ patterns?: Partial<Record<ConsentCategory, string[]>>;
113
153
  /** Consumer callbacks. */
114
154
  callbacks?: ZestCallbacks;
115
155
  /** Anything else — Zest tolerates unknown keys at runtime. */
@@ -70,6 +70,27 @@ export interface ZestCallbacks {
70
70
  onReady?: (consent: ConsentState) => void;
71
71
  }
72
72
 
73
+ /**
74
+ * Granular toggles for Zest's interceptor layer. Default is `true` on
75
+ * every channel — back-compat with previous versions.
76
+ *
77
+ * Consumers that gate optional scripts and storage themselves (typical
78
+ * for headless integrations) can disable interception per channel and
79
+ * use Zest as a pure consent-state engine.
80
+ */
81
+ export interface InterceptToggles {
82
+ cookies?: boolean;
83
+ storage?: boolean;
84
+ scripts?: boolean;
85
+ /**
86
+ * fetch / XMLHttpRequest / navigator.sendBeacon interception. Catches
87
+ * trackers that ship via CMS first-party proxies (HubSpot, Cloudflare
88
+ * Zaraz, server-side GTM) where the <script> tag is same-origin but
89
+ * the runtime beacon is third-party.
90
+ */
91
+ network?: boolean;
92
+ }
93
+
73
94
  /** Configuration accepted by `init()`. */
74
95
  export interface InitOptions {
75
96
  /** Respect Do Not Track / Global Privacy Control. Default `true`. */
@@ -78,6 +99,25 @@ export interface InitOptions {
78
99
  dntBehavior?: DNTBehavior;
79
100
  /** Cookie expiration in days. Default `365`. */
80
101
  expiration?: number;
102
+ /** Disable individual interceptors. Default: all on. */
103
+ intercept?: InterceptToggles;
104
+ /**
105
+ * Exact storage / cookie names to treat as strictly-necessary. Each
106
+ * is appended to the essential category as a fully-anchored regex,
107
+ * so the built-in essential patterns (zest_*, csrf*, …) stay intact.
108
+ */
109
+ essentialKeys?: string[];
110
+ /**
111
+ * Regex source strings to treat as strictly-necessary. Validated via
112
+ * safeRegExp, appended (not replaced) to the essential category.
113
+ */
114
+ essentialPatterns?: string[];
115
+ /**
116
+ * Override patterns per category. Note: this REPLACES the category's
117
+ * built-in patterns. Prefer `essentialKeys` / `essentialPatterns` if
118
+ * you only want to add to the essential category.
119
+ */
120
+ patterns?: Partial<Record<ConsentCategory, string[]>>;
81
121
  /** Consumer callbacks. */
82
122
  callbacks?: ZestCallbacks;
83
123
  /** Anything else — Zest tolerates unknown keys at runtime. */
@@ -212,6 +212,32 @@
212
212
  }
213
213
  }
214
214
  },
215
+ "intercept": {
216
+ "type": "object",
217
+ "description": "Granular toggles for Zest's interceptor layer. Default is true on every channel.",
218
+ "properties": {
219
+ "cookies": {
220
+ "type": "boolean",
221
+ "default": true,
222
+ "description": "Intercept document.cookie writes"
223
+ },
224
+ "storage": {
225
+ "type": "boolean",
226
+ "default": true,
227
+ "description": "Intercept localStorage / sessionStorage writes"
228
+ },
229
+ "scripts": {
230
+ "type": "boolean",
231
+ "default": true,
232
+ "description": "Block third-party tracker <script> tags before they execute"
233
+ },
234
+ "network": {
235
+ "type": "boolean",
236
+ "default": true,
237
+ "description": "Intercept fetch / XMLHttpRequest / navigator.sendBeacon calls. Catches trackers that ship via CMS first-party proxies (HubSpot, Cloudflare Zaraz, server-side GTM) where the <script> tag is same-origin but the runtime beacon is third-party."
238
+ }
239
+ }
240
+ },
215
241
  "callbacks": {
216
242
  "type": "object",
217
243
  "description": "Callback functions for consent events",