@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.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
|
* Built-in translations for Zest
|
|
847
1179
|
* Language is auto-detected from <html lang=""> or navigator.language
|
|
@@ -1441,6 +1773,10 @@ var Zest = (function () {
|
|
|
1441
1773
|
// Custom styles to inject into Shadow DOM
|
|
1442
1774
|
customStyles: '',
|
|
1443
1775
|
|
|
1776
|
+
// Vendor consent mode integrations (optional)
|
|
1777
|
+
consentModeGoogle: false,
|
|
1778
|
+
consentModeMicrosoft: false,
|
|
1779
|
+
|
|
1444
1780
|
// Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
|
|
1445
1781
|
mode: 'safe',
|
|
1446
1782
|
|
|
@@ -1471,7 +1807,7 @@ var Zest = (function () {
|
|
|
1471
1807
|
}
|
|
1472
1808
|
|
|
1473
1809
|
// Simple properties
|
|
1474
|
-
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior'];
|
|
1810
|
+
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior', 'consentModeGoogle', 'consentModeMicrosoft'];
|
|
1475
1811
|
for (const key of simpleKeys) {
|
|
1476
1812
|
if (userConfig[key] !== undefined) {
|
|
1477
1813
|
config[key] = userConfig[key];
|
|
@@ -1581,6 +1917,13 @@ var Zest = (function () {
|
|
|
1581
1917
|
const expiration = script.getAttribute('data-expiration');
|
|
1582
1918
|
if (expiration) config.expiration = parseInt(expiration, 10);
|
|
1583
1919
|
|
|
1920
|
+
// Consent mode integrations
|
|
1921
|
+
const consentModeGoogle = script.getAttribute('data-consent-mode-google');
|
|
1922
|
+
if (consentModeGoogle !== null) config.consentModeGoogle = consentModeGoogle !== 'false';
|
|
1923
|
+
|
|
1924
|
+
const consentModeMicrosoft = script.getAttribute('data-consent-mode-microsoft');
|
|
1925
|
+
if (consentModeMicrosoft !== null) config.consentModeMicrosoft = consentModeMicrosoft !== 'false';
|
|
1926
|
+
|
|
1584
1927
|
return config;
|
|
1585
1928
|
}
|
|
1586
1929
|
|
|
@@ -1612,18 +1955,18 @@ var Zest = (function () {
|
|
|
1612
1955
|
/**
|
|
1613
1956
|
* Update configuration at runtime
|
|
1614
1957
|
*/
|
|
1615
|
-
let currentConfig = null;
|
|
1958
|
+
let currentConfig$1 = null;
|
|
1616
1959
|
|
|
1617
1960
|
function setConfig(config) {
|
|
1618
|
-
currentConfig = mergeConfig(config);
|
|
1619
|
-
return currentConfig;
|
|
1961
|
+
currentConfig$1 = mergeConfig(config);
|
|
1962
|
+
return currentConfig$1;
|
|
1620
1963
|
}
|
|
1621
1964
|
|
|
1622
1965
|
function getCurrentConfig() {
|
|
1623
|
-
if (!currentConfig) {
|
|
1624
|
-
currentConfig = getConfig();
|
|
1966
|
+
if (!currentConfig$1) {
|
|
1967
|
+
currentConfig$1 = getConfig();
|
|
1625
1968
|
}
|
|
1626
|
-
return currentConfig;
|
|
1969
|
+
return currentConfig$1;
|
|
1627
1970
|
}
|
|
1628
1971
|
|
|
1629
1972
|
/**
|
|
@@ -1634,6 +1977,20 @@ var Zest = (function () {
|
|
|
1634
1977
|
const COOKIE_NAME = 'zest_consent';
|
|
1635
1978
|
const CONSENT_VERSION = '1.0';
|
|
1636
1979
|
|
|
1980
|
+
/**
|
|
1981
|
+
* Return the Secure flag fragment when running over HTTPS, empty otherwise.
|
|
1982
|
+
* On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
|
|
1983
|
+
*/
|
|
1984
|
+
function secureAttribute() {
|
|
1985
|
+
try {
|
|
1986
|
+
return typeof location !== 'undefined' && location.protocol === 'https:'
|
|
1987
|
+
? '; Secure'
|
|
1988
|
+
: '';
|
|
1989
|
+
} catch (_) {
|
|
1990
|
+
return '';
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1637
1994
|
// Current consent state
|
|
1638
1995
|
let consent = null;
|
|
1639
1996
|
|
|
@@ -1662,7 +2019,12 @@ var Zest = (function () {
|
|
|
1662
2019
|
}
|
|
1663
2020
|
|
|
1664
2021
|
/**
|
|
1665
|
-
* Load consent from cookie
|
|
2022
|
+
* Load consent from cookie.
|
|
2023
|
+
*
|
|
2024
|
+
* The parsed cookie is validated against the expected schema via
|
|
2025
|
+
* sanitizeConsentPayload — only known category keys with boolean values
|
|
2026
|
+
* survive, so a tampered cookie can't inject prototype-polluting props
|
|
2027
|
+
* or unexpected category shapes.
|
|
1666
2028
|
*/
|
|
1667
2029
|
function loadConsent() {
|
|
1668
2030
|
try {
|
|
@@ -1670,9 +2032,12 @@ var Zest = (function () {
|
|
|
1670
2032
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1671
2033
|
|
|
1672
2034
|
if (match) {
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
|
|
2035
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2036
|
+
const clean = sanitizeConsentPayload(raw, getCategoryIds());
|
|
2037
|
+
if (clean && clean.categories) {
|
|
2038
|
+
consent = { ...getDefaultConsent(), ...clean.categories };
|
|
2039
|
+
return { ...consent };
|
|
2040
|
+
}
|
|
1676
2041
|
}
|
|
1677
2042
|
} catch (e) {
|
|
1678
2043
|
// Invalid or missing cookie
|
|
@@ -1697,7 +2062,7 @@ var Zest = (function () {
|
|
|
1697
2062
|
};
|
|
1698
2063
|
|
|
1699
2064
|
const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
|
|
1700
|
-
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
|
|
2065
|
+
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
|
|
1701
2066
|
|
|
1702
2067
|
setRawCookie(cookieValue);
|
|
1703
2068
|
}
|
|
@@ -1766,7 +2131,7 @@ var Zest = (function () {
|
|
|
1766
2131
|
* Reset consent (clear cookie)
|
|
1767
2132
|
*/
|
|
1768
2133
|
function resetConsent() {
|
|
1769
|
-
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path
|
|
2134
|
+
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
|
|
1770
2135
|
consent = null;
|
|
1771
2136
|
}
|
|
1772
2137
|
|
|
@@ -1791,7 +2156,8 @@ var Zest = (function () {
|
|
|
1791
2156
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1792
2157
|
|
|
1793
2158
|
if (match) {
|
|
1794
|
-
|
|
2159
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2160
|
+
return sanitizeConsentPayload(raw, getCategoryIds());
|
|
1795
2161
|
}
|
|
1796
2162
|
} catch (e) {
|
|
1797
2163
|
// Invalid cookie
|
|
@@ -1870,15 +2236,207 @@ var Zest = (function () {
|
|
|
1870
2236
|
return emit(EVENTS.HIDE, { type });
|
|
1871
2237
|
}
|
|
1872
2238
|
|
|
2239
|
+
/**
|
|
2240
|
+
* Subscribe to an event
|
|
2241
|
+
*/
|
|
2242
|
+
function on(eventName, callback) {
|
|
2243
|
+
document.addEventListener(eventName, callback);
|
|
2244
|
+
return () => document.removeEventListener(eventName, callback);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Subscribe to an event once
|
|
2249
|
+
*/
|
|
2250
|
+
function once(eventName, callback) {
|
|
2251
|
+
document.addEventListener(eventName, callback, { once: true });
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* Core lifecycle - UI-agnostic initialization and consent actions.
|
|
2256
|
+
*
|
|
2257
|
+
* This module contains everything the main entry (with UI) and the
|
|
2258
|
+
* headless entry (no UI) share: interceptor setup, consent load/save,
|
|
2259
|
+
* replay, DNT handling, and the events/callbacks fan-out. It intentionally
|
|
2260
|
+
* does NOT import anything from `./ui/*` so tree-shakers can drop the UI
|
|
2261
|
+
* bundle entirely when only the headless API is used.
|
|
2262
|
+
*/
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
let initialized = false;
|
|
2266
|
+
let currentConfig = null;
|
|
2267
|
+
|
|
2268
|
+
function checkConsent(category) {
|
|
2269
|
+
return hasConsent(category);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function replayAll(categories) {
|
|
2273
|
+
replayCookies(categories);
|
|
2274
|
+
replayStorage(categories);
|
|
2275
|
+
replayScripts(categories);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Run the non-UI half of init. Returns a snapshot the caller (UI or
|
|
2280
|
+
* headless) can use to decide what to do next.
|
|
2281
|
+
*/
|
|
2282
|
+
function coreInit(userConfig = {}) {
|
|
2283
|
+
if (initialized) {
|
|
2284
|
+
return {
|
|
2285
|
+
alreadyInitialized: true,
|
|
2286
|
+
config: currentConfig,
|
|
2287
|
+
consent: loadConsent(),
|
|
2288
|
+
hasDecision: hasConsentDecision(),
|
|
2289
|
+
dntApplied: false
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
currentConfig = setConfig(userConfig);
|
|
2294
|
+
|
|
2295
|
+
// Push default-denied state to vendor consent mode APIs BEFORE any
|
|
2296
|
+
// third-party script has a chance to fire.
|
|
2297
|
+
applyConsentSignals(
|
|
2298
|
+
{ functional: false, analytics: false, marketing: false },
|
|
2299
|
+
currentConfig,
|
|
2300
|
+
true
|
|
2301
|
+
);
|
|
2302
|
+
|
|
2303
|
+
if (currentConfig.patterns) {
|
|
2304
|
+
setPatterns(currentConfig.patterns);
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
setConsentChecker$2(checkConsent);
|
|
2308
|
+
setConsentChecker$1(checkConsent);
|
|
2309
|
+
setConsentChecker(checkConsent);
|
|
2310
|
+
|
|
2311
|
+
interceptCookies();
|
|
2312
|
+
interceptStorage();
|
|
2313
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
2314
|
+
|
|
2315
|
+
const consent = loadConsent();
|
|
2316
|
+
initialized = true;
|
|
2317
|
+
|
|
2318
|
+
if (hasConsentDecision()) {
|
|
2319
|
+
applyConsentSignals(consent, currentConfig, false);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// DNT / GPC handling — if the user signalled opt-out at the browser
|
|
2323
|
+
// level and the site opts to respect it, auto-reject before the UI
|
|
2324
|
+
// layer ever runs.
|
|
2325
|
+
const dntEnabled = isDoNotTrackEnabled();
|
|
2326
|
+
let dntApplied = false;
|
|
2327
|
+
|
|
2328
|
+
if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
|
|
2329
|
+
if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2330
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2331
|
+
dntApplied = true;
|
|
2332
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2333
|
+
emitReject(result.current);
|
|
2334
|
+
emitChange(result.current, result.previous);
|
|
2335
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2336
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
emitReady(consent);
|
|
2341
|
+
safeInvoke(currentConfig.callbacks?.onReady, consent);
|
|
2342
|
+
|
|
2343
|
+
return {
|
|
2344
|
+
alreadyInitialized: false,
|
|
2345
|
+
config: currentConfig,
|
|
2346
|
+
consent,
|
|
2347
|
+
hasDecision: hasConsentDecision(),
|
|
2348
|
+
dntApplied
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Accept all categories, replay queued items, fire events + callbacks.
|
|
2354
|
+
* Returns { current, previous } or null if not yet initialized.
|
|
2355
|
+
*/
|
|
2356
|
+
function coreAcceptAll() {
|
|
2357
|
+
if (!initialized) return null;
|
|
2358
|
+
const result = acceptAll(currentConfig.expiration);
|
|
2359
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2360
|
+
replayAll(getCategoryIds());
|
|
2361
|
+
emitConsent(result.current, result.previous);
|
|
2362
|
+
emitChange(result.current, result.previous);
|
|
2363
|
+
safeInvoke(currentConfig.callbacks?.onAccept, result.current);
|
|
2364
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2365
|
+
return result;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* Reject all non-essential categories, fire events + callbacks.
|
|
2370
|
+
*/
|
|
2371
|
+
function coreRejectAll() {
|
|
2372
|
+
if (!initialized) return null;
|
|
2373
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2374
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2375
|
+
emitReject(result.current);
|
|
2376
|
+
emitChange(result.current, result.previous);
|
|
2377
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2378
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2379
|
+
return result;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* Save custom selections and replay only the newly-allowed categories.
|
|
2384
|
+
*/
|
|
2385
|
+
function coreUpdateConsent(selections) {
|
|
2386
|
+
if (!initialized) return null;
|
|
2387
|
+
const result = updateConsent(selections, currentConfig.expiration);
|
|
2388
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2389
|
+
|
|
2390
|
+
const newlyAllowed = Object.keys(result.current).filter(
|
|
2391
|
+
(cat) => result.current[cat] && !result.previous[cat]
|
|
2392
|
+
);
|
|
2393
|
+
if (newlyAllowed.length > 0) {
|
|
2394
|
+
replayAll(newlyAllowed);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
const hasNonEssential = Object.entries(selections || {}).some(
|
|
2398
|
+
([cat, val]) => cat !== 'essential' && val
|
|
2399
|
+
);
|
|
2400
|
+
if (hasNonEssential) {
|
|
2401
|
+
emitConsent(result.current, result.previous);
|
|
2402
|
+
} else {
|
|
2403
|
+
emitReject(result.current);
|
|
2404
|
+
}
|
|
2405
|
+
emitChange(result.current, result.previous);
|
|
2406
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2407
|
+
return result;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* Clear the consent cookie. The caller is responsible for any UI reset.
|
|
2412
|
+
*/
|
|
2413
|
+
function coreReset() {
|
|
2414
|
+
resetConsent();
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
function isInitialized() {
|
|
2418
|
+
return initialized;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
function getActiveConfig() {
|
|
2422
|
+
return currentConfig;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
1873
2425
|
/**
|
|
1874
2426
|
* Styles - Shadow DOM encapsulated CSS with theming
|
|
1875
2427
|
*/
|
|
1876
2428
|
|
|
2429
|
+
|
|
2430
|
+
const DEFAULT_ACCENT = '#4F46E5';
|
|
2431
|
+
|
|
1877
2432
|
/**
|
|
1878
2433
|
* Generate CSS with custom properties
|
|
1879
2434
|
*/
|
|
1880
2435
|
function generateStyles(config) {
|
|
1881
|
-
|
|
2436
|
+
// Only accept colors that pass strict validation — an unvalidated
|
|
2437
|
+
// value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
|
|
2438
|
+
const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
|
|
2439
|
+
const customCss = sanitizeCustomStyles(config.customStyles);
|
|
1882
2440
|
|
|
1883
2441
|
return `
|
|
1884
2442
|
:host {
|
|
@@ -2348,15 +2906,29 @@ var Zest = (function () {
|
|
|
2348
2906
|
.zest-hidden {
|
|
2349
2907
|
display: none !important;
|
|
2350
2908
|
}
|
|
2351
|
-
${
|
|
2909
|
+
${customCss}
|
|
2352
2910
|
`;
|
|
2353
2911
|
}
|
|
2354
2912
|
|
|
2355
2913
|
/**
|
|
2356
|
-
* Adjust color brightness
|
|
2914
|
+
* Adjust color brightness. Falls back to the default accent if the input
|
|
2915
|
+
* cannot be parsed as a hex color (non-hex inputs pass safeColor but
|
|
2916
|
+
* can't be brightness-shifted mathematically).
|
|
2357
2917
|
*/
|
|
2358
2918
|
function adjustColor(hex, percent) {
|
|
2359
|
-
|
|
2919
|
+
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
|
|
2920
|
+
hex = DEFAULT_ACCENT;
|
|
2921
|
+
}
|
|
2922
|
+
let clean = hex.trim().replace('#', '');
|
|
2923
|
+
// Expand 3-digit form to 6
|
|
2924
|
+
if (clean.length === 3) {
|
|
2925
|
+
clean = clean.split('').map(c => c + c).join('');
|
|
2926
|
+
}
|
|
2927
|
+
// Strip alpha if present
|
|
2928
|
+
if (clean.length === 8) clean = clean.slice(0, 6);
|
|
2929
|
+
if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
|
|
2930
|
+
|
|
2931
|
+
const num = parseInt(clean, 16);
|
|
2360
2932
|
const amt = Math.round(2.55 * percent);
|
|
2361
2933
|
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
|
|
2362
2934
|
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
|
|
@@ -2377,26 +2949,29 @@ ${config.customStyles || ''}
|
|
|
2377
2949
|
let bannerElement = null;
|
|
2378
2950
|
let shadowRoot$2 = null;
|
|
2379
2951
|
|
|
2952
|
+
const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
|
|
2953
|
+
|
|
2380
2954
|
/**
|
|
2381
2955
|
* Create the banner HTML
|
|
2382
2956
|
*/
|
|
2383
2957
|
function createBannerHTML(config) {
|
|
2384
2958
|
const labels = config.labels.banner;
|
|
2385
|
-
const
|
|
2959
|
+
const rawPosition = config.position || 'bottom';
|
|
2960
|
+
const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
|
|
2386
2961
|
|
|
2387
2962
|
return `
|
|
2388
|
-
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
|
|
2389
|
-
<h2 class="zest-banner__title">${labels.title}</h2>
|
|
2390
|
-
<p class="zest-banner__description">${labels.description}</p>
|
|
2963
|
+
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
|
|
2964
|
+
<h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
|
|
2965
|
+
<p class="zest-banner__description">${escapeHTML(labels.description)}</p>
|
|
2391
2966
|
<div class="zest-banner__buttons">
|
|
2392
2967
|
<button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
|
|
2393
|
-
${labels.acceptAll}
|
|
2968
|
+
${escapeHTML(labels.acceptAll)}
|
|
2394
2969
|
</button>
|
|
2395
2970
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
|
|
2396
|
-
${labels.rejectAll}
|
|
2971
|
+
${escapeHTML(labels.rejectAll)}
|
|
2397
2972
|
</button>
|
|
2398
2973
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
|
|
2399
|
-
${labels.settings}
|
|
2974
|
+
${escapeHTML(labels.settings)}
|
|
2400
2975
|
</button>
|
|
2401
2976
|
</div>
|
|
2402
2977
|
</div>
|
|
@@ -2506,22 +3081,24 @@ ${config.customStyles || ''}
|
|
|
2506
3081
|
function createCategoryHTML(category, isChecked, isRequired) {
|
|
2507
3082
|
const disabled = isRequired ? 'disabled' : '';
|
|
2508
3083
|
const checked = isChecked ? 'checked' : '';
|
|
3084
|
+
const safeId = escapeHTML(category.id);
|
|
3085
|
+
const safeLabel = escapeHTML(category.label);
|
|
2509
3086
|
|
|
2510
3087
|
return `
|
|
2511
3088
|
<div class="zest-category">
|
|
2512
3089
|
<div class="zest-category__header">
|
|
2513
3090
|
<div class="zest-category__info">
|
|
2514
|
-
<span class="zest-category__label">${
|
|
2515
|
-
<p class="zest-category__description">${category.description}</p>
|
|
3091
|
+
<span class="zest-category__label">${safeLabel}</span>
|
|
3092
|
+
<p class="zest-category__description">${escapeHTML(category.description)}</p>
|
|
2516
3093
|
</div>
|
|
2517
3094
|
<label class="zest-toggle">
|
|
2518
3095
|
<input
|
|
2519
3096
|
type="checkbox"
|
|
2520
3097
|
class="zest-toggle__input"
|
|
2521
|
-
data-category="${
|
|
3098
|
+
data-category="${safeId}"
|
|
2522
3099
|
${checked}
|
|
2523
3100
|
${disabled}
|
|
2524
|
-
aria-label="${
|
|
3101
|
+
aria-label="${safeLabel}"
|
|
2525
3102
|
>
|
|
2526
3103
|
<span class="zest-toggle__slider"></span>
|
|
2527
3104
|
</label>
|
|
@@ -2545,29 +3122,30 @@ ${config.customStyles || ''}
|
|
|
2545
3122
|
))
|
|
2546
3123
|
.join('');
|
|
2547
3124
|
|
|
2548
|
-
const
|
|
2549
|
-
|
|
3125
|
+
const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
|
|
3126
|
+
const policyLink = validatedPolicyUrl
|
|
3127
|
+
? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
|
|
2550
3128
|
: '';
|
|
2551
3129
|
|
|
2552
3130
|
return `
|
|
2553
|
-
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
|
|
3131
|
+
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
|
|
2554
3132
|
<div class="zest-modal">
|
|
2555
3133
|
<div class="zest-modal__header">
|
|
2556
|
-
<h2 class="zest-modal__title">${labels.title}</h2>
|
|
2557
|
-
<p class="zest-modal__description">${labels.description} ${policyLink}</p>
|
|
3134
|
+
<h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
|
|
3135
|
+
<p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
|
|
2558
3136
|
</div>
|
|
2559
3137
|
<div class="zest-modal__body">
|
|
2560
3138
|
${categoriesHTML}
|
|
2561
3139
|
</div>
|
|
2562
3140
|
<div class="zest-modal__footer">
|
|
2563
3141
|
<button type="button" class="zest-btn zest-btn--primary" data-action="save">
|
|
2564
|
-
${labels.save}
|
|
3142
|
+
${escapeHTML(labels.save)}
|
|
2565
3143
|
</button>
|
|
2566
3144
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
|
|
2567
|
-
${labels.acceptAll}
|
|
3145
|
+
${escapeHTML(labels.acceptAll)}
|
|
2568
3146
|
</button>
|
|
2569
3147
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
|
|
2570
|
-
${labels.rejectAll}
|
|
3148
|
+
${escapeHTML(labels.rejectAll)}
|
|
2571
3149
|
</button>
|
|
2572
3150
|
</div>
|
|
2573
3151
|
</div>
|
|
@@ -2699,10 +3277,11 @@ ${config.customStyles || ''}
|
|
|
2699
3277
|
*/
|
|
2700
3278
|
function createWidgetHTML(config) {
|
|
2701
3279
|
const labels = config.labels.widget;
|
|
3280
|
+
const safeLabel = escapeHTML(labels.label);
|
|
2702
3281
|
|
|
2703
3282
|
return `
|
|
2704
3283
|
<div class="zest-widget">
|
|
2705
|
-
<button type="button" class="zest-widget__btn" aria-label="${
|
|
3284
|
+
<button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
|
|
2706
3285
|
<span class="zest-widget__icon">${COOKIE_ICON}</span>
|
|
2707
3286
|
</button>
|
|
2708
3287
|
</div>
|
|
@@ -2781,108 +3360,59 @@ ${config.customStyles || ''}
|
|
|
2781
3360
|
|
|
2782
3361
|
/**
|
|
2783
3362
|
* Zest - Lightweight Cookie Consent Toolkit
|
|
2784
|
-
* Main entry
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
// State
|
|
2789
|
-
let initialized = false;
|
|
2790
|
-
let config = null;
|
|
2791
|
-
|
|
2792
|
-
/**
|
|
2793
|
-
* Consent checker function shared across interceptors
|
|
3363
|
+
* Main entry (full build: logic + UI).
|
|
3364
|
+
*
|
|
3365
|
+
* For a logic-only build without any CSS / Shadow DOM mounting, import
|
|
3366
|
+
* from `@freshjuice/zest/headless` instead.
|
|
2794
3367
|
*/
|
|
2795
|
-
function checkConsent(category) {
|
|
2796
|
-
return hasConsent(category);
|
|
2797
|
-
}
|
|
2798
3368
|
|
|
2799
|
-
/**
|
|
2800
|
-
* Replay all queued items for newly allowed categories
|
|
2801
|
-
*/
|
|
2802
|
-
function replayAll(allowedCategories) {
|
|
2803
|
-
replayCookies(allowedCategories);
|
|
2804
|
-
replayStorage(allowedCategories);
|
|
2805
|
-
replayScripts(allowedCategories);
|
|
2806
|
-
}
|
|
2807
3369
|
|
|
2808
3370
|
/**
|
|
2809
|
-
* Handle accept all
|
|
3371
|
+
* Handle accept all — delegates consent logic to core, handles UI swap.
|
|
2810
3372
|
*/
|
|
2811
3373
|
function handleAcceptAll() {
|
|
2812
|
-
|
|
2813
|
-
const
|
|
3374
|
+
coreAcceptAll();
|
|
3375
|
+
const config = getActiveConfig();
|
|
2814
3376
|
|
|
2815
3377
|
hideBanner();
|
|
2816
3378
|
hideModal();
|
|
2817
3379
|
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
if (config.showWidget) {
|
|
3380
|
+
if (config?.showWidget) {
|
|
2821
3381
|
showWidget({ onClick: handleShowSettings });
|
|
2822
3382
|
}
|
|
2823
|
-
|
|
2824
|
-
emitConsent(result.current, result.previous);
|
|
2825
|
-
emitChange(result.current, result.previous);
|
|
2826
|
-
config.callbacks?.onAccept?.(result.current);
|
|
2827
|
-
config.callbacks?.onChange?.(result.current);
|
|
2828
3383
|
}
|
|
2829
3384
|
|
|
2830
3385
|
/**
|
|
2831
|
-
* Handle reject all
|
|
3386
|
+
* Handle reject all.
|
|
2832
3387
|
*/
|
|
2833
3388
|
function handleRejectAll() {
|
|
2834
|
-
|
|
3389
|
+
coreRejectAll();
|
|
3390
|
+
const config = getActiveConfig();
|
|
2835
3391
|
|
|
2836
3392
|
hideBanner();
|
|
2837
3393
|
hideModal();
|
|
2838
3394
|
|
|
2839
|
-
if (config
|
|
3395
|
+
if (config?.showWidget) {
|
|
2840
3396
|
showWidget({ onClick: handleShowSettings });
|
|
2841
3397
|
}
|
|
2842
|
-
|
|
2843
|
-
emitReject(result.current);
|
|
2844
|
-
emitChange(result.current, result.previous);
|
|
2845
|
-
config.callbacks?.onReject?.();
|
|
2846
|
-
config.callbacks?.onChange?.(result.current);
|
|
2847
3398
|
}
|
|
2848
3399
|
|
|
2849
3400
|
/**
|
|
2850
|
-
* Handle save preferences from modal
|
|
3401
|
+
* Handle save preferences from modal.
|
|
2851
3402
|
*/
|
|
2852
3403
|
function handleSavePreferences(selections) {
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
// Find newly allowed categories
|
|
2856
|
-
const newlyAllowed = Object.keys(result.current).filter(
|
|
2857
|
-
cat => result.current[cat] && !result.previous[cat]
|
|
2858
|
-
);
|
|
2859
|
-
|
|
2860
|
-
if (newlyAllowed.length > 0) {
|
|
2861
|
-
replayAll(newlyAllowed);
|
|
2862
|
-
}
|
|
3404
|
+
coreUpdateConsent(selections);
|
|
3405
|
+
const config = getActiveConfig();
|
|
2863
3406
|
|
|
2864
3407
|
hideModal();
|
|
2865
3408
|
|
|
2866
|
-
if (config
|
|
3409
|
+
if (config?.showWidget) {
|
|
2867
3410
|
showWidget({ onClick: handleShowSettings });
|
|
2868
3411
|
}
|
|
2869
|
-
|
|
2870
|
-
// Determine if this was acceptance or rejection based on selections
|
|
2871
|
-
const hasNonEssential = Object.entries(selections)
|
|
2872
|
-
.some(([cat, val]) => cat !== 'essential' && val);
|
|
2873
|
-
|
|
2874
|
-
if (hasNonEssential) {
|
|
2875
|
-
emitConsent(result.current, result.previous);
|
|
2876
|
-
} else {
|
|
2877
|
-
emitReject(result.current);
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
|
-
emitChange(result.current, result.previous);
|
|
2881
|
-
config.callbacks?.onChange?.(result.current);
|
|
2882
3412
|
}
|
|
2883
3413
|
|
|
2884
3414
|
/**
|
|
2885
|
-
*
|
|
3415
|
+
* Open the settings modal.
|
|
2886
3416
|
*/
|
|
2887
3417
|
function handleShowSettings() {
|
|
2888
3418
|
hideBanner();
|
|
@@ -2899,17 +3429,17 @@ ${config.customStyles || ''}
|
|
|
2899
3429
|
}
|
|
2900
3430
|
|
|
2901
3431
|
/**
|
|
2902
|
-
*
|
|
3432
|
+
* Close the modal — either bring the widget back (decision made) or
|
|
3433
|
+
* fall back to the banner (no decision yet).
|
|
2903
3434
|
*/
|
|
2904
3435
|
function handleCloseModal() {
|
|
2905
3436
|
hideModal();
|
|
2906
3437
|
emitHide('modal');
|
|
2907
3438
|
|
|
2908
|
-
|
|
2909
|
-
if (hasConsentDecision() && config
|
|
3439
|
+
const config = getActiveConfig();
|
|
3440
|
+
if (hasConsentDecision() && config?.showWidget) {
|
|
2910
3441
|
showWidget({ onClick: handleShowSettings });
|
|
2911
3442
|
} else {
|
|
2912
|
-
// Show banner again if no decision made
|
|
2913
3443
|
showBanner({
|
|
2914
3444
|
onAcceptAll: handleAcceptAll,
|
|
2915
3445
|
onRejectAll: handleRejectAll,
|
|
@@ -2919,89 +3449,37 @@ ${config.customStyles || ''}
|
|
|
2919
3449
|
}
|
|
2920
3450
|
|
|
2921
3451
|
/**
|
|
2922
|
-
* Initialize Zest
|
|
3452
|
+
* Initialize Zest with UI.
|
|
2923
3453
|
*/
|
|
2924
3454
|
function init(userConfig = {}) {
|
|
2925
|
-
|
|
3455
|
+
const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
|
|
3456
|
+
if (alreadyInitialized) {
|
|
2926
3457
|
console.warn('[Zest] Already initialized');
|
|
2927
3458
|
return Zest;
|
|
2928
3459
|
}
|
|
2929
3460
|
|
|
2930
|
-
|
|
2931
|
-
config = setConfig(userConfig);
|
|
2932
|
-
|
|
2933
|
-
// Set patterns if provided
|
|
2934
|
-
if (config.patterns) {
|
|
2935
|
-
setPatterns(config.patterns);
|
|
2936
|
-
}
|
|
2937
|
-
|
|
2938
|
-
// Set up consent checkers
|
|
2939
|
-
setConsentChecker$2(checkConsent);
|
|
2940
|
-
setConsentChecker$1(checkConsent);
|
|
2941
|
-
setConsentChecker(checkConsent);
|
|
2942
|
-
|
|
2943
|
-
// Start interception
|
|
2944
|
-
interceptCookies();
|
|
2945
|
-
interceptStorage();
|
|
2946
|
-
startScriptBlocking(config.mode, config.blockedDomains);
|
|
2947
|
-
|
|
2948
|
-
// Load saved consent
|
|
2949
|
-
const consent = loadConsent();
|
|
2950
|
-
|
|
2951
|
-
initialized = true;
|
|
2952
|
-
|
|
2953
|
-
// Check Do Not Track / Global Privacy Control
|
|
2954
|
-
const dntEnabled = isDoNotTrackEnabled();
|
|
2955
|
-
let dntApplied = false;
|
|
2956
|
-
|
|
2957
|
-
if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
|
|
2958
|
-
if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2959
|
-
// Auto-reject non-essential cookies silently
|
|
2960
|
-
const result = rejectAll(config.expiration);
|
|
2961
|
-
dntApplied = true;
|
|
2962
|
-
|
|
2963
|
-
// Emit events
|
|
2964
|
-
emitReject(result.current);
|
|
2965
|
-
emitChange(result.current, result.previous);
|
|
2966
|
-
config.callbacks?.onReject?.();
|
|
2967
|
-
config.callbacks?.onChange?.(result.current);
|
|
2968
|
-
}
|
|
2969
|
-
// 'preselect' behavior is handled by default (banner shows with defaults off)
|
|
2970
|
-
}
|
|
2971
|
-
|
|
2972
|
-
// Emit ready event
|
|
2973
|
-
emitReady(consent);
|
|
2974
|
-
config.callbacks?.onReady?.(consent);
|
|
3461
|
+
const config = getActiveConfig();
|
|
2975
3462
|
|
|
2976
|
-
|
|
2977
|
-
if (!hasConsentDecision() && !dntApplied) {
|
|
2978
|
-
// No consent decision yet - show banner
|
|
3463
|
+
if (!hasDecision && !dntApplied) {
|
|
2979
3464
|
showBanner({
|
|
2980
3465
|
onAcceptAll: handleAcceptAll,
|
|
2981
3466
|
onRejectAll: handleRejectAll,
|
|
2982
3467
|
onSettings: handleShowSettings
|
|
2983
3468
|
});
|
|
2984
3469
|
emitShow('banner');
|
|
2985
|
-
} else {
|
|
2986
|
-
|
|
2987
|
-
if (config.showWidget) {
|
|
2988
|
-
showWidget({ onClick: handleShowSettings });
|
|
2989
|
-
}
|
|
3470
|
+
} else if (config?.showWidget) {
|
|
3471
|
+
showWidget({ onClick: handleShowSettings });
|
|
2990
3472
|
}
|
|
2991
3473
|
|
|
2992
3474
|
return Zest;
|
|
2993
3475
|
}
|
|
2994
3476
|
|
|
2995
|
-
/**
|
|
2996
|
-
* Public API
|
|
2997
|
-
*/
|
|
2998
3477
|
const Zest = {
|
|
2999
|
-
// Initialization
|
|
3000
3478
|
init,
|
|
3001
3479
|
|
|
3002
3480
|
// Banner control
|
|
3003
3481
|
show() {
|
|
3004
|
-
if (!
|
|
3482
|
+
if (!isInitialized()) {
|
|
3005
3483
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3006
3484
|
return;
|
|
3007
3485
|
}
|
|
@@ -3022,7 +3500,7 @@ ${config.customStyles || ''}
|
|
|
3022
3500
|
|
|
3023
3501
|
// Settings modal
|
|
3024
3502
|
showSettings() {
|
|
3025
|
-
if (!
|
|
3503
|
+
if (!isInitialized()) {
|
|
3026
3504
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3027
3505
|
return;
|
|
3028
3506
|
}
|
|
@@ -3034,19 +3512,19 @@ ${config.customStyles || ''}
|
|
|
3034
3512
|
emitHide('modal');
|
|
3035
3513
|
},
|
|
3036
3514
|
|
|
3037
|
-
// Consent
|
|
3515
|
+
// Consent state
|
|
3038
3516
|
getConsent,
|
|
3039
3517
|
hasConsent,
|
|
3040
3518
|
hasConsentDecision,
|
|
3041
3519
|
getConsentProof,
|
|
3042
3520
|
|
|
3043
|
-
// DNT
|
|
3521
|
+
// DNT
|
|
3044
3522
|
isDoNotTrackEnabled,
|
|
3045
3523
|
getDNTDetails,
|
|
3046
3524
|
|
|
3047
|
-
//
|
|
3525
|
+
// Programmatic accept / reject
|
|
3048
3526
|
acceptAll() {
|
|
3049
|
-
if (!
|
|
3527
|
+
if (!isInitialized()) {
|
|
3050
3528
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3051
3529
|
return;
|
|
3052
3530
|
}
|
|
@@ -3054,20 +3532,19 @@ ${config.customStyles || ''}
|
|
|
3054
3532
|
},
|
|
3055
3533
|
|
|
3056
3534
|
rejectAll() {
|
|
3057
|
-
if (!
|
|
3535
|
+
if (!isInitialized()) {
|
|
3058
3536
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3059
3537
|
return;
|
|
3060
3538
|
}
|
|
3061
3539
|
handleRejectAll();
|
|
3062
3540
|
},
|
|
3063
3541
|
|
|
3064
|
-
// Reset and
|
|
3542
|
+
// Reset everything and reshow the banner
|
|
3065
3543
|
reset() {
|
|
3066
|
-
|
|
3544
|
+
coreReset();
|
|
3067
3545
|
hideModal();
|
|
3068
3546
|
removeWidget();
|
|
3069
|
-
|
|
3070
|
-
if (initialized) {
|
|
3547
|
+
if (isInitialized()) {
|
|
3071
3548
|
showBanner({
|
|
3072
3549
|
onAcceptAll: handleAcceptAll,
|
|
3073
3550
|
onRejectAll: handleRejectAll,
|
|
@@ -3077,17 +3554,30 @@ ${config.customStyles || ''}
|
|
|
3077
3554
|
}
|
|
3078
3555
|
},
|
|
3079
3556
|
|
|
3080
|
-
// Config
|
|
3557
|
+
// Config introspection
|
|
3081
3558
|
getConfig: getCurrentConfig,
|
|
3082
3559
|
|
|
3083
3560
|
// Events
|
|
3561
|
+
on,
|
|
3562
|
+
once,
|
|
3084
3563
|
EVENTS
|
|
3085
3564
|
};
|
|
3086
3565
|
|
|
3087
3566
|
// Auto-init if config present
|
|
3088
3567
|
if (typeof window !== 'undefined') {
|
|
3089
|
-
// Make Zest available globally
|
|
3090
|
-
|
|
3568
|
+
// Make Zest available globally. defineProperty with writable:false +
|
|
3569
|
+
// configurable:false stops a later-loaded script from replacing the
|
|
3570
|
+
// global with a trojanned stand-in.
|
|
3571
|
+
try {
|
|
3572
|
+
Object.defineProperty(window, 'Zest', {
|
|
3573
|
+
value: Object.freeze(Zest),
|
|
3574
|
+
writable: false,
|
|
3575
|
+
configurable: false,
|
|
3576
|
+
enumerable: true
|
|
3577
|
+
});
|
|
3578
|
+
} catch (e) {
|
|
3579
|
+
window.Zest = Zest;
|
|
3580
|
+
}
|
|
3091
3581
|
|
|
3092
3582
|
const autoInit = () => {
|
|
3093
3583
|
const cfg = getConfig();
|