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