@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.esm.js
CHANGED
|
@@ -1,7 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities - escaping, validation, and safe parsing helpers
|
|
3
|
+
*
|
|
4
|
+
* These helpers are used across UI components, URL/CSS validation, and
|
|
5
|
+
* consent-cookie parsing to provide defense-in-depth against untrusted
|
|
6
|
+
* config (CMS-driven, i18n-loaded, or attacker-supplied).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HTML_ESCAPE_MAP = {
|
|
10
|
+
'&': '&',
|
|
11
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'"': '"',
|
|
14
|
+
"'": ''',
|
|
15
|
+
'`': '`'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escape a value for safe embedding in HTML text nodes and attribute values.
|
|
20
|
+
* Accepts any value — non-strings are stringified first. null/undefined -> ''.
|
|
21
|
+
*/
|
|
22
|
+
function escapeHTML(value) {
|
|
23
|
+
if (value === null || value === undefined) return '';
|
|
24
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
25
|
+
return str.replace(/[&<>"'`]/g, (ch) => HTML_ESCAPE_MAP[ch]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a URL — return the URL if it uses http:/https:/mailto:/tel:,
|
|
30
|
+
* otherwise return null. Blocks javascript:, data:, vbscript:, file:, etc.
|
|
31
|
+
*/
|
|
32
|
+
function safeUrl(url) {
|
|
33
|
+
if (typeof url !== 'string' || url.length === 0) return null;
|
|
34
|
+
const trimmed = url.trim();
|
|
35
|
+
if (trimmed.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
// Relative URLs (no protocol) are safe — treat as same-origin path
|
|
38
|
+
if (/^[/?#]/.test(trimmed)) return trimmed;
|
|
39
|
+
|
|
40
|
+
// Check protocol explicitly, do NOT rely on URL parsing alone —
|
|
41
|
+
// attackers may use whitespace/control characters to confuse parsers.
|
|
42
|
+
const match = trimmed.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
43
|
+
if (!match) {
|
|
44
|
+
// No protocol — treat as relative
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const protocol = match[1].toLowerCase();
|
|
49
|
+
if (protocol === 'http' || protocol === 'https' || protocol === 'mailto' || protocol === 'tel') {
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a CSS color value. Accepts #rgb/#rrggbb/#rrggbbaa and a
|
|
57
|
+
* small allowlist of CSS named colors and rgb()/rgba()/hsl()/hsla()
|
|
58
|
+
* functional forms with numeric-only arguments.
|
|
59
|
+
*/
|
|
60
|
+
const NAMED_COLORS = new Set([
|
|
61
|
+
'transparent', 'black', 'white', 'red', 'green', 'blue', 'yellow',
|
|
62
|
+
'orange', 'purple', 'pink', 'gray', 'grey', 'brown', 'cyan', 'magenta',
|
|
63
|
+
'silver', 'gold', 'navy', 'teal', 'maroon', 'olive', 'lime', 'aqua',
|
|
64
|
+
'fuchsia', 'indigo', 'violet', 'crimson', 'coral', 'salmon', 'tomato'
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function safeColor(color) {
|
|
68
|
+
if (typeof color !== 'string') return null;
|
|
69
|
+
const trimmed = color.trim();
|
|
70
|
+
|
|
71
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
|
72
|
+
if (NAMED_COLORS.has(trimmed.toLowerCase())) return trimmed;
|
|
73
|
+
|
|
74
|
+
// Functional notations: only digits, dots, commas, %, whitespace between parens
|
|
75
|
+
if (/^(rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\s*\)$/i.test(trimmed)) return trimmed;
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate a regex pattern string. Rejects patterns that contain known
|
|
82
|
+
* catastrophic-backtracking shapes (nested quantifiers). Compiles with
|
|
83
|
+
* try/catch.
|
|
84
|
+
*
|
|
85
|
+
* Returns a RegExp on success, null on failure.
|
|
86
|
+
*/
|
|
87
|
+
const REDOS_PATTERNS = [
|
|
88
|
+
/(\([^)]*[+*][^)]*\)|\[[^\]]*\]|\\w|\\d|\\s)\s*[+*]/, // nested quantifier
|
|
89
|
+
/\(\?[=!][^)]*[+*][^)]*\)[+*]/, // lookahead with quantifier, then quantifier
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function safeRegExp(pattern, flags) {
|
|
93
|
+
if (pattern instanceof RegExp) return pattern;
|
|
94
|
+
if (typeof pattern !== 'string') return null;
|
|
95
|
+
|
|
96
|
+
// Cap pattern length to limit compiled-regex state
|
|
97
|
+
if (pattern.length > 500) return null;
|
|
98
|
+
|
|
99
|
+
// Heuristic: reject obviously dangerous patterns
|
|
100
|
+
for (const bad of REDOS_PATTERNS) {
|
|
101
|
+
if (bad.test(pattern)) return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return new RegExp(pattern, flags);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sanitize a consent-cookie payload. Only known category keys with
|
|
113
|
+
* boolean values survive; prototype-polluting keys are stripped.
|
|
114
|
+
*/
|
|
115
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
116
|
+
|
|
117
|
+
function sanitizeConsentPayload(raw, knownCategoryIds) {
|
|
118
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
119
|
+
|
|
120
|
+
const result = {
|
|
121
|
+
version: typeof raw.version === 'string' ? raw.version : null,
|
|
122
|
+
timestamp: typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp) ? raw.timestamp : null,
|
|
123
|
+
categories: {}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cats = raw.categories;
|
|
127
|
+
if (!cats || typeof cats !== 'object' || Array.isArray(cats)) return null;
|
|
128
|
+
|
|
129
|
+
for (const key of knownCategoryIds) {
|
|
130
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(cats, key)) {
|
|
132
|
+
result.categories[key] = cats[key] === true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// essential is always true regardless of stored value
|
|
137
|
+
if (knownCategoryIds.includes('essential')) {
|
|
138
|
+
result.categories.essential = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Invoke a user-supplied callback, swallowing and logging exceptions so
|
|
146
|
+
* a misbehaving callback can't break the consent flow.
|
|
147
|
+
*/
|
|
148
|
+
function safeInvoke(fn, ...args) {
|
|
149
|
+
if (typeof fn !== 'function') return undefined;
|
|
150
|
+
try {
|
|
151
|
+
return fn(...args);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
try {
|
|
154
|
+
console.error('[Zest] User callback threw:', e);
|
|
155
|
+
} catch (_) {
|
|
156
|
+
/* no-op */
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Strip comments and selector-level content from a customStyles string
|
|
164
|
+
* while still allowing property/value declarations scoped under the
|
|
165
|
+
* component selectors the author is targeting. We cannot fully sandbox
|
|
166
|
+
* CSS without a parser, but we can at least neutralise the most
|
|
167
|
+
* dangerous clickjacking vector (rules targeting Zest's own buttons).
|
|
168
|
+
*
|
|
169
|
+
* Returns the sanitized CSS string (possibly empty).
|
|
170
|
+
*/
|
|
171
|
+
function sanitizeCustomStyles(css) {
|
|
172
|
+
if (typeof css !== 'string' || css.length === 0) return '';
|
|
173
|
+
|
|
174
|
+
// Hard cap on size to avoid runaway payloads
|
|
175
|
+
if (css.length > 20000) return '';
|
|
176
|
+
|
|
177
|
+
// Remove CSS comments (can hide payloads)
|
|
178
|
+
let out = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
179
|
+
|
|
180
|
+
// Block at-rules that can load external resources or alter behavior
|
|
181
|
+
out = out.replace(/@import\s+[^;]+;?/gi, '');
|
|
182
|
+
out = out.replace(/@charset\s+[^;]+;?/gi, '');
|
|
183
|
+
|
|
184
|
+
// Block url() values pointing outside of data: or https:
|
|
185
|
+
out = out.replace(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/gi, (match, quote, value) => {
|
|
186
|
+
const v = value.trim().toLowerCase();
|
|
187
|
+
if (v.startsWith('https:') || v.startsWith('data:image/') || v.startsWith('/') || v.startsWith('#')) {
|
|
188
|
+
return match;
|
|
189
|
+
}
|
|
190
|
+
return 'url(#)';
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Block selectors that target the built-in reject button, which could
|
|
194
|
+
// be used to hide it for clickjacking consent bypass.
|
|
195
|
+
out = out.replace(/\.zest-btn--secondary\s*\{[^}]*\}/gi, '');
|
|
196
|
+
out = out.replace(/\[data-action\s*=\s*["']reject-all["']\]\s*\{[^}]*\}/gi, '');
|
|
197
|
+
out = out.replace(/\[data-action\s*=\s*["']accept-all["']\]\s*\{[^}]*\}/gi, '');
|
|
198
|
+
|
|
199
|
+
// Block expression() (ancient IE) and -moz-binding (ancient FF)
|
|
200
|
+
out = out.replace(/expression\s*\([^)]*\)/gi, '');
|
|
201
|
+
out = out.replace(/-moz-binding\s*:[^;}]*/gi, '');
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
1
206
|
/**
|
|
2
207
|
* Pattern Matcher - Categorizes cookies and storage keys by pattern
|
|
3
208
|
*/
|
|
4
209
|
|
|
210
|
+
|
|
5
211
|
/**
|
|
6
212
|
* Default patterns for each category
|
|
7
213
|
*/
|
|
@@ -50,16 +256,29 @@ const DEFAULT_PATTERNS = {
|
|
|
50
256
|
let patterns = { ...DEFAULT_PATTERNS };
|
|
51
257
|
|
|
52
258
|
/**
|
|
53
|
-
* Set custom patterns
|
|
259
|
+
* Set custom patterns. User-supplied strings are validated with safeRegExp,
|
|
260
|
+
* which rejects catastrophic-backtracking shapes and syntax errors.
|
|
261
|
+
* Invalid patterns are silently dropped with a console warning.
|
|
54
262
|
*/
|
|
55
263
|
function setPatterns(customPatterns) {
|
|
56
264
|
patterns = { ...DEFAULT_PATTERNS };
|
|
265
|
+
if (!customPatterns || typeof customPatterns !== 'object') return;
|
|
266
|
+
|
|
57
267
|
for (const [category, regexList] of Object.entries(customPatterns)) {
|
|
58
|
-
if (Array.isArray(regexList))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
268
|
+
if (!Array.isArray(regexList)) continue;
|
|
269
|
+
|
|
270
|
+
const compiled = [];
|
|
271
|
+
for (const p of regexList) {
|
|
272
|
+
const re = safeRegExp(p);
|
|
273
|
+
if (re) {
|
|
274
|
+
compiled.push(re);
|
|
275
|
+
} else {
|
|
276
|
+
try {
|
|
277
|
+
console.warn('[Zest] Rejected unsafe pattern:', p);
|
|
278
|
+
} catch (_) { /* no-op */ }
|
|
279
|
+
}
|
|
62
280
|
}
|
|
281
|
+
patterns[category] = compiled;
|
|
63
282
|
}
|
|
64
283
|
}
|
|
65
284
|
|
|
@@ -96,6 +315,11 @@ function parseCookieName(cookieString) {
|
|
|
96
315
|
// Store original descriptor
|
|
97
316
|
let originalCookieDescriptor = null;
|
|
98
317
|
|
|
318
|
+
// Upper bound on the number of queued cookies awaiting consent replay.
|
|
319
|
+
// An unbounded queue is a memory-exhaustion DoS vector — a hostile
|
|
320
|
+
// script could flood it with document.cookie writes.
|
|
321
|
+
const MAX_QUEUE_SIZE$2 = 100;
|
|
322
|
+
|
|
99
323
|
// Queue for blocked cookies
|
|
100
324
|
const cookieQueue = [];
|
|
101
325
|
|
|
@@ -165,8 +389,8 @@ function interceptCookies() {
|
|
|
165
389
|
if (checkConsent$3(category)) {
|
|
166
390
|
// Consent given - set cookie
|
|
167
391
|
originalCookieDescriptor.set.call(document, value);
|
|
168
|
-
} else {
|
|
169
|
-
// No consent - queue for later
|
|
392
|
+
} else if (cookieQueue.length < MAX_QUEUE_SIZE$2) {
|
|
393
|
+
// No consent - queue for later (capped to prevent DoS)
|
|
170
394
|
cookieQueue.push({
|
|
171
395
|
value,
|
|
172
396
|
name,
|
|
@@ -175,7 +399,9 @@ function interceptCookies() {
|
|
|
175
399
|
});
|
|
176
400
|
}
|
|
177
401
|
},
|
|
178
|
-
configurable:
|
|
402
|
+
// configurable: false prevents a later-loaded script from
|
|
403
|
+
// overriding our descriptor and bypassing the interceptor.
|
|
404
|
+
configurable: false
|
|
179
405
|
});
|
|
180
406
|
|
|
181
407
|
return true;
|
|
@@ -186,6 +412,10 @@ function interceptCookies() {
|
|
|
186
412
|
*/
|
|
187
413
|
|
|
188
414
|
|
|
415
|
+
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
416
|
+
// growth would be a memory-exhaustion DoS vector.
|
|
417
|
+
const MAX_QUEUE_SIZE$1 = 200;
|
|
418
|
+
|
|
189
419
|
// Store originals
|
|
190
420
|
let originalLocalStorage = null;
|
|
191
421
|
let originalSessionStorage = null;
|
|
@@ -216,7 +446,7 @@ function createStorageProxy(storage, queue, storageName) {
|
|
|
216
446
|
|
|
217
447
|
if (checkConsent$2(category)) {
|
|
218
448
|
target.setItem(key, value);
|
|
219
|
-
} else {
|
|
449
|
+
} else if (queue.length < MAX_QUEUE_SIZE$1) {
|
|
220
450
|
queue.push({
|
|
221
451
|
key,
|
|
222
452
|
value,
|
|
@@ -401,29 +631,56 @@ const STRICT_TRACKERS = {
|
|
|
401
631
|
};
|
|
402
632
|
|
|
403
633
|
/**
|
|
404
|
-
* Check if a URL matches any tracker in the list
|
|
634
|
+
* Check if a URL matches any tracker in the list.
|
|
635
|
+
*
|
|
636
|
+
* Matching is restricted to hostname (and, when the list entry contains
|
|
637
|
+
* a path, the URL path prefix). A naive `fullUrl.includes(domain)` was
|
|
638
|
+
* previously used, which would false-positive on e.g.
|
|
639
|
+
* https://mysite.com/page?ref=google-analytics.com
|
|
405
640
|
*/
|
|
406
641
|
function matchesTrackerList(url, trackerList) {
|
|
642
|
+
let urlObj;
|
|
407
643
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
644
|
+
urlObj = new URL(url);
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
649
|
+
const path = urlObj.pathname.toLowerCase();
|
|
650
|
+
|
|
651
|
+
for (const rawEntry of trackerList) {
|
|
652
|
+
if (typeof rawEntry !== 'string') continue;
|
|
653
|
+
const entry = rawEntry.toLowerCase();
|
|
654
|
+
|
|
655
|
+
// Partial-prefix match on hostname (entry ends with a dot),
|
|
656
|
+
// e.g. "matomo." matches "analytics.matomo.cloud"
|
|
657
|
+
if (entry.endsWith('.')) {
|
|
658
|
+
const needle = entry.slice(0, -1);
|
|
659
|
+
const segments = hostname.split('.');
|
|
660
|
+
if (segments.some(seg => seg === needle) || hostname.startsWith(entry)) {
|
|
419
661
|
return true;
|
|
420
|
-
}
|
|
662
|
+
}
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Entries containing a slash specify hostname + path prefix
|
|
667
|
+
const slashIdx = entry.indexOf('/');
|
|
668
|
+
if (slashIdx !== -1) {
|
|
669
|
+
const entryHost = entry.slice(0, slashIdx);
|
|
670
|
+
const entryPath = entry.slice(slashIdx);
|
|
671
|
+
if ((hostname === entryHost || hostname.endsWith('.' + entryHost)) &&
|
|
672
|
+
path.startsWith(entryPath)) {
|
|
421
673
|
return true;
|
|
422
674
|
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Plain hostname: exact or subdomain match only
|
|
679
|
+
if (hostname === entry || hostname.endsWith('.' + entry)) {
|
|
680
|
+
return true;
|
|
423
681
|
}
|
|
424
|
-
} catch (e) {
|
|
425
|
-
// Invalid URL
|
|
426
682
|
}
|
|
683
|
+
|
|
427
684
|
return false;
|
|
428
685
|
}
|
|
429
686
|
|
|
@@ -470,7 +727,17 @@ function isThirdParty(url) {
|
|
|
470
727
|
*/
|
|
471
728
|
|
|
472
729
|
|
|
473
|
-
//
|
|
730
|
+
// Categories the author has declared blockable. A script can self-label
|
|
731
|
+
// into one of these, but not into 'essential' (a common bypass).
|
|
732
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
733
|
+
|
|
734
|
+
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
735
|
+
// hostile page from flooding the queue with <script> nodes.
|
|
736
|
+
const MAX_QUEUE_SIZE = 500;
|
|
737
|
+
|
|
738
|
+
// Queue for blocked scripts — the authoritative source for replay,
|
|
739
|
+
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
740
|
+
// cannot hijack what gets executed.
|
|
474
741
|
const scriptQueue = [];
|
|
475
742
|
|
|
476
743
|
// MutationObserver instance
|
|
@@ -517,55 +784,61 @@ function matchesCustomDomains(url) {
|
|
|
517
784
|
}
|
|
518
785
|
|
|
519
786
|
/**
|
|
520
|
-
* Determine if a script should be blocked and get its category
|
|
787
|
+
* Determine if a script should be blocked and get its category.
|
|
788
|
+
*
|
|
789
|
+
* A self-applied 'essential' label is ignored — only explicit blockable
|
|
790
|
+
* categories are accepted. That prevents a third-party script from
|
|
791
|
+
* stamping itself with data-consent-category="essential" to slip past
|
|
792
|
+
* mode-based blocking.
|
|
521
793
|
*/
|
|
522
794
|
function getScriptBlockCategory(script) {
|
|
523
|
-
//
|
|
524
|
-
const explicitCategory = script.getAttribute('data-consent-category');
|
|
525
|
-
if (explicitCategory) {
|
|
526
|
-
return explicitCategory;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// 2. Skip if script has data-zest-allow attribute
|
|
795
|
+
// Skip if script has data-zest-allow attribute (opt-out)
|
|
530
796
|
if (script.hasAttribute('data-zest-allow')) {
|
|
531
797
|
return null;
|
|
532
798
|
}
|
|
533
799
|
|
|
800
|
+
// 1. Check for explicit data-consent-category attribute.
|
|
801
|
+
// Only honor values from the blockable set; 'essential' and unknown
|
|
802
|
+
// values fall through to the other checks.
|
|
803
|
+
const explicitCategory = script.getAttribute('data-consent-category');
|
|
804
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
805
|
+
? explicitCategory
|
|
806
|
+
: null;
|
|
807
|
+
|
|
534
808
|
const src = script.src;
|
|
535
809
|
|
|
536
|
-
// No src = inline script, only block if explicitly tagged
|
|
810
|
+
// No src = inline script, only block if explicitly tagged (blockable only)
|
|
537
811
|
if (!src) {
|
|
538
|
-
return
|
|
812
|
+
return explicitBlockable;
|
|
539
813
|
}
|
|
540
814
|
|
|
541
|
-
//
|
|
815
|
+
// 2. Check custom blocked domains
|
|
542
816
|
const customCategory = matchesCustomDomains(src);
|
|
543
|
-
if (customCategory) {
|
|
544
|
-
return customCategory;
|
|
545
|
-
}
|
|
546
817
|
|
|
547
|
-
//
|
|
818
|
+
// 3. Mode-based blocking
|
|
819
|
+
let modeCategory = null;
|
|
548
820
|
switch (blockingMode) {
|
|
549
821
|
case 'manual':
|
|
550
|
-
|
|
551
|
-
return null;
|
|
822
|
+
break;
|
|
552
823
|
|
|
553
824
|
case 'safe':
|
|
554
825
|
case 'strict':
|
|
555
|
-
|
|
556
|
-
|
|
826
|
+
modeCategory = getCategoryForScript(src, blockingMode);
|
|
827
|
+
break;
|
|
557
828
|
|
|
558
829
|
case 'doomsday':
|
|
559
|
-
// Block all third-party scripts
|
|
560
830
|
if (isThirdParty(src)) {
|
|
561
|
-
|
|
562
|
-
return getCategoryForScript(src, 'strict') || 'marketing';
|
|
831
|
+
modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
|
|
563
832
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
default:
|
|
567
|
-
return null;
|
|
833
|
+
break;
|
|
568
834
|
}
|
|
835
|
+
|
|
836
|
+
// Use the strictest category among explicit/custom/mode decisions.
|
|
837
|
+
// We collect all categories the script matches and pick the first
|
|
838
|
+
// that appears in the blockable set (any match wins — but we prefer
|
|
839
|
+
// the mode-assigned one since it's authoritative for third-party
|
|
840
|
+
// trackers that try to self-label as 'functional').
|
|
841
|
+
return modeCategory || customCategory || explicitBlockable;
|
|
569
842
|
}
|
|
570
843
|
|
|
571
844
|
/**
|
|
@@ -590,14 +863,17 @@ function blockScript(script) {
|
|
|
590
863
|
return false;
|
|
591
864
|
}
|
|
592
865
|
|
|
593
|
-
// Store script info for later execution
|
|
866
|
+
// Store script info for later execution. Snapshot the src/text BEFORE
|
|
867
|
+
// mutating the DOM — this snapshot is the authoritative replay source
|
|
868
|
+
// so later DOM tampering cannot hijack the replayed script URL.
|
|
594
869
|
const scriptInfo = {
|
|
595
870
|
category,
|
|
596
|
-
src: script.src,
|
|
871
|
+
src: script.src || '',
|
|
597
872
|
inline: script.textContent,
|
|
598
873
|
type: script.type,
|
|
599
874
|
async: script.async,
|
|
600
875
|
defer: script.defer,
|
|
876
|
+
element: script,
|
|
601
877
|
timestamp: Date.now()
|
|
602
878
|
};
|
|
603
879
|
|
|
@@ -608,77 +884,61 @@ function blockScript(script) {
|
|
|
608
884
|
// Disable the script
|
|
609
885
|
script.type = 'text/plain';
|
|
610
886
|
|
|
611
|
-
//
|
|
887
|
+
// Remove src to prevent loading. We no longer stash it on the element
|
|
888
|
+
// (data-blocked-src was a tampering vector); scriptQueue is the single
|
|
889
|
+
// source of truth for replay.
|
|
612
890
|
if (script.src) {
|
|
613
|
-
script.setAttribute('data-blocked-src', script.src);
|
|
614
891
|
script.removeAttribute('src');
|
|
615
892
|
}
|
|
616
893
|
|
|
617
|
-
scriptQueue.
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Execute a queued script
|
|
623
|
-
*/
|
|
624
|
-
function executeScript(scriptInfo) {
|
|
625
|
-
const script = document.createElement('script');
|
|
626
|
-
|
|
627
|
-
if (scriptInfo.src) {
|
|
628
|
-
script.src = scriptInfo.src;
|
|
629
|
-
} else if (scriptInfo.inline) {
|
|
630
|
-
script.textContent = scriptInfo.inline;
|
|
894
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
895
|
+
scriptQueue.push(scriptInfo);
|
|
631
896
|
}
|
|
632
|
-
|
|
633
|
-
if (scriptInfo.async) script.async = true;
|
|
634
|
-
if (scriptInfo.defer) script.defer = true;
|
|
635
|
-
|
|
636
|
-
script.setAttribute('data-zest-processed', 'executed');
|
|
637
|
-
script.setAttribute('data-consent-executed', 'true');
|
|
638
|
-
|
|
639
|
-
document.head.appendChild(script);
|
|
897
|
+
return true;
|
|
640
898
|
}
|
|
641
899
|
|
|
642
900
|
/**
|
|
643
|
-
* Replay queued scripts for allowed categories
|
|
901
|
+
* Replay queued scripts for allowed categories.
|
|
902
|
+
*
|
|
903
|
+
* scriptQueue is the single source of truth for src and inline body —
|
|
904
|
+
* we never re-read data-* attributes from the DOM (which an attacker
|
|
905
|
+
* could have rewritten in the intervening time).
|
|
644
906
|
*/
|
|
645
907
|
function replayScripts(allowedCategories) {
|
|
646
908
|
const remaining = [];
|
|
647
909
|
|
|
648
910
|
for (const scriptInfo of scriptQueue) {
|
|
649
|
-
if (allowedCategories.includes(scriptInfo.category)) {
|
|
650
|
-
executeScript(scriptInfo);
|
|
651
|
-
} else {
|
|
911
|
+
if (!allowedCategories.includes(scriptInfo.category)) {
|
|
652
912
|
remaining.push(scriptInfo);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const newScript = document.createElement('script');
|
|
917
|
+
if (scriptInfo.src) {
|
|
918
|
+
newScript.src = scriptInfo.src;
|
|
919
|
+
} else if (scriptInfo.inline) {
|
|
920
|
+
newScript.textContent = scriptInfo.inline;
|
|
921
|
+
}
|
|
922
|
+
if (scriptInfo.async) newScript.async = true;
|
|
923
|
+
if (scriptInfo.defer) newScript.defer = true;
|
|
924
|
+
if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
|
|
925
|
+
newScript.type = scriptInfo.type;
|
|
926
|
+
}
|
|
927
|
+
newScript.setAttribute('data-zest-processed', 'executed');
|
|
928
|
+
newScript.setAttribute('data-consent-executed', 'true');
|
|
929
|
+
|
|
930
|
+
// If the original element is still in the DOM, replace it in place
|
|
931
|
+
// so execution order is preserved. Otherwise append to <head>.
|
|
932
|
+
const original = scriptInfo.element;
|
|
933
|
+
if (original && original.isConnected && original.parentNode) {
|
|
934
|
+
original.parentNode.replaceChild(newScript, original);
|
|
935
|
+
} else {
|
|
936
|
+
document.head.appendChild(newScript);
|
|
653
937
|
}
|
|
654
938
|
}
|
|
655
939
|
|
|
656
940
|
scriptQueue.length = 0;
|
|
657
941
|
scriptQueue.push(...remaining);
|
|
658
|
-
|
|
659
|
-
// Also re-enable any blocked scripts in the DOM
|
|
660
|
-
const blockedScripts = document.querySelectorAll('script[data-zest-processed="blocked"]');
|
|
661
|
-
blockedScripts.forEach(script => {
|
|
662
|
-
const category = script.getAttribute('data-consent-category');
|
|
663
|
-
if (allowedCategories.includes(category)) {
|
|
664
|
-
// Clone and replace to execute
|
|
665
|
-
const newScript = document.createElement('script');
|
|
666
|
-
|
|
667
|
-
const blockedSrc = script.getAttribute('data-blocked-src');
|
|
668
|
-
if (blockedSrc) {
|
|
669
|
-
newScript.src = blockedSrc;
|
|
670
|
-
} else {
|
|
671
|
-
newScript.textContent = script.textContent;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (script.async) newScript.async = true;
|
|
675
|
-
if (script.defer) newScript.defer = true;
|
|
676
|
-
|
|
677
|
-
newScript.setAttribute('data-zest-processed', 'executed');
|
|
678
|
-
newScript.setAttribute('data-consent-executed', 'true');
|
|
679
|
-
script.parentNode?.replaceChild(newScript, script);
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
942
|
}
|
|
683
943
|
|
|
684
944
|
/**
|
|
@@ -839,6 +1099,78 @@ function getDNTDetails() {
|
|
|
839
1099
|
return { enabled: false, source: null };
|
|
840
1100
|
}
|
|
841
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Consent Signals - Optional vendor consent mode integrations
|
|
1104
|
+
*
|
|
1105
|
+
* Pushes consent state to Google Consent Mode v2 and/or Microsoft UET
|
|
1106
|
+
* Consent Mode when enabled via config.
|
|
1107
|
+
*/
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Map Zest consent state to Google Consent Mode v2 signals
|
|
1111
|
+
*/
|
|
1112
|
+
function toGoogleSignals(consent) {
|
|
1113
|
+
const g = (val) => val ? 'granted' : 'denied';
|
|
1114
|
+
return {
|
|
1115
|
+
ad_storage: g(consent.marketing),
|
|
1116
|
+
ad_user_data: g(consent.marketing),
|
|
1117
|
+
ad_personalization: g(consent.marketing),
|
|
1118
|
+
analytics_storage: g(consent.analytics),
|
|
1119
|
+
functionality_storage: 'granted', // essential is always true
|
|
1120
|
+
personalization_storage: g(consent.functional)
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Push consent signal to Google via gtag or dataLayer fallback.
|
|
1126
|
+
* Uses a local function to preserve the `arguments` object shape
|
|
1127
|
+
* that gtag/dataLayer expects (not an array).
|
|
1128
|
+
*/
|
|
1129
|
+
function pushGoogle(type, signals) {
|
|
1130
|
+
window.dataLayer = window.dataLayer || [];
|
|
1131
|
+
if (typeof window.gtag === 'function') {
|
|
1132
|
+
window.gtag('consent', type, signals);
|
|
1133
|
+
} else {
|
|
1134
|
+
function gtagFallback() { window.dataLayer.push(arguments); }
|
|
1135
|
+
gtagFallback('consent', type, signals);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Map Zest consent state to Microsoft UET signal.
|
|
1141
|
+
* Microsoft UET only exposes ad_storage.
|
|
1142
|
+
*/
|
|
1143
|
+
function toMicrosoftSignals(consent) {
|
|
1144
|
+
return { ad_storage: consent.marketing ? 'granted' : 'denied' };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Push consent signal to Microsoft UET
|
|
1149
|
+
*/
|
|
1150
|
+
function pushMicrosoft(type, signals) {
|
|
1151
|
+
window.uetq = window.uetq || [];
|
|
1152
|
+
window.uetq.push('consent', type, signals);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Apply consent signals to enabled vendor integrations.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {Object} consent Current Zest consent state
|
|
1159
|
+
* @param {Object} config Merged Zest config
|
|
1160
|
+
* @param {boolean} isDefault true on first call (pushes 'default'), false for updates
|
|
1161
|
+
*/
|
|
1162
|
+
function applyConsentSignals(consent, config, isDefault) {
|
|
1163
|
+
const type = isDefault ? 'default' : 'update';
|
|
1164
|
+
|
|
1165
|
+
if (config.consentModeGoogle) {
|
|
1166
|
+
pushGoogle(type, toGoogleSignals(consent));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (config.consentModeMicrosoft) {
|
|
1170
|
+
pushMicrosoft(type, toMicrosoftSignals(consent));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
842
1174
|
/**
|
|
843
1175
|
* Built-in translations for Zest
|
|
844
1176
|
* Language is auto-detected from <html lang=""> or navigator.language
|
|
@@ -1438,6 +1770,10 @@ const DEFAULTS = {
|
|
|
1438
1770
|
// Custom styles to inject into Shadow DOM
|
|
1439
1771
|
customStyles: '',
|
|
1440
1772
|
|
|
1773
|
+
// Vendor consent mode integrations (optional)
|
|
1774
|
+
consentModeGoogle: false,
|
|
1775
|
+
consentModeMicrosoft: false,
|
|
1776
|
+
|
|
1441
1777
|
// Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
|
|
1442
1778
|
mode: 'safe',
|
|
1443
1779
|
|
|
@@ -1468,7 +1804,7 @@ function mergeConfig(userConfig) {
|
|
|
1468
1804
|
}
|
|
1469
1805
|
|
|
1470
1806
|
// Simple properties
|
|
1471
|
-
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior'];
|
|
1807
|
+
const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior', 'consentModeGoogle', 'consentModeMicrosoft'];
|
|
1472
1808
|
for (const key of simpleKeys) {
|
|
1473
1809
|
if (userConfig[key] !== undefined) {
|
|
1474
1810
|
config[key] = userConfig[key];
|
|
@@ -1578,6 +1914,13 @@ function parseDataAttributes() {
|
|
|
1578
1914
|
const expiration = script.getAttribute('data-expiration');
|
|
1579
1915
|
if (expiration) config.expiration = parseInt(expiration, 10);
|
|
1580
1916
|
|
|
1917
|
+
// Consent mode integrations
|
|
1918
|
+
const consentModeGoogle = script.getAttribute('data-consent-mode-google');
|
|
1919
|
+
if (consentModeGoogle !== null) config.consentModeGoogle = consentModeGoogle !== 'false';
|
|
1920
|
+
|
|
1921
|
+
const consentModeMicrosoft = script.getAttribute('data-consent-mode-microsoft');
|
|
1922
|
+
if (consentModeMicrosoft !== null) config.consentModeMicrosoft = consentModeMicrosoft !== 'false';
|
|
1923
|
+
|
|
1581
1924
|
return config;
|
|
1582
1925
|
}
|
|
1583
1926
|
|
|
@@ -1609,18 +1952,18 @@ function getConfig() {
|
|
|
1609
1952
|
/**
|
|
1610
1953
|
* Update configuration at runtime
|
|
1611
1954
|
*/
|
|
1612
|
-
let currentConfig = null;
|
|
1955
|
+
let currentConfig$1 = null;
|
|
1613
1956
|
|
|
1614
1957
|
function setConfig(config) {
|
|
1615
|
-
currentConfig = mergeConfig(config);
|
|
1616
|
-
return currentConfig;
|
|
1958
|
+
currentConfig$1 = mergeConfig(config);
|
|
1959
|
+
return currentConfig$1;
|
|
1617
1960
|
}
|
|
1618
1961
|
|
|
1619
1962
|
function getCurrentConfig() {
|
|
1620
|
-
if (!currentConfig) {
|
|
1621
|
-
currentConfig = getConfig();
|
|
1963
|
+
if (!currentConfig$1) {
|
|
1964
|
+
currentConfig$1 = getConfig();
|
|
1622
1965
|
}
|
|
1623
|
-
return currentConfig;
|
|
1966
|
+
return currentConfig$1;
|
|
1624
1967
|
}
|
|
1625
1968
|
|
|
1626
1969
|
/**
|
|
@@ -1631,6 +1974,20 @@ function getCurrentConfig() {
|
|
|
1631
1974
|
const COOKIE_NAME = 'zest_consent';
|
|
1632
1975
|
const CONSENT_VERSION = '1.0';
|
|
1633
1976
|
|
|
1977
|
+
/**
|
|
1978
|
+
* Return the Secure flag fragment when running over HTTPS, empty otherwise.
|
|
1979
|
+
* On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
|
|
1980
|
+
*/
|
|
1981
|
+
function secureAttribute() {
|
|
1982
|
+
try {
|
|
1983
|
+
return typeof location !== 'undefined' && location.protocol === 'https:'
|
|
1984
|
+
? '; Secure'
|
|
1985
|
+
: '';
|
|
1986
|
+
} catch (_) {
|
|
1987
|
+
return '';
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1634
1991
|
// Current consent state
|
|
1635
1992
|
let consent = null;
|
|
1636
1993
|
|
|
@@ -1659,7 +2016,12 @@ function getRawCookie() {
|
|
|
1659
2016
|
}
|
|
1660
2017
|
|
|
1661
2018
|
/**
|
|
1662
|
-
* Load consent from cookie
|
|
2019
|
+
* Load consent from cookie.
|
|
2020
|
+
*
|
|
2021
|
+
* The parsed cookie is validated against the expected schema via
|
|
2022
|
+
* sanitizeConsentPayload — only known category keys with boolean values
|
|
2023
|
+
* survive, so a tampered cookie can't inject prototype-polluting props
|
|
2024
|
+
* or unexpected category shapes.
|
|
1663
2025
|
*/
|
|
1664
2026
|
function loadConsent() {
|
|
1665
2027
|
try {
|
|
@@ -1667,9 +2029,12 @@ function loadConsent() {
|
|
|
1667
2029
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1668
2030
|
|
|
1669
2031
|
if (match) {
|
|
1670
|
-
const
|
|
1671
|
-
|
|
1672
|
-
|
|
2032
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2033
|
+
const clean = sanitizeConsentPayload(raw, getCategoryIds());
|
|
2034
|
+
if (clean && clean.categories) {
|
|
2035
|
+
consent = { ...getDefaultConsent(), ...clean.categories };
|
|
2036
|
+
return { ...consent };
|
|
2037
|
+
}
|
|
1673
2038
|
}
|
|
1674
2039
|
} catch (e) {
|
|
1675
2040
|
// Invalid or missing cookie
|
|
@@ -1694,7 +2059,7 @@ function saveConsent(expirationDays = 365) {
|
|
|
1694
2059
|
};
|
|
1695
2060
|
|
|
1696
2061
|
const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
|
|
1697
|
-
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
|
|
2062
|
+
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
|
|
1698
2063
|
|
|
1699
2064
|
setRawCookie(cookieValue);
|
|
1700
2065
|
}
|
|
@@ -1763,7 +2128,7 @@ function rejectAll(expirationDays = 365) {
|
|
|
1763
2128
|
* Reset consent (clear cookie)
|
|
1764
2129
|
*/
|
|
1765
2130
|
function resetConsent() {
|
|
1766
|
-
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path
|
|
2131
|
+
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
|
|
1767
2132
|
consent = null;
|
|
1768
2133
|
}
|
|
1769
2134
|
|
|
@@ -1788,7 +2153,8 @@ function getConsentProof() {
|
|
|
1788
2153
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
1789
2154
|
|
|
1790
2155
|
if (match) {
|
|
1791
|
-
|
|
2156
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
2157
|
+
return sanitizeConsentPayload(raw, getCategoryIds());
|
|
1792
2158
|
}
|
|
1793
2159
|
} catch (e) {
|
|
1794
2160
|
// Invalid cookie
|
|
@@ -1867,15 +2233,207 @@ function emitHide(type = 'banner') {
|
|
|
1867
2233
|
return emit(EVENTS.HIDE, { type });
|
|
1868
2234
|
}
|
|
1869
2235
|
|
|
2236
|
+
/**
|
|
2237
|
+
* Subscribe to an event
|
|
2238
|
+
*/
|
|
2239
|
+
function on(eventName, callback) {
|
|
2240
|
+
document.addEventListener(eventName, callback);
|
|
2241
|
+
return () => document.removeEventListener(eventName, callback);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Subscribe to an event once
|
|
2246
|
+
*/
|
|
2247
|
+
function once(eventName, callback) {
|
|
2248
|
+
document.addEventListener(eventName, callback, { once: true });
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Core lifecycle - UI-agnostic initialization and consent actions.
|
|
2253
|
+
*
|
|
2254
|
+
* This module contains everything the main entry (with UI) and the
|
|
2255
|
+
* headless entry (no UI) share: interceptor setup, consent load/save,
|
|
2256
|
+
* replay, DNT handling, and the events/callbacks fan-out. It intentionally
|
|
2257
|
+
* does NOT import anything from `./ui/*` so tree-shakers can drop the UI
|
|
2258
|
+
* bundle entirely when only the headless API is used.
|
|
2259
|
+
*/
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
let initialized = false;
|
|
2263
|
+
let currentConfig = null;
|
|
2264
|
+
|
|
2265
|
+
function checkConsent(category) {
|
|
2266
|
+
return hasConsent(category);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function replayAll(categories) {
|
|
2270
|
+
replayCookies(categories);
|
|
2271
|
+
replayStorage(categories);
|
|
2272
|
+
replayScripts(categories);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Run the non-UI half of init. Returns a snapshot the caller (UI or
|
|
2277
|
+
* headless) can use to decide what to do next.
|
|
2278
|
+
*/
|
|
2279
|
+
function coreInit(userConfig = {}) {
|
|
2280
|
+
if (initialized) {
|
|
2281
|
+
return {
|
|
2282
|
+
alreadyInitialized: true,
|
|
2283
|
+
config: currentConfig,
|
|
2284
|
+
consent: loadConsent(),
|
|
2285
|
+
hasDecision: hasConsentDecision(),
|
|
2286
|
+
dntApplied: false
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
currentConfig = setConfig(userConfig);
|
|
2291
|
+
|
|
2292
|
+
// Push default-denied state to vendor consent mode APIs BEFORE any
|
|
2293
|
+
// third-party script has a chance to fire.
|
|
2294
|
+
applyConsentSignals(
|
|
2295
|
+
{ functional: false, analytics: false, marketing: false },
|
|
2296
|
+
currentConfig,
|
|
2297
|
+
true
|
|
2298
|
+
);
|
|
2299
|
+
|
|
2300
|
+
if (currentConfig.patterns) {
|
|
2301
|
+
setPatterns(currentConfig.patterns);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
setConsentChecker$2(checkConsent);
|
|
2305
|
+
setConsentChecker$1(checkConsent);
|
|
2306
|
+
setConsentChecker(checkConsent);
|
|
2307
|
+
|
|
2308
|
+
interceptCookies();
|
|
2309
|
+
interceptStorage();
|
|
2310
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
2311
|
+
|
|
2312
|
+
const consent = loadConsent();
|
|
2313
|
+
initialized = true;
|
|
2314
|
+
|
|
2315
|
+
if (hasConsentDecision()) {
|
|
2316
|
+
applyConsentSignals(consent, currentConfig, false);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// DNT / GPC handling — if the user signalled opt-out at the browser
|
|
2320
|
+
// level and the site opts to respect it, auto-reject before the UI
|
|
2321
|
+
// layer ever runs.
|
|
2322
|
+
const dntEnabled = isDoNotTrackEnabled();
|
|
2323
|
+
let dntApplied = false;
|
|
2324
|
+
|
|
2325
|
+
if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
|
|
2326
|
+
if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2327
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2328
|
+
dntApplied = true;
|
|
2329
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2330
|
+
emitReject(result.current);
|
|
2331
|
+
emitChange(result.current, result.previous);
|
|
2332
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2333
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
emitReady(consent);
|
|
2338
|
+
safeInvoke(currentConfig.callbacks?.onReady, consent);
|
|
2339
|
+
|
|
2340
|
+
return {
|
|
2341
|
+
alreadyInitialized: false,
|
|
2342
|
+
config: currentConfig,
|
|
2343
|
+
consent,
|
|
2344
|
+
hasDecision: hasConsentDecision(),
|
|
2345
|
+
dntApplied
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Accept all categories, replay queued items, fire events + callbacks.
|
|
2351
|
+
* Returns { current, previous } or null if not yet initialized.
|
|
2352
|
+
*/
|
|
2353
|
+
function coreAcceptAll() {
|
|
2354
|
+
if (!initialized) return null;
|
|
2355
|
+
const result = acceptAll(currentConfig.expiration);
|
|
2356
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2357
|
+
replayAll(getCategoryIds());
|
|
2358
|
+
emitConsent(result.current, result.previous);
|
|
2359
|
+
emitChange(result.current, result.previous);
|
|
2360
|
+
safeInvoke(currentConfig.callbacks?.onAccept, result.current);
|
|
2361
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2362
|
+
return result;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/**
|
|
2366
|
+
* Reject all non-essential categories, fire events + callbacks.
|
|
2367
|
+
*/
|
|
2368
|
+
function coreRejectAll() {
|
|
2369
|
+
if (!initialized) return null;
|
|
2370
|
+
const result = rejectAll(currentConfig.expiration);
|
|
2371
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2372
|
+
emitReject(result.current);
|
|
2373
|
+
emitChange(result.current, result.previous);
|
|
2374
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
2375
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2376
|
+
return result;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* Save custom selections and replay only the newly-allowed categories.
|
|
2381
|
+
*/
|
|
2382
|
+
function coreUpdateConsent(selections) {
|
|
2383
|
+
if (!initialized) return null;
|
|
2384
|
+
const result = updateConsent(selections, currentConfig.expiration);
|
|
2385
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
2386
|
+
|
|
2387
|
+
const newlyAllowed = Object.keys(result.current).filter(
|
|
2388
|
+
(cat) => result.current[cat] && !result.previous[cat]
|
|
2389
|
+
);
|
|
2390
|
+
if (newlyAllowed.length > 0) {
|
|
2391
|
+
replayAll(newlyAllowed);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const hasNonEssential = Object.entries(selections || {}).some(
|
|
2395
|
+
([cat, val]) => cat !== 'essential' && val
|
|
2396
|
+
);
|
|
2397
|
+
if (hasNonEssential) {
|
|
2398
|
+
emitConsent(result.current, result.previous);
|
|
2399
|
+
} else {
|
|
2400
|
+
emitReject(result.current);
|
|
2401
|
+
}
|
|
2402
|
+
emitChange(result.current, result.previous);
|
|
2403
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
2404
|
+
return result;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Clear the consent cookie. The caller is responsible for any UI reset.
|
|
2409
|
+
*/
|
|
2410
|
+
function coreReset() {
|
|
2411
|
+
resetConsent();
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function isInitialized() {
|
|
2415
|
+
return initialized;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function getActiveConfig() {
|
|
2419
|
+
return currentConfig;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
1870
2422
|
/**
|
|
1871
2423
|
* Styles - Shadow DOM encapsulated CSS with theming
|
|
1872
2424
|
*/
|
|
1873
2425
|
|
|
2426
|
+
|
|
2427
|
+
const DEFAULT_ACCENT = '#4F46E5';
|
|
2428
|
+
|
|
1874
2429
|
/**
|
|
1875
2430
|
* Generate CSS with custom properties
|
|
1876
2431
|
*/
|
|
1877
2432
|
function generateStyles(config) {
|
|
1878
|
-
|
|
2433
|
+
// Only accept colors that pass strict validation — an unvalidated
|
|
2434
|
+
// value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
|
|
2435
|
+
const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
|
|
2436
|
+
const customCss = sanitizeCustomStyles(config.customStyles);
|
|
1879
2437
|
|
|
1880
2438
|
return `
|
|
1881
2439
|
:host {
|
|
@@ -2345,15 +2903,29 @@ function generateStyles(config) {
|
|
|
2345
2903
|
.zest-hidden {
|
|
2346
2904
|
display: none !important;
|
|
2347
2905
|
}
|
|
2348
|
-
${
|
|
2906
|
+
${customCss}
|
|
2349
2907
|
`;
|
|
2350
2908
|
}
|
|
2351
2909
|
|
|
2352
2910
|
/**
|
|
2353
|
-
* Adjust color brightness
|
|
2911
|
+
* Adjust color brightness. Falls back to the default accent if the input
|
|
2912
|
+
* cannot be parsed as a hex color (non-hex inputs pass safeColor but
|
|
2913
|
+
* can't be brightness-shifted mathematically).
|
|
2354
2914
|
*/
|
|
2355
2915
|
function adjustColor(hex, percent) {
|
|
2356
|
-
|
|
2916
|
+
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
|
|
2917
|
+
hex = DEFAULT_ACCENT;
|
|
2918
|
+
}
|
|
2919
|
+
let clean = hex.trim().replace('#', '');
|
|
2920
|
+
// Expand 3-digit form to 6
|
|
2921
|
+
if (clean.length === 3) {
|
|
2922
|
+
clean = clean.split('').map(c => c + c).join('');
|
|
2923
|
+
}
|
|
2924
|
+
// Strip alpha if present
|
|
2925
|
+
if (clean.length === 8) clean = clean.slice(0, 6);
|
|
2926
|
+
if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
|
|
2927
|
+
|
|
2928
|
+
const num = parseInt(clean, 16);
|
|
2357
2929
|
const amt = Math.round(2.55 * percent);
|
|
2358
2930
|
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
|
|
2359
2931
|
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
|
|
@@ -2374,26 +2946,29 @@ const COOKIE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
|
2374
2946
|
let bannerElement = null;
|
|
2375
2947
|
let shadowRoot$2 = null;
|
|
2376
2948
|
|
|
2949
|
+
const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
|
|
2950
|
+
|
|
2377
2951
|
/**
|
|
2378
2952
|
* Create the banner HTML
|
|
2379
2953
|
*/
|
|
2380
2954
|
function createBannerHTML(config) {
|
|
2381
2955
|
const labels = config.labels.banner;
|
|
2382
|
-
const
|
|
2956
|
+
const rawPosition = config.position || 'bottom';
|
|
2957
|
+
const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
|
|
2383
2958
|
|
|
2384
2959
|
return `
|
|
2385
|
-
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
|
|
2386
|
-
<h2 class="zest-banner__title">${labels.title}</h2>
|
|
2387
|
-
<p class="zest-banner__description">${labels.description}</p>
|
|
2960
|
+
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
|
|
2961
|
+
<h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
|
|
2962
|
+
<p class="zest-banner__description">${escapeHTML(labels.description)}</p>
|
|
2388
2963
|
<div class="zest-banner__buttons">
|
|
2389
2964
|
<button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
|
|
2390
|
-
${labels.acceptAll}
|
|
2965
|
+
${escapeHTML(labels.acceptAll)}
|
|
2391
2966
|
</button>
|
|
2392
2967
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
|
|
2393
|
-
${labels.rejectAll}
|
|
2968
|
+
${escapeHTML(labels.rejectAll)}
|
|
2394
2969
|
</button>
|
|
2395
2970
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
|
|
2396
|
-
${labels.settings}
|
|
2971
|
+
${escapeHTML(labels.settings)}
|
|
2397
2972
|
</button>
|
|
2398
2973
|
</div>
|
|
2399
2974
|
</div>
|
|
@@ -2503,22 +3078,24 @@ let currentSelections = {};
|
|
|
2503
3078
|
function createCategoryHTML(category, isChecked, isRequired) {
|
|
2504
3079
|
const disabled = isRequired ? 'disabled' : '';
|
|
2505
3080
|
const checked = isChecked ? 'checked' : '';
|
|
3081
|
+
const safeId = escapeHTML(category.id);
|
|
3082
|
+
const safeLabel = escapeHTML(category.label);
|
|
2506
3083
|
|
|
2507
3084
|
return `
|
|
2508
3085
|
<div class="zest-category">
|
|
2509
3086
|
<div class="zest-category__header">
|
|
2510
3087
|
<div class="zest-category__info">
|
|
2511
|
-
<span class="zest-category__label">${
|
|
2512
|
-
<p class="zest-category__description">${category.description}</p>
|
|
3088
|
+
<span class="zest-category__label">${safeLabel}</span>
|
|
3089
|
+
<p class="zest-category__description">${escapeHTML(category.description)}</p>
|
|
2513
3090
|
</div>
|
|
2514
3091
|
<label class="zest-toggle">
|
|
2515
3092
|
<input
|
|
2516
3093
|
type="checkbox"
|
|
2517
3094
|
class="zest-toggle__input"
|
|
2518
|
-
data-category="${
|
|
3095
|
+
data-category="${safeId}"
|
|
2519
3096
|
${checked}
|
|
2520
3097
|
${disabled}
|
|
2521
|
-
aria-label="${
|
|
3098
|
+
aria-label="${safeLabel}"
|
|
2522
3099
|
>
|
|
2523
3100
|
<span class="zest-toggle__slider"></span>
|
|
2524
3101
|
</label>
|
|
@@ -2542,29 +3119,30 @@ function createModalHTML(config, consent) {
|
|
|
2542
3119
|
))
|
|
2543
3120
|
.join('');
|
|
2544
3121
|
|
|
2545
|
-
const
|
|
2546
|
-
|
|
3122
|
+
const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
|
|
3123
|
+
const policyLink = validatedPolicyUrl
|
|
3124
|
+
? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
|
|
2547
3125
|
: '';
|
|
2548
3126
|
|
|
2549
3127
|
return `
|
|
2550
|
-
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
|
|
3128
|
+
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
|
|
2551
3129
|
<div class="zest-modal">
|
|
2552
3130
|
<div class="zest-modal__header">
|
|
2553
|
-
<h2 class="zest-modal__title">${labels.title}</h2>
|
|
2554
|
-
<p class="zest-modal__description">${labels.description} ${policyLink}</p>
|
|
3131
|
+
<h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
|
|
3132
|
+
<p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
|
|
2555
3133
|
</div>
|
|
2556
3134
|
<div class="zest-modal__body">
|
|
2557
3135
|
${categoriesHTML}
|
|
2558
3136
|
</div>
|
|
2559
3137
|
<div class="zest-modal__footer">
|
|
2560
3138
|
<button type="button" class="zest-btn zest-btn--primary" data-action="save">
|
|
2561
|
-
${labels.save}
|
|
3139
|
+
${escapeHTML(labels.save)}
|
|
2562
3140
|
</button>
|
|
2563
3141
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
|
|
2564
|
-
${labels.acceptAll}
|
|
3142
|
+
${escapeHTML(labels.acceptAll)}
|
|
2565
3143
|
</button>
|
|
2566
3144
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
|
|
2567
|
-
${labels.rejectAll}
|
|
3145
|
+
${escapeHTML(labels.rejectAll)}
|
|
2568
3146
|
</button>
|
|
2569
3147
|
</div>
|
|
2570
3148
|
</div>
|
|
@@ -2696,10 +3274,11 @@ let shadowRoot = null;
|
|
|
2696
3274
|
*/
|
|
2697
3275
|
function createWidgetHTML(config) {
|
|
2698
3276
|
const labels = config.labels.widget;
|
|
3277
|
+
const safeLabel = escapeHTML(labels.label);
|
|
2699
3278
|
|
|
2700
3279
|
return `
|
|
2701
3280
|
<div class="zest-widget">
|
|
2702
|
-
<button type="button" class="zest-widget__btn" aria-label="${
|
|
3281
|
+
<button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
|
|
2703
3282
|
<span class="zest-widget__icon">${COOKIE_ICON}</span>
|
|
2704
3283
|
</button>
|
|
2705
3284
|
</div>
|
|
@@ -2778,108 +3357,59 @@ function removeWidget() {
|
|
|
2778
3357
|
|
|
2779
3358
|
/**
|
|
2780
3359
|
* Zest - Lightweight Cookie Consent Toolkit
|
|
2781
|
-
* Main entry
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
// State
|
|
2786
|
-
let initialized = false;
|
|
2787
|
-
let config = null;
|
|
2788
|
-
|
|
2789
|
-
/**
|
|
2790
|
-
* Consent checker function shared across interceptors
|
|
3360
|
+
* Main entry (full build: logic + UI).
|
|
3361
|
+
*
|
|
3362
|
+
* For a logic-only build without any CSS / Shadow DOM mounting, import
|
|
3363
|
+
* from `@freshjuice/zest/headless` instead.
|
|
2791
3364
|
*/
|
|
2792
|
-
function checkConsent(category) {
|
|
2793
|
-
return hasConsent(category);
|
|
2794
|
-
}
|
|
2795
3365
|
|
|
2796
|
-
/**
|
|
2797
|
-
* Replay all queued items for newly allowed categories
|
|
2798
|
-
*/
|
|
2799
|
-
function replayAll(allowedCategories) {
|
|
2800
|
-
replayCookies(allowedCategories);
|
|
2801
|
-
replayStorage(allowedCategories);
|
|
2802
|
-
replayScripts(allowedCategories);
|
|
2803
|
-
}
|
|
2804
3366
|
|
|
2805
3367
|
/**
|
|
2806
|
-
* Handle accept all
|
|
3368
|
+
* Handle accept all — delegates consent logic to core, handles UI swap.
|
|
2807
3369
|
*/
|
|
2808
3370
|
function handleAcceptAll() {
|
|
2809
|
-
|
|
2810
|
-
const
|
|
3371
|
+
coreAcceptAll();
|
|
3372
|
+
const config = getActiveConfig();
|
|
2811
3373
|
|
|
2812
3374
|
hideBanner();
|
|
2813
3375
|
hideModal();
|
|
2814
3376
|
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
if (config.showWidget) {
|
|
3377
|
+
if (config?.showWidget) {
|
|
2818
3378
|
showWidget({ onClick: handleShowSettings });
|
|
2819
3379
|
}
|
|
2820
|
-
|
|
2821
|
-
emitConsent(result.current, result.previous);
|
|
2822
|
-
emitChange(result.current, result.previous);
|
|
2823
|
-
config.callbacks?.onAccept?.(result.current);
|
|
2824
|
-
config.callbacks?.onChange?.(result.current);
|
|
2825
3380
|
}
|
|
2826
3381
|
|
|
2827
3382
|
/**
|
|
2828
|
-
* Handle reject all
|
|
3383
|
+
* Handle reject all.
|
|
2829
3384
|
*/
|
|
2830
3385
|
function handleRejectAll() {
|
|
2831
|
-
|
|
3386
|
+
coreRejectAll();
|
|
3387
|
+
const config = getActiveConfig();
|
|
2832
3388
|
|
|
2833
3389
|
hideBanner();
|
|
2834
3390
|
hideModal();
|
|
2835
3391
|
|
|
2836
|
-
if (config
|
|
3392
|
+
if (config?.showWidget) {
|
|
2837
3393
|
showWidget({ onClick: handleShowSettings });
|
|
2838
3394
|
}
|
|
2839
|
-
|
|
2840
|
-
emitReject(result.current);
|
|
2841
|
-
emitChange(result.current, result.previous);
|
|
2842
|
-
config.callbacks?.onReject?.();
|
|
2843
|
-
config.callbacks?.onChange?.(result.current);
|
|
2844
3395
|
}
|
|
2845
3396
|
|
|
2846
3397
|
/**
|
|
2847
|
-
* Handle save preferences from modal
|
|
3398
|
+
* Handle save preferences from modal.
|
|
2848
3399
|
*/
|
|
2849
3400
|
function handleSavePreferences(selections) {
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
// Find newly allowed categories
|
|
2853
|
-
const newlyAllowed = Object.keys(result.current).filter(
|
|
2854
|
-
cat => result.current[cat] && !result.previous[cat]
|
|
2855
|
-
);
|
|
2856
|
-
|
|
2857
|
-
if (newlyAllowed.length > 0) {
|
|
2858
|
-
replayAll(newlyAllowed);
|
|
2859
|
-
}
|
|
3401
|
+
coreUpdateConsent(selections);
|
|
3402
|
+
const config = getActiveConfig();
|
|
2860
3403
|
|
|
2861
3404
|
hideModal();
|
|
2862
3405
|
|
|
2863
|
-
if (config
|
|
3406
|
+
if (config?.showWidget) {
|
|
2864
3407
|
showWidget({ onClick: handleShowSettings });
|
|
2865
3408
|
}
|
|
2866
|
-
|
|
2867
|
-
// Determine if this was acceptance or rejection based on selections
|
|
2868
|
-
const hasNonEssential = Object.entries(selections)
|
|
2869
|
-
.some(([cat, val]) => cat !== 'essential' && val);
|
|
2870
|
-
|
|
2871
|
-
if (hasNonEssential) {
|
|
2872
|
-
emitConsent(result.current, result.previous);
|
|
2873
|
-
} else {
|
|
2874
|
-
emitReject(result.current);
|
|
2875
|
-
}
|
|
2876
|
-
|
|
2877
|
-
emitChange(result.current, result.previous);
|
|
2878
|
-
config.callbacks?.onChange?.(result.current);
|
|
2879
3409
|
}
|
|
2880
3410
|
|
|
2881
3411
|
/**
|
|
2882
|
-
*
|
|
3412
|
+
* Open the settings modal.
|
|
2883
3413
|
*/
|
|
2884
3414
|
function handleShowSettings() {
|
|
2885
3415
|
hideBanner();
|
|
@@ -2896,17 +3426,17 @@ function handleShowSettings() {
|
|
|
2896
3426
|
}
|
|
2897
3427
|
|
|
2898
3428
|
/**
|
|
2899
|
-
*
|
|
3429
|
+
* Close the modal — either bring the widget back (decision made) or
|
|
3430
|
+
* fall back to the banner (no decision yet).
|
|
2900
3431
|
*/
|
|
2901
3432
|
function handleCloseModal() {
|
|
2902
3433
|
hideModal();
|
|
2903
3434
|
emitHide('modal');
|
|
2904
3435
|
|
|
2905
|
-
|
|
2906
|
-
if (hasConsentDecision() && config
|
|
3436
|
+
const config = getActiveConfig();
|
|
3437
|
+
if (hasConsentDecision() && config?.showWidget) {
|
|
2907
3438
|
showWidget({ onClick: handleShowSettings });
|
|
2908
3439
|
} else {
|
|
2909
|
-
// Show banner again if no decision made
|
|
2910
3440
|
showBanner({
|
|
2911
3441
|
onAcceptAll: handleAcceptAll,
|
|
2912
3442
|
onRejectAll: handleRejectAll,
|
|
@@ -2916,89 +3446,37 @@ function handleCloseModal() {
|
|
|
2916
3446
|
}
|
|
2917
3447
|
|
|
2918
3448
|
/**
|
|
2919
|
-
* Initialize Zest
|
|
3449
|
+
* Initialize Zest with UI.
|
|
2920
3450
|
*/
|
|
2921
3451
|
function init(userConfig = {}) {
|
|
2922
|
-
|
|
3452
|
+
const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
|
|
3453
|
+
if (alreadyInitialized) {
|
|
2923
3454
|
console.warn('[Zest] Already initialized');
|
|
2924
3455
|
return Zest;
|
|
2925
3456
|
}
|
|
2926
3457
|
|
|
2927
|
-
|
|
2928
|
-
config = setConfig(userConfig);
|
|
2929
|
-
|
|
2930
|
-
// Set patterns if provided
|
|
2931
|
-
if (config.patterns) {
|
|
2932
|
-
setPatterns(config.patterns);
|
|
2933
|
-
}
|
|
2934
|
-
|
|
2935
|
-
// Set up consent checkers
|
|
2936
|
-
setConsentChecker$2(checkConsent);
|
|
2937
|
-
setConsentChecker$1(checkConsent);
|
|
2938
|
-
setConsentChecker(checkConsent);
|
|
2939
|
-
|
|
2940
|
-
// Start interception
|
|
2941
|
-
interceptCookies();
|
|
2942
|
-
interceptStorage();
|
|
2943
|
-
startScriptBlocking(config.mode, config.blockedDomains);
|
|
2944
|
-
|
|
2945
|
-
// Load saved consent
|
|
2946
|
-
const consent = loadConsent();
|
|
2947
|
-
|
|
2948
|
-
initialized = true;
|
|
2949
|
-
|
|
2950
|
-
// Check Do Not Track / Global Privacy Control
|
|
2951
|
-
const dntEnabled = isDoNotTrackEnabled();
|
|
2952
|
-
let dntApplied = false;
|
|
2953
|
-
|
|
2954
|
-
if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
|
|
2955
|
-
if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
2956
|
-
// Auto-reject non-essential cookies silently
|
|
2957
|
-
const result = rejectAll(config.expiration);
|
|
2958
|
-
dntApplied = true;
|
|
2959
|
-
|
|
2960
|
-
// Emit events
|
|
2961
|
-
emitReject(result.current);
|
|
2962
|
-
emitChange(result.current, result.previous);
|
|
2963
|
-
config.callbacks?.onReject?.();
|
|
2964
|
-
config.callbacks?.onChange?.(result.current);
|
|
2965
|
-
}
|
|
2966
|
-
// 'preselect' behavior is handled by default (banner shows with defaults off)
|
|
2967
|
-
}
|
|
2968
|
-
|
|
2969
|
-
// Emit ready event
|
|
2970
|
-
emitReady(consent);
|
|
2971
|
-
config.callbacks?.onReady?.(consent);
|
|
3458
|
+
const config = getActiveConfig();
|
|
2972
3459
|
|
|
2973
|
-
|
|
2974
|
-
if (!hasConsentDecision() && !dntApplied) {
|
|
2975
|
-
// No consent decision yet - show banner
|
|
3460
|
+
if (!hasDecision && !dntApplied) {
|
|
2976
3461
|
showBanner({
|
|
2977
3462
|
onAcceptAll: handleAcceptAll,
|
|
2978
3463
|
onRejectAll: handleRejectAll,
|
|
2979
3464
|
onSettings: handleShowSettings
|
|
2980
3465
|
});
|
|
2981
3466
|
emitShow('banner');
|
|
2982
|
-
} else {
|
|
2983
|
-
|
|
2984
|
-
if (config.showWidget) {
|
|
2985
|
-
showWidget({ onClick: handleShowSettings });
|
|
2986
|
-
}
|
|
3467
|
+
} else if (config?.showWidget) {
|
|
3468
|
+
showWidget({ onClick: handleShowSettings });
|
|
2987
3469
|
}
|
|
2988
3470
|
|
|
2989
3471
|
return Zest;
|
|
2990
3472
|
}
|
|
2991
3473
|
|
|
2992
|
-
/**
|
|
2993
|
-
* Public API
|
|
2994
|
-
*/
|
|
2995
3474
|
const Zest = {
|
|
2996
|
-
// Initialization
|
|
2997
3475
|
init,
|
|
2998
3476
|
|
|
2999
3477
|
// Banner control
|
|
3000
3478
|
show() {
|
|
3001
|
-
if (!
|
|
3479
|
+
if (!isInitialized()) {
|
|
3002
3480
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3003
3481
|
return;
|
|
3004
3482
|
}
|
|
@@ -3019,7 +3497,7 @@ const Zest = {
|
|
|
3019
3497
|
|
|
3020
3498
|
// Settings modal
|
|
3021
3499
|
showSettings() {
|
|
3022
|
-
if (!
|
|
3500
|
+
if (!isInitialized()) {
|
|
3023
3501
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3024
3502
|
return;
|
|
3025
3503
|
}
|
|
@@ -3031,19 +3509,19 @@ const Zest = {
|
|
|
3031
3509
|
emitHide('modal');
|
|
3032
3510
|
},
|
|
3033
3511
|
|
|
3034
|
-
// Consent
|
|
3512
|
+
// Consent state
|
|
3035
3513
|
getConsent,
|
|
3036
3514
|
hasConsent,
|
|
3037
3515
|
hasConsentDecision,
|
|
3038
3516
|
getConsentProof,
|
|
3039
3517
|
|
|
3040
|
-
// DNT
|
|
3518
|
+
// DNT
|
|
3041
3519
|
isDoNotTrackEnabled,
|
|
3042
3520
|
getDNTDetails,
|
|
3043
3521
|
|
|
3044
|
-
//
|
|
3522
|
+
// Programmatic accept / reject
|
|
3045
3523
|
acceptAll() {
|
|
3046
|
-
if (!
|
|
3524
|
+
if (!isInitialized()) {
|
|
3047
3525
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3048
3526
|
return;
|
|
3049
3527
|
}
|
|
@@ -3051,20 +3529,19 @@ const Zest = {
|
|
|
3051
3529
|
},
|
|
3052
3530
|
|
|
3053
3531
|
rejectAll() {
|
|
3054
|
-
if (!
|
|
3532
|
+
if (!isInitialized()) {
|
|
3055
3533
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
3056
3534
|
return;
|
|
3057
3535
|
}
|
|
3058
3536
|
handleRejectAll();
|
|
3059
3537
|
},
|
|
3060
3538
|
|
|
3061
|
-
// Reset and
|
|
3539
|
+
// Reset everything and reshow the banner
|
|
3062
3540
|
reset() {
|
|
3063
|
-
|
|
3541
|
+
coreReset();
|
|
3064
3542
|
hideModal();
|
|
3065
3543
|
removeWidget();
|
|
3066
|
-
|
|
3067
|
-
if (initialized) {
|
|
3544
|
+
if (isInitialized()) {
|
|
3068
3545
|
showBanner({
|
|
3069
3546
|
onAcceptAll: handleAcceptAll,
|
|
3070
3547
|
onRejectAll: handleRejectAll,
|
|
@@ -3074,17 +3551,30 @@ const Zest = {
|
|
|
3074
3551
|
}
|
|
3075
3552
|
},
|
|
3076
3553
|
|
|
3077
|
-
// Config
|
|
3554
|
+
// Config introspection
|
|
3078
3555
|
getConfig: getCurrentConfig,
|
|
3079
3556
|
|
|
3080
3557
|
// Events
|
|
3558
|
+
on,
|
|
3559
|
+
once,
|
|
3081
3560
|
EVENTS
|
|
3082
3561
|
};
|
|
3083
3562
|
|
|
3084
3563
|
// Auto-init if config present
|
|
3085
3564
|
if (typeof window !== 'undefined') {
|
|
3086
|
-
// Make Zest available globally
|
|
3087
|
-
|
|
3565
|
+
// Make Zest available globally. defineProperty with writable:false +
|
|
3566
|
+
// configurable:false stops a later-loaded script from replacing the
|
|
3567
|
+
// global with a trojanned stand-in.
|
|
3568
|
+
try {
|
|
3569
|
+
Object.defineProperty(window, 'Zest', {
|
|
3570
|
+
value: Object.freeze(Zest),
|
|
3571
|
+
writable: false,
|
|
3572
|
+
configurable: false,
|
|
3573
|
+
enumerable: true
|
|
3574
|
+
});
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
window.Zest = Zest;
|
|
3577
|
+
}
|
|
3088
3578
|
|
|
3089
3579
|
const autoInit = () => {
|
|
3090
3580
|
const cfg = getConfig();
|