@freshjuice/zest 0.1.0 → 2.0.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 +216 -70
- package/dist/zest.de.js +776 -286
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +776 -286
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +776 -286
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +776 -286
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +776 -286
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- 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 +776 -286
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +776 -286
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +776 -286
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +776 -286
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +776 -286
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +776 -286
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +776 -286
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +776 -286
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +776 -286
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +17 -4
- package/src/api/public-api.js +97 -0
- package/src/config/defaults.js +150 -0
- package/src/config/parser.js +104 -0
- package/src/core/categories.js +52 -0
- package/src/core/cookie-interceptor.js +131 -0
- package/src/core/dnt.js +56 -0
- package/src/core/known-trackers.js +195 -0
- package/src/core/pattern-matcher.js +111 -0
- package/src/core/script-blocker.js +314 -0
- package/src/core/security.js +204 -0
- package/src/core/storage-interceptor.js +173 -0
- package/src/core-lifecycle.js +192 -0
- package/src/headless.js +133 -0
- package/src/i18n/lang-en.js +54 -0
- package/src/i18n/single/lang-de.js +55 -0
- package/src/i18n/single/lang-en.js +55 -0
- package/src/i18n/single/lang-es.js +55 -0
- package/src/i18n/single/lang-fr.js +55 -0
- package/src/i18n/single/lang-it.js +55 -0
- package/src/i18n/single/lang-ja.js +55 -0
- package/src/i18n/single/lang-nl.js +55 -0
- package/src/i18n/single/lang-pl.js +55 -0
- package/src/i18n/single/lang-pt.js +55 -0
- package/src/i18n/single/lang-ru.js +55 -0
- package/src/i18n/single/lang-uk.js +55 -0
- package/src/i18n/single/lang-zh.js +55 -0
- package/src/i18n/translations.js +546 -0
- package/src/index.js +266 -0
- package/src/integrations/consent-signals.js +71 -0
- package/src/storage/consent-store.js +201 -0
- package/src/storage/events.js +84 -0
- package/src/ui/banner.js +134 -0
- package/src/ui/modal.js +215 -0
- package/src/ui/styles.js +519 -0
- package/src/ui/widget.js +105 -0
package/dist/zest.en.js
CHANGED
|
@@ -1,10 +1,216 @@
|
|
|
1
1
|
var Zest = (function () {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Security utilities - escaping, validation, and safe parsing helpers
|
|
6
|
+
*
|
|
7
|
+
* These helpers are used across UI components, URL/CSS validation, and
|
|
8
|
+
* consent-cookie parsing to provide defense-in-depth against untrusted
|
|
9
|
+
* config (CMS-driven, i18n-loaded, or attacker-supplied).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HTML_ESCAPE_MAP = {
|
|
13
|
+
'&': '&',
|
|
14
|
+
'<': '<',
|
|
15
|
+
'>': '>',
|
|
16
|
+
'"': '"',
|
|
17
|
+
"'": ''',
|
|
18
|
+
'`': '`'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Escape a value for safe embedding in HTML text nodes and attribute values.
|
|
23
|
+
* Accepts any value — non-strings are stringified first. null/undefined -> ''.
|
|
24
|
+
*/
|
|
25
|
+
function escapeHTML(value) {
|
|
26
|
+
if (value === null || value === undefined) return '';
|
|
27
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
28
|
+
return str.replace(/[&<>"'`]/g, (ch) => HTML_ESCAPE_MAP[ch]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a URL — return the URL if it uses http:/https:/mailto:/tel:,
|
|
33
|
+
* otherwise return null. Blocks javascript:, data:, vbscript:, file:, etc.
|
|
34
|
+
*/
|
|
35
|
+
function safeUrl(url) {
|
|
36
|
+
if (typeof url !== 'string' || url.length === 0) return null;
|
|
37
|
+
const trimmed = url.trim();
|
|
38
|
+
if (trimmed.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
// Relative URLs (no protocol) are safe — treat as same-origin path
|
|
41
|
+
if (/^[/?#]/.test(trimmed)) return trimmed;
|
|
42
|
+
|
|
43
|
+
// Check protocol explicitly, do NOT rely on URL parsing alone —
|
|
44
|
+
// attackers may use whitespace/control characters to confuse parsers.
|
|
45
|
+
const match = trimmed.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
46
|
+
if (!match) {
|
|
47
|
+
// No protocol — treat as relative
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const protocol = match[1].toLowerCase();
|
|
52
|
+
if (protocol === 'http' || protocol === 'https' || protocol === 'mailto' || protocol === 'tel') {
|
|
53
|
+
return trimmed;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a CSS color value. Accepts #rgb/#rrggbb/#rrggbbaa and a
|
|
60
|
+
* small allowlist of CSS named colors and rgb()/rgba()/hsl()/hsla()
|
|
61
|
+
* functional forms with numeric-only arguments.
|
|
62
|
+
*/
|
|
63
|
+
const NAMED_COLORS = new Set([
|
|
64
|
+
'transparent', 'black', 'white', 'red', 'green', 'blue', 'yellow',
|
|
65
|
+
'orange', 'purple', 'pink', 'gray', 'grey', 'brown', 'cyan', 'magenta',
|
|
66
|
+
'silver', 'gold', 'navy', 'teal', 'maroon', 'olive', 'lime', 'aqua',
|
|
67
|
+
'fuchsia', 'indigo', 'violet', 'crimson', 'coral', 'salmon', 'tomato'
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function safeColor(color) {
|
|
71
|
+
if (typeof color !== 'string') return null;
|
|
72
|
+
const trimmed = color.trim();
|
|
73
|
+
|
|
74
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
|
75
|
+
if (NAMED_COLORS.has(trimmed.toLowerCase())) return trimmed;
|
|
76
|
+
|
|
77
|
+
// Functional notations: only digits, dots, commas, %, whitespace between parens
|
|
78
|
+
if (/^(rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\s*\)$/i.test(trimmed)) return trimmed;
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a regex pattern string. Rejects patterns that contain known
|
|
85
|
+
* catastrophic-backtracking shapes (nested quantifiers). Compiles with
|
|
86
|
+
* try/catch.
|
|
87
|
+
*
|
|
88
|
+
* Returns a RegExp on success, null on failure.
|
|
89
|
+
*/
|
|
90
|
+
const REDOS_PATTERNS = [
|
|
91
|
+
/(\([^)]*[+*][^)]*\)|\[[^\]]*\]|\\w|\\d|\\s)\s*[+*]/, // nested quantifier
|
|
92
|
+
/\(\?[=!][^)]*[+*][^)]*\)[+*]/, // lookahead with quantifier, then quantifier
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function safeRegExp(pattern, flags) {
|
|
96
|
+
if (pattern instanceof RegExp) return pattern;
|
|
97
|
+
if (typeof pattern !== 'string') return null;
|
|
98
|
+
|
|
99
|
+
// Cap pattern length to limit compiled-regex state
|
|
100
|
+
if (pattern.length > 500) return null;
|
|
101
|
+
|
|
102
|
+
// Heuristic: reject obviously dangerous patterns
|
|
103
|
+
for (const bad of REDOS_PATTERNS) {
|
|
104
|
+
if (bad.test(pattern)) return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return new RegExp(pattern, flags);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sanitize a consent-cookie payload. Only known category keys with
|
|
116
|
+
* boolean values survive; prototype-polluting keys are stripped.
|
|
117
|
+
*/
|
|
118
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
119
|
+
|
|
120
|
+
function sanitizeConsentPayload(raw, knownCategoryIds) {
|
|
121
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
122
|
+
|
|
123
|
+
const result = {
|
|
124
|
+
version: typeof raw.version === 'string' ? raw.version : null,
|
|
125
|
+
timestamp: typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp) ? raw.timestamp : null,
|
|
126
|
+
categories: {}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const cats = raw.categories;
|
|
130
|
+
if (!cats || typeof cats !== 'object' || Array.isArray(cats)) return null;
|
|
131
|
+
|
|
132
|
+
for (const key of knownCategoryIds) {
|
|
133
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(cats, key)) {
|
|
135
|
+
result.categories[key] = cats[key] === true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// essential is always true regardless of stored value
|
|
140
|
+
if (knownCategoryIds.includes('essential')) {
|
|
141
|
+
result.categories.essential = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Invoke a user-supplied callback, swallowing and logging exceptions so
|
|
149
|
+
* a misbehaving callback can't break the consent flow.
|
|
150
|
+
*/
|
|
151
|
+
function safeInvoke(fn, ...args) {
|
|
152
|
+
if (typeof fn !== 'function') return undefined;
|
|
153
|
+
try {
|
|
154
|
+
return fn(...args);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
try {
|
|
157
|
+
console.error('[Zest] User callback threw:', e);
|
|
158
|
+
} catch (_) {
|
|
159
|
+
/* no-op */
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Strip comments and selector-level content from a customStyles string
|
|
167
|
+
* while still allowing property/value declarations scoped under the
|
|
168
|
+
* component selectors the author is targeting. We cannot fully sandbox
|
|
169
|
+
* CSS without a parser, but we can at least neutralise the most
|
|
170
|
+
* dangerous clickjacking vector (rules targeting Zest's own buttons).
|
|
171
|
+
*
|
|
172
|
+
* Returns the sanitized CSS string (possibly empty).
|
|
173
|
+
*/
|
|
174
|
+
function sanitizeCustomStyles(css) {
|
|
175
|
+
if (typeof css !== 'string' || css.length === 0) return '';
|
|
176
|
+
|
|
177
|
+
// Hard cap on size to avoid runaway payloads
|
|
178
|
+
if (css.length > 20000) return '';
|
|
179
|
+
|
|
180
|
+
// Remove CSS comments (can hide payloads)
|
|
181
|
+
let out = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
182
|
+
|
|
183
|
+
// Block at-rules that can load external resources or alter behavior
|
|
184
|
+
out = out.replace(/@import\s+[^;]+;?/gi, '');
|
|
185
|
+
out = out.replace(/@charset\s+[^;]+;?/gi, '');
|
|
186
|
+
|
|
187
|
+
// Block url() values pointing outside of data: or https:
|
|
188
|
+
out = out.replace(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/gi, (match, quote, value) => {
|
|
189
|
+
const v = value.trim().toLowerCase();
|
|
190
|
+
if (v.startsWith('https:') || v.startsWith('data:image/') || v.startsWith('/') || v.startsWith('#')) {
|
|
191
|
+
return match;
|
|
192
|
+
}
|
|
193
|
+
return 'url(#)';
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Block selectors that target the built-in reject button, which could
|
|
197
|
+
// be used to hide it for clickjacking consent bypass.
|
|
198
|
+
out = out.replace(/\.zest-btn--secondary\s*\{[^}]*\}/gi, '');
|
|
199
|
+
out = out.replace(/\[data-action\s*=\s*["']reject-all["']\]\s*\{[^}]*\}/gi, '');
|
|
200
|
+
out = out.replace(/\[data-action\s*=\s*["']accept-all["']\]\s*\{[^}]*\}/gi, '');
|
|
201
|
+
|
|
202
|
+
// Block expression() (ancient IE) and -moz-binding (ancient FF)
|
|
203
|
+
out = out.replace(/expression\s*\([^)]*\)/gi, '');
|
|
204
|
+
out = out.replace(/-moz-binding\s*:[^;}]*/gi, '');
|
|
205
|
+
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
4
209
|
/**
|
|
5
210
|
* Pattern Matcher - Categorizes cookies and storage keys by pattern
|
|
6
211
|
*/
|
|
7
212
|
|
|
213
|
+
|
|
8
214
|
/**
|
|
9
215
|
* Default patterns for each category
|
|
10
216
|
*/
|
|
@@ -53,16 +259,29 @@ var Zest = (function () {
|
|
|
53
259
|
let patterns = { ...DEFAULT_PATTERNS };
|
|
54
260
|
|
|
55
261
|
/**
|
|
56
|
-
* Set custom patterns
|
|
262
|
+
* Set custom patterns. User-supplied strings are validated with safeRegExp,
|
|
263
|
+
* which rejects catastrophic-backtracking shapes and syntax errors.
|
|
264
|
+
* Invalid patterns are silently dropped with a console warning.
|
|
57
265
|
*/
|
|
58
266
|
function setPatterns(customPatterns) {
|
|
59
267
|
patterns = { ...DEFAULT_PATTERNS };
|
|
268
|
+
if (!customPatterns || typeof customPatterns !== 'object') return;
|
|
269
|
+
|
|
60
270
|
for (const [category, regexList] of Object.entries(customPatterns)) {
|
|
61
|
-
if (Array.isArray(regexList))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
271
|
+
if (!Array.isArray(regexList)) continue;
|
|
272
|
+
|
|
273
|
+
const compiled = [];
|
|
274
|
+
for (const p of regexList) {
|
|
275
|
+
const re = safeRegExp(p);
|
|
276
|
+
if (re) {
|
|
277
|
+
compiled.push(re);
|
|
278
|
+
} else {
|
|
279
|
+
try {
|
|
280
|
+
console.warn('[Zest] Rejected unsafe pattern:', p);
|
|
281
|
+
} catch (_) { /* no-op */ }
|
|
282
|
+
}
|
|
65
283
|
}
|
|
284
|
+
patterns[category] = compiled;
|
|
66
285
|
}
|
|
67
286
|
}
|
|
68
287
|
|
|
@@ -99,6 +318,11 @@ var Zest = (function () {
|
|
|
99
318
|
// Store original descriptor
|
|
100
319
|
let originalCookieDescriptor = null;
|
|
101
320
|
|
|
321
|
+
// Upper bound on the number of queued cookies awaiting consent replay.
|
|
322
|
+
// An unbounded queue is a memory-exhaustion DoS vector — a hostile
|
|
323
|
+
// script could flood it with document.cookie writes.
|
|
324
|
+
const MAX_QUEUE_SIZE$2 = 100;
|
|
325
|
+
|
|
102
326
|
// Queue for blocked cookies
|
|
103
327
|
const cookieQueue = [];
|
|
104
328
|
|
|
@@ -168,8 +392,8 @@ var Zest = (function () {
|
|
|
168
392
|
if (checkConsent$3(category)) {
|
|
169
393
|
// Consent given - set cookie
|
|
170
394
|
originalCookieDescriptor.set.call(document, value);
|
|
171
|
-
} else {
|
|
172
|
-
// No consent - queue for later
|
|
395
|
+
} else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
|
|
396
|
+
// No consent - queue for later (capped to prevent DoS)
|
|
173
397
|
cookieQueue.push({
|
|
174
398
|
value,
|
|
175
399
|
name,
|
|
@@ -178,7 +402,9 @@ var Zest = (function () {
|
|
|
178
402
|
});
|
|
179
403
|
}
|
|
180
404
|
},
|
|
181
|
-
configurable:
|
|
405
|
+
// configurable: false prevents a later-loaded script from
|
|
406
|
+
// overriding our descriptor and bypassing the interceptor.
|
|
407
|
+
configurable: false
|
|
182
408
|
});
|
|
183
409
|
|
|
184
410
|
return true;
|
|
@@ -189,6 +415,10 @@ var Zest = (function () {
|
|
|
189
415
|
*/
|
|
190
416
|
|
|
191
417
|
|
|
418
|
+
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
419
|
+
// growth would be a memory-exhaustion DoS vector.
|
|
420
|
+
const MAX_QUEUE_SIZE$1 = 200;
|
|
421
|
+
|
|
192
422
|
// Store originals
|
|
193
423
|
let originalLocalStorage = null;
|
|
194
424
|
let originalSessionStorage = null;
|
|
@@ -219,7 +449,7 @@ var Zest = (function () {
|
|
|
219
449
|
|
|
220
450
|
if (checkConsent$2(category)) {
|
|
221
451
|
target.setItem(key, value);
|
|
222
|
-
} else {
|
|
452
|
+
} else if (queue.length < MAX_QUEUE_SIZE$1) {
|
|
223
453
|
queue.push({
|
|
224
454
|
key,
|
|
225
455
|
value,
|
|
@@ -404,29 +634,56 @@ var Zest = (function () {
|
|
|
404
634
|
};
|
|
405
635
|
|
|
406
636
|
/**
|
|
407
|
-
* Check if a URL matches any tracker in the list
|
|
637
|
+
* Check if a URL matches any tracker in the list.
|
|
638
|
+
*
|
|
639
|
+
* Matching is restricted to hostname (and, when the list entry contains
|
|
640
|
+
* a path, the URL path prefix). A naive `fullUrl.includes(domain)` was
|
|
641
|
+
* previously used, which would false-positive on e.g.
|
|
642
|
+
* https://mysite.com/page?ref=google-analytics.com
|
|
408
643
|
*/
|
|
409
644
|
function matchesTrackerList(url, trackerList) {
|
|
645
|
+
let urlObj;
|
|
410
646
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
647
|
+
urlObj = new URL(url);
|
|
648
|
+
} catch (e) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
652
|
+
const path = urlObj.pathname.toLowerCase();
|
|
653
|
+
|
|
654
|
+
for (const rawEntry of trackerList) {
|
|
655
|
+
if (typeof rawEntry !== 'string') continue;
|
|
656
|
+
const entry = rawEntry.toLowerCase();
|
|
657
|
+
|
|
658
|
+
// Partial-prefix match on hostname (entry ends with a dot),
|
|
659
|
+
// e.g. "matomo." matches "analytics.matomo.cloud"
|
|
660
|
+
if (entry.endsWith('.')) {
|
|
661
|
+
const needle = entry.slice(0, -1);
|
|
662
|
+
const segments = hostname.split('.');
|
|
663
|
+
if (segments.some(seg => seg === needle) || hostname.startsWith(entry)) {
|
|
422
664
|
return true;
|
|
423
|
-
}
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Entries containing a slash specify hostname + path prefix
|
|
670
|
+
const slashIdx = entry.indexOf('/');
|
|
671
|
+
if (slashIdx !== -1) {
|
|
672
|
+
const entryHost = entry.slice(0, slashIdx);
|
|
673
|
+
const entryPath = entry.slice(slashIdx);
|
|
674
|
+
if ((hostname === entryHost || hostname.endsWith('.' + entryHost)) &&
|
|
675
|
+
path.startsWith(entryPath)) {
|
|
424
676
|
return true;
|
|
425
677
|
}
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Plain hostname: exact or subdomain match only
|
|
682
|
+
if (hostname === entry || hostname.endsWith('.' + entry)) {
|
|
683
|
+
return true;
|
|
426
684
|
}
|
|
427
|
-
} catch (e) {
|
|
428
|
-
// Invalid URL
|
|
429
685
|
}
|
|
686
|
+
|
|
430
687
|
return false;
|
|
431
688
|
}
|
|
432
689
|
|
|
@@ -473,7 +730,17 @@ var Zest = (function () {
|
|
|
473
730
|
*/
|
|
474
731
|
|
|
475
732
|
|
|
476
|
-
//
|
|
733
|
+
// Categories the author has declared blockable. A script can self-label
|
|
734
|
+
// into one of these, but not into 'essential' (a common bypass).
|
|
735
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
736
|
+
|
|
737
|
+
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
738
|
+
// hostile page from flooding the queue with <script> nodes.
|
|
739
|
+
const MAX_QUEUE_SIZE = 500;
|
|
740
|
+
|
|
741
|
+
// Queue for blocked scripts — the authoritative source for replay,
|
|
742
|
+
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
743
|
+
// cannot hijack what gets executed.
|
|
477
744
|
const scriptQueue = [];
|
|
478
745
|
|
|
479
746
|
// MutationObserver instance
|
|
@@ -520,55 +787,61 @@ var Zest = (function () {
|
|
|
520
787
|
}
|
|
521
788
|
|
|
522
789
|
/**
|
|
523
|
-
* Determine if a script should be blocked and get its category
|
|
790
|
+
* Determine if a script should be blocked and get its category.
|
|
791
|
+
*
|
|
792
|
+
* A self-applied 'essential' label is ignored — only explicit blockable
|
|
793
|
+
* categories are accepted. That prevents a third-party script from
|
|
794
|
+
* stamping itself with data-consent-category="essential" to slip past
|
|
795
|
+
* mode-based blocking.
|
|
524
796
|
*/
|
|
525
797
|
function getScriptBlockCategory(script) {
|
|
526
|
-
//
|
|
527
|
-
const explicitCategory = script.getAttribute('data-consent-category');
|
|
528
|
-
if (explicitCategory) {
|
|
529
|
-
return explicitCategory;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// 2. Skip if script has data-zest-allow attribute
|
|
798
|
+
// Skip if script has data-zest-allow attribute (opt-out)
|
|
533
799
|
if (script.hasAttribute('data-zest-allow')) {
|
|
534
800
|
return null;
|
|
535
801
|
}
|
|
536
802
|
|
|
803
|
+
// 1. Check for explicit data-consent-category attribute.
|
|
804
|
+
// Only honor values from the blockable set; 'essential' and unknown
|
|
805
|
+
// values fall through to the other checks.
|
|
806
|
+
const explicitCategory = script.getAttribute('data-consent-category');
|
|
807
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
808
|
+
? explicitCategory
|
|
809
|
+
: null;
|
|
810
|
+
|
|
537
811
|
const src = script.src;
|
|
538
812
|
|
|
539
|
-
// No src = inline script, only block if explicitly tagged
|
|
813
|
+
// No src = inline script, only block if explicitly tagged (blockable only)
|
|
540
814
|
if (!src) {
|
|
541
|
-
return
|
|
815
|
+
return explicitBlockable;
|
|
542
816
|
}
|
|
543
817
|
|
|
544
|
-
//
|
|
818
|
+
// 2. Check custom blocked domains
|
|
545
819
|
const customCategory = matchesCustomDomains(src);
|
|
546
|
-
if (customCategory) {
|
|
547
|
-
return customCategory;
|
|
548
|
-
}
|
|
549
820
|
|
|
550
|
-
//
|
|
821
|
+
// 3. Mode-based blocking
|
|
822
|
+
let modeCategory = null;
|
|
551
823
|
switch (blockingMode) {
|
|
552
824
|
case 'manual':
|
|
553
|
-
|
|
554
|
-
return null;
|
|
825
|
+
break;
|
|
555
826
|
|
|
556
827
|
case 'safe':
|
|
557
828
|
case 'strict':
|
|
558
|
-
|
|
559
|
-
|
|
829
|
+
modeCategory = getCategoryForScript(src, blockingMode);
|
|
830
|
+
break;
|
|
560
831
|
|
|
561
832
|
case 'doomsday':
|
|
562
|
-
// Block all third-party scripts
|
|
563
833
|
if (isThirdParty(src)) {
|
|
564
|
-
|
|
565
|
-
return getCategoryForScript(src, 'strict') || 'marketing';
|
|
834
|
+
modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
|
|
566
835
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
default:
|
|
570
|
-
return null;
|
|
836
|
+
break;
|
|
571
837
|
}
|
|
838
|
+
|
|
839
|
+
// Use the strictest category among explicit/custom/mode decisions.
|
|
840
|
+
// We collect all categories the script matches and pick the first
|
|
841
|
+
// that appears in the blockable set (any match wins — but we prefer
|
|
842
|
+
// the mode-assigned one since it's authoritative for third-party
|
|
843
|
+
// trackers that try to self-label as 'functional').
|
|
844
|
+
return modeCategory || customCategory || explicitBlockable;
|
|
572
845
|
}
|
|
573
846
|
|
|
574
847
|
/**
|
|
@@ -593,14 +866,17 @@ var Zest = (function () {
|
|
|
593
866
|
return false;
|
|
594
867
|
}
|
|
595
868
|
|
|
596
|
-
// Store script info for later execution
|
|
869
|
+
// Store script info for later execution. Snapshot the src/text BEFORE
|
|
870
|
+
// mutating the DOM — this snapshot is the authoritative replay source
|
|
871
|
+
// so later DOM tampering cannot hijack the replayed script URL.
|
|
597
872
|
const scriptInfo = {
|
|
598
873
|
category,
|
|
599
|
-
src: script.src,
|
|
874
|
+
src: script.src || '',
|
|
600
875
|
inline: script.textContent,
|
|
601
876
|
type: script.type,
|
|
602
877
|
async: script.async,
|
|
603
878
|
defer: script.defer,
|
|
879
|
+
element: script,
|
|
604
880
|
timestamp: Date.now()
|
|
605
881
|
};
|
|
606
882
|
|
|
@@ -611,77 +887,61 @@ var Zest = (function () {
|
|
|
611
887
|
// Disable the script
|
|
612
888
|
script.type = 'text/plain';
|
|
613
889
|
|
|
614
|
-
//
|
|
890
|
+
// Remove src to prevent loading. We no longer stash it on the element
|
|
891
|
+
// (data-blocked-src was a tampering vector); scriptQueue is the single
|
|
892
|
+
// source of truth for replay.
|
|
615
893
|
if (script.src) {
|
|
616
|
-
script.setAttribute('data-blocked-src', script.src);
|
|
617
894
|
script.removeAttribute('src');
|
|
618
895
|
}
|
|
619
896
|
|
|
620
|
-
scriptQueue.
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Execute a queued script
|
|
626
|
-
*/
|
|
627
|
-
function executeScript(scriptInfo) {
|
|
628
|
-
const script = document.createElement('script');
|
|
629
|
-
|
|
630
|
-
if (scriptInfo.src) {
|
|
631
|
-
script.src = scriptInfo.src;
|
|
632
|
-
} else if (scriptInfo.inline) {
|
|
633
|
-
script.textContent = scriptInfo.inline;
|
|
897
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
898
|
+
scriptQueue.push(scriptInfo);
|
|
634
899
|
}
|
|
635
|
-
|
|
636
|
-
if (scriptInfo.async) script.async = true;
|
|
637
|
-
if (scriptInfo.defer) script.defer = true;
|
|
638
|
-
|
|
639
|
-
script.setAttribute('data-zest-processed', 'executed');
|
|
640
|
-
script.setAttribute('data-consent-executed', 'true');
|
|
641
|
-
|
|
642
|
-
document.head.appendChild(script);
|
|
900
|
+
return true;
|
|
643
901
|
}
|
|
644
902
|
|
|
645
903
|
/**
|
|
646
|
-
* Replay queued scripts for allowed categories
|
|
904
|
+
* Replay queued scripts for allowed categories.
|
|
905
|
+
*
|
|
906
|
+
* scriptQueue is the single source of truth for src and inline body —
|
|
907
|
+
* we never re-read data-* attributes from the DOM (which an attacker
|
|
908
|
+
* could have rewritten in the intervening time).
|
|
647
909
|
*/
|
|
648
910
|
function replayScripts(allowedCategories) {
|
|
649
911
|
const remaining = [];
|
|
650
912
|
|
|
651
913
|
for (const scriptInfo of scriptQueue) {
|
|
652
|
-
if (allowedCategories.includes(scriptInfo.category)) {
|
|
653
|
-
executeScript(scriptInfo);
|
|
654
|
-
} else {
|
|
914
|
+
if (!allowedCategories.includes(scriptInfo.category)) {
|
|
655
915
|
remaining.push(scriptInfo);
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const newScript = document.createElement('script');
|
|
920
|
+
if (scriptInfo.src) {
|
|
921
|
+
newScript.src = scriptInfo.src;
|
|
922
|
+
} else if (scriptInfo.inline) {
|
|
923
|
+
newScript.textContent = scriptInfo.inline;
|
|
924
|
+
}
|
|
925
|
+
if (scriptInfo.async) newScript.async = true;
|
|
926
|
+
if (scriptInfo.defer) newScript.defer = true;
|
|
927
|
+
if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
|
|
928
|
+
newScript.type = scriptInfo.type;
|
|
929
|
+
}
|
|
930
|
+
newScript.setAttribute('data-zest-processed', 'executed');
|
|
931
|
+
newScript.setAttribute('data-consent-executed', 'true');
|
|
932
|
+
|
|
933
|
+
// If the original element is still in the DOM, replace it in place
|
|
934
|
+
// so execution order is preserved. Otherwise append to <head>.
|
|
935
|
+
const original = scriptInfo.element;
|
|
936
|
+
if (original && original.isConnected && original.parentNode) {
|
|
937
|
+
original.parentNode.replaceChild(newScript, original);
|
|
938
|
+
} else {
|
|
939
|
+
document.head.appendChild(newScript);
|
|
656
940
|
}
|
|
657
941
|
}
|
|
658
942
|
|
|
659
943
|
scriptQueue.length = 0;
|
|
660
944
|
scriptQueue.push(...remaining);
|
|
661
|
-
|
|
662
|
-
// Also re-enable any blocked scripts in the DOM
|
|
663
|
-
const blockedScripts = document.querySelectorAll('script[data-zest-processed="blocked"]');
|
|
664
|
-
blockedScripts.forEach(script => {
|
|
665
|
-
const category = script.getAttribute('data-consent-category');
|
|
666
|
-
if (allowedCategories.includes(category)) {
|
|
667
|
-
// Clone and replace to execute
|
|
668
|
-
const newScript = document.createElement('script');
|
|
669
|
-
|
|
670
|
-
const blockedSrc = script.getAttribute('data-blocked-src');
|
|
671
|
-
if (blockedSrc) {
|
|
672
|
-
newScript.src = blockedSrc;
|
|
673
|
-
} else {
|
|
674
|
-
newScript.textContent = script.textContent;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
if (script.async) newScript.async = true;
|
|
678
|
-
if (script.defer) newScript.defer = true;
|
|
679
|
-
|
|
680
|
-
newScript.setAttribute('data-zest-processed', 'executed');
|
|
681
|
-
newScript.setAttribute('data-consent-executed', 'true');
|
|
682
|
-
script.parentNode?.replaceChild(newScript, script);
|
|
683
|
-
}
|
|
684
|
-
});
|
|
685
945
|
}
|
|
686
946
|
|
|
687
947
|
/**
|
|
@@ -842,6 +1102,78 @@ var Zest = (function () {
|
|
|
842
1102
|
return { enabled: false, source: null };
|
|
843
1103
|
}
|
|
844
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* Consent Signals - Optional vendor consent mode integrations
|
|
1107
|
+
*
|
|
1108
|
+
* Pushes consent state to Google Consent Mode v2 and/or Microsoft UET
|
|
1109
|
+
* Consent Mode when enabled via config.
|
|
1110
|
+
*/
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Map Zest consent state to Google Consent Mode v2 signals
|
|
1114
|
+
*/
|
|
1115
|
+
function toGoogleSignals(consent) {
|
|
1116
|
+
const g = (val) => val ? 'granted' : 'denied';
|
|
1117
|
+
return {
|
|
1118
|
+
ad_storage: g(consent.marketing),
|
|
1119
|
+
ad_user_data: g(consent.marketing),
|
|
1120
|
+
ad_personalization: g(consent.marketing),
|
|
1121
|
+
analytics_storage: g(consent.analytics),
|
|
1122
|
+
functionality_storage: 'granted', // essential is always true
|
|
1123
|
+
personalization_storage: g(consent.functional)
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Push consent signal to Google via gtag or dataLayer fallback.
|
|
1129
|
+
* Uses a local function to preserve the `arguments` object shape
|
|
1130
|
+
* that gtag/dataLayer expects (not an array).
|
|
1131
|
+
*/
|
|
1132
|
+
function pushGoogle(type, signals) {
|
|
1133
|
+
window.dataLayer = window.dataLayer || [];
|
|
1134
|
+
if (typeof window.gtag === 'function') {
|
|
1135
|
+
window.gtag('consent', type, signals);
|
|
1136
|
+
} else {
|
|
1137
|
+
function gtagFallback() { window.dataLayer.push(arguments); }
|
|
1138
|
+
gtagFallback('consent', type, signals);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Map Zest consent state to Microsoft UET signal.
|
|
1144
|
+
* Microsoft UET only exposes ad_storage.
|
|
1145
|
+
*/
|
|
1146
|
+
function toMicrosoftSignals(consent) {
|
|
1147
|
+
return { ad_storage: consent.marketing ? 'granted' : 'denied' };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Push consent signal to Microsoft UET
|
|
1152
|
+
*/
|
|
1153
|
+
function pushMicrosoft(type, signals) {
|
|
1154
|
+
window.uetq = window.uetq || [];
|
|
1155
|
+
window.uetq.push('consent', type, signals);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Apply consent signals to enabled vendor integrations.
|
|
1160
|
+
*
|
|
1161
|
+
* @param {Object} consent Current Zest consent state
|
|
1162
|
+
* @param {Object} config Merged Zest config
|
|
1163
|
+
* @param {boolean} isDefault true on first call (pushes 'default'), false for updates
|
|
1164
|
+
*/
|
|
1165
|
+
function applyConsentSignals(consent, config, isDefault) {
|
|
1166
|
+
const type = isDefault ? 'default' : 'update';
|
|
1167
|
+
|
|
1168
|
+
if (config.consentModeGoogle) {
|
|
1169
|
+
pushGoogle(type, toGoogleSignals(consent));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (config.consentModeMicrosoft) {
|
|
1173
|
+
pushMicrosoft(type, toMicrosoftSignals(consent));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
845
1177
|
/**
|
|
846
1178
|
* EN only translation - auto-generated
|
|
847
1179
|
* Do not edit manually, run: npm run build
|
|
@@ -953,6 +1285,10 @@ var Zest = (function () {
|
|
|
953
1285
|
// Custom styles to inject into Shadow DOM
|
|
954
1286
|
customStyles: '',
|
|
955
1287
|
|
|
1288
|
+
// Vendor consent mode integrations (optional)
|
|
1289
|
+
consentModeGoogle: false,
|
|
1290
|
+
consentModeMicrosoft: false,
|
|
1291
|
+
|
|
956
1292
|
// Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
|
|
957
1293
|
mode: 'safe',
|
|
958
1294
|
|
|
@@ -983,7 +1319,7 @@ var Zest = (function () {
|
|
|
983
1319
|
}
|
|
984
1320
|
|
|
985
1321
|
// Simple properties
|
|
986
|
-
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior'];
|
|
1322
|
+
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior', 'consentModeGoogle', 'consentModeMicrosoft'];
|
|
987
1323
|
for (const key of simpleKeys) {
|
|
988
1324
|
if (userConfig[key] !== undefined) {
|
|
989
1325
|
config[key] = userConfig[key];
|
|
@@ -1093,6 +1429,13 @@ var Zest = (function () {
|
|
|
1093
1429
|
const expiration = script.getAttribute('data-expiration');
|
|
1094
1430
|
if (expiration) config.expiration = parseInt(expiration, 10);
|
|
1095
1431
|
|
|
1432
|
+
// Consent mode integrations
|
|
1433
|
+
const consentModeGoogle = script.getAttribute('data-consent-mode-google');
|
|
1434
|
+
if (consentModeGoogle !== null) config.consentModeGoogle = consentModeGoogle !== 'false';
|
|
1435
|
+
|
|
1436
|
+
const consentModeMicrosoft = script.getAttribute('data-consent-mode-microsoft');
|
|
1437
|
+
if (consentModeMicrosoft !== null) config.consentModeMicrosoft = consentModeMicrosoft !== 'false';
|
|
1438
|
+
|
|
1096
1439
|
return config;
|
|
1097
1440
|
}
|
|
1098
1441
|
|
|
@@ -1124,18 +1467,18 @@ var Zest = (function () {
|
|
|
1124
1467
|
/**
|
|
1125
1468
|
* Update configuration at runtime
|
|
1126
1469
|
*/
|
|
1127
|
-
let currentConfig = null;
|
|
1470
|
+
let currentConfig$1 = null;
|
|
1128
1471
|
|
|
1129
1472
|
function setConfig(config) {
|
|
1130
|
-
currentConfig = mergeConfig(config);
|
|
1131
|
-
return currentConfig;
|
|
1473
|
+
currentConfig$1 = mergeConfig(config);
|
|
1474
|
+
return currentConfig$1;
|
|
1132
1475
|
}
|
|
1133
1476
|
|
|
1134
1477
|
function getCurrentConfig() {
|
|
1135
|
-
if (!currentConfig) {
|
|
1136
|
-
currentConfig = getConfig();
|
|
1478
|
+
if (!currentConfig$1) {
|
|
1479
|
+
currentConfig$1 = getConfig();
|
|
1137
1480
|
}
|
|
1138
|
-
return currentConfig;
|
|
1481
|
+
return currentConfig$1;
|
|
1139
1482
|
}
|
|
1140
1483
|
|
|
1141
1484
|
/**
|
|
@@ -1146,6 +1489,20 @@ var Zest = (function () {
|
|
|
1146
1489
|
const COOKIE_NAME = 'zest_consent';
|
|
1147
1490
|
const CONSENT_VERSION = '1.0';
|
|
1148
1491
|
|
|
1492
|
+
/**
|
|
1493
|
+
* Return the Secure flag fragment when running over HTTPS, empty otherwise.
|
|
1494
|
+
* On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
|
|
1495
|
+
*/
|
|
1496
|
+
function secureAttribute() {
|
|
1497
|
+
try {
|
|
1498
|
+
return typeof location !== 'undefined' && location.protocol === 'https:'
|
|
1499
|
+
? '; Secure'
|
|
1500
|
+
: '';
|
|
1501
|
+
} catch (_) {
|
|
1502
|
+
return '';
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1149
1506
|
// Current consent state
|
|
1150
1507
|
let consent = null;
|
|
1151
1508
|
|
|
@@ -1174,7 +1531,12 @@ var Zest = (function () {
|
|
|
1174
1531
|
}
|
|
1175
1532
|
|
|
1176
1533
|
/**
|
|
1177
|
-
* Load consent from cookie
|
|
1534
|
+
* Load consent from cookie.
|
|
1535
|
+
*
|
|
1536
|
+
* The parsed cookie is validated against the expected schema via
|
|
1537
|
+
* sanitizeConsentPayload — only known category keys with boolean values
|
|
1538
|
+
* survive, so a tampered cookie can't inject prototype-polluting props
|
|
1539
|
+
* or unexpected category shapes.
|
|
1178
1540
|
*/
|
|
1179
1541
|
function loadConsent() {
|
|
1180
1542
|
try {
|
|
@@ -1182,9 +1544,12 @@ var Zest = (function () {
|
|
|
1182
1544
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1183
1545
|
|
|
1184
1546
|
if (match) {
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1547
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
1548
|
+
const clean = sanitizeConsentPayload(raw, getCategoryIds());
|
|
1549
|
+
if (clean && clean.categories) {
|
|
1550
|
+
consent = { ...getDefaultConsent(), ...clean.categories };
|
|
1551
|
+
return { ...consent };
|
|
1552
|
+
}
|
|
1188
1553
|
}
|
|
1189
1554
|
} catch (e) {
|
|
1190
1555
|
// Invalid or missing cookie
|
|
@@ -1209,7 +1574,7 @@ var Zest = (function () {
|
|
|
1209
1574
|
};
|
|
1210
1575
|
|
|
1211
1576
|
const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
|
|
1212
|
-
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
|
|
1577
|
+
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
|
|
1213
1578
|
|
|
1214
1579
|
setRawCookie(cookieValue);
|
|
1215
1580
|
}
|
|
@@ -1278,7 +1643,7 @@ var Zest = (function () {
|
|
|
1278
1643
|
* Reset consent (clear cookie)
|
|
1279
1644
|
*/
|
|
1280
1645
|
function resetConsent() {
|
|
1281
|
-
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path
|
|
1646
|
+
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
|
|
1282
1647
|
consent = null;
|
|
1283
1648
|
}
|
|
1284
1649
|
|
|
@@ -1303,7 +1668,8 @@ var Zest = (function () {
|
|
|
1303
1668
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1304
1669
|
|
|
1305
1670
|
if (match) {
|
|
1306
|
-
|
|
1671
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
1672
|
+
return sanitizeConsentPayload(raw, getCategoryIds());
|
|
1307
1673
|
}
|
|
1308
1674
|
} catch (e) {
|
|
1309
1675
|
// Invalid cookie
|
|
@@ -1382,15 +1748,207 @@ var Zest = (function () {
|
|
|
1382
1748
|
return emit(EVENTS.HIDE, { type });
|
|
1383
1749
|
}
|
|
1384
1750
|
|
|
1751
|
+
/**
|
|
1752
|
+
* Subscribe to an event
|
|
1753
|
+
*/
|
|
1754
|
+
function on(eventName, callback) {
|
|
1755
|
+
document.addEventListener(eventName, callback);
|
|
1756
|
+
return () => document.removeEventListener(eventName, callback);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Subscribe to an event once
|
|
1761
|
+
*/
|
|
1762
|
+
function once(eventName, callback) {
|
|
1763
|
+
document.addEventListener(eventName, callback, { once: true });
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Core lifecycle - UI-agnostic initialization and consent actions.
|
|
1768
|
+
*
|
|
1769
|
+
* This module contains everything the main entry (with UI) and the
|
|
1770
|
+
* headless entry (no UI) share: interceptor setup, consent load/save,
|
|
1771
|
+
* replay, DNT handling, and the events/callbacks fan-out. It intentionally
|
|
1772
|
+
* does NOT import anything from `./ui/*` so tree-shakers can drop the UI
|
|
1773
|
+
* bundle entirely when only the headless API is used.
|
|
1774
|
+
*/
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
let initialized = false;
|
|
1778
|
+
let currentConfig = null;
|
|
1779
|
+
|
|
1780
|
+
function checkConsent(category) {
|
|
1781
|
+
return hasConsent(category);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function replayAll(categories) {
|
|
1785
|
+
replayCookies(categories);
|
|
1786
|
+
replayStorage(categories);
|
|
1787
|
+
replayScripts(categories);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* Run the non-UI half of init. Returns a snapshot the caller (UI or
|
|
1792
|
+
* headless) can use to decide what to do next.
|
|
1793
|
+
*/
|
|
1794
|
+
function coreInit(userConfig = {}) {
|
|
1795
|
+
if (initialized) {
|
|
1796
|
+
return {
|
|
1797
|
+
alreadyInitialized: true,
|
|
1798
|
+
config: currentConfig,
|
|
1799
|
+
consent: loadConsent(),
|
|
1800
|
+
hasDecision: hasConsentDecision(),
|
|
1801
|
+
dntApplied: false
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
currentConfig = setConfig(userConfig);
|
|
1806
|
+
|
|
1807
|
+
// Push default-denied state to vendor consent mode APIs BEFORE any
|
|
1808
|
+
// third-party script has a chance to fire.
|
|
1809
|
+
applyConsentSignals(
|
|
1810
|
+
{ functional: false, analytics: false, marketing: false },
|
|
1811
|
+
currentConfig,
|
|
1812
|
+
true
|
|
1813
|
+
);
|
|
1814
|
+
|
|
1815
|
+
if (currentConfig.patterns) {
|
|
1816
|
+
setPatterns(currentConfig.patterns);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
setConsentChecker$2(checkConsent);
|
|
1820
|
+
setConsentChecker$1(checkConsent);
|
|
1821
|
+
setConsentChecker(checkConsent);
|
|
1822
|
+
|
|
1823
|
+
interceptCookies();
|
|
1824
|
+
interceptStorage();
|
|
1825
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
1826
|
+
|
|
1827
|
+
const consent = loadConsent();
|
|
1828
|
+
initialized = true;
|
|
1829
|
+
|
|
1830
|
+
if (hasConsentDecision()) {
|
|
1831
|
+
applyConsentSignals(consent, currentConfig, false);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// DNT / GPC handling — if the user signalled opt-out at the browser
|
|
1835
|
+
// level and the site opts to respect it, auto-reject before the UI
|
|
1836
|
+
// layer ever runs.
|
|
1837
|
+
const dntEnabled = isDoNotTrackEnabled();
|
|
1838
|
+
let dntApplied = false;
|
|
1839
|
+
|
|
1840
|
+
if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
|
|
1841
|
+
if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
1842
|
+
const result = rejectAll(currentConfig.expiration);
|
|
1843
|
+
dntApplied = true;
|
|
1844
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
1845
|
+
emitReject(result.current);
|
|
1846
|
+
emitChange(result.current, result.previous);
|
|
1847
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
1848
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
emitReady(consent);
|
|
1853
|
+
safeInvoke(currentConfig.callbacks?.onReady, consent);
|
|
1854
|
+
|
|
1855
|
+
return {
|
|
1856
|
+
alreadyInitialized: false,
|
|
1857
|
+
config: currentConfig,
|
|
1858
|
+
consent,
|
|
1859
|
+
hasDecision: hasConsentDecision(),
|
|
1860
|
+
dntApplied
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Accept all categories, replay queued items, fire events + callbacks.
|
|
1866
|
+
* Returns { current, previous } or null if not yet initialized.
|
|
1867
|
+
*/
|
|
1868
|
+
function coreAcceptAll() {
|
|
1869
|
+
if (!initialized) return null;
|
|
1870
|
+
const result = acceptAll(currentConfig.expiration);
|
|
1871
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
1872
|
+
replayAll(getCategoryIds());
|
|
1873
|
+
emitConsent(result.current, result.previous);
|
|
1874
|
+
emitChange(result.current, result.previous);
|
|
1875
|
+
safeInvoke(currentConfig.callbacks?.onAccept, result.current);
|
|
1876
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
1877
|
+
return result;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Reject all non-essential categories, fire events + callbacks.
|
|
1882
|
+
*/
|
|
1883
|
+
function coreRejectAll() {
|
|
1884
|
+
if (!initialized) return null;
|
|
1885
|
+
const result = rejectAll(currentConfig.expiration);
|
|
1886
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
1887
|
+
emitReject(result.current);
|
|
1888
|
+
emitChange(result.current, result.previous);
|
|
1889
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
1890
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
1891
|
+
return result;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* Save custom selections and replay only the newly-allowed categories.
|
|
1896
|
+
*/
|
|
1897
|
+
function coreUpdateConsent(selections) {
|
|
1898
|
+
if (!initialized) return null;
|
|
1899
|
+
const result = updateConsent(selections, currentConfig.expiration);
|
|
1900
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
1901
|
+
|
|
1902
|
+
const newlyAllowed = Object.keys(result.current).filter(
|
|
1903
|
+
(cat) => result.current[cat] && !result.previous[cat]
|
|
1904
|
+
);
|
|
1905
|
+
if (newlyAllowed.length > 0) {
|
|
1906
|
+
replayAll(newlyAllowed);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const hasNonEssential = Object.entries(selections || {}).some(
|
|
1910
|
+
([cat, val]) => cat !== 'essential' && val
|
|
1911
|
+
);
|
|
1912
|
+
if (hasNonEssential) {
|
|
1913
|
+
emitConsent(result.current, result.previous);
|
|
1914
|
+
} else {
|
|
1915
|
+
emitReject(result.current);
|
|
1916
|
+
}
|
|
1917
|
+
emitChange(result.current, result.previous);
|
|
1918
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
1919
|
+
return result;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Clear the consent cookie. The caller is responsible for any UI reset.
|
|
1924
|
+
*/
|
|
1925
|
+
function coreReset() {
|
|
1926
|
+
resetConsent();
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function isInitialized() {
|
|
1930
|
+
return initialized;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function getActiveConfig() {
|
|
1934
|
+
return currentConfig;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1385
1937
|
/**
|
|
1386
1938
|
* Styles - Shadow DOM encapsulated CSS with theming
|
|
1387
1939
|
*/
|
|
1388
1940
|
|
|
1941
|
+
|
|
1942
|
+
const DEFAULT_ACCENT = '#4F46E5';
|
|
1943
|
+
|
|
1389
1944
|
/**
|
|
1390
1945
|
* Generate CSS with custom properties
|
|
1391
1946
|
*/
|
|
1392
1947
|
function generateStyles(config) {
|
|
1393
|
-
|
|
1948
|
+
// Only accept colors that pass strict validation — an unvalidated
|
|
1949
|
+
// value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
|
|
1950
|
+
const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
|
|
1951
|
+
const customCss = sanitizeCustomStyles(config.customStyles);
|
|
1394
1952
|
|
|
1395
1953
|
return `
|
|
1396
1954
|
:host {
|
|
@@ -1860,15 +2418,29 @@ var Zest = (function () {
|
|
|
1860
2418
|
.zest-hidden {
|
|
1861
2419
|
display: none !important;
|
|
1862
2420
|
}
|
|
1863
|
-
${
|
|
2421
|
+
${customCss}
|
|
1864
2422
|
`;
|
|
1865
2423
|
}
|
|
1866
2424
|
|
|
1867
2425
|
/**
|
|
1868
|
-
* Adjust color brightness
|
|
2426
|
+
* Adjust color brightness. Falls back to the default accent if the input
|
|
2427
|
+
* cannot be parsed as a hex color (non-hex inputs pass safeColor but
|
|
2428
|
+
* can't be brightness-shifted mathematically).
|
|
1869
2429
|
*/
|
|
1870
2430
|
function adjustColor(hex, percent) {
|
|
1871
|
-
|
|
2431
|
+
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
|
|
2432
|
+
hex = DEFAULT_ACCENT;
|
|
2433
|
+
}
|
|
2434
|
+
let clean = hex.trim().replace('#', '');
|
|
2435
|
+
// Expand 3-digit form to 6
|
|
2436
|
+
if (clean.length === 3) {
|
|
2437
|
+
clean = clean.split('').map(c => c + c).join('');
|
|
2438
|
+
}
|
|
2439
|
+
// Strip alpha if present
|
|
2440
|
+
if (clean.length === 8) clean = clean.slice(0, 6);
|
|
2441
|
+
if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
|
|
2442
|
+
|
|
2443
|
+
const num = parseInt(clean, 16);
|
|
1872
2444
|
const amt = Math.round(2.55 * percent);
|
|
1873
2445
|
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
|
|
1874
2446
|
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
|
|
@@ -1889,26 +2461,29 @@ ${config.customStyles || ''}
|
|
|
1889
2461
|
let bannerElement = null;
|
|
1890
2462
|
let shadowRoot$2 = null;
|
|
1891
2463
|
|
|
2464
|
+
const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
|
|
2465
|
+
|
|
1892
2466
|
/**
|
|
1893
2467
|
* Create the banner HTML
|
|
1894
2468
|
*/
|
|
1895
2469
|
function createBannerHTML(config) {
|
|
1896
2470
|
const labels = config.labels.banner;
|
|
1897
|
-
const
|
|
2471
|
+
const rawPosition = config.position || 'bottom';
|
|
2472
|
+
const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
|
|
1898
2473
|
|
|
1899
2474
|
return `
|
|
1900
|
-
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
|
|
1901
|
-
<h2 class="zest-banner__title">${labels.title}</h2>
|
|
1902
|
-
<p class="zest-banner__description">${labels.description}</p>
|
|
2475
|
+
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
|
|
2476
|
+
<h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
|
|
2477
|
+
<p class="zest-banner__description">${escapeHTML(labels.description)}</p>
|
|
1903
2478
|
<div class="zest-banner__buttons">
|
|
1904
2479
|
<button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
|
|
1905
|
-
${labels.acceptAll}
|
|
2480
|
+
${escapeHTML(labels.acceptAll)}
|
|
1906
2481
|
</button>
|
|
1907
2482
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
|
|
1908
|
-
${labels.rejectAll}
|
|
2483
|
+
${escapeHTML(labels.rejectAll)}
|
|
1909
2484
|
</button>
|
|
1910
2485
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
|
|
1911
|
-
${labels.settings}
|
|
2486
|
+
${escapeHTML(labels.settings)}
|
|
1912
2487
|
</button>
|
|
1913
2488
|
</div>
|
|
1914
2489
|
</div>
|
|
@@ -2018,22 +2593,24 @@ ${config.customStyles || ''}
|
|
|
2018
2593
|
function createCategoryHTML(category, isChecked, isRequired) {
|
|
2019
2594
|
const disabled = isRequired ? 'disabled' : '';
|
|
2020
2595
|
const checked = isChecked ? 'checked' : '';
|
|
2596
|
+
const safeId = escapeHTML(category.id);
|
|
2597
|
+
const safeLabel = escapeHTML(category.label);
|
|
2021
2598
|
|
|
2022
2599
|
return `
|
|
2023
2600
|
<div class="zest-category">
|
|
2024
2601
|
<div class="zest-category__header">
|
|
2025
2602
|
<div class="zest-category__info">
|
|
2026
|
-
<span class="zest-category__label">${
|
|
2027
|
-
<p class="zest-category__description">${category.description}</p>
|
|
2603
|
+
<span class="zest-category__label">${safeLabel}</span>
|
|
2604
|
+
<p class="zest-category__description">${escapeHTML(category.description)}</p>
|
|
2028
2605
|
</div>
|
|
2029
2606
|
<label class="zest-toggle">
|
|
2030
2607
|
<input
|
|
2031
2608
|
type="checkbox"
|
|
2032
2609
|
class="zest-toggle__input"
|
|
2033
|
-
data-category="${
|
|
2610
|
+
data-category="${safeId}"
|
|
2034
2611
|
${checked}
|
|
2035
2612
|
${disabled}
|
|
2036
|
-
aria-label="${
|
|
2613
|
+
aria-label="${safeLabel}"
|
|
2037
2614
|
>
|
|
2038
2615
|
<span class="zest-toggle__slider"></span>
|
|
2039
2616
|
</label>
|
|
@@ -2057,29 +2634,30 @@ ${config.customStyles || ''}
|
|
|
2057
2634
|
))
|
|
2058
2635
|
.join('');
|
|
2059
2636
|
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2637
|
+
const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
|
|
2638
|
+
const policyLink = validatedPolicyUrl
|
|
2639
|
+
? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
|
|
2062
2640
|
: '';
|
|
2063
2641
|
|
|
2064
2642
|
return `
|
|
2065
|
-
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
|
|
2643
|
+
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
|
|
2066
2644
|
<div class="zest-modal">
|
|
2067
2645
|
<div class="zest-modal__header">
|
|
2068
|
-
<h2 class="zest-modal__title">${labels.title}</h2>
|
|
2069
|
-
<p class="zest-modal__description">${labels.description} ${policyLink}</p>
|
|
2646
|
+
<h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
|
|
2647
|
+
<p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
|
|
2070
2648
|
</div>
|
|
2071
2649
|
<div class="zest-modal__body">
|
|
2072
2650
|
${categoriesHTML}
|
|
2073
2651
|
</div>
|
|
2074
2652
|
<div class="zest-modal__footer">
|
|
2075
2653
|
<button type="button" class="zest-btn zest-btn--primary" data-action="save">
|
|
2076
|
-
${labels.save}
|
|
2654
|
+
${escapeHTML(labels.save)}
|
|
2077
2655
|
</button>
|
|
2078
2656
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
|
|
2079
|
-
${labels.acceptAll}
|
|
2657
|
+
${escapeHTML(labels.acceptAll)}
|
|
2080
2658
|
</button>
|
|
2081
2659
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
|
|
2082
|
-
${labels.rejectAll}
|
|
2660
|
+
${escapeHTML(labels.rejectAll)}
|
|
2083
2661
|
</button>
|
|
2084
2662
|
</div>
|
|
2085
2663
|
</div>
|
|
@@ -2211,10 +2789,11 @@ ${config.customStyles || ''}
|
|
|
2211
2789
|
*/
|
|
2212
2790
|
function createWidgetHTML(config) {
|
|
2213
2791
|
const labels = config.labels.widget;
|
|
2792
|
+
const safeLabel = escapeHTML(labels.label);
|
|
2214
2793
|
|
|
2215
2794
|
return `
|
|
2216
2795
|
<div class="zest-widget">
|
|
2217
|
-
<button type="button" class="zest-widget__btn" aria-label="${
|
|
2796
|
+
<button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
|
|
2218
2797
|
<span class="zest-widget__icon">${COOKIE_ICON}</span>
|
|
2219
2798
|
</button>
|
|
2220
2799
|
</div>
|
|
@@ -2293,108 +2872,59 @@ ${config.customStyles || ''}
|
|
|
2293
2872
|
|
|
2294
2873
|
/**
|
|
2295
2874
|
* Zest - Lightweight Cookie Consent Toolkit
|
|
2296
|
-
* Main entry
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
// State
|
|
2301
|
-
let initialized = false;
|
|
2302
|
-
let config = null;
|
|
2303
|
-
|
|
2304
|
-
/**
|
|
2305
|
-
* Consent checker function shared across interceptors
|
|
2875
|
+
* Main entry (full build: logic + UI).
|
|
2876
|
+
*
|
|
2877
|
+
* For a logic-only build without any CSS / Shadow DOM mounting, import
|
|
2878
|
+
* from `@freshjuice/zest/headless` instead.
|
|
2306
2879
|
*/
|
|
2307
|
-
function checkConsent(category) {
|
|
2308
|
-
return hasConsent(category);
|
|
2309
|
-
}
|
|
2310
2880
|
|
|
2311
|
-
/**
|
|
2312
|
-
* Replay all queued items for newly allowed categories
|
|
2313
|
-
*/
|
|
2314
|
-
function replayAll(allowedCategories) {
|
|
2315
|
-
replayCookies(allowedCategories);
|
|
2316
|
-
replayStorage(allowedCategories);
|
|
2317
|
-
replayScripts(allowedCategories);
|
|
2318
|
-
}
|
|
2319
2881
|
|
|
2320
2882
|
/**
|
|
2321
|
-
* Handle accept all
|
|
2883
|
+
* Handle accept all — delegates consent logic to core, handles UI swap.
|
|
2322
2884
|
*/
|
|
2323
2885
|
function handleAcceptAll() {
|
|
2324
|
-
|
|
2325
|
-
const
|
|
2886
|
+
coreAcceptAll();
|
|
2887
|
+
const config = getActiveConfig();
|
|
2326
2888
|
|
|
2327
2889
|
hideBanner();
|
|
2328
2890
|
hideModal();
|
|
2329
2891
|
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
if (config.showWidget) {
|
|
2892
|
+
if (config?.showWidget) {
|
|
2333
2893
|
showWidget({ onClick: handleShowSettings });
|
|
2334
2894
|
}
|
|
2335
|
-
|
|
2336
|
-
emitConsent(result.current, result.previous);
|
|
2337
|
-
emitChange(result.current, result.previous);
|
|
2338
|
-
config.callbacks?.onAccept?.(result.current);
|
|
2339
|
-
config.callbacks?.onChange?.(result.current);
|
|
2340
2895
|
}
|
|
2341
2896
|
|
|
2342
2897
|
/**
|
|
2343
|
-
* Handle reject all
|
|
2898
|
+
* Handle reject all.
|
|
2344
2899
|
*/
|
|
2345
2900
|
function handleRejectAll() {
|
|
2346
|
-
|
|
2901
|
+
coreRejectAll();
|
|
2902
|
+
const config = getActiveConfig();
|
|
2347
2903
|
|
|
2348
2904
|
hideBanner();
|
|
2349
2905
|
hideModal();
|
|
2350
2906
|
|
|
2351
|
-
if (config
|
|
2907
|
+
if (config?.showWidget) {
|
|
2352
2908
|
showWidget({ onClick: handleShowSettings });
|
|
2353
2909
|
}
|
|
2354
|
-
|
|
2355
|
-
emitReject(result.current);
|
|
2356
|
-
emitChange(result.current, result.previous);
|
|
2357
|
-
config.callbacks?.onReject?.();
|
|
2358
|
-
config.callbacks?.onChange?.(result.current);
|
|
2359
2910
|
}
|
|
2360
2911
|
|
|
2361
2912
|
/**
|
|
2362
|
-
* Handle save preferences from modal
|
|
2913
|
+
* Handle save preferences from modal.
|
|
2363
2914
|
*/
|
|
2364
2915
|
function handleSavePreferences(selections) {
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
// Find newly allowed categories
|
|
2368
|
-
const newlyAllowed = Object.keys(result.current).filter(
|
|
2369
|
-
cat => result.current[cat] && !result.previous[cat]
|
|
2370
|
-
);
|
|
2371
|
-
|
|
2372
|
-
if (newlyAllowed.length > 0) {
|
|
2373
|
-
replayAll(newlyAllowed);
|
|
2374
|
-
}
|
|
2916
|
+
coreUpdateConsent(selections);
|
|
2917
|
+
const config = getActiveConfig();
|
|
2375
2918
|
|
|
2376
2919
|
hideModal();
|
|
2377
2920
|
|
|
2378
|
-
if (config
|
|
2921
|
+
if (config?.showWidget) {
|
|
2379
2922
|
showWidget({ onClick: handleShowSettings });
|
|
2380
2923
|
}
|
|
2381
|
-
|
|
2382
|
-
// Determine if this was acceptance or rejection based on selections
|
|
2383
|
-
const hasNonEssential = Object.entries(selections)
|
|
2384
|
-
.some(([cat, val]) => cat !== 'essential' && val);
|
|
2385
|
-
|
|
2386
|
-
if (hasNonEssential) {
|
|
2387
|
-
emitConsent(result.current, result.previous);
|
|
2388
|
-
} else {
|
|
2389
|
-
emitReject(result.current);
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
emitChange(result.current, result.previous);
|
|
2393
|
-
config.callbacks?.onChange?.(result.current);
|
|
2394
2924
|
}
|
|
2395
2925
|
|
|
2396
2926
|
/**
|
|
2397
|
-
*
|
|
2927
|
+
* Open the settings modal.
|
|
2398
2928
|
*/
|
|
2399
2929
|
function handleShowSettings() {
|
|
2400
2930
|
hideBanner();
|
|
@@ -2411,17 +2941,17 @@ ${config.customStyles || ''}
|
|
|
2411
2941
|
}
|
|
2412
2942
|
|
|
2413
2943
|
/**
|
|
2414
|
-
*
|
|
2944
|
+
* Close the modal — either bring the widget back (decision made) or
|
|
2945
|
+
* fall back to the banner (no decision yet).
|
|
2415
2946
|
*/
|
|
2416
2947
|
function handleCloseModal() {
|
|
2417
2948
|
hideModal();
|
|
2418
2949
|
emitHide('modal');
|
|
2419
2950
|
|
|
2420
|
-
|
|
2421
|
-
if (hasConsentDecision() && config
|
|
2951
|
+
const config = getActiveConfig();
|
|
2952
|
+
if (hasConsentDecision() && config?.showWidget) {
|
|
2422
2953
|
showWidget({ onClick: handleShowSettings });
|
|
2423
2954
|
} else {
|
|
2424
|
-
// Show banner again if no decision made
|
|
2425
2955
|
showBanner({
|
|
2426
2956
|
onAcceptAll: handleAcceptAll,
|
|
2427
2957
|
onRejectAll: handleRejectAll,
|
|
@@ -2431,89 +2961,37 @@ ${config.customStyles || ''}
|
|
|
2431
2961
|
}
|
|
2432
2962
|
|
|
2433
2963
|
/**
|
|
2434
|
-
* Initialize Zest
|
|
2964
|
+
* Initialize Zest with UI.
|
|
2435
2965
|
*/
|
|
2436
2966
|
function init(userConfig = {}) {
|
|
2437
|
-
|
|
2967
|
+
const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
|
|
2968
|
+
if (alreadyInitialized) {
|
|
2438
2969
|
console.warn('[Zest] Already initialized');
|
|
2439
2970
|
return Zest;
|
|
2440
2971
|
}
|
|
2441
2972
|
|
|
2442
|
-
|
|
2443
|
-
config = setConfig(userConfig);
|
|
2444
|
-
|
|
2445
|
-
// Set patterns if provided
|
|
2446
|
-
if (config.patterns) {
|
|
2447
|
-
setPatterns(config.patterns);
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
// Set up consent checkers
|
|
2451
|
-
setConsentChecker$2(checkConsent);
|
|
2452
|
-
setConsentChecker$1(checkConsent);
|
|
2453
|
-
setConsentChecker(checkConsent);
|
|
2454
|
-
|
|
2455
|
-
// Start interception
|
|
2456
|
-
interceptCookies();
|
|
2457
|
-
interceptStorage();
|
|
2458
|
-
startScriptBlocking(config.mode, config.blockedDomains);
|
|
2459
|
-
|
|
2460
|
-
// Load saved consent
|
|
2461
|
-
const consent = loadConsent();
|
|
2462
|
-
|
|
2463
|
-
initialized = true;
|
|
2464
|
-
|
|
2465
|
-
// Check Do Not Track / Global Privacy Control
|
|
2466
|
-
const dntEnabled = isDoNotTrackEnabled();
|
|
2467
|
-
let dntApplied = false;
|
|
2468
|
-
|
|
2469
|
-
if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
|
|
2470
|
-
if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2471
|
-
// Auto-reject non-essential cookies silently
|
|
2472
|
-
const result = rejectAll(config.expiration);
|
|
2473
|
-
dntApplied = true;
|
|
2474
|
-
|
|
2475
|
-
// Emit events
|
|
2476
|
-
emitReject(result.current);
|
|
2477
|
-
emitChange(result.current, result.previous);
|
|
2478
|
-
config.callbacks?.onReject?.();
|
|
2479
|
-
config.callbacks?.onChange?.(result.current);
|
|
2480
|
-
}
|
|
2481
|
-
// 'preselect' behavior is handled by default (banner shows with defaults off)
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
// Emit ready event
|
|
2485
|
-
emitReady(consent);
|
|
2486
|
-
config.callbacks?.onReady?.(consent);
|
|
2973
|
+
const config = getActiveConfig();
|
|
2487
2974
|
|
|
2488
|
-
|
|
2489
|
-
if (!hasConsentDecision() && !dntApplied) {
|
|
2490
|
-
// No consent decision yet - show banner
|
|
2975
|
+
if (!hasDecision && !dntApplied) {
|
|
2491
2976
|
showBanner({
|
|
2492
2977
|
onAcceptAll: handleAcceptAll,
|
|
2493
2978
|
onRejectAll: handleRejectAll,
|
|
2494
2979
|
onSettings: handleShowSettings
|
|
2495
2980
|
});
|
|
2496
2981
|
emitShow('banner');
|
|
2497
|
-
} else {
|
|
2498
|
-
|
|
2499
|
-
if (config.showWidget) {
|
|
2500
|
-
showWidget({ onClick: handleShowSettings });
|
|
2501
|
-
}
|
|
2982
|
+
} else if (config?.showWidget) {
|
|
2983
|
+
showWidget({ onClick: handleShowSettings });
|
|
2502
2984
|
}
|
|
2503
2985
|
|
|
2504
2986
|
return Zest;
|
|
2505
2987
|
}
|
|
2506
2988
|
|
|
2507
|
-
/**
|
|
2508
|
-
* Public API
|
|
2509
|
-
*/
|
|
2510
2989
|
const Zest = {
|
|
2511
|
-
// Initialization
|
|
2512
2990
|
init,
|
|
2513
2991
|
|
|
2514
2992
|
// Banner control
|
|
2515
2993
|
show() {
|
|
2516
|
-
if (!
|
|
2994
|
+
if (!isInitialized()) {
|
|
2517
2995
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
2518
2996
|
return;
|
|
2519
2997
|
}
|
|
@@ -2534,7 +3012,7 @@ ${config.customStyles || ''}
|
|
|
2534
3012
|
|
|
2535
3013
|
// Settings modal
|
|
2536
3014
|
showSettings() {
|
|
2537
|
-
if (!
|
|
3015
|
+
if (!isInitialized()) {
|
|
2538
3016
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
2539
3017
|
return;
|
|
2540
3018
|
}
|
|
@@ -2546,19 +3024,19 @@ ${config.customStyles || ''}
|
|
|
2546
3024
|
emitHide('modal');
|
|
2547
3025
|
},
|
|
2548
3026
|
|
|
2549
|
-
// Consent
|
|
3027
|
+
// Consent state
|
|
2550
3028
|
getConsent,
|
|
2551
3029
|
hasConsent,
|
|
2552
3030
|
hasConsentDecision,
|
|
2553
3031
|
getConsentProof,
|
|
2554
3032
|
|
|
2555
|
-
// DNT
|
|
3033
|
+
// DNT
|
|
2556
3034
|
isDoNotTrackEnabled,
|
|
2557
3035
|
getDNTDetails,
|
|
2558
3036
|
|
|
2559
|
-
//
|
|
3037
|
+
// Programmatic accept / reject
|
|
2560
3038
|
acceptAll() {
|
|
2561
|
-
if (!
|
|
3039
|
+
if (!isInitialized()) {
|
|
2562
3040
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
2563
3041
|
return;
|
|
2564
3042
|
}
|
|
@@ -2566,20 +3044,19 @@ ${config.customStyles || ''}
|
|
|
2566
3044
|
},
|
|
2567
3045
|
|
|
2568
3046
|
rejectAll() {
|
|
2569
|
-
if (!
|
|
3047
|
+
if (!isInitialized()) {
|
|
2570
3048
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
2571
3049
|
return;
|
|
2572
3050
|
}
|
|
2573
3051
|
handleRejectAll();
|
|
2574
3052
|
},
|
|
2575
3053
|
|
|
2576
|
-
// Reset and
|
|
3054
|
+
// Reset everything and reshow the banner
|
|
2577
3055
|
reset() {
|
|
2578
|
-
|
|
3056
|
+
coreReset();
|
|
2579
3057
|
hideModal();
|
|
2580
3058
|
removeWidget();
|
|
2581
|
-
|
|
2582
|
-
if (initialized) {
|
|
3059
|
+
if (isInitialized()) {
|
|
2583
3060
|
showBanner({
|
|
2584
3061
|
onAcceptAll: handleAcceptAll,
|
|
2585
3062
|
onRejectAll: handleRejectAll,
|
|
@@ -2589,17 +3066,30 @@ ${config.customStyles || ''}
|
|
|
2589
3066
|
}
|
|
2590
3067
|
},
|
|
2591
3068
|
|
|
2592
|
-
// Config
|
|
3069
|
+
// Config introspection
|
|
2593
3070
|
getConfig: getCurrentConfig,
|
|
2594
3071
|
|
|
2595
3072
|
// Events
|
|
3073
|
+
on,
|
|
3074
|
+
once,
|
|
2596
3075
|
EVENTS
|
|
2597
3076
|
};
|
|
2598
3077
|
|
|
2599
3078
|
// Auto-init if config present
|
|
2600
3079
|
if (typeof window !== 'undefined') {
|
|
2601
|
-
// Make Zest available globally
|
|
2602
|
-
|
|
3080
|
+
// Make Zest available globally. defineProperty with writable:false +
|
|
3081
|
+
// configurable:false stops a later-loaded script from replacing the
|
|
3082
|
+
// global with a trojanned stand-in.
|
|
3083
|
+
try {
|
|
3084
|
+
Object.defineProperty(window, 'Zest', {
|
|
3085
|
+
value: Object.freeze(Zest),
|
|
3086
|
+
writable: false,
|
|
3087
|
+
configurable: false,
|
|
3088
|
+
enumerable: true
|
|
3089
|
+
});
|
|
3090
|
+
} catch (e) {
|
|
3091
|
+
window.Zest = Zest;
|
|
3092
|
+
}
|
|
2603
3093
|
|
|
2604
3094
|
const autoInit = () => {
|
|
2605
3095
|
const cfg = getConfig();
|