@freshjuice/zest 1.0.0 → 2.1.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/README.md +178 -78
- package/dist/zest.d.ts +214 -0
- package/dist/zest.de.js +692 -305
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +692 -305
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +692 -305
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +692 -305
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +692 -305
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.d.ts +178 -0
- package/dist/zest.headless.esm.js +2299 -0
- package/dist/zest.headless.esm.js.map +1 -0
- package/dist/zest.headless.esm.min.js +1 -0
- package/dist/zest.it.js +692 -305
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +692 -305
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +692 -305
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +692 -305
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +692 -305
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +692 -305
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +692 -305
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +692 -305
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +692 -305
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +23 -4
- package/src/core/cookie-interceptor.js +20 -5
- package/src/core/known-trackers.js +41 -14
- package/src/core/pattern-matcher.js +20 -5
- package/src/core/script-blocker.js +85 -79
- package/src/core/security.js +204 -0
- package/src/core/storage-interceptor.js +5 -1
- package/src/core-lifecycle.js +192 -0
- package/src/headless.js +133 -0
- package/src/index.js +73 -184
- package/src/storage/consent-store.js +32 -8
- package/src/types/zest.d.ts +214 -0
- package/src/types/zest.headless.d.ts +178 -0
- package/src/ui/banner.js +11 -7
- package/src/ui/modal.js +16 -12
- package/src/ui/styles.js +25 -4
- package/src/ui/widget.js +3 -1
package/dist/zest.esm.js
CHANGED
|
@@ -1,7 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities - escaping, validation, and safe parsing helpers
|
|
3
|
+
*
|
|
4
|
+
* These helpers are used across UI components, URL/CSS validation, and
|
|
5
|
+
* consent-cookie parsing to provide defense-in-depth against untrusted
|
|
6
|
+
* config (CMS-driven, i18n-loaded, or attacker-supplied).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HTML_ESCAPE_MAP = {
|
|
10
|
+
'&': '&',
|
|
11
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'"': '"',
|
|
14
|
+
"'": ''',
|
|
15
|
+
'`': '`'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escape a value for safe embedding in HTML text nodes and attribute values.
|
|
20
|
+
* Accepts any value — non-strings are stringified first. null/undefined -> ''.
|
|
21
|
+
*/
|
|
22
|
+
function escapeHTML(value) {
|
|
23
|
+
if (value === null || value === undefined) return '';
|
|
24
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
25
|
+
return str.replace(/[&<>"'`]/g, (ch) => HTML_ESCAPE_MAP[ch]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a URL — return the URL if it uses http:/https:/mailto:/tel:,
|
|
30
|
+
* otherwise return null. Blocks javascript:, data:, vbscript:, file:, etc.
|
|
31
|
+
*/
|
|
32
|
+
function safeUrl(url) {
|
|
33
|
+
if (typeof url !== 'string' || url.length === 0) return null;
|
|
34
|
+
const trimmed = url.trim();
|
|
35
|
+
if (trimmed.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
// Relative URLs (no protocol) are safe — treat as same-origin path
|
|
38
|
+
if (/^[/?#]/.test(trimmed)) return trimmed;
|
|
39
|
+
|
|
40
|
+
// Check protocol explicitly, do NOT rely on URL parsing alone —
|
|
41
|
+
// attackers may use whitespace/control characters to confuse parsers.
|
|
42
|
+
const match = trimmed.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
43
|
+
if (!match) {
|
|
44
|
+
// No protocol — treat as relative
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const protocol = match[1].toLowerCase();
|
|
49
|
+
if (protocol === 'http' || protocol === 'https' || protocol === 'mailto' || protocol === 'tel') {
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a CSS color value. Accepts #rgb/#rrggbb/#rrggbbaa and a
|
|
57
|
+
* small allowlist of CSS named colors and rgb()/rgba()/hsl()/hsla()
|
|
58
|
+
* functional forms with numeric-only arguments.
|
|
59
|
+
*/
|
|
60
|
+
const NAMED_COLORS = new Set([
|
|
61
|
+
'transparent', 'black', 'white', 'red', 'green', 'blue', 'yellow',
|
|
62
|
+
'orange', 'purple', 'pink', 'gray', 'grey', 'brown', 'cyan', 'magenta',
|
|
63
|
+
'silver', 'gold', 'navy', 'teal', 'maroon', 'olive', 'lime', 'aqua',
|
|
64
|
+
'fuchsia', 'indigo', 'violet', 'crimson', 'coral', 'salmon', 'tomato'
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function safeColor(color) {
|
|
68
|
+
if (typeof color !== 'string') return null;
|
|
69
|
+
const trimmed = color.trim();
|
|
70
|
+
|
|
71
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
|
72
|
+
if (NAMED_COLORS.has(trimmed.toLowerCase())) return trimmed;
|
|
73
|
+
|
|
74
|
+
// Functional notations: only digits, dots, commas, %, whitespace between parens
|
|
75
|
+
if (/^(rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\s*\)$/i.test(trimmed)) return trimmed;
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate a regex pattern string. Rejects patterns that contain known
|
|
82
|
+
* catastrophic-backtracking shapes (nested quantifiers). Compiles with
|
|
83
|
+
* try/catch.
|
|
84
|
+
*
|
|
85
|
+
* Returns a RegExp on success, null on failure.
|
|
86
|
+
*/
|
|
87
|
+
const REDOS_PATTERNS = [
|
|
88
|
+
/(\([^)]*[+*][^)]*\)|\[[^\]]*\]|\\w|\\d|\\s)\s*[+*]/, // nested quantifier
|
|
89
|
+
/\(\?[=!][^)]*[+*][^)]*\)[+*]/, // lookahead with quantifier, then quantifier
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function safeRegExp(pattern, flags) {
|
|
93
|
+
if (pattern instanceof RegExp) return pattern;
|
|
94
|
+
if (typeof pattern !== 'string') return null;
|
|
95
|
+
|
|
96
|
+
// Cap pattern length to limit compiled-regex state
|
|
97
|
+
if (pattern.length > 500) return null;
|
|
98
|
+
|
|
99
|
+
// Heuristic: reject obviously dangerous patterns
|
|
100
|
+
for (const bad of REDOS_PATTERNS) {
|
|
101
|
+
if (bad.test(pattern)) return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return new RegExp(pattern, flags);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sanitize a consent-cookie payload. Only known category keys with
|
|
113
|
+
* boolean values survive; prototype-polluting keys are stripped.
|
|
114
|
+
*/
|
|
115
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
116
|
+
|
|
117
|
+
function sanitizeConsentPayload(raw, knownCategoryIds) {
|
|
118
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
119
|
+
|
|
120
|
+
const result = {
|
|
121
|
+
version: typeof raw.version === 'string' ? raw.version : null,
|
|
122
|
+
timestamp: typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp) ? raw.timestamp : null,
|
|
123
|
+
categories: {}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cats = raw.categories;
|
|
127
|
+
if (!cats || typeof cats !== 'object' || Array.isArray(cats)) return null;
|
|
128
|
+
|
|
129
|
+
for (const key of knownCategoryIds) {
|
|
130
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(cats, key)) {
|
|
132
|
+
result.categories[key] = cats[key] === true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// essential is always true regardless of stored value
|
|
137
|
+
if (knownCategoryIds.includes('essential')) {
|
|
138
|
+
result.categories.essential = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Invoke a user-supplied callback, swallowing and logging exceptions so
|
|
146
|
+
* a misbehaving callback can't break the consent flow.
|
|
147
|
+
*/
|
|
148
|
+
function safeInvoke(fn, ...args) {
|
|
149
|
+
if (typeof fn !== 'function') return undefined;
|
|
150
|
+
try {
|
|
151
|
+
return fn(...args);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
try {
|
|
154
|
+
console.error('[Zest] User callback threw:', e);
|
|
155
|
+
} catch (_) {
|
|
156
|
+
/* no-op */
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Strip comments and selector-level content from a customStyles string
|
|
164
|
+
* while still allowing property/value declarations scoped under the
|
|
165
|
+
* component selectors the author is targeting. We cannot fully sandbox
|
|
166
|
+
* CSS without a parser, but we can at least neutralise the most
|
|
167
|
+
* dangerous clickjacking vector (rules targeting Zest's own buttons).
|
|
168
|
+
*
|
|
169
|
+
* Returns the sanitized CSS string (possibly empty).
|
|
170
|
+
*/
|
|
171
|
+
function sanitizeCustomStyles(css) {
|
|
172
|
+
if (typeof css !== 'string' || css.length === 0) return '';
|
|
173
|
+
|
|
174
|
+
// Hard cap on size to avoid runaway payloads
|
|
175
|
+
if (css.length > 20000) return '';
|
|
176
|
+
|
|
177
|
+
// Remove CSS comments (can hide payloads)
|
|
178
|
+
let out = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
179
|
+
|
|
180
|
+
// Block at-rules that can load external resources or alter behavior
|
|
181
|
+
out = out.replace(/@import\s+[^;]+;?/gi, '');
|
|
182
|
+
out = out.replace(/@charset\s+[^;]+;?/gi, '');
|
|
183
|
+
|
|
184
|
+
// Block url() values pointing outside of data: or https:
|
|
185
|
+
out = out.replace(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/gi, (match, quote, value) => {
|
|
186
|
+
const v = value.trim().toLowerCase();
|
|
187
|
+
if (v.startsWith('https:') || v.startsWith('data:image/') || v.startsWith('/') || v.startsWith('#')) {
|
|
188
|
+
return match;
|
|
189
|
+
}
|
|
190
|
+
return 'url(#)';
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Block selectors that target the built-in reject button, which could
|
|
194
|
+
// be used to hide it for clickjacking consent bypass.
|
|
195
|
+
out = out.replace(/\.zest-btn--secondary\s*\{[^}]*\}/gi, '');
|
|
196
|
+
out = out.replace(/\[data-action\s*=\s*["']reject-all["']\]\s*\{[^}]*\}/gi, '');
|
|
197
|
+
out = out.replace(/\[data-action\s*=\s*["']accept-all["']\]\s*\{[^}]*\}/gi, '');
|
|
198
|
+
|
|
199
|
+
// Block expression() (ancient IE) and -moz-binding (ancient FF)
|
|
200
|
+
out = out.replace(/expression\s*\([^)]*\)/gi, '');
|
|
201
|
+
out = out.replace(/-moz-binding\s*:[^;}]*/gi, '');
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
1
206
|
/**
|
|
2
207
|
* Pattern Matcher - Categorizes cookies and storage keys by pattern
|
|
3
208
|
*/
|
|
4
209
|
|
|
210
|
+
|
|
5
211
|
/**
|
|
6
212
|
* Default patterns for each category
|
|
7
213
|
*/
|
|
@@ -50,16 +256,29 @@ const DEFAULT_PATTERNS = {
|
|
|
50
256
|
let patterns = { ...DEFAULT_PATTERNS };
|
|
51
257
|
|
|
52
258
|
/**
|
|
53
|
-
* Set custom patterns
|
|
259
|
+
* Set custom patterns. User-supplied strings are validated with safeRegExp,
|
|
260
|
+
* which rejects catastrophic-backtracking shapes and syntax errors.
|
|
261
|
+
* Invalid patterns are silently dropped with a console warning.
|
|
54
262
|
*/
|
|
55
263
|
function setPatterns(customPatterns) {
|
|
56
264
|
patterns = { ...DEFAULT_PATTERNS };
|
|
265
|
+
if (!customPatterns || typeof customPatterns !== 'object') return;
|
|
266
|
+
|
|
57
267
|
for (const [category, regexList] of Object.entries(customPatterns)) {
|
|
58
|
-
if (Array.isArray(regexList))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
268
|
+
if (!Array.isArray(regexList)) continue;
|
|
269
|
+
|
|
270
|
+
const compiled = [];
|
|
271
|
+
for (const p of regexList) {
|
|
272
|
+
const re = safeRegExp(p);
|
|
273
|
+
if (re) {
|
|
274
|
+
compiled.push(re);
|
|
275
|
+
} else {
|
|
276
|
+
try {
|
|
277
|
+
console.warn('[Zest] Rejected unsafe pattern:', p);
|
|
278
|
+
} catch (_) { /* no-op */ }
|
|
279
|
+
}
|
|
62
280
|
}
|
|
281
|
+
patterns[category] = compiled;
|
|
63
282
|
}
|
|
64
283
|
}
|
|
65
284
|
|
|
@@ -96,6 +315,11 @@ function parseCookieName(cookieString) {
|
|
|
96
315
|
// Store original descriptor
|
|
97
316
|
let originalCookieDescriptor = null;
|
|
98
317
|
|
|
318
|
+
// Upper bound on the number of queued cookies awaiting consent replay.
|
|
319
|
+
// An unbounded queue is a memory-exhaustion DoS vector — a hostile
|
|
320
|
+
// script could flood it with document.cookie writes.
|
|
321
|
+
const MAX_QUEUE_SIZE$2 = 100;
|
|
322
|
+
|
|
99
323
|
// Queue for blocked cookies
|
|
100
324
|
const cookieQueue = [];
|
|
101
325
|
|
|
@@ -165,8 +389,8 @@ function interceptCookies() {
|
|
|
165
389
|
if (checkConsent$3(category)) {
|
|
166
390
|
// Consent given - set cookie
|
|
167
391
|
originalCookieDescriptor.set.call(document, value);
|
|
168
|
-
} else {
|
|
169
|
-
// No consent - queue for later
|
|
392
|
+
} else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
|
|
393
|
+
// No consent - queue for later (capped to prevent DoS)
|
|
170
394
|
cookieQueue.push({
|
|
171
395
|
value,
|
|
172
396
|
name,
|
|
@@ -175,7 +399,9 @@ function interceptCookies() {
|
|
|
175
399
|
});
|
|
176
400
|
}
|
|
177
401
|
},
|
|
178
|
-
configurable:
|
|
402
|
+
// configurable: false prevents a later-loaded script from
|
|
403
|
+
// overriding our descriptor and bypassing the interceptor.
|
|
404
|
+
configurable: false
|
|
179
405
|
});
|
|
180
406
|
|
|
181
407
|
return true;
|
|
@@ -186,6 +412,10 @@ function interceptCookies() {
|
|
|
186
412
|
*/
|
|
187
413
|
|
|
188
414
|
|
|
415
|
+
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
416
|
+
// growth would be a memory-exhaustion DoS vector.
|
|
417
|
+
const MAX_QUEUE_SIZE$1 = 200;
|
|
418
|
+
|
|
189
419
|
// Store originals
|
|
190
420
|
let originalLocalStorage = null;
|
|
191
421
|
let originalSessionStorage = null;
|
|
@@ -216,7 +446,7 @@ function createStorageProxy(storage, queue, storageName) {
|
|
|
216
446
|
|
|
217
447
|
if (checkConsent$2(category)) {
|
|
218
448
|
target.setItem(key, value);
|
|
219
|
-
} else {
|
|
449
|
+
} else if (queue.length < MAX_QUEUE_SIZE$1) {
|
|
220
450
|
queue.push({
|
|
221
451
|
key,
|
|
222
452
|
value,
|
|
@@ -401,29 +631,56 @@ const STRICT_TRACKERS = {
|
|
|
401
631
|
};
|
|
402
632
|
|
|
403
633
|
/**
|
|
404
|
-
* Check if a URL matches any tracker in the list
|
|
634
|
+
* Check if a URL matches any tracker in the list.
|
|
635
|
+
*
|
|
636
|
+
* Matching is restricted to hostname (and, when the list entry contains
|
|
637
|
+
* a path, the URL path prefix). A naive `fullUrl.includes(domain)` was
|
|
638
|
+
* previously used, which would false-positive on e.g.
|
|
639
|
+
* https://mysite.com/page?ref=google-analytics.com
|
|
405
640
|
*/
|
|
406
641
|
function matchesTrackerList(url, trackerList) {
|
|
642
|
+
let urlObj;
|
|
407
643
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
644
|
+
urlObj = new URL(url);
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
649
|
+
const path = urlObj.pathname.toLowerCase();
|
|
650
|
+
|
|
651
|
+
for (const rawEntry of trackerList) {
|
|
652
|
+
if (typeof rawEntry !== 'string') continue;
|
|
653
|
+
const entry = rawEntry.toLowerCase();
|
|
654
|
+
|
|
655
|
+
// Partial-prefix match on hostname (entry ends with a dot),
|
|
656
|
+
// e.g. "matomo." matches "analytics.matomo.cloud"
|
|
657
|
+
if (entry.endsWith('.')) {
|
|
658
|
+
const needle = entry.slice(0, -1);
|
|
659
|
+
const segments = hostname.split('.');
|
|
660
|
+
if (segments.some(seg => seg === needle) || hostname.startsWith(entry)) {
|
|
419
661
|
return true;
|
|
420
|
-
}
|
|
662
|
+
}
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Entries containing a slash specify hostname + path prefix
|
|
667
|
+
const slashIdx = entry.indexOf('/');
|
|
668
|
+
if (slashIdx !== -1) {
|
|
669
|
+
const entryHost = entry.slice(0, slashIdx);
|
|
670
|
+
const entryPath = entry.slice(slashIdx);
|
|
671
|
+
if ((hostname === entryHost || hostname.endsWith('.' + entryHost)) &&
|
|
672
|
+
path.startsWith(entryPath)) {
|
|
421
673
|
return true;
|
|
422
674
|
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Plain hostname: exact or subdomain match only
|
|
679
|
+
if (hostname === entry || hostname.endsWith('.' + entry)) {
|
|
680
|
+
return true;
|
|
423
681
|
}
|
|
424
|
-
} catch (e) {
|
|
425
|
-
// Invalid URL
|
|
426
682
|
}
|
|
683
|
+
|
|
427
684
|
return false;
|
|
428
685
|
}
|
|
429
686
|
|
|
@@ -470,7 +727,17 @@ function isThirdParty(url) {
|
|
|
470
727
|
*/
|
|
471
728
|
|
|
472
729
|
|
|
473
|
-
//
|
|
730
|
+
// Categories the author has declared blockable. A script can self-label
|
|
731
|
+
// into one of these, but not into 'essential' (a common bypass).
|
|
732
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
733
|
+
|
|
734
|
+
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
735
|
+
// hostile page from flooding the queue with <script> nodes.
|
|
736
|
+
const MAX_QUEUE_SIZE = 500;
|
|
737
|
+
|
|
738
|
+
// Queue for blocked scripts — the authoritative source for replay,
|
|
739
|
+
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
740
|
+
// cannot hijack what gets executed.
|
|
474
741
|
const scriptQueue = [];
|
|
475
742
|
|
|
476
743
|
// MutationObserver instance
|
|
@@ -517,55 +784,61 @@ function matchesCustomDomains(url) {
|
|
|
517
784
|
}
|
|
518
785
|
|
|
519
786
|
/**
|
|
520
|
-
* Determine if a script should be blocked and get its category
|
|
787
|
+
* Determine if a script should be blocked and get its category.
|
|
788
|
+
*
|
|
789
|
+
* A self-applied 'essential' label is ignored — only explicit blockable
|
|
790
|
+
* categories are accepted. That prevents a third-party script from
|
|
791
|
+
* stamping itself with data-consent-category="essential" to slip past
|
|
792
|
+
* mode-based blocking.
|
|
521
793
|
*/
|
|
522
794
|
function getScriptBlockCategory(script) {
|
|
523
|
-
//
|
|
524
|
-
const explicitCategory = script.getAttribute('data-consent-category');
|
|
525
|
-
if (explicitCategory) {
|
|
526
|
-
return explicitCategory;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// 2. Skip if script has data-zest-allow attribute
|
|
795
|
+
// Skip if script has data-zest-allow attribute (opt-out)
|
|
530
796
|
if (script.hasAttribute('data-zest-allow')) {
|
|
531
797
|
return null;
|
|
532
798
|
}
|
|
533
799
|
|
|
800
|
+
// 1. Check for explicit data-consent-category attribute.
|
|
801
|
+
// Only honor values from the blockable set; 'essential' and unknown
|
|
802
|
+
// values fall through to the other checks.
|
|
803
|
+
const explicitCategory = script.getAttribute('data-consent-category');
|
|
804
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
805
|
+
? explicitCategory
|
|
806
|
+
: null;
|
|
807
|
+
|
|
534
808
|
const src = script.src;
|
|
535
809
|
|
|
536
|
-
// No src = inline script, only block if explicitly tagged
|
|
810
|
+
// No src = inline script, only block if explicitly tagged (blockable only)
|
|
537
811
|
if (!src) {
|
|
538
|
-
return
|
|
812
|
+
return explicitBlockable;
|
|
539
813
|
}
|
|
540
814
|
|
|
541
|
-
//
|
|
815
|
+
// 2. Check custom blocked domains
|
|
542
816
|
const customCategory = matchesCustomDomains(src);
|
|
543
|
-
if (customCategory) {
|
|
544
|
-
return customCategory;
|
|
545
|
-
}
|
|
546
817
|
|
|
547
|
-
//
|
|
818
|
+
// 3. Mode-based blocking
|
|
819
|
+
let modeCategory = null;
|
|
548
820
|
switch (blockingMode) {
|
|
549
821
|
case 'manual':
|
|
550
|
-
|
|
551
|
-
return null;
|
|
822
|
+
break;
|
|
552
823
|
|
|
553
824
|
case 'safe':
|
|
554
825
|
case 'strict':
|
|
555
|
-
|
|
556
|
-
|
|
826
|
+
modeCategory = getCategoryForScript(src, blockingMode);
|
|
827
|
+
break;
|
|
557
828
|
|
|
558
829
|
case 'doomsday':
|
|
559
|
-
// Block all third-party scripts
|
|
560
830
|
if (isThirdParty(src)) {
|
|
561
|
-
|
|
562
|
-
return getCategoryForScript(src, 'strict') || 'marketing';
|
|
831
|
+
modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
|
|
563
832
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
default:
|
|
567
|
-
return null;
|
|
833
|
+
break;
|
|
568
834
|
}
|
|
835
|
+
|
|
836
|
+
// Use the strictest category among explicit/custom/mode decisions.
|
|
837
|
+
// We collect all categories the script matches and pick the first
|
|
838
|
+
// that appears in the blockable set (any match wins — but we prefer
|
|
839
|
+
// the mode-assigned one since it's authoritative for third-party
|
|
840
|
+
// trackers that try to self-label as 'functional').
|
|
841
|
+
return modeCategory || customCategory || explicitBlockable;
|
|
569
842
|
}
|
|
570
843
|
|
|
571
844
|
/**
|
|
@@ -590,14 +863,17 @@ function blockScript(script) {
|
|
|
590
863
|
return false;
|
|
591
864
|
}
|
|
592
865
|
|
|
593
|
-
// Store script info for later execution
|
|
866
|
+
// Store script info for later execution. Snapshot the src/text BEFORE
|
|
867
|
+
// mutating the DOM — this snapshot is the authoritative replay source
|
|
868
|
+
// so later DOM tampering cannot hijack the replayed script URL.
|
|
594
869
|
const scriptInfo = {
|
|
595
870
|
category,
|
|
596
|
-
src: script.src,
|
|
871
|
+
src: script.src || '',
|
|
597
872
|
inline: script.textContent,
|
|
598
873
|
type: script.type,
|
|
599
874
|
async: script.async,
|
|
600
875
|
defer: script.defer,
|
|
876
|
+
element: script,
|
|
601
877
|
timestamp: Date.now()
|
|
602
878
|
};
|
|
603
879
|
|
|
@@ -608,77 +884,61 @@ function blockScript(script) {
|
|
|
608
884
|
// Disable the script
|
|
609
885
|
script.type = 'text/plain';
|
|
610
886
|
|
|
611
|
-
//
|
|
887
|
+
// Remove src to prevent loading. We no longer stash it on the element
|
|
888
|
+
// (data-blocked-src was a tampering vector); scriptQueue is the single
|
|
889
|
+
// source of truth for replay.
|
|
612
890
|
if (script.src) {
|
|
613
|
-
script.setAttribute('data-blocked-src', script.src);
|
|
614
891
|
script.removeAttribute('src');
|
|
615
892
|
}
|
|
616
893
|
|
|
617
|
-
scriptQueue.
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Execute a queued script
|
|
623
|
-
*/
|
|
624
|
-
function executeScript(scriptInfo) {
|
|
625
|
-
const script = document.createElement('script');
|
|
626
|
-
|
|
627
|
-
if (scriptInfo.src) {
|
|
628
|
-
script.src = scriptInfo.src;
|
|
629
|
-
} else if (scriptInfo.inline) {
|
|
630
|
-
script.textContent = scriptInfo.inline;
|
|
894
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
895
|
+
scriptQueue.push(scriptInfo);
|
|
631
896
|
}
|
|
632
|
-
|
|
633
|
-
if (scriptInfo.async) script.async = true;
|
|
634
|
-
if (scriptInfo.defer) script.defer = true;
|
|
635
|
-
|
|
636
|
-
script.setAttribute('data-zest-processed', 'executed');
|
|
637
|
-
script.setAttribute('data-consent-executed', 'true');
|
|
638
|
-
|
|
639
|
-
document.head.appendChild(script);
|
|
897
|
+
return true;
|
|
640
898
|
}
|
|
641
899
|
|
|
642
900
|
/**
|
|
643
|
-
* Replay queued scripts for allowed categories
|
|
901
|
+
* Replay queued scripts for allowed categories.
|
|
902
|
+
*
|
|
903
|
+
* scriptQueue is the single source of truth for src and inline body —
|
|
904
|
+
* we never re-read data-* attributes from the DOM (which an attacker
|
|
905
|
+
* could have rewritten in the intervening time).
|
|
644
906
|
*/
|
|
645
907
|
function replayScripts(allowedCategories) {
|
|
646
908
|
const remaining = [];
|
|
647
909
|
|
|
648
910
|
for (const scriptInfo of scriptQueue) {
|
|
649
|
-
if (allowedCategories.includes(scriptInfo.category)) {
|
|
650
|
-
executeScript(scriptInfo);
|
|
651
|
-
} else {
|
|
911
|
+
if (!allowedCategories.includes(scriptInfo.category)) {
|
|
652
912
|
remaining.push(scriptInfo);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const newScript = document.createElement('script');
|
|
917
|
+
if (scriptInfo.src) {
|
|
918
|
+
newScript.src = scriptInfo.src;
|
|
919
|
+
} else if (scriptInfo.inline) {
|
|
920
|
+
newScript.textContent = scriptInfo.inline;
|
|
921
|
+
}
|
|
922
|
+
if (scriptInfo.async) newScript.async = true;
|
|
923
|
+
if (scriptInfo.defer) newScript.defer = true;
|
|
924
|
+
if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
|
|
925
|
+
newScript.type = scriptInfo.type;
|
|
926
|
+
}
|
|
927
|
+
newScript.setAttribute('data-zest-processed', 'executed');
|
|
928
|
+
newScript.setAttribute('data-consent-executed', 'true');
|
|
929
|
+
|
|
930
|
+
// If the original element is still in the DOM, replace it in place
|
|
931
|
+
// so execution order is preserved. Otherwise append to <head>.
|
|
932
|
+
const original = scriptInfo.element;
|
|
933
|
+
if (original && original.isConnected && original.parentNode) {
|
|
934
|
+
original.parentNode.replaceChild(newScript, original);
|
|
935
|
+
} else {
|
|
936
|
+
document.head.appendChild(newScript);
|
|
653
937
|
}
|
|
654
938
|
}
|
|
655
939
|
|
|
656
940
|
scriptQueue.length = 0;
|
|
657
941
|
scriptQueue.push(...remaining);
|
|
658
|
-
|
|
659
|
-
// Also re-enable any blocked scripts in the DOM
|
|
660
|
-
const blockedScripts = document.querySelectorAll('script[data-zest-processed="blocked"]');
|
|
661
|
-
blockedScripts.forEach(script => {
|
|
662
|
-
const category = script.getAttribute('data-consent-category');
|
|
663
|
-
if (allowedCategories.includes(category)) {
|
|
664
|
-
// Clone and replace to execute
|
|
665
|
-
const newScript = document.createElement('script');
|
|
666
|
-
|
|
667
|
-
const blockedSrc = script.getAttribute('data-blocked-src');
|
|
668
|
-
if (blockedSrc) {
|
|
669
|
-
newScript.src = blockedSrc;
|
|
670
|
-
} else {
|
|
671
|
-
newScript.textContent = script.textContent;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (script.async) newScript.async = true;
|
|
675
|
-
if (script.defer) newScript.defer = true;
|
|
676
|
-
|
|
677
|
-
newScript.setAttribute('data-zest-processed', 'executed');
|
|
678
|
-
newScript.setAttribute('data-consent-executed', 'true');
|
|
679
|
-
script.parentNode?.replaceChild(newScript, script);
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
942
|
}
|
|
683
943
|
|
|
684
944
|
/**
|
|
@@ -1692,18 +1952,18 @@ function getConfig() {
|
|
|
1692
1952
|
/**
|
|
1693
1953
|
* Update configuration at runtime
|
|
1694
1954
|
*/
|
|
1695
|
-
let currentConfig = null;
|
|
1955
|
+
let currentConfig$1 = null;
|
|
1696
1956
|
|
|
1697
1957
|
function setConfig(config) {
|
|
1698
|
-
currentConfig = mergeConfig(config);
|
|
1699
|
-
return currentConfig;
|
|
1958
|
+
currentConfig$1 = mergeConfig(config);
|
|
1959
|
+
return currentConfig$1;
|
|
1700
1960
|
}
|
|
1701
1961
|
|
|
1702
1962
|
function getCurrentConfig() {
|
|
1703
|
-
if (!currentConfig) {
|
|
1704
|
-
currentConfig = getConfig();
|
|
1963
|
+
if (!currentConfig$1) {
|
|
1964
|
+
currentConfig$1 = getConfig();
|
|
1705
1965
|
}
|
|
1706
|
-
return currentConfig;
|
|
1966
|
+
return currentConfig$1;
|
|
1707
1967
|
}
|
|
1708
1968
|
|
|
1709
1969
|
/**
|
|
@@ -1714,6 +1974,20 @@ function getCurrentConfig() {
|
|
|
1714
1974
|
const COOKIE_NAME = 'zest_consent';
|
|
1715
1975
|
const CONSENT_VERSION = '1.0';
|
|
1716
1976
|
|
|
1977
|
+
/**
|
|
1978
|
+
* Return the Secure flag fragment when running over HTTPS, empty otherwise.
|
|
1979
|
+
* On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
|
|
1980
|
+
*/
|
|
1981
|
+
function secureAttribute() {
|
|
1982
|
+
try {
|
|
1983
|
+
return typeof location !== 'undefined' && location.protocol === 'https:'
|
|
1984
|
+
? '; Secure'
|
|
1985
|
+
: '';
|
|
1986
|
+
} catch (_) {
|
|
1987
|
+
return '';
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1717
1991
|
// Current consent state
|
|
1718
1992
|
let consent = null;
|
|
1719
1993
|
|
|
@@ -1742,7 +2016,12 @@ function getRawCookie() {
|
|
|
1742
2016
|
}
|
|
1743
2017
|
|
|
1744
2018
|
/**
|
|
1745
|
-
* Load consent from cookie
|
|
2019
|
+
* Load consent from cookie.
|
|
2020
|
+
*
|
|
2021
|
+
* The parsed cookie is validated against the expected schema via
|
|
2022
|
+
* sanitizeConsentPayload — only known category keys with boolean values
|
|
2023
|
+
* survive, so a tampered cookie can't inject prototype-polluting props
|
|
2024
|
+
* or unexpected category shapes.
|
|
1746
2025
|
*/
|
|
1747
2026
|
function loadConsent() {
|
|
1748
2027
|
try {
|
|
@@ -1750,9 +2029,12 @@ function loadConsent() {
|
|
|
1750
2029
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1751
2030
|
|
|
1752
2031
|
if (match) {
|
|
1753
|
-
const
|
|
1754
|
-
|
|
1755
|
-
|
|
2032
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2033
|
+
const clean = sanitizeConsentPayload(raw, getCategoryIds());
|
|
2034
|
+
if (clean && clean.categories) {
|
|
2035
|
+
consent = { ...getDefaultConsent(), ...clean.categories };
|
|
2036
|
+
return { ...consent };
|
|
2037
|
+
}
|
|
1756
2038
|
}
|
|
1757
2039
|
} catch (e) {
|
|
1758
2040
|
// Invalid or missing cookie
|
|
@@ -1777,7 +2059,7 @@ function saveConsent(expirationDays = 365) {
|
|
|
1777
2059
|
};
|
|
1778
2060
|
|
|
1779
2061
|
const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
|
|
1780
|
-
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
|
|
2062
|
+
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
|
|
1781
2063
|
|
|
1782
2064
|
setRawCookie(cookieValue);
|
|
1783
2065
|
}
|
|
@@ -1846,7 +2128,7 @@ function rejectAll(expirationDays = 365) {
|
|
|
1846
2128
|
* Reset consent (clear cookie)
|
|
1847
2129
|
*/
|
|
1848
2130
|
function resetConsent() {
|
|
1849
|
-
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path
|
|
2131
|
+
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
|
|
1850
2132
|
consent = null;
|
|
1851
2133
|
}
|
|
1852
2134
|
|
|
@@ -1871,7 +2153,8 @@ function getConsentProof() {
|
|
|
1871
2153
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1872
2154
|
|
|
1873
2155
|
if (match) {
|
|
1874
|
-
|
|
2156
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2157
|
+
return sanitizeConsentPayload(raw, getCategoryIds());
|
|
1875
2158
|
}
|
|
1876
2159
|
} catch (e) {
|
|
1877
2160
|
// Invalid cookie
|
|
@@ -1950,15 +2233,207 @@ function emitHide(type = 'banner') {
|
|
|
1950
2233
|
return emit(EVENTS.HIDE, { type });
|
|
1951
2234
|
}
|
|
1952
2235
|
|
|
2236
|
+
/**
|
|
2237
|
+
* Subscribe to an event
|
|
2238
|
+
*/
|
|
2239
|
+
function on(eventName, callback) {
|
|
2240
|
+
document.addEventListener(eventName, callback);
|
|
2241
|
+
return () => document.removeEventListener(eventName, callback);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Subscribe to an event once
|
|
2246
|
+
*/
|
|
2247
|
+
function once(eventName, callback) {
|
|
2248
|
+
document.addEventListener(eventName, callback, { once: true });
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Core lifecycle - UI-agnostic initialization and consent actions.
|
|
2253
|
+
*
|
|
2254
|
+
* This module contains everything the main entry (with UI) and the
|
|
2255
|
+
* headless entry (no UI) share: interceptor setup, consent load/save,
|
|
2256
|
+
* replay, DNT handling, and the events/callbacks fan-out. It intentionally
|
|
2257
|
+
* does NOT import anything from `./ui/*` so tree-shakers can drop the UI
|
|
2258
|
+
* bundle entirely when only the headless API is used.
|
|
2259
|
+
*/
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
let initialized = false;
|
|
2263
|
+
let currentConfig = null;
|
|
2264
|
+
|
|
2265
|
+
function checkConsent(category) {
|
|
2266
|
+
return hasConsent(category);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function replayAll(categories) {
|
|
2270
|
+
replayCookies(categories);
|
|
2271
|
+
replayStorage(categories);
|
|
2272
|
+
replayScripts(categories);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Run the non-UI half of init. Returns a snapshot the caller (UI or
|
|
2277
|
+
* headless) can use to decide what to do next.
|
|
2278
|
+
*/
|
|
2279
|
+
function coreInit(userConfig = {}) {
|
|
2280
|
+
if (initialized) {
|
|
2281
|
+
return {
|
|
2282
|
+
alreadyInitialized: true,
|
|
2283
|
+
config: currentConfig,
|
|
2284
|
+
consent: loadConsent(),
|
|
2285
|
+
hasDecision: hasConsentDecision(),
|
|
2286
|
+
dntApplied: false
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
currentConfig = setConfig(userConfig);
|
|
2291
|
+
|
|
2292
|
+
// Push default-denied state to vendor consent mode APIs BEFORE any
|
|
2293
|
+
// third-party script has a chance to fire.
|
|
2294
|
+
applyConsentSignals(
|
|
2295
|
+
{ functional: false, analytics: false, marketing: false },
|
|
2296
|
+
currentConfig,
|
|
2297
|
+
true
|
|
2298
|
+
);
|
|
2299
|
+
|
|
2300
|
+
if (currentConfig.patterns) {
|
|
2301
|
+
setPatterns(currentConfig.patterns);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
setConsentChecker$2(checkConsent);
|
|
2305
|
+
setConsentChecker$1(checkConsent);
|
|
2306
|
+
setConsentChecker(checkConsent);
|
|
2307
|
+
|
|
2308
|
+
interceptCookies();
|
|
2309
|
+
interceptStorage();
|
|
2310
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
2311
|
+
|
|
2312
|
+
const consent = loadConsent();
|
|
2313
|
+
initialized = true;
|
|
2314
|
+
|
|
2315
|
+
if (hasConsentDecision()) {
|
|
2316
|
+
applyConsentSignals(consent, currentConfig, false);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// DNT / GPC handling — if the user signalled opt-out at the browser
|
|
2320
|
+
// level and the site opts to respect it, auto-reject before the UI
|
|
2321
|
+
// layer ever runs.
|
|
2322
|
+
const dntEnabled = isDoNotTrackEnabled();
|
|
2323
|
+
let dntApplied = false;
|
|
2324
|
+
|
|
2325
|
+
if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
|
|
2326
|
+
if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2327
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2328
|
+
dntApplied = true;
|
|
2329
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2330
|
+
emitReject(result.current);
|
|
2331
|
+
emitChange(result.current, result.previous);
|
|
2332
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2333
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
emitReady(consent);
|
|
2338
|
+
safeInvoke(currentConfig.callbacks?.onReady, consent);
|
|
2339
|
+
|
|
2340
|
+
return {
|
|
2341
|
+
alreadyInitialized: false,
|
|
2342
|
+
config: currentConfig,
|
|
2343
|
+
consent,
|
|
2344
|
+
hasDecision: hasConsentDecision(),
|
|
2345
|
+
dntApplied
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Accept all categories, replay queued items, fire events + callbacks.
|
|
2351
|
+
* Returns { current, previous } or null if not yet initialized.
|
|
2352
|
+
*/
|
|
2353
|
+
function coreAcceptAll() {
|
|
2354
|
+
if (!initialized) return null;
|
|
2355
|
+
const result = acceptAll(currentConfig.expiration);
|
|
2356
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2357
|
+
replayAll(getCategoryIds());
|
|
2358
|
+
emitConsent(result.current, result.previous);
|
|
2359
|
+
emitChange(result.current, result.previous);
|
|
2360
|
+
safeInvoke(currentConfig.callbacks?.onAccept, result.current);
|
|
2361
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2362
|
+
return result;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/**
|
|
2366
|
+
* Reject all non-essential categories, fire events + callbacks.
|
|
2367
|
+
*/
|
|
2368
|
+
function coreRejectAll() {
|
|
2369
|
+
if (!initialized) return null;
|
|
2370
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2371
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2372
|
+
emitReject(result.current);
|
|
2373
|
+
emitChange(result.current, result.previous);
|
|
2374
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2375
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2376
|
+
return result;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* Save custom selections and replay only the newly-allowed categories.
|
|
2381
|
+
*/
|
|
2382
|
+
function coreUpdateConsent(selections) {
|
|
2383
|
+
if (!initialized) return null;
|
|
2384
|
+
const result = updateConsent(selections, currentConfig.expiration);
|
|
2385
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2386
|
+
|
|
2387
|
+
const newlyAllowed = Object.keys(result.current).filter(
|
|
2388
|
+
(cat) => result.current[cat] && !result.previous[cat]
|
|
2389
|
+
);
|
|
2390
|
+
if (newlyAllowed.length > 0) {
|
|
2391
|
+
replayAll(newlyAllowed);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const hasNonEssential = Object.entries(selections || {}).some(
|
|
2395
|
+
([cat, val]) => cat !== 'essential' && val
|
|
2396
|
+
);
|
|
2397
|
+
if (hasNonEssential) {
|
|
2398
|
+
emitConsent(result.current, result.previous);
|
|
2399
|
+
} else {
|
|
2400
|
+
emitReject(result.current);
|
|
2401
|
+
}
|
|
2402
|
+
emitChange(result.current, result.previous);
|
|
2403
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2404
|
+
return result;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Clear the consent cookie. The caller is responsible for any UI reset.
|
|
2409
|
+
*/
|
|
2410
|
+
function coreReset() {
|
|
2411
|
+
resetConsent();
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function isInitialized() {
|
|
2415
|
+
return initialized;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function getActiveConfig() {
|
|
2419
|
+
return currentConfig;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
1953
2422
|
/**
|
|
1954
2423
|
* Styles - Shadow DOM encapsulated CSS with theming
|
|
1955
2424
|
*/
|
|
1956
2425
|
|
|
2426
|
+
|
|
2427
|
+
const DEFAULT_ACCENT = '#4F46E5';
|
|
2428
|
+
|
|
1957
2429
|
/**
|
|
1958
2430
|
* Generate CSS with custom properties
|
|
1959
2431
|
*/
|
|
1960
2432
|
function generateStyles(config) {
|
|
1961
|
-
|
|
2433
|
+
// Only accept colors that pass strict validation — an unvalidated
|
|
2434
|
+
// value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
|
|
2435
|
+
const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
|
|
2436
|
+
const customCss = sanitizeCustomStyles(config.customStyles);
|
|
1962
2437
|
|
|
1963
2438
|
return `
|
|
1964
2439
|
:host {
|
|
@@ -2428,15 +2903,29 @@ function generateStyles(config) {
|
|
|
2428
2903
|
.zest-hidden {
|
|
2429
2904
|
display: none !important;
|
|
2430
2905
|
}
|
|
2431
|
-
${
|
|
2906
|
+
${customCss}
|
|
2432
2907
|
`;
|
|
2433
2908
|
}
|
|
2434
2909
|
|
|
2435
2910
|
/**
|
|
2436
|
-
* Adjust color brightness
|
|
2911
|
+
* Adjust color brightness. Falls back to the default accent if the input
|
|
2912
|
+
* cannot be parsed as a hex color (non-hex inputs pass safeColor but
|
|
2913
|
+
* can't be brightness-shifted mathematically).
|
|
2437
2914
|
*/
|
|
2438
2915
|
function adjustColor(hex, percent) {
|
|
2439
|
-
|
|
2916
|
+
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
|
|
2917
|
+
hex = DEFAULT_ACCENT;
|
|
2918
|
+
}
|
|
2919
|
+
let clean = hex.trim().replace('#', '');
|
|
2920
|
+
// Expand 3-digit form to 6
|
|
2921
|
+
if (clean.length === 3) {
|
|
2922
|
+
clean = clean.split('').map(c => c + c).join('');
|
|
2923
|
+
}
|
|
2924
|
+
// Strip alpha if present
|
|
2925
|
+
if (clean.length === 8) clean = clean.slice(0, 6);
|
|
2926
|
+
if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
|
|
2927
|
+
|
|
2928
|
+
const num = parseInt(clean, 16);
|
|
2440
2929
|
const amt = Math.round(2.55 * percent);
|
|
2441
2930
|
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
|
|
2442
2931
|
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
|
|
@@ -2457,26 +2946,29 @@ const COOKIE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
|
2457
2946
|
let bannerElement = null;
|
|
2458
2947
|
let shadowRoot$2 = null;
|
|
2459
2948
|
|
|
2949
|
+
const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
|
|
2950
|
+
|
|
2460
2951
|
/**
|
|
2461
2952
|
* Create the banner HTML
|
|
2462
2953
|
*/
|
|
2463
2954
|
function createBannerHTML(config) {
|
|
2464
2955
|
const labels = config.labels.banner;
|
|
2465
|
-
const
|
|
2956
|
+
const rawPosition = config.position || 'bottom';
|
|
2957
|
+
const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
|
|
2466
2958
|
|
|
2467
2959
|
return `
|
|
2468
|
-
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
|
|
2469
|
-
<h2 class="zest-banner__title">${labels.title}</h2>
|
|
2470
|
-
<p class="zest-banner__description">${labels.description}</p>
|
|
2960
|
+
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
|
|
2961
|
+
<h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
|
|
2962
|
+
<p class="zest-banner__description">${escapeHTML(labels.description)}</p>
|
|
2471
2963
|
<div class="zest-banner__buttons">
|
|
2472
2964
|
<button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
|
|
2473
|
-
${labels.acceptAll}
|
|
2965
|
+
${escapeHTML(labels.acceptAll)}
|
|
2474
2966
|
</button>
|
|
2475
2967
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
|
|
2476
|
-
${labels.rejectAll}
|
|
2968
|
+
${escapeHTML(labels.rejectAll)}
|
|
2477
2969
|
</button>
|
|
2478
2970
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
|
|
2479
|
-
${labels.settings}
|
|
2971
|
+
${escapeHTML(labels.settings)}
|
|
2480
2972
|
</button>
|
|
2481
2973
|
</div>
|
|
2482
2974
|
</div>
|
|
@@ -2586,22 +3078,24 @@ let currentSelections = {};
|
|
|
2586
3078
|
function createCategoryHTML(category, isChecked, isRequired) {
|
|
2587
3079
|
const disabled = isRequired ? 'disabled' : '';
|
|
2588
3080
|
const checked = isChecked ? 'checked' : '';
|
|
3081
|
+
const safeId = escapeHTML(category.id);
|
|
3082
|
+
const safeLabel = escapeHTML(category.label);
|
|
2589
3083
|
|
|
2590
3084
|
return `
|
|
2591
3085
|
<div class="zest-category">
|
|
2592
3086
|
<div class="zest-category__header">
|
|
2593
3087
|
<div class="zest-category__info">
|
|
2594
|
-
<span class="zest-category__label">${
|
|
2595
|
-
<p class="zest-category__description">${category.description}</p>
|
|
3088
|
+
<span class="zest-category__label">${safeLabel}</span>
|
|
3089
|
+
<p class="zest-category__description">${escapeHTML(category.description)}</p>
|
|
2596
3090
|
</div>
|
|
2597
3091
|
<label class="zest-toggle">
|
|
2598
3092
|
<input
|
|
2599
3093
|
type="checkbox"
|
|
2600
3094
|
class="zest-toggle__input"
|
|
2601
|
-
data-category="${
|
|
3095
|
+
data-category="${safeId}"
|
|
2602
3096
|
${checked}
|
|
2603
3097
|
${disabled}
|
|
2604
|
-
aria-label="${
|
|
3098
|
+
aria-label="${safeLabel}"
|
|
2605
3099
|
>
|
|
2606
3100
|
<span class="zest-toggle__slider"></span>
|
|
2607
3101
|
</label>
|
|
@@ -2625,29 +3119,30 @@ function createModalHTML(config, consent) {
|
|
|
2625
3119
|
))
|
|
2626
3120
|
.join('');
|
|
2627
3121
|
|
|
2628
|
-
const
|
|
2629
|
-
|
|
3122
|
+
const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
|
|
3123
|
+
const policyLink = validatedPolicyUrl
|
|
3124
|
+
? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
|
|
2630
3125
|
: '';
|
|
2631
3126
|
|
|
2632
3127
|
return `
|
|
2633
|
-
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
|
|
3128
|
+
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
|
|
2634
3129
|
<div class="zest-modal">
|
|
2635
3130
|
<div class="zest-modal__header">
|
|
2636
|
-
<h2 class="zest-modal__title">${labels.title}</h2>
|
|
2637
|
-
<p class="zest-modal__description">${labels.description} ${policyLink}</p>
|
|
3131
|
+
<h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
|
|
3132
|
+
<p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
|
|
2638
3133
|
</div>
|
|
2639
3134
|
<div class="zest-modal__body">
|
|
2640
3135
|
${categoriesHTML}
|
|
2641
3136
|
</div>
|
|
2642
3137
|
<div class="zest-modal__footer">
|
|
2643
3138
|
<button type="button" class="zest-btn zest-btn--primary" data-action="save">
|
|
2644
|
-
${labels.save}
|
|
3139
|
+
${escapeHTML(labels.save)}
|
|
2645
3140
|
</button>
|
|
2646
3141
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
|
|
2647
|
-
${labels.acceptAll}
|
|
3142
|
+
${escapeHTML(labels.acceptAll)}
|
|
2648
3143
|
</button>
|
|
2649
3144
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
|
|
2650
|
-
${labels.rejectAll}
|
|
3145
|
+
${escapeHTML(labels.rejectAll)}
|
|
2651
3146
|
</button>
|
|
2652
3147
|
</div>
|
|
2653
3148
|
</div>
|
|
@@ -2779,10 +3274,11 @@ let shadowRoot = null;
|
|
|
2779
3274
|
*/
|
|
2780
3275
|
function createWidgetHTML(config) {
|
|
2781
3276
|
const labels = config.labels.widget;
|
|
3277
|
+
const safeLabel = escapeHTML(labels.label);
|
|
2782
3278
|
|
|
2783
3279
|
return `
|
|
2784
3280
|
<div class="zest-widget">
|
|
2785
|
-
<button type="button" class="zest-widget__btn" aria-label="${
|
|
3281
|
+
<button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
|
|
2786
3282
|
<span class="zest-widget__icon">${COOKIE_ICON}</span>
|
|
2787
3283
|
</button>
|
|
2788
3284
|
</div>
|
|
@@ -2861,114 +3357,59 @@ function removeWidget() {
|
|
|
2861
3357
|
|
|
2862
3358
|
/**
|
|
2863
3359
|
* Zest - Lightweight Cookie Consent Toolkit
|
|
2864
|
-
* Main entry
|
|
3360
|
+
* Main entry (full build: logic + UI).
|
|
3361
|
+
*
|
|
3362
|
+
* For a logic-only build without any CSS / Shadow DOM mounting, import
|
|
3363
|
+
* from `@freshjuice/zest/headless` instead.
|
|
2865
3364
|
*/
|
|
2866
3365
|
|
|
2867
3366
|
|
|
2868
|
-
// State
|
|
2869
|
-
let initialized = false;
|
|
2870
|
-
let config = null;
|
|
2871
|
-
|
|
2872
3367
|
/**
|
|
2873
|
-
*
|
|
2874
|
-
*/
|
|
2875
|
-
function checkConsent(category) {
|
|
2876
|
-
return hasConsent(category);
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
/**
|
|
2880
|
-
* Replay all queued items for newly allowed categories
|
|
2881
|
-
*/
|
|
2882
|
-
function replayAll(allowedCategories) {
|
|
2883
|
-
replayCookies(allowedCategories);
|
|
2884
|
-
replayStorage(allowedCategories);
|
|
2885
|
-
replayScripts(allowedCategories);
|
|
2886
|
-
}
|
|
2887
|
-
|
|
2888
|
-
/**
|
|
2889
|
-
* Handle accept all
|
|
3368
|
+
* Handle accept all — delegates consent logic to core, handles UI swap.
|
|
2890
3369
|
*/
|
|
2891
3370
|
function handleAcceptAll() {
|
|
2892
|
-
|
|
2893
|
-
const
|
|
2894
|
-
|
|
2895
|
-
applyConsentSignals(result.current, config, false);
|
|
3371
|
+
coreAcceptAll();
|
|
3372
|
+
const config = getActiveConfig();
|
|
2896
3373
|
|
|
2897
3374
|
hideBanner();
|
|
2898
3375
|
hideModal();
|
|
2899
3376
|
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
if (config.showWidget) {
|
|
3377
|
+
if (config?.showWidget) {
|
|
2903
3378
|
showWidget({ onClick: handleShowSettings });
|
|
2904
3379
|
}
|
|
2905
|
-
|
|
2906
|
-
emitConsent(result.current, result.previous);
|
|
2907
|
-
emitChange(result.current, result.previous);
|
|
2908
|
-
config.callbacks?.onAccept?.(result.current);
|
|
2909
|
-
config.callbacks?.onChange?.(result.current);
|
|
2910
3380
|
}
|
|
2911
3381
|
|
|
2912
3382
|
/**
|
|
2913
|
-
* Handle reject all
|
|
3383
|
+
* Handle reject all.
|
|
2914
3384
|
*/
|
|
2915
3385
|
function handleRejectAll() {
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
applyConsentSignals(result.current, config, false);
|
|
3386
|
+
coreRejectAll();
|
|
3387
|
+
const config = getActiveConfig();
|
|
2919
3388
|
|
|
2920
3389
|
hideBanner();
|
|
2921
3390
|
hideModal();
|
|
2922
3391
|
|
|
2923
|
-
if (config
|
|
3392
|
+
if (config?.showWidget) {
|
|
2924
3393
|
showWidget({ onClick: handleShowSettings });
|
|
2925
3394
|
}
|
|
2926
|
-
|
|
2927
|
-
emitReject(result.current);
|
|
2928
|
-
emitChange(result.current, result.previous);
|
|
2929
|
-
config.callbacks?.onReject?.();
|
|
2930
|
-
config.callbacks?.onChange?.(result.current);
|
|
2931
3395
|
}
|
|
2932
3396
|
|
|
2933
3397
|
/**
|
|
2934
|
-
* Handle save preferences from modal
|
|
3398
|
+
* Handle save preferences from modal.
|
|
2935
3399
|
*/
|
|
2936
3400
|
function handleSavePreferences(selections) {
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
applyConsentSignals(result.current, config, false);
|
|
2940
|
-
|
|
2941
|
-
// Find newly allowed categories
|
|
2942
|
-
const newlyAllowed = Object.keys(result.current).filter(
|
|
2943
|
-
cat => result.current[cat] && !result.previous[cat]
|
|
2944
|
-
);
|
|
2945
|
-
|
|
2946
|
-
if (newlyAllowed.length > 0) {
|
|
2947
|
-
replayAll(newlyAllowed);
|
|
2948
|
-
}
|
|
3401
|
+
coreUpdateConsent(selections);
|
|
3402
|
+
const config = getActiveConfig();
|
|
2949
3403
|
|
|
2950
3404
|
hideModal();
|
|
2951
3405
|
|
|
2952
|
-
if (config
|
|
3406
|
+
if (config?.showWidget) {
|
|
2953
3407
|
showWidget({ onClick: handleShowSettings });
|
|
2954
3408
|
}
|
|
2955
|
-
|
|
2956
|
-
// Determine if this was acceptance or rejection based on selections
|
|
2957
|
-
const hasNonEssential = Object.entries(selections)
|
|
2958
|
-
.some(([cat, val]) => cat !== 'essential' && val);
|
|
2959
|
-
|
|
2960
|
-
if (hasNonEssential) {
|
|
2961
|
-
emitConsent(result.current, result.previous);
|
|
2962
|
-
} else {
|
|
2963
|
-
emitReject(result.current);
|
|
2964
|
-
}
|
|
2965
|
-
|
|
2966
|
-
emitChange(result.current, result.previous);
|
|
2967
|
-
config.callbacks?.onChange?.(result.current);
|
|
2968
3409
|
}
|
|
2969
3410
|
|
|
2970
3411
|
/**
|
|
2971
|
-
*
|
|
3412
|
+
* Open the settings modal.
|
|
2972
3413
|
*/
|
|
2973
3414
|
function handleShowSettings() {
|
|
2974
3415
|
hideBanner();
|
|
@@ -2985,17 +3426,17 @@ function handleShowSettings() {
|
|
|
2985
3426
|
}
|
|
2986
3427
|
|
|
2987
3428
|
/**
|
|
2988
|
-
*
|
|
3429
|
+
* Close the modal — either bring the widget back (decision made) or
|
|
3430
|
+
* fall back to the banner (no decision yet).
|
|
2989
3431
|
*/
|
|
2990
3432
|
function handleCloseModal() {
|
|
2991
3433
|
hideModal();
|
|
2992
3434
|
emitHide('modal');
|
|
2993
3435
|
|
|
2994
|
-
|
|
2995
|
-
if (hasConsentDecision() && config
|
|
3436
|
+
const config = getActiveConfig();
|
|
3437
|
+
if (hasConsentDecision() && config?.showWidget) {
|
|
2996
3438
|
showWidget({ onClick: handleShowSettings });
|
|
2997
3439
|
} else {
|
|
2998
|
-
// Show banner again if no decision made
|
|
2999
3440
|
showBanner({
|
|
3000
3441
|
onAcceptAll: handleAcceptAll,
|
|
3001
3442
|
onRejectAll: handleRejectAll,
|
|
@@ -3005,103 +3446,37 @@ function handleCloseModal() {
|
|
|
3005
3446
|
}
|
|
3006
3447
|
|
|
3007
3448
|
/**
|
|
3008
|
-
* Initialize Zest
|
|
3449
|
+
* Initialize Zest with UI.
|
|
3009
3450
|
*/
|
|
3010
3451
|
function init(userConfig = {}) {
|
|
3011
|
-
|
|
3452
|
+
const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
|
|
3453
|
+
if (alreadyInitialized) {
|
|
3012
3454
|
console.warn('[Zest] Already initialized');
|
|
3013
3455
|
return Zest;
|
|
3014
3456
|
}
|
|
3015
3457
|
|
|
3016
|
-
|
|
3017
|
-
config = setConfig(userConfig);
|
|
3018
|
-
|
|
3019
|
-
// Push default denied state to vendor consent mode APIs (must happen before scripts load)
|
|
3020
|
-
applyConsentSignals(
|
|
3021
|
-
{ functional: false, analytics: false, marketing: false },
|
|
3022
|
-
config,
|
|
3023
|
-
true
|
|
3024
|
-
);
|
|
3025
|
-
|
|
3026
|
-
// Set patterns if provided
|
|
3027
|
-
if (config.patterns) {
|
|
3028
|
-
setPatterns(config.patterns);
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
// Set up consent checkers
|
|
3032
|
-
setConsentChecker$2(checkConsent);
|
|
3033
|
-
setConsentChecker$1(checkConsent);
|
|
3034
|
-
setConsentChecker(checkConsent);
|
|
3035
|
-
|
|
3036
|
-
// Start interception
|
|
3037
|
-
interceptCookies();
|
|
3038
|
-
interceptStorage();
|
|
3039
|
-
startScriptBlocking(config.mode, config.blockedDomains);
|
|
3040
|
-
|
|
3041
|
-
// Load saved consent
|
|
3042
|
-
const consent = loadConsent();
|
|
3043
|
-
|
|
3044
|
-
initialized = true;
|
|
3045
|
-
|
|
3046
|
-
// Push update for returning visitors with saved consent
|
|
3047
|
-
if (hasConsentDecision()) {
|
|
3048
|
-
applyConsentSignals(consent, config, false);
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
// Check Do Not Track / Global Privacy Control
|
|
3052
|
-
const dntEnabled = isDoNotTrackEnabled();
|
|
3053
|
-
let dntApplied = false;
|
|
3054
|
-
|
|
3055
|
-
if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
|
|
3056
|
-
if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
3057
|
-
// Auto-reject non-essential cookies silently
|
|
3058
|
-
const result = rejectAll(config.expiration);
|
|
3059
|
-
dntApplied = true;
|
|
3060
|
-
|
|
3061
|
-
applyConsentSignals(result.current, config, false);
|
|
3062
|
-
|
|
3063
|
-
// Emit events
|
|
3064
|
-
emitReject(result.current);
|
|
3065
|
-
emitChange(result.current, result.previous);
|
|
3066
|
-
config.callbacks?.onReject?.();
|
|
3067
|
-
config.callbacks?.onChange?.(result.current);
|
|
3068
|
-
}
|
|
3069
|
-
// 'preselect' behavior is handled by default (banner shows with defaults off)
|
|
3070
|
-
}
|
|
3071
|
-
|
|
3072
|
-
// Emit ready event
|
|
3073
|
-
emitReady(consent);
|
|
3074
|
-
config.callbacks?.onReady?.(consent);
|
|
3458
|
+
const config = getActiveConfig();
|
|
3075
3459
|
|
|
3076
|
-
|
|
3077
|
-
if (!hasConsentDecision() && !dntApplied) {
|
|
3078
|
-
// No consent decision yet - show banner
|
|
3460
|
+
if (!hasDecision && !dntApplied) {
|
|
3079
3461
|
showBanner({
|
|
3080
3462
|
onAcceptAll: handleAcceptAll,
|
|
3081
3463
|
onRejectAll: handleRejectAll,
|
|
3082
3464
|
onSettings: handleShowSettings
|
|
3083
3465
|
});
|
|
3084
3466
|
emitShow('banner');
|
|
3085
|
-
} else {
|
|
3086
|
-
|
|
3087
|
-
if (config.showWidget) {
|
|
3088
|
-
showWidget({ onClick: handleShowSettings });
|
|
3089
|
-
}
|
|
3467
|
+
} else if (config?.showWidget) {
|
|
3468
|
+
showWidget({ onClick: handleShowSettings });
|
|
3090
3469
|
}
|
|
3091
3470
|
|
|
3092
3471
|
return Zest;
|
|
3093
3472
|
}
|
|
3094
3473
|
|
|
3095
|
-
/**
|
|
3096
|
-
* Public API
|
|
3097
|
-
*/
|
|
3098
3474
|
const Zest = {
|
|
3099
|
-
// Initialization
|
|
3100
3475
|
init,
|
|
3101
3476
|
|
|
3102
3477
|
// Banner control
|
|
3103
3478
|
show() {
|
|
3104
|
-
if (!
|
|
3479
|
+
if (!isInitialized()) {
|
|
3105
3480
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3106
3481
|
return;
|
|
3107
3482
|
}
|
|
@@ -3122,7 +3497,7 @@ const Zest = {
|
|
|
3122
3497
|
|
|
3123
3498
|
// Settings modal
|
|
3124
3499
|
showSettings() {
|
|
3125
|
-
if (!
|
|
3500
|
+
if (!isInitialized()) {
|
|
3126
3501
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3127
3502
|
return;
|
|
3128
3503
|
}
|
|
@@ -3134,19 +3509,19 @@ const Zest = {
|
|
|
3134
3509
|
emitHide('modal');
|
|
3135
3510
|
},
|
|
3136
3511
|
|
|
3137
|
-
// Consent
|
|
3512
|
+
// Consent state
|
|
3138
3513
|
getConsent,
|
|
3139
3514
|
hasConsent,
|
|
3140
3515
|
hasConsentDecision,
|
|
3141
3516
|
getConsentProof,
|
|
3142
3517
|
|
|
3143
|
-
// DNT
|
|
3518
|
+
// DNT
|
|
3144
3519
|
isDoNotTrackEnabled,
|
|
3145
3520
|
getDNTDetails,
|
|
3146
3521
|
|
|
3147
|
-
//
|
|
3522
|
+
// Programmatic accept / reject
|
|
3148
3523
|
acceptAll() {
|
|
3149
|
-
if (!
|
|
3524
|
+
if (!isInitialized()) {
|
|
3150
3525
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3151
3526
|
return;
|
|
3152
3527
|
}
|
|
@@ -3154,20 +3529,19 @@ const Zest = {
|
|
|
3154
3529
|
},
|
|
3155
3530
|
|
|
3156
3531
|
rejectAll() {
|
|
3157
|
-
if (!
|
|
3532
|
+
if (!isInitialized()) {
|
|
3158
3533
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3159
3534
|
return;
|
|
3160
3535
|
}
|
|
3161
3536
|
handleRejectAll();
|
|
3162
3537
|
},
|
|
3163
3538
|
|
|
3164
|
-
// Reset and
|
|
3539
|
+
// Reset everything and reshow the banner
|
|
3165
3540
|
reset() {
|
|
3166
|
-
|
|
3541
|
+
coreReset();
|
|
3167
3542
|
hideModal();
|
|
3168
3543
|
removeWidget();
|
|
3169
|
-
|
|
3170
|
-
if (initialized) {
|
|
3544
|
+
if (isInitialized()) {
|
|
3171
3545
|
showBanner({
|
|
3172
3546
|
onAcceptAll: handleAcceptAll,
|
|
3173
3547
|
onRejectAll: handleRejectAll,
|
|
@@ -3177,17 +3551,30 @@ const Zest = {
|
|
|
3177
3551
|
}
|
|
3178
3552
|
},
|
|
3179
3553
|
|
|
3180
|
-
// Config
|
|
3554
|
+
// Config introspection
|
|
3181
3555
|
getConfig: getCurrentConfig,
|
|
3182
3556
|
|
|
3183
3557
|
// Events
|
|
3558
|
+
on,
|
|
3559
|
+
once,
|
|
3184
3560
|
EVENTS
|
|
3185
3561
|
};
|
|
3186
3562
|
|
|
3187
3563
|
// Auto-init if config present
|
|
3188
3564
|
if (typeof window !== 'undefined') {
|
|
3189
|
-
// Make Zest available globally
|
|
3190
|
-
|
|
3565
|
+
// Make Zest available globally. defineProperty with writable:false +
|
|
3566
|
+
// configurable:false stops a later-loaded script from replacing the
|
|
3567
|
+
// global with a trojanned stand-in.
|
|
3568
|
+
try {
|
|
3569
|
+
Object.defineProperty(window, 'Zest', {
|
|
3570
|
+
value: Object.freeze(Zest),
|
|
3571
|
+
writable: false,
|
|
3572
|
+
configurable: false,
|
|
3573
|
+
enumerable: true
|
|
3574
|
+
});
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
window.Zest = Zest;
|
|
3577
|
+
}
|
|
3191
3578
|
|
|
3192
3579
|
const autoInit = () => {
|
|
3193
3580
|
const cfg = getConfig();
|