@freshjuice/zest 1.0.0 → 2.1.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 (65) hide show
  1. package/README.md +178 -78
  2. package/dist/zest.d.ts +214 -0
  3. package/dist/zest.de.js +692 -305
  4. package/dist/zest.de.js.map +1 -1
  5. package/dist/zest.de.min.js +1 -1
  6. package/dist/zest.en.js +692 -305
  7. package/dist/zest.en.js.map +1 -1
  8. package/dist/zest.en.min.js +1 -1
  9. package/dist/zest.es.js +692 -305
  10. package/dist/zest.es.js.map +1 -1
  11. package/dist/zest.es.min.js +1 -1
  12. package/dist/zest.esm.js +692 -305
  13. package/dist/zest.esm.js.map +1 -1
  14. package/dist/zest.esm.min.js +1 -1
  15. package/dist/zest.fr.js +692 -305
  16. package/dist/zest.fr.js.map +1 -1
  17. package/dist/zest.fr.min.js +1 -1
  18. package/dist/zest.headless.d.ts +178 -0
  19. package/dist/zest.headless.esm.js +2299 -0
  20. package/dist/zest.headless.esm.js.map +1 -0
  21. package/dist/zest.headless.esm.min.js +1 -0
  22. package/dist/zest.it.js +692 -305
  23. package/dist/zest.it.js.map +1 -1
  24. package/dist/zest.it.min.js +1 -1
  25. package/dist/zest.ja.js +692 -305
  26. package/dist/zest.ja.js.map +1 -1
  27. package/dist/zest.ja.min.js +1 -1
  28. package/dist/zest.js +692 -305
  29. package/dist/zest.js.map +1 -1
  30. package/dist/zest.min.js +1 -1
  31. package/dist/zest.nl.js +692 -305
  32. package/dist/zest.nl.js.map +1 -1
  33. package/dist/zest.nl.min.js +1 -1
  34. package/dist/zest.pl.js +692 -305
  35. package/dist/zest.pl.js.map +1 -1
  36. package/dist/zest.pl.min.js +1 -1
  37. package/dist/zest.pt.js +692 -305
  38. package/dist/zest.pt.js.map +1 -1
  39. package/dist/zest.pt.min.js +1 -1
  40. package/dist/zest.ru.js +692 -305
  41. package/dist/zest.ru.js.map +1 -1
  42. package/dist/zest.ru.min.js +1 -1
  43. package/dist/zest.uk.js +692 -305
  44. package/dist/zest.uk.js.map +1 -1
  45. package/dist/zest.uk.min.js +1 -1
  46. package/dist/zest.zh.js +692 -305
  47. package/dist/zest.zh.js.map +1 -1
  48. package/dist/zest.zh.min.js +1 -1
  49. package/package.json +23 -4
  50. package/src/core/cookie-interceptor.js +20 -5
  51. package/src/core/known-trackers.js +41 -14
  52. package/src/core/pattern-matcher.js +20 -5
  53. package/src/core/script-blocker.js +85 -79
  54. package/src/core/security.js +204 -0
  55. package/src/core/storage-interceptor.js +5 -1
  56. package/src/core-lifecycle.js +192 -0
  57. package/src/headless.js +133 -0
  58. package/src/index.js +73 -184
  59. package/src/storage/consent-store.js +32 -8
  60. package/src/types/zest.d.ts +214 -0
  61. package/src/types/zest.headless.d.ts +178 -0
  62. package/src/ui/banner.js +11 -7
  63. package/src/ui/modal.js +16 -12
  64. package/src/ui/styles.js +25 -4
  65. package/src/ui/widget.js +3 -1
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
  /**
@@ -1692,18 +1952,18 @@ function getConfig() {
1692
1952
  /**
1693
1953
  * Update configuration at runtime
1694
1954
  */
1695
- let currentConfig = null;
1955
+ let currentConfig$1 = null;
1696
1956
 
1697
1957
  function setConfig(config) {
1698
- currentConfig = mergeConfig(config);
1699
- return currentConfig;
1958
+ currentConfig$1 = mergeConfig(config);
1959
+ return currentConfig$1;
1700
1960
  }
1701
1961
 
1702
1962
  function getCurrentConfig() {
1703
- if (!currentConfig) {
1704
- currentConfig = getConfig();
1963
+ if (!currentConfig$1) {
1964
+ currentConfig$1 = getConfig();
1705
1965
  }
1706
- return currentConfig;
1966
+ return currentConfig$1;
1707
1967
  }
1708
1968
 
1709
1969
  /**
@@ -1714,6 +1974,20 @@ function getCurrentConfig() {
1714
1974
  const COOKIE_NAME = 'zest_consent';
1715
1975
  const CONSENT_VERSION = '1.0';
1716
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
+
1717
1991
  // Current consent state
1718
1992
  let consent = null;
1719
1993
 
@@ -1742,7 +2016,12 @@ function getRawCookie() {
1742
2016
  }
1743
2017
 
1744
2018
  /**
1745
- * 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.
1746
2025
  */
1747
2026
  function loadConsent() {
1748
2027
  try {
@@ -1750,9 +2029,12 @@ function loadConsent() {
1750
2029
  const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1751
2030
 
1752
2031
  if (match) {
1753
- const data = JSON.parse(decodeURIComponent(match[1]));
1754
- consent = data.categories || getDefaultConsent();
1755
- 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
+ }
1756
2038
  }
1757
2039
  } catch (e) {
1758
2040
  // Invalid or missing cookie
@@ -1777,7 +2059,7 @@ function saveConsent(expirationDays = 365) {
1777
2059
  };
1778
2060
 
1779
2061
  const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
1780
- 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()}`;
1781
2063
 
1782
2064
  setRawCookie(cookieValue);
1783
2065
  }
@@ -1846,7 +2128,7 @@ function rejectAll(expirationDays = 365) {
1846
2128
  * Reset consent (clear cookie)
1847
2129
  */
1848
2130
  function resetConsent() {
1849
- 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()}`);
1850
2132
  consent = null;
1851
2133
  }
1852
2134
 
@@ -1871,7 +2153,8 @@ function getConsentProof() {
1871
2153
  const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
1872
2154
 
1873
2155
  if (match) {
1874
- return JSON.parse(decodeURIComponent(match[1]));
2156
+ const raw = JSON.parse(decodeURIComponent(match[1]));
2157
+ return sanitizeConsentPayload(raw, getCategoryIds());
1875
2158
  }
1876
2159
  } catch (e) {
1877
2160
  // Invalid cookie
@@ -1950,15 +2233,207 @@ function emitHide(type = 'banner') {
1950
2233
  return emit(EVENTS.HIDE, { type });
1951
2234
  }
1952
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
+
1953
2422
  /**
1954
2423
  * Styles - Shadow DOM encapsulated CSS with theming
1955
2424
  */
1956
2425
 
2426
+
2427
+ const DEFAULT_ACCENT = '#4F46E5';
2428
+
1957
2429
  /**
1958
2430
  * Generate CSS with custom properties
1959
2431
  */
1960
2432
  function generateStyles(config) {
1961
- 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);
1962
2437
 
1963
2438
  return `
1964
2439
  :host {
@@ -2428,15 +2903,29 @@ function generateStyles(config) {
2428
2903
  .zest-hidden {
2429
2904
  display: none !important;
2430
2905
  }
2431
- ${config.customStyles || ''}
2906
+ ${customCss}
2432
2907
  `;
2433
2908
  }
2434
2909
 
2435
2910
  /**
2436
- * 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).
2437
2914
  */
2438
2915
  function adjustColor(hex, percent) {
2439
- 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);
2440
2929
  const amt = Math.round(2.55 * percent);
2441
2930
  const R = Math.min(255, Math.max(0, (num >> 16) + amt));
2442
2931
  const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
@@ -2457,26 +2946,29 @@ const COOKIE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
2457
2946
  let bannerElement = null;
2458
2947
  let shadowRoot$2 = null;
2459
2948
 
2949
+ const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
2950
+
2460
2951
  /**
2461
2952
  * Create the banner HTML
2462
2953
  */
2463
2954
  function createBannerHTML(config) {
2464
2955
  const labels = config.labels.banner;
2465
- const position = config.position || 'bottom';
2956
+ const rawPosition = config.position || 'bottom';
2957
+ const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
2466
2958
 
2467
2959
  return `
2468
- <div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
2469
- <h2 class="zest-banner__title">${labels.title}</h2>
2470
- <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>
2471
2963
  <div class="zest-banner__buttons">
2472
2964
  <button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
2473
- ${labels.acceptAll}
2965
+ ${escapeHTML(labels.acceptAll)}
2474
2966
  </button>
2475
2967
  <button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
2476
- ${labels.rejectAll}
2968
+ ${escapeHTML(labels.rejectAll)}
2477
2969
  </button>
2478
2970
  <button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
2479
- ${labels.settings}
2971
+ ${escapeHTML(labels.settings)}
2480
2972
  </button>
2481
2973
  </div>
2482
2974
  </div>
@@ -2586,22 +3078,24 @@ let currentSelections = {};
2586
3078
  function createCategoryHTML(category, isChecked, isRequired) {
2587
3079
  const disabled = isRequired ? 'disabled' : '';
2588
3080
  const checked = isChecked ? 'checked' : '';
3081
+ const safeId = escapeHTML(category.id);
3082
+ const safeLabel = escapeHTML(category.label);
2589
3083
 
2590
3084
  return `
2591
3085
  <div class="zest-category">
2592
3086
  <div class="zest-category__header">
2593
3087
  <div class="zest-category__info">
2594
- <span class="zest-category__label">${category.label}</span>
2595
- <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>
2596
3090
  </div>
2597
3091
  <label class="zest-toggle">
2598
3092
  <input
2599
3093
  type="checkbox"
2600
3094
  class="zest-toggle__input"
2601
- data-category="${category.id}"
3095
+ data-category="${safeId}"
2602
3096
  ${checked}
2603
3097
  ${disabled}
2604
- aria-label="${category.label}"
3098
+ aria-label="${safeLabel}"
2605
3099
  >
2606
3100
  <span class="zest-toggle__slider"></span>
2607
3101
  </label>
@@ -2625,29 +3119,30 @@ function createModalHTML(config, consent) {
2625
3119
  ))
2626
3120
  .join('');
2627
3121
 
2628
- const policyLink = config.policyUrl
2629
- ? `<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>`
2630
3125
  : '';
2631
3126
 
2632
3127
  return `
2633
- <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)}">
2634
3129
  <div class="zest-modal">
2635
3130
  <div class="zest-modal__header">
2636
- <h2 class="zest-modal__title">${labels.title}</h2>
2637
- <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>
2638
3133
  </div>
2639
3134
  <div class="zest-modal__body">
2640
3135
  ${categoriesHTML}
2641
3136
  </div>
2642
3137
  <div class="zest-modal__footer">
2643
3138
  <button type="button" class="zest-btn zest-btn--primary" data-action="save">
2644
- ${labels.save}
3139
+ ${escapeHTML(labels.save)}
2645
3140
  </button>
2646
3141
  <button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
2647
- ${labels.acceptAll}
3142
+ ${escapeHTML(labels.acceptAll)}
2648
3143
  </button>
2649
3144
  <button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
2650
- ${labels.rejectAll}
3145
+ ${escapeHTML(labels.rejectAll)}
2651
3146
  </button>
2652
3147
  </div>
2653
3148
  </div>
@@ -2779,10 +3274,11 @@ let shadowRoot = null;
2779
3274
  */
2780
3275
  function createWidgetHTML(config) {
2781
3276
  const labels = config.labels.widget;
3277
+ const safeLabel = escapeHTML(labels.label);
2782
3278
 
2783
3279
  return `
2784
3280
  <div class="zest-widget">
2785
- <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}">
2786
3282
  <span class="zest-widget__icon">${COOKIE_ICON}</span>
2787
3283
  </button>
2788
3284
  </div>
@@ -2861,114 +3357,59 @@ function removeWidget() {
2861
3357
 
2862
3358
  /**
2863
3359
  * Zest - Lightweight Cookie Consent Toolkit
2864
- * Main entry point
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.
2865
3364
  */
2866
3365
 
2867
3366
 
2868
- // State
2869
- let initialized = false;
2870
- let config = null;
2871
-
2872
3367
  /**
2873
- * Consent checker function shared across interceptors
2874
- */
2875
- function checkConsent(category) {
2876
- return hasConsent(category);
2877
- }
2878
-
2879
- /**
2880
- * Replay all queued items for newly allowed categories
2881
- */
2882
- function replayAll(allowedCategories) {
2883
- replayCookies(allowedCategories);
2884
- replayStorage(allowedCategories);
2885
- replayScripts(allowedCategories);
2886
- }
2887
-
2888
- /**
2889
- * Handle accept all
3368
+ * Handle accept all delegates consent logic to core, handles UI swap.
2890
3369
  */
2891
3370
  function handleAcceptAll() {
2892
- const result = acceptAll(config.expiration);
2893
- const categories = getCategoryIds();
2894
-
2895
- applyConsentSignals(result.current, config, false);
3371
+ coreAcceptAll();
3372
+ const config = getActiveConfig();
2896
3373
 
2897
3374
  hideBanner();
2898
3375
  hideModal();
2899
3376
 
2900
- replayAll(categories);
2901
-
2902
- if (config.showWidget) {
3377
+ if (config?.showWidget) {
2903
3378
  showWidget({ onClick: handleShowSettings });
2904
3379
  }
2905
-
2906
- emitConsent(result.current, result.previous);
2907
- emitChange(result.current, result.previous);
2908
- config.callbacks?.onAccept?.(result.current);
2909
- config.callbacks?.onChange?.(result.current);
2910
3380
  }
2911
3381
 
2912
3382
  /**
2913
- * Handle reject all
3383
+ * Handle reject all.
2914
3384
  */
2915
3385
  function handleRejectAll() {
2916
- const result = rejectAll(config.expiration);
2917
-
2918
- applyConsentSignals(result.current, config, false);
3386
+ coreRejectAll();
3387
+ const config = getActiveConfig();
2919
3388
 
2920
3389
  hideBanner();
2921
3390
  hideModal();
2922
3391
 
2923
- if (config.showWidget) {
3392
+ if (config?.showWidget) {
2924
3393
  showWidget({ onClick: handleShowSettings });
2925
3394
  }
2926
-
2927
- emitReject(result.current);
2928
- emitChange(result.current, result.previous);
2929
- config.callbacks?.onReject?.();
2930
- config.callbacks?.onChange?.(result.current);
2931
3395
  }
2932
3396
 
2933
3397
  /**
2934
- * Handle save preferences from modal
3398
+ * Handle save preferences from modal.
2935
3399
  */
2936
3400
  function handleSavePreferences(selections) {
2937
- const result = updateConsent(selections, config.expiration);
2938
-
2939
- applyConsentSignals(result.current, config, false);
2940
-
2941
- // Find newly allowed categories
2942
- const newlyAllowed = Object.keys(result.current).filter(
2943
- cat => result.current[cat] && !result.previous[cat]
2944
- );
2945
-
2946
- if (newlyAllowed.length > 0) {
2947
- replayAll(newlyAllowed);
2948
- }
3401
+ coreUpdateConsent(selections);
3402
+ const config = getActiveConfig();
2949
3403
 
2950
3404
  hideModal();
2951
3405
 
2952
- if (config.showWidget) {
3406
+ if (config?.showWidget) {
2953
3407
  showWidget({ onClick: handleShowSettings });
2954
3408
  }
2955
-
2956
- // Determine if this was acceptance or rejection based on selections
2957
- const hasNonEssential = Object.entries(selections)
2958
- .some(([cat, val]) => cat !== 'essential' && val);
2959
-
2960
- if (hasNonEssential) {
2961
- emitConsent(result.current, result.previous);
2962
- } else {
2963
- emitReject(result.current);
2964
- }
2965
-
2966
- emitChange(result.current, result.previous);
2967
- config.callbacks?.onChange?.(result.current);
2968
3409
  }
2969
3410
 
2970
3411
  /**
2971
- * Handle show settings
3412
+ * Open the settings modal.
2972
3413
  */
2973
3414
  function handleShowSettings() {
2974
3415
  hideBanner();
@@ -2985,17 +3426,17 @@ function handleShowSettings() {
2985
3426
  }
2986
3427
 
2987
3428
  /**
2988
- * Handle close modal
3429
+ * Close the modal — either bring the widget back (decision made) or
3430
+ * fall back to the banner (no decision yet).
2989
3431
  */
2990
3432
  function handleCloseModal() {
2991
3433
  hideModal();
2992
3434
  emitHide('modal');
2993
3435
 
2994
- // Show widget if consent was already given
2995
- if (hasConsentDecision() && config.showWidget) {
3436
+ const config = getActiveConfig();
3437
+ if (hasConsentDecision() && config?.showWidget) {
2996
3438
  showWidget({ onClick: handleShowSettings });
2997
3439
  } else {
2998
- // Show banner again if no decision made
2999
3440
  showBanner({
3000
3441
  onAcceptAll: handleAcceptAll,
3001
3442
  onRejectAll: handleRejectAll,
@@ -3005,103 +3446,37 @@ function handleCloseModal() {
3005
3446
  }
3006
3447
 
3007
3448
  /**
3008
- * Initialize Zest
3449
+ * Initialize Zest with UI.
3009
3450
  */
3010
3451
  function init(userConfig = {}) {
3011
- if (initialized) {
3452
+ const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
3453
+ if (alreadyInitialized) {
3012
3454
  console.warn('[Zest] Already initialized');
3013
3455
  return Zest;
3014
3456
  }
3015
3457
 
3016
- // Merge config
3017
- config = setConfig(userConfig);
3018
-
3019
- // Push default denied state to vendor consent mode APIs (must happen before scripts load)
3020
- applyConsentSignals(
3021
- { functional: false, analytics: false, marketing: false },
3022
- config,
3023
- true
3024
- );
3025
-
3026
- // Set patterns if provided
3027
- if (config.patterns) {
3028
- setPatterns(config.patterns);
3029
- }
3030
-
3031
- // Set up consent checkers
3032
- setConsentChecker$2(checkConsent);
3033
- setConsentChecker$1(checkConsent);
3034
- setConsentChecker(checkConsent);
3035
-
3036
- // Start interception
3037
- interceptCookies();
3038
- interceptStorage();
3039
- startScriptBlocking(config.mode, config.blockedDomains);
3040
-
3041
- // Load saved consent
3042
- const consent = loadConsent();
3043
-
3044
- initialized = true;
3045
-
3046
- // Push update for returning visitors with saved consent
3047
- if (hasConsentDecision()) {
3048
- applyConsentSignals(consent, config, false);
3049
- }
3050
-
3051
- // Check Do Not Track / Global Privacy Control
3052
- const dntEnabled = isDoNotTrackEnabled();
3053
- let dntApplied = false;
3054
-
3055
- if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
3056
- if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
3057
- // Auto-reject non-essential cookies silently
3058
- const result = rejectAll(config.expiration);
3059
- dntApplied = true;
3060
-
3061
- applyConsentSignals(result.current, config, false);
3062
-
3063
- // Emit events
3064
- emitReject(result.current);
3065
- emitChange(result.current, result.previous);
3066
- config.callbacks?.onReject?.();
3067
- config.callbacks?.onChange?.(result.current);
3068
- }
3069
- // 'preselect' behavior is handled by default (banner shows with defaults off)
3070
- }
3071
-
3072
- // Emit ready event
3073
- emitReady(consent);
3074
- config.callbacks?.onReady?.(consent);
3458
+ const config = getActiveConfig();
3075
3459
 
3076
- // Show UI based on consent state
3077
- if (!hasConsentDecision() && !dntApplied) {
3078
- // No consent decision yet - show banner
3460
+ if (!hasDecision && !dntApplied) {
3079
3461
  showBanner({
3080
3462
  onAcceptAll: handleAcceptAll,
3081
3463
  onRejectAll: handleRejectAll,
3082
3464
  onSettings: handleShowSettings
3083
3465
  });
3084
3466
  emitShow('banner');
3085
- } else {
3086
- // Consent already given (or DNT auto-rejected) - show widget for reopening settings
3087
- if (config.showWidget) {
3088
- showWidget({ onClick: handleShowSettings });
3089
- }
3467
+ } else if (config?.showWidget) {
3468
+ showWidget({ onClick: handleShowSettings });
3090
3469
  }
3091
3470
 
3092
3471
  return Zest;
3093
3472
  }
3094
3473
 
3095
- /**
3096
- * Public API
3097
- */
3098
3474
  const Zest = {
3099
- // Initialization
3100
3475
  init,
3101
3476
 
3102
3477
  // Banner control
3103
3478
  show() {
3104
- if (!initialized) {
3479
+ if (!isInitialized()) {
3105
3480
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
3106
3481
  return;
3107
3482
  }
@@ -3122,7 +3497,7 @@ const Zest = {
3122
3497
 
3123
3498
  // Settings modal
3124
3499
  showSettings() {
3125
- if (!initialized) {
3500
+ if (!isInitialized()) {
3126
3501
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
3127
3502
  return;
3128
3503
  }
@@ -3134,19 +3509,19 @@ const Zest = {
3134
3509
  emitHide('modal');
3135
3510
  },
3136
3511
 
3137
- // Consent management
3512
+ // Consent state
3138
3513
  getConsent,
3139
3514
  hasConsent,
3140
3515
  hasConsentDecision,
3141
3516
  getConsentProof,
3142
3517
 
3143
- // DNT detection
3518
+ // DNT
3144
3519
  isDoNotTrackEnabled,
3145
3520
  getDNTDetails,
3146
3521
 
3147
- // Accept/Reject programmatically
3522
+ // Programmatic accept / reject
3148
3523
  acceptAll() {
3149
- if (!initialized) {
3524
+ if (!isInitialized()) {
3150
3525
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
3151
3526
  return;
3152
3527
  }
@@ -3154,20 +3529,19 @@ const Zest = {
3154
3529
  },
3155
3530
 
3156
3531
  rejectAll() {
3157
- if (!initialized) {
3532
+ if (!isInitialized()) {
3158
3533
  console.warn('[Zest] Not initialized. Call Zest.init() first.');
3159
3534
  return;
3160
3535
  }
3161
3536
  handleRejectAll();
3162
3537
  },
3163
3538
 
3164
- // Reset and show banner again
3539
+ // Reset everything and reshow the banner
3165
3540
  reset() {
3166
- resetConsent();
3541
+ coreReset();
3167
3542
  hideModal();
3168
3543
  removeWidget();
3169
-
3170
- if (initialized) {
3544
+ if (isInitialized()) {
3171
3545
  showBanner({
3172
3546
  onAcceptAll: handleAcceptAll,
3173
3547
  onRejectAll: handleRejectAll,
@@ -3177,17 +3551,30 @@ const Zest = {
3177
3551
  }
3178
3552
  },
3179
3553
 
3180
- // Config
3554
+ // Config introspection
3181
3555
  getConfig: getCurrentConfig,
3182
3556
 
3183
3557
  // Events
3558
+ on,
3559
+ once,
3184
3560
  EVENTS
3185
3561
  };
3186
3562
 
3187
3563
  // Auto-init if config present
3188
3564
  if (typeof window !== 'undefined') {
3189
- // Make Zest available globally
3190
- 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
+ }
3191
3578
 
3192
3579
  const autoInit = () => {
3193
3580
  const cfg = getConfig();