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