@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.
Files changed (82) hide show
  1. package/README.md +216 -70
  2. package/dist/zest.de.js +776 -286
  3. package/dist/zest.de.js.map +1 -1
  4. package/dist/zest.de.min.js +1 -1
  5. package/dist/zest.en.js +776 -286
  6. package/dist/zest.en.js.map +1 -1
  7. package/dist/zest.en.min.js +1 -1
  8. package/dist/zest.es.js +776 -286
  9. package/dist/zest.es.js.map +1 -1
  10. package/dist/zest.es.min.js +1 -1
  11. package/dist/zest.esm.js +776 -286
  12. package/dist/zest.esm.js.map +1 -1
  13. package/dist/zest.esm.min.js +1 -1
  14. package/dist/zest.fr.js +776 -286
  15. package/dist/zest.fr.js.map +1 -1
  16. package/dist/zest.fr.min.js +1 -1
  17. package/dist/zest.headless.esm.js +2299 -0
  18. package/dist/zest.headless.esm.js.map +1 -0
  19. package/dist/zest.headless.esm.min.js +1 -0
  20. package/dist/zest.it.js +776 -286
  21. package/dist/zest.it.js.map +1 -1
  22. package/dist/zest.it.min.js +1 -1
  23. package/dist/zest.ja.js +776 -286
  24. package/dist/zest.ja.js.map +1 -1
  25. package/dist/zest.ja.min.js +1 -1
  26. package/dist/zest.js +776 -286
  27. package/dist/zest.js.map +1 -1
  28. package/dist/zest.min.js +1 -1
  29. package/dist/zest.nl.js +776 -286
  30. package/dist/zest.nl.js.map +1 -1
  31. package/dist/zest.nl.min.js +1 -1
  32. package/dist/zest.pl.js +776 -286
  33. package/dist/zest.pl.js.map +1 -1
  34. package/dist/zest.pl.min.js +1 -1
  35. package/dist/zest.pt.js +776 -286
  36. package/dist/zest.pt.js.map +1 -1
  37. package/dist/zest.pt.min.js +1 -1
  38. package/dist/zest.ru.js +776 -286
  39. package/dist/zest.ru.js.map +1 -1
  40. package/dist/zest.ru.min.js +1 -1
  41. package/dist/zest.uk.js +776 -286
  42. package/dist/zest.uk.js.map +1 -1
  43. package/dist/zest.uk.min.js +1 -1
  44. package/dist/zest.zh.js +776 -286
  45. package/dist/zest.zh.js.map +1 -1
  46. package/dist/zest.zh.min.js +1 -1
  47. package/package.json +17 -4
  48. package/src/api/public-api.js +97 -0
  49. package/src/config/defaults.js +150 -0
  50. package/src/config/parser.js +104 -0
  51. package/src/core/categories.js +52 -0
  52. package/src/core/cookie-interceptor.js +131 -0
  53. package/src/core/dnt.js +56 -0
  54. package/src/core/known-trackers.js +195 -0
  55. package/src/core/pattern-matcher.js +111 -0
  56. package/src/core/script-blocker.js +314 -0
  57. package/src/core/security.js +204 -0
  58. package/src/core/storage-interceptor.js +173 -0
  59. package/src/core-lifecycle.js +192 -0
  60. package/src/headless.js +133 -0
  61. package/src/i18n/lang-en.js +54 -0
  62. package/src/i18n/single/lang-de.js +55 -0
  63. package/src/i18n/single/lang-en.js +55 -0
  64. package/src/i18n/single/lang-es.js +55 -0
  65. package/src/i18n/single/lang-fr.js +55 -0
  66. package/src/i18n/single/lang-it.js +55 -0
  67. package/src/i18n/single/lang-ja.js +55 -0
  68. package/src/i18n/single/lang-nl.js +55 -0
  69. package/src/i18n/single/lang-pl.js +55 -0
  70. package/src/i18n/single/lang-pt.js +55 -0
  71. package/src/i18n/single/lang-ru.js +55 -0
  72. package/src/i18n/single/lang-uk.js +55 -0
  73. package/src/i18n/single/lang-zh.js +55 -0
  74. package/src/i18n/translations.js +546 -0
  75. package/src/index.js +266 -0
  76. package/src/integrations/consent-signals.js +71 -0
  77. package/src/storage/consent-store.js +201 -0
  78. package/src/storage/events.js +84 -0
  79. package/src/ui/banner.js +134 -0
  80. package/src/ui/modal.js +215 -0
  81. package/src/ui/styles.js +519 -0
  82. package/src/ui/widget.js +105 -0
package/dist/zest.nl.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
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
18
+ '`': '&#96;'
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
- patterns[category] = regexList.map(p =>
63
- p instanceof RegExp ? p : new RegExp(p)
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: true
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
- const urlObj = new URL(url);
412
- const hostname = urlObj.hostname.toLowerCase();
413
- const fullUrl = url.toLowerCase();
414
-
415
- for (const domain of trackerList) {
416
- // Support partial matches (e.g., "matomo." matches "analytics.matomo.cloud")
417
- if (domain.endsWith('.')) {
418
- if (hostname.includes(domain.slice(0, -1))) {
419
- return true;
420
- }
421
- } else if (hostname === domain || hostname.endsWith('.' + domain)) {
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
- } else if (fullUrl.includes(domain)) {
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
- // Queue for blocked scripts
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
- // 1. Check for explicit data-consent-category attribute (always respected)
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 null;
815
+ return explicitBlockable;
542
816
  }
543
817
 
544
- // 3. Check custom blocked domains
818
+ // 2. Check custom blocked domains
545
819
  const customCategory = matchesCustomDomains(src);
546
- if (customCategory) {
547
- return customCategory;
548
- }
549
820
 
550
- // 4. Mode-based blocking
821
+ // 3. Mode-based blocking
822
+ let modeCategory = null;
551
823
  switch (blockingMode) {
552
824
  case 'manual':
553
- // Only explicit tags, already checked above
554
- return null;
825
+ break;
555
826
 
556
827
  case 'safe':
557
828
  case 'strict':
558
- // Check against known tracker lists
559
- return getCategoryForScript(src, blockingMode);
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
- // Try to categorize, default to marketing
565
- return getCategoryForScript(src, 'strict') || 'marketing';
834
+ modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
566
835
  }
567
- return null;
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
- // If it has a src, also remove it to prevent loading
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.push(scriptInfo);
621
- return true;
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
  * NL only translation - auto-generated
847
1179
  * Do not edit manually, run: npm run build
@@ -953,6 +1285,10 @@ var Zest = (function () {
953
1285
  // Custom styles to inject into Shadow DOM
954
1286
  customStyles: '',
955
1287
 
1288
+ // Vendor consent mode integrations (optional)
1289
+ consentModeGoogle: false,
1290
+ consentModeMicrosoft: false,
1291
+
956
1292
  // Blocking mode: 'manual' | 'safe' | 'strict' | 'doomsday'
957
1293
  mode: 'safe',
958
1294
 
@@ -983,7 +1319,7 @@ var Zest = (function () {
983
1319
  }
984
1320
 
985
1321
  // Simple properties
986
- const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior'];
1322
+ const simpleKeys = ['lang', 'position', 'theme', 'accentColor', 'autoInit', 'showWidget', 'expiration', 'policyUrl', 'imprintUrl', 'customStyles', 'mode', 'blockedDomains', 'respectDNT', 'dntBehavior', 'consentModeGoogle', 'consentModeMicrosoft'];
987
1323
  for (const key of simpleKeys) {
988
1324
  if (userConfig[key] !== undefined) {
989
1325
  config[key] = userConfig[key];
@@ -1093,6 +1429,13 @@ var Zest = (function () {
1093
1429
  const expiration = script.getAttribute('data-expiration');
1094
1430
  if (expiration) config.expiration = parseInt(expiration, 10);
1095
1431
 
1432
+ // Consent mode integrations
1433
+ const consentModeGoogle = script.getAttribute('data-consent-mode-google');
1434
+ if (consentModeGoogle !== null) config.consentModeGoogle = consentModeGoogle !== 'false';
1435
+
1436
+ const consentModeMicrosoft = script.getAttribute('data-consent-mode-microsoft');
1437
+ if (consentModeMicrosoft !== null) config.consentModeMicrosoft = consentModeMicrosoft !== 'false';
1438
+
1096
1439
  return config;
1097
1440
  }
1098
1441
 
@@ -1124,18 +1467,18 @@ var Zest = (function () {
1124
1467
  /**
1125
1468
  * Update configuration at runtime
1126
1469
  */
1127
- let currentConfig = null;
1470
+ let currentConfig$1 = null;
1128
1471
 
1129
1472
  function setConfig(config) {
1130
- currentConfig = mergeConfig(config);
1131
- return currentConfig;
1473
+ currentConfig$1 = mergeConfig(config);
1474
+ return currentConfig$1;
1132
1475
  }
1133
1476
 
1134
1477
  function getCurrentConfig() {
1135
- if (!currentConfig) {
1136
- currentConfig = getConfig();
1478
+ if (!currentConfig$1) {
1479
+ currentConfig$1 = getConfig();
1137
1480
  }
1138
- return currentConfig;
1481
+ return currentConfig$1;
1139
1482
  }
1140
1483
 
1141
1484
  /**
@@ -1146,6 +1489,20 @@ var Zest = (function () {
1146
1489
  const COOKIE_NAME = 'zest_consent';
1147
1490
  const CONSENT_VERSION = '1.0';
1148
1491
 
1492
+ /**
1493
+ * Return the Secure flag fragment when running over HTTPS, empty otherwise.
1494
+ * On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
1495
+ */
1496
+ function secureAttribute() {
1497
+ try {
1498
+ return typeof location !== 'undefined' && location.protocol === 'https:'
1499
+ ? '; Secure'
1500
+ : '';
1501
+ } catch (_) {
1502
+ return '';
1503
+ }
1504
+ }
1505
+
1149
1506
  // Current consent state
1150
1507
  let consent = null;
1151
1508
 
@@ -1174,7 +1531,12 @@ var Zest = (function () {
1174
1531
  }
1175
1532
 
1176
1533
  /**
1177
- * Load consent from cookie
1534
+ * Load consent from cookie.
1535
+ *
1536
+ * The parsed cookie is validated against the expected schema via
1537
+ * sanitizeConsentPayload — only known category keys with boolean values
1538
+ * survive, so a tampered cookie can't inject prototype-polluting props
1539
+ * or unexpected category shapes.
1178
1540
  */
1179
1541
  function loadConsent() {
1180
1542
  try {
@@ -1182,9 +1544,12 @@ var Zest = (function () {
1182
1544
  const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1183
1545
 
1184
1546
  if (match) {
1185
- const data = JSON.parse(decodeURIComponent(match[1]));
1186
- consent = data.categories || getDefaultConsent();
1187
- return { ...consent };
1547
+ const raw = JSON.parse(decodeURIComponent(match[1]));
1548
+ const clean = sanitizeConsentPayload(raw, getCategoryIds());
1549
+ if (clean && clean.categories) {
1550
+ consent = { ...getDefaultConsent(), ...clean.categories };
1551
+ return { ...consent };
1552
+ }
1188
1553
  }
1189
1554
  } catch (e) {
1190
1555
  // Invalid or missing cookie
@@ -1209,7 +1574,7 @@ var Zest = (function () {
1209
1574
  };
1210
1575
 
1211
1576
  const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
1212
- const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
1577
+ const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
1213
1578
 
1214
1579
  setRawCookie(cookieValue);
1215
1580
  }
@@ -1278,7 +1643,7 @@ var Zest = (function () {
1278
1643
  * Reset consent (clear cookie)
1279
1644
  */
1280
1645
  function resetConsent() {
1281
- setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`);
1646
+ setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
1282
1647
  consent = null;
1283
1648
  }
1284
1649
 
@@ -1303,7 +1668,8 @@ var Zest = (function () {
1303
1668
  const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1304
1669
 
1305
1670
  if (match) {
1306
- return JSON.parse(decodeURIComponent(match[1]));
1671
+ const raw = JSON.parse(decodeURIComponent(match[1]));
1672
+ return sanitizeConsentPayload(raw, getCategoryIds());
1307
1673
  }
1308
1674
  } catch (e) {
1309
1675
  // Invalid cookie
@@ -1382,15 +1748,207 @@ var Zest = (function () {
1382
1748
  return emit(EVENTS.HIDE, { type });
1383
1749
  }
1384
1750
 
1751
+ /**
1752
+ * Subscribe to an event
1753
+ */
1754
+ function on(eventName, callback) {
1755
+ document.addEventListener(eventName, callback);
1756
+ return () => document.removeEventListener(eventName, callback);
1757
+ }
1758
+
1759
+ /**
1760
+ * Subscribe to an event once
1761
+ */
1762
+ function once(eventName, callback) {
1763
+ document.addEventListener(eventName, callback, { once: true });
1764
+ }
1765
+
1766
+ /**
1767
+ * Core lifecycle - UI-agnostic initialization and consent actions.
1768
+ *
1769
+ * This module contains everything the main entry (with UI) and the
1770
+ * headless entry (no UI) share: interceptor setup, consent load/save,
1771
+ * replay, DNT handling, and the events/callbacks fan-out. It intentionally
1772
+ * does NOT import anything from `./ui/*` so tree-shakers can drop the UI
1773
+ * bundle entirely when only the headless API is used.
1774
+ */
1775
+
1776
+
1777
+ let initialized = false;
1778
+ let currentConfig = null;
1779
+
1780
+ function checkConsent(category) {
1781
+ return hasConsent(category);
1782
+ }
1783
+
1784
+ function replayAll(categories) {
1785
+ replayCookies(categories);
1786
+ replayStorage(categories);
1787
+ replayScripts(categories);
1788
+ }
1789
+
1790
+ /**
1791
+ * Run the non-UI half of init. Returns a snapshot the caller (UI or
1792
+ * headless) can use to decide what to do next.
1793
+ */
1794
+ function coreInit(userConfig = {}) {
1795
+ if (initialized) {
1796
+ return {
1797
+ alreadyInitialized: true,
1798
+ config: currentConfig,
1799
+ consent: loadConsent(),
1800
+ hasDecision: hasConsentDecision(),
1801
+ dntApplied: false
1802
+ };
1803
+ }
1804
+
1805
+ currentConfig = setConfig(userConfig);
1806
+
1807
+ // Push default-denied state to vendor consent mode APIs BEFORE any
1808
+ // third-party script has a chance to fire.
1809
+ applyConsentSignals(
1810
+ { functional: false, analytics: false, marketing: false },
1811
+ currentConfig,
1812
+ true
1813
+ );
1814
+
1815
+ if (currentConfig.patterns) {
1816
+ setPatterns(currentConfig.patterns);
1817
+ }
1818
+
1819
+ setConsentChecker$2(checkConsent);
1820
+ setConsentChecker$1(checkConsent);
1821
+ setConsentChecker(checkConsent);
1822
+
1823
+ interceptCookies();
1824
+ interceptStorage();
1825
+ startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
1826
+
1827
+ const consent = loadConsent();
1828
+ initialized = true;
1829
+
1830
+ if (hasConsentDecision()) {
1831
+ applyConsentSignals(consent, currentConfig, false);
1832
+ }
1833
+
1834
+ // DNT / GPC handling — if the user signalled opt-out at the browser
1835
+ // level and the site opts to respect it, auto-reject before the UI
1836
+ // layer ever runs.
1837
+ const dntEnabled = isDoNotTrackEnabled();
1838
+ let dntApplied = false;
1839
+
1840
+ if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
1841
+ if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
1842
+ const result = rejectAll(currentConfig.expiration);
1843
+ dntApplied = true;
1844
+ applyConsentSignals(result.current, currentConfig, false);
1845
+ emitReject(result.current);
1846
+ emitChange(result.current, result.previous);
1847
+ safeInvoke(currentConfig.callbacks?.onReject);
1848
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
1849
+ }
1850
+ }
1851
+
1852
+ emitReady(consent);
1853
+ safeInvoke(currentConfig.callbacks?.onReady, consent);
1854
+
1855
+ return {
1856
+ alreadyInitialized: false,
1857
+ config: currentConfig,
1858
+ consent,
1859
+ hasDecision: hasConsentDecision(),
1860
+ dntApplied
1861
+ };
1862
+ }
1863
+
1864
+ /**
1865
+ * Accept all categories, replay queued items, fire events + callbacks.
1866
+ * Returns { current, previous } or null if not yet initialized.
1867
+ */
1868
+ function coreAcceptAll() {
1869
+ if (!initialized) return null;
1870
+ const result = acceptAll(currentConfig.expiration);
1871
+ applyConsentSignals(result.current, currentConfig, false);
1872
+ replayAll(getCategoryIds());
1873
+ emitConsent(result.current, result.previous);
1874
+ emitChange(result.current, result.previous);
1875
+ safeInvoke(currentConfig.callbacks?.onAccept, result.current);
1876
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
1877
+ return result;
1878
+ }
1879
+
1880
+ /**
1881
+ * Reject all non-essential categories, fire events + callbacks.
1882
+ */
1883
+ function coreRejectAll() {
1884
+ if (!initialized) return null;
1885
+ const result = rejectAll(currentConfig.expiration);
1886
+ applyConsentSignals(result.current, currentConfig, false);
1887
+ emitReject(result.current);
1888
+ emitChange(result.current, result.previous);
1889
+ safeInvoke(currentConfig.callbacks?.onReject);
1890
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
1891
+ return result;
1892
+ }
1893
+
1894
+ /**
1895
+ * Save custom selections and replay only the newly-allowed categories.
1896
+ */
1897
+ function coreUpdateConsent(selections) {
1898
+ if (!initialized) return null;
1899
+ const result = updateConsent(selections, currentConfig.expiration);
1900
+ applyConsentSignals(result.current, currentConfig, false);
1901
+
1902
+ const newlyAllowed = Object.keys(result.current).filter(
1903
+ (cat) => result.current[cat] && !result.previous[cat]
1904
+ );
1905
+ if (newlyAllowed.length > 0) {
1906
+ replayAll(newlyAllowed);
1907
+ }
1908
+
1909
+ const hasNonEssential = Object.entries(selections || {}).some(
1910
+ ([cat, val]) => cat !== 'essential' && val
1911
+ );
1912
+ if (hasNonEssential) {
1913
+ emitConsent(result.current, result.previous);
1914
+ } else {
1915
+ emitReject(result.current);
1916
+ }
1917
+ emitChange(result.current, result.previous);
1918
+ safeInvoke(currentConfig.callbacks?.onChange, result.current);
1919
+ return result;
1920
+ }
1921
+
1922
+ /**
1923
+ * Clear the consent cookie. The caller is responsible for any UI reset.
1924
+ */
1925
+ function coreReset() {
1926
+ resetConsent();
1927
+ }
1928
+
1929
+ function isInitialized() {
1930
+ return initialized;
1931
+ }
1932
+
1933
+ function getActiveConfig() {
1934
+ return currentConfig;
1935
+ }
1936
+
1385
1937
  /**
1386
1938
  * Styles - Shadow DOM encapsulated CSS with theming
1387
1939
  */
1388
1940
 
1941
+
1942
+ const DEFAULT_ACCENT = '#4F46E5';
1943
+
1389
1944
  /**
1390
1945
  * Generate CSS with custom properties
1391
1946
  */
1392
1947
  function generateStyles(config) {
1393
- const accentColor = config.accentColor || '#4F46E5';
1948
+ // Only accept colors that pass strict validation — an unvalidated
1949
+ // value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
1950
+ const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
1951
+ const customCss = sanitizeCustomStyles(config.customStyles);
1394
1952
 
1395
1953
  return `
1396
1954
  :host {
@@ -1860,15 +2418,29 @@ var Zest = (function () {
1860
2418
  .zest-hidden {
1861
2419
  display: none !important;
1862
2420
  }
1863
- ${config.customStyles || ''}
2421
+ ${customCss}
1864
2422
  `;
1865
2423
  }
1866
2424
 
1867
2425
  /**
1868
- * Adjust color brightness
2426
+ * Adjust color brightness. Falls back to the default accent if the input
2427
+ * cannot be parsed as a hex color (non-hex inputs pass safeColor but
2428
+ * can't be brightness-shifted mathematically).
1869
2429
  */
1870
2430
  function adjustColor(hex, percent) {
1871
- const num = parseInt(hex.replace('#', ''), 16);
2431
+ if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
2432
+ hex = DEFAULT_ACCENT;
2433
+ }
2434
+ let clean = hex.trim().replace('#', '');
2435
+ // Expand 3-digit form to 6
2436
+ if (clean.length === 3) {
2437
+ clean = clean.split('').map(c => c + c).join('');
2438
+ }
2439
+ // Strip alpha if present
2440
+ if (clean.length === 8) clean = clean.slice(0, 6);
2441
+ if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
2442
+
2443
+ const num = parseInt(clean, 16);
1872
2444
  const amt = Math.round(2.55 * percent);
1873
2445
  const R = Math.min(255, Math.max(0, (num >> 16) + amt));
1874
2446
  const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
@@ -1889,26 +2461,29 @@ ${config.customStyles || ''}
1889
2461
  let bannerElement = null;
1890
2462
  let shadowRoot$2 = null;
1891
2463
 
2464
+ const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
2465
+
1892
2466
  /**
1893
2467
  * Create the banner HTML
1894
2468
  */
1895
2469
  function createBannerHTML(config) {
1896
2470
  const labels = config.labels.banner;
1897
- const position = config.position || 'bottom';
2471
+ const rawPosition = config.position || 'bottom';
2472
+ const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
1898
2473
 
1899
2474
  return `
1900
- <div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
1901
- <h2 class="zest-banner__title">${labels.title}</h2>
1902
- <p class="zest-banner__description">${labels.description}</p>
2475
+ <div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
2476
+ <h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
2477
+ <p class="zest-banner__description">${escapeHTML(labels.description)}</p>
1903
2478
  <div class="zest-banner__buttons">
1904
2479
  <button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
1905
- ${labels.acceptAll}
2480
+ ${escapeHTML(labels.acceptAll)}
1906
2481
  </button>
1907
2482
  <button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
1908
- ${labels.rejectAll}
2483
+ ${escapeHTML(labels.rejectAll)}
1909
2484
  </button>
1910
2485
  <button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
1911
- ${labels.settings}
2486
+ ${escapeHTML(labels.settings)}
1912
2487
  </button>
1913
2488
  </div>
1914
2489
  </div>
@@ -2018,22 +2593,24 @@ ${config.customStyles || ''}
2018
2593
  function createCategoryHTML(category, isChecked, isRequired) {
2019
2594
  const disabled = isRequired ? 'disabled' : '';
2020
2595
  const checked = isChecked ? 'checked' : '';
2596
+ const safeId = escapeHTML(category.id);
2597
+ const safeLabel = escapeHTML(category.label);
2021
2598
 
2022
2599
  return `
2023
2600
  <div class="zest-category">
2024
2601
  <div class="zest-category__header">
2025
2602
  <div class="zest-category__info">
2026
- <span class="zest-category__label">${category.label}</span>
2027
- <p class="zest-category__description">${category.description}</p>
2603
+ <span class="zest-category__label">${safeLabel}</span>
2604
+ <p class="zest-category__description">${escapeHTML(category.description)}</p>
2028
2605
  </div>
2029
2606
  <label class="zest-toggle">
2030
2607
  <input
2031
2608
  type="checkbox"
2032
2609
  class="zest-toggle__input"
2033
- data-category="${category.id}"
2610
+ data-category="${safeId}"
2034
2611
  ${checked}
2035
2612
  ${disabled}
2036
- aria-label="${category.label}"
2613
+ aria-label="${safeLabel}"
2037
2614
  >
2038
2615
  <span class="zest-toggle__slider"></span>
2039
2616
  </label>
@@ -2057,29 +2634,30 @@ ${config.customStyles || ''}
2057
2634
  ))
2058
2635
  .join('');
2059
2636
 
2060
- const policyLink = config.policyUrl
2061
- ? `<a href="${config.policyUrl}" class="zest-link" target="_blank" rel="noopener">Privacy Policy</a>`
2637
+ const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
2638
+ const policyLink = validatedPolicyUrl
2639
+ ? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
2062
2640
  : '';
2063
2641
 
2064
2642
  return `
2065
- <div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
2643
+ <div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
2066
2644
  <div class="zest-modal">
2067
2645
  <div class="zest-modal__header">
2068
- <h2 class="zest-modal__title">${labels.title}</h2>
2069
- <p class="zest-modal__description">${labels.description} ${policyLink}</p>
2646
+ <h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
2647
+ <p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
2070
2648
  </div>
2071
2649
  <div class="zest-modal__body">
2072
2650
  ${categoriesHTML}
2073
2651
  </div>
2074
2652
  <div class="zest-modal__footer">
2075
2653
  <button type="button" class="zest-btn zest-btn--primary" data-action="save">
2076
- ${labels.save}
2654
+ ${escapeHTML(labels.save)}
2077
2655
  </button>
2078
2656
  <button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
2079
- ${labels.acceptAll}
2657
+ ${escapeHTML(labels.acceptAll)}
2080
2658
  </button>
2081
2659
  <button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
2082
- ${labels.rejectAll}
2660
+ ${escapeHTML(labels.rejectAll)}
2083
2661
  </button>
2084
2662
  </div>
2085
2663
  </div>
@@ -2211,10 +2789,11 @@ ${config.customStyles || ''}
2211
2789
  */
2212
2790
  function createWidgetHTML(config) {
2213
2791
  const labels = config.labels.widget;
2792
+ const safeLabel = escapeHTML(labels.label);
2214
2793
 
2215
2794
  return `
2216
2795
  <div class="zest-widget">
2217
- <button type="button" class="zest-widget__btn" aria-label="${labels.label}" title="${labels.label}">
2796
+ <button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
2218
2797
  <span class="zest-widget__icon">${COOKIE_ICON}</span>
2219
2798
  </button>
2220
2799
  </div>
@@ -2293,108 +2872,59 @@ ${config.customStyles || ''}
2293
2872
 
2294
2873
  /**
2295
2874
  * Zest - Lightweight Cookie Consent Toolkit
2296
- * Main entry point
2297
- */
2298
-
2299
-
2300
- // State
2301
- let initialized = false;
2302
- let config = null;
2303
-
2304
- /**
2305
- * Consent checker function shared across interceptors
2875
+ * Main entry (full build: logic + UI).
2876
+ *
2877
+ * For a logic-only build without any CSS / Shadow DOM mounting, import
2878
+ * from `@freshjuice/zest/headless` instead.
2306
2879
  */
2307
- function checkConsent(category) {
2308
- return hasConsent(category);
2309
- }
2310
2880
 
2311
- /**
2312
- * Replay all queued items for newly allowed categories
2313
- */
2314
- function replayAll(allowedCategories) {
2315
- replayCookies(allowedCategories);
2316
- replayStorage(allowedCategories);
2317
- replayScripts(allowedCategories);
2318
- }
2319
2881
 
2320
2882
  /**
2321
- * Handle accept all
2883
+ * Handle accept all — delegates consent logic to core, handles UI swap.
2322
2884
  */
2323
2885
  function handleAcceptAll() {
2324
- const result = acceptAll(config.expiration);
2325
- const categories = getCategoryIds();
2886
+ coreAcceptAll();
2887
+ const config = getActiveConfig();
2326
2888
 
2327
2889
  hideBanner();
2328
2890
  hideModal();
2329
2891
 
2330
- replayAll(categories);
2331
-
2332
- if (config.showWidget) {
2892
+ if (config?.showWidget) {
2333
2893
  showWidget({ onClick: handleShowSettings });
2334
2894
  }
2335
-
2336
- emitConsent(result.current, result.previous);
2337
- emitChange(result.current, result.previous);
2338
- config.callbacks?.onAccept?.(result.current);
2339
- config.callbacks?.onChange?.(result.current);
2340
2895
  }
2341
2896
 
2342
2897
  /**
2343
- * Handle reject all
2898
+ * Handle reject all.
2344
2899
  */
2345
2900
  function handleRejectAll() {
2346
- const result = rejectAll(config.expiration);
2901
+ coreRejectAll();
2902
+ const config = getActiveConfig();
2347
2903
 
2348
2904
  hideBanner();
2349
2905
  hideModal();
2350
2906
 
2351
- if (config.showWidget) {
2907
+ if (config?.showWidget) {
2352
2908
  showWidget({ onClick: handleShowSettings });
2353
2909
  }
2354
-
2355
- emitReject(result.current);
2356
- emitChange(result.current, result.previous);
2357
- config.callbacks?.onReject?.();
2358
- config.callbacks?.onChange?.(result.current);
2359
2910
  }
2360
2911
 
2361
2912
  /**
2362
- * Handle save preferences from modal
2913
+ * Handle save preferences from modal.
2363
2914
  */
2364
2915
  function handleSavePreferences(selections) {
2365
- const result = updateConsent(selections, config.expiration);
2366
-
2367
- // Find newly allowed categories
2368
- const newlyAllowed = Object.keys(result.current).filter(
2369
- cat => result.current[cat] && !result.previous[cat]
2370
- );
2371
-
2372
- if (newlyAllowed.length > 0) {
2373
- replayAll(newlyAllowed);
2374
- }
2916
+ coreUpdateConsent(selections);
2917
+ const config = getActiveConfig();
2375
2918
 
2376
2919
  hideModal();
2377
2920
 
2378
- if (config.showWidget) {
2921
+ if (config?.showWidget) {
2379
2922
  showWidget({ onClick: handleShowSettings });
2380
2923
  }
2381
-
2382
- // Determine if this was acceptance or rejection based on selections
2383
- const hasNonEssential = Object.entries(selections)
2384
- .some(([cat, val]) => cat !== 'essential' && val);
2385
-
2386
- if (hasNonEssential) {
2387
- emitConsent(result.current, result.previous);
2388
- } else {
2389
- emitReject(result.current);
2390
- }
2391
-
2392
- emitChange(result.current, result.previous);
2393
- config.callbacks?.onChange?.(result.current);
2394
2924
  }
2395
2925
 
2396
2926
  /**
2397
- * Handle show settings
2927
+ * Open the settings modal.
2398
2928
  */
2399
2929
  function handleShowSettings() {
2400
2930
  hideBanner();
@@ -2411,17 +2941,17 @@ ${config.customStyles || ''}
2411
2941
  }
2412
2942
 
2413
2943
  /**
2414
- * Handle close modal
2944
+ * Close the modal — either bring the widget back (decision made) or
2945
+ * fall back to the banner (no decision yet).
2415
2946
  */
2416
2947
  function handleCloseModal() {
2417
2948
  hideModal();
2418
2949
  emitHide('modal');
2419
2950
 
2420
- // Show widget if consent was already given
2421
- if (hasConsentDecision() && config.showWidget) {
2951
+ const config = getActiveConfig();
2952
+ if (hasConsentDecision() && config?.showWidget) {
2422
2953
  showWidget({ onClick: handleShowSettings });
2423
2954
  } else {
2424
- // Show banner again if no decision made
2425
2955
  showBanner({
2426
2956
  onAcceptAll: handleAcceptAll,
2427
2957
  onRejectAll: handleRejectAll,
@@ -2431,89 +2961,37 @@ ${config.customStyles || ''}
2431
2961
  }
2432
2962
 
2433
2963
  /**
2434
- * Initialize Zest
2964
+ * Initialize Zest with UI.
2435
2965
  */
2436
2966
  function init(userConfig = {}) {
2437
- if (initialized) {
2967
+ const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
2968
+ if (alreadyInitialized) {
2438
2969
  console.warn('[Zest] Already initialized');
2439
2970
  return Zest;
2440
2971
  }
2441
2972
 
2442
- // Merge config
2443
- config = setConfig(userConfig);
2444
-
2445
- // Set patterns if provided
2446
- if (config.patterns) {
2447
- setPatterns(config.patterns);
2448
- }
2449
-
2450
- // Set up consent checkers
2451
- setConsentChecker$2(checkConsent);
2452
- setConsentChecker$1(checkConsent);
2453
- setConsentChecker(checkConsent);
2454
-
2455
- // Start interception
2456
- interceptCookies();
2457
- interceptStorage();
2458
- startScriptBlocking(config.mode, config.blockedDomains);
2459
-
2460
- // Load saved consent
2461
- const consent = loadConsent();
2462
-
2463
- initialized = true;
2464
-
2465
- // Check Do Not Track / Global Privacy Control
2466
- const dntEnabled = isDoNotTrackEnabled();
2467
- let dntApplied = false;
2468
-
2469
- if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
2470
- if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
2471
- // Auto-reject non-essential cookies silently
2472
- const result = rejectAll(config.expiration);
2473
- dntApplied = true;
2474
-
2475
- // Emit events
2476
- emitReject(result.current);
2477
- emitChange(result.current, result.previous);
2478
- config.callbacks?.onReject?.();
2479
- config.callbacks?.onChange?.(result.current);
2480
- }
2481
- // 'preselect' behavior is handled by default (banner shows with defaults off)
2482
- }
2483
-
2484
- // Emit ready event
2485
- emitReady(consent);
2486
- config.callbacks?.onReady?.(consent);
2973
+ const config = getActiveConfig();
2487
2974
 
2488
- // Show UI based on consent state
2489
- if (!hasConsentDecision() && !dntApplied) {
2490
- // No consent decision yet - show banner
2975
+ if (!hasDecision && !dntApplied) {
2491
2976
  showBanner({
2492
2977
  onAcceptAll: handleAcceptAll,
2493
2978
  onRejectAll: handleRejectAll,
2494
2979
  onSettings: handleShowSettings
2495
2980
  });
2496
2981
  emitShow('banner');
2497
- } else {
2498
- // Consent already given (or DNT auto-rejected) - show widget for reopening settings
2499
- if (config.showWidget) {
2500
- showWidget({ onClick: handleShowSettings });
2501
- }
2982
+ } else if (config?.showWidget) {
2983
+ showWidget({ onClick: handleShowSettings });
2502
2984
  }
2503
2985
 
2504
2986
  return Zest;
2505
2987
  }
2506
2988
 
2507
- /**
2508
- * Public API
2509
- */
2510
2989
  const Zest = {
2511
- // Initialization
2512
2990
  init,
2513
2991
 
2514
2992
  // Banner control
2515
2993
  show() {
2516
- if (!initialized) {
2994
+ if (!isInitialized()) {
2517
2995
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
2518
2996
  return;
2519
2997
  }
@@ -2534,7 +3012,7 @@ ${config.customStyles || ''}
2534
3012
 
2535
3013
  // Settings modal
2536
3014
  showSettings() {
2537
- if (!initialized) {
3015
+ if (!isInitialized()) {
2538
3016
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
2539
3017
  return;
2540
3018
  }
@@ -2546,19 +3024,19 @@ ${config.customStyles || ''}
2546
3024
  emitHide('modal');
2547
3025
  },
2548
3026
 
2549
- // Consent management
3027
+ // Consent state
2550
3028
  getConsent,
2551
3029
  hasConsent,
2552
3030
  hasConsentDecision,
2553
3031
  getConsentProof,
2554
3032
 
2555
- // DNT detection
3033
+ // DNT
2556
3034
  isDoNotTrackEnabled,
2557
3035
  getDNTDetails,
2558
3036
 
2559
- // Accept/Reject programmatically
3037
+ // Programmatic accept / reject
2560
3038
  acceptAll() {
2561
- if (!initialized) {
3039
+ if (!isInitialized()) {
2562
3040
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
2563
3041
  return;
2564
3042
  }
@@ -2566,20 +3044,19 @@ ${config.customStyles || ''}
2566
3044
  },
2567
3045
 
2568
3046
  rejectAll() {
2569
- if (!initialized) {
3047
+ if (!isInitialized()) {
2570
3048
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
2571
3049
  return;
2572
3050
  }
2573
3051
  handleRejectAll();
2574
3052
  },
2575
3053
 
2576
- // Reset and show banner again
3054
+ // Reset everything and reshow the banner
2577
3055
  reset() {
2578
- resetConsent();
3056
+ coreReset();
2579
3057
  hideModal();
2580
3058
  removeWidget();
2581
-
2582
- if (initialized) {
3059
+ if (isInitialized()) {
2583
3060
  showBanner({
2584
3061
  onAcceptAll: handleAcceptAll,
2585
3062
  onRejectAll: handleRejectAll,
@@ -2589,17 +3066,30 @@ ${config.customStyles || ''}
2589
3066
  }
2590
3067
  },
2591
3068
 
2592
- // Config
3069
+ // Config introspection
2593
3070
  getConfig: getCurrentConfig,
2594
3071
 
2595
3072
  // Events
3073
+ on,
3074
+ once,
2596
3075
  EVENTS
2597
3076
  };
2598
3077
 
2599
3078
  // Auto-init if config present
2600
3079
  if (typeof window !== 'undefined') {
2601
- // Make Zest available globally
2602
- window.Zest = Zest;
3080
+ // Make Zest available globally. defineProperty with writable:false +
3081
+ // configurable:false stops a later-loaded script from replacing the
3082
+ // global with a trojanned stand-in.
3083
+ try {
3084
+ Object.defineProperty(window, 'Zest', {
3085
+ value: Object.freeze(Zest),
3086
+ writable: false,
3087
+ configurable: false,
3088
+ enumerable: true
3089
+ });
3090
+ } catch (e) {
3091
+ window.Zest = Zest;
3092
+ }
2603
3093
 
2604
3094
  const autoInit = () => {
2605
3095
  const cfg = getConfig();