@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
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Script Blocker - Blocks and manages consent-gated scripts
3
+ *
4
+ * Modes:
5
+ * - manual: Only blocks scripts with data-consent-category attribute
6
+ * - safe: Manual + known major trackers (Google, Facebook, etc.)
7
+ * - strict: Safe + extended tracker list (Hotjar, Mixpanel, etc.)
8
+ * - doomsday: Block ALL third-party scripts
9
+ */
10
+
11
+ import { getCategoryForScript, isThirdParty } from './known-trackers.js';
12
+
13
+ // Categories the author has declared blockable. A script can self-label
14
+ // into one of these, but not into 'essential' (a common bypass).
15
+ const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
16
+
17
+ // Upper bound on queued scripts awaiting consent replay — prevents a
18
+ // hostile page from flooding the queue with <script> nodes.
19
+ const MAX_QUEUE_SIZE = 500;
20
+
21
+ // Queue for blocked scripts — the authoritative source for replay,
22
+ // snapshotting src/inline BEFORE any DOM mutation so later tampering
23
+ // cannot hijack what gets executed.
24
+ const scriptQueue = [];
25
+
26
+ // MutationObserver instance
27
+ let observer = null;
28
+
29
+ // Current blocking mode
30
+ let blockingMode = 'safe';
31
+
32
+ // Custom blocked domains (user-defined)
33
+ let customBlockedDomains = [];
34
+
35
+ // Reference to consent checker function
36
+ let checkConsent = () => false;
37
+
38
+ /**
39
+ * Set the consent checker function
40
+ */
41
+ export function setConsentChecker(fn) {
42
+ checkConsent = fn;
43
+ }
44
+
45
+ /**
46
+ * Set blocking mode
47
+ */
48
+ export function setBlockingMode(mode) {
49
+ blockingMode = mode;
50
+ }
51
+
52
+ /**
53
+ * Set custom blocked domains
54
+ */
55
+ export function setCustomBlockedDomains(domains) {
56
+ customBlockedDomains = domains || [];
57
+ }
58
+
59
+ /**
60
+ * Get queued scripts
61
+ */
62
+ export function getScriptQueue() {
63
+ return [...scriptQueue];
64
+ }
65
+
66
+ /**
67
+ * Clear the script queue
68
+ */
69
+ export function clearScriptQueue() {
70
+ scriptQueue.length = 0;
71
+ }
72
+
73
+ /**
74
+ * Check if script URL matches custom blocked domains
75
+ */
76
+ function matchesCustomDomains(url) {
77
+ if (!url || customBlockedDomains.length === 0) return null;
78
+
79
+ try {
80
+ const hostname = new URL(url).hostname.toLowerCase();
81
+
82
+ for (const entry of customBlockedDomains) {
83
+ const domain = typeof entry === 'string' ? entry : entry.domain;
84
+ const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
85
+
86
+ if (hostname === domain || hostname.endsWith('.' + domain)) {
87
+ return category;
88
+ }
89
+ }
90
+ } catch (e) {
91
+ // Invalid URL
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Determine if a script should be blocked and get its category.
99
+ *
100
+ * A self-applied 'essential' label is ignored — only explicit blockable
101
+ * categories are accepted. That prevents a third-party script from
102
+ * stamping itself with data-consent-category="essential" to slip past
103
+ * mode-based blocking.
104
+ */
105
+ function getScriptBlockCategory(script) {
106
+ // Skip if script has data-zest-allow attribute (opt-out)
107
+ if (script.hasAttribute('data-zest-allow')) {
108
+ return null;
109
+ }
110
+
111
+ // 1. Check for explicit data-consent-category attribute.
112
+ // Only honor values from the blockable set; 'essential' and unknown
113
+ // values fall through to the other checks.
114
+ const explicitCategory = script.getAttribute('data-consent-category');
115
+ const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
116
+ ? explicitCategory
117
+ : null;
118
+
119
+ const src = script.src;
120
+
121
+ // No src = inline script, only block if explicitly tagged (blockable only)
122
+ if (!src) {
123
+ return explicitBlockable;
124
+ }
125
+
126
+ // 2. Check custom blocked domains
127
+ const customCategory = matchesCustomDomains(src);
128
+
129
+ // 3. Mode-based blocking
130
+ let modeCategory = null;
131
+ switch (blockingMode) {
132
+ case 'manual':
133
+ break;
134
+
135
+ case 'safe':
136
+ case 'strict':
137
+ modeCategory = getCategoryForScript(src, blockingMode);
138
+ break;
139
+
140
+ case 'doomsday':
141
+ if (isThirdParty(src)) {
142
+ modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
143
+ }
144
+ break;
145
+
146
+ default:
147
+ break;
148
+ }
149
+
150
+ // Use the strictest category among explicit/custom/mode decisions.
151
+ // We collect all categories the script matches and pick the first
152
+ // that appears in the blockable set (any match wins — but we prefer
153
+ // the mode-assigned one since it's authoritative for third-party
154
+ // trackers that try to self-label as 'functional').
155
+ return modeCategory || customCategory || explicitBlockable;
156
+ }
157
+
158
+ /**
159
+ * Block a script element
160
+ */
161
+ function blockScript(script) {
162
+ // Skip already processed scripts
163
+ if (script.hasAttribute('data-zest-processed')) {
164
+ return false;
165
+ }
166
+
167
+ const category = getScriptBlockCategory(script);
168
+
169
+ if (!category) {
170
+ script.setAttribute('data-zest-processed', 'allowed');
171
+ return false;
172
+ }
173
+
174
+ if (checkConsent(category)) {
175
+ // Consent already given - allow script
176
+ script.setAttribute('data-zest-processed', 'allowed');
177
+ return false;
178
+ }
179
+
180
+ // Store script info for later execution. Snapshot the src/text BEFORE
181
+ // mutating the DOM — this snapshot is the authoritative replay source
182
+ // so later DOM tampering cannot hijack the replayed script URL.
183
+ const scriptInfo = {
184
+ category,
185
+ src: script.src || '',
186
+ inline: script.textContent,
187
+ type: script.type,
188
+ async: script.async,
189
+ defer: script.defer,
190
+ element: script,
191
+ timestamp: Date.now()
192
+ };
193
+
194
+ // Mark as processed
195
+ script.setAttribute('data-zest-processed', 'blocked');
196
+ script.setAttribute('data-consent-category', category);
197
+
198
+ // Disable the script
199
+ script.type = 'text/plain';
200
+
201
+ // Remove src to prevent loading. We no longer stash it on the element
202
+ // (data-blocked-src was a tampering vector); scriptQueue is the single
203
+ // source of truth for replay.
204
+ if (script.src) {
205
+ script.removeAttribute('src');
206
+ }
207
+
208
+ if (scriptQueue.length < MAX_QUEUE_SIZE) {
209
+ scriptQueue.push(scriptInfo);
210
+ }
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * Replay queued scripts for allowed categories.
216
+ *
217
+ * scriptQueue is the single source of truth for src and inline body —
218
+ * we never re-read data-* attributes from the DOM (which an attacker
219
+ * could have rewritten in the intervening time).
220
+ */
221
+ export function replayScripts(allowedCategories) {
222
+ const remaining = [];
223
+
224
+ for (const scriptInfo of scriptQueue) {
225
+ if (!allowedCategories.includes(scriptInfo.category)) {
226
+ remaining.push(scriptInfo);
227
+ continue;
228
+ }
229
+
230
+ const newScript = document.createElement('script');
231
+ if (scriptInfo.src) {
232
+ newScript.src = scriptInfo.src;
233
+ } else if (scriptInfo.inline) {
234
+ newScript.textContent = scriptInfo.inline;
235
+ }
236
+ if (scriptInfo.async) newScript.async = true;
237
+ if (scriptInfo.defer) newScript.defer = true;
238
+ if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
239
+ newScript.type = scriptInfo.type;
240
+ }
241
+ newScript.setAttribute('data-zest-processed', 'executed');
242
+ newScript.setAttribute('data-consent-executed', 'true');
243
+
244
+ // If the original element is still in the DOM, replace it in place
245
+ // so execution order is preserved. Otherwise append to <head>.
246
+ const original = scriptInfo.element;
247
+ if (original && original.isConnected && original.parentNode) {
248
+ original.parentNode.replaceChild(newScript, original);
249
+ } else {
250
+ document.head.appendChild(newScript);
251
+ }
252
+ }
253
+
254
+ scriptQueue.length = 0;
255
+ scriptQueue.push(...remaining);
256
+ }
257
+
258
+ /**
259
+ * Process existing scripts in the DOM
260
+ */
261
+ function processExistingScripts() {
262
+ const scripts = document.querySelectorAll('script:not([data-zest-processed])');
263
+ scripts.forEach(blockScript);
264
+ }
265
+
266
+ /**
267
+ * Handle mutations (new scripts added to DOM)
268
+ */
269
+ function handleMutations(mutations) {
270
+ for (const mutation of mutations) {
271
+ for (const node of mutation.addedNodes) {
272
+ if (node.nodeName === 'SCRIPT' && !node.hasAttribute('data-zest-processed')) {
273
+ blockScript(node);
274
+ }
275
+
276
+ // Check child scripts
277
+ if (node.querySelectorAll) {
278
+ const scripts = node.querySelectorAll('script:not([data-zest-processed])');
279
+ scripts.forEach(blockScript);
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Start observing for new scripts
287
+ */
288
+ export function startScriptBlocking(mode = 'safe', customDomains = []) {
289
+ blockingMode = mode;
290
+ customBlockedDomains = customDomains;
291
+
292
+ // Process existing scripts
293
+ processExistingScripts();
294
+
295
+ // Watch for new scripts
296
+ observer = new MutationObserver(handleMutations);
297
+
298
+ observer.observe(document.documentElement, {
299
+ childList: true,
300
+ subtree: true
301
+ });
302
+
303
+ return true;
304
+ }
305
+
306
+ /**
307
+ * Stop observing for new scripts
308
+ */
309
+ export function stopScriptBlocking() {
310
+ if (observer) {
311
+ observer.disconnect();
312
+ observer = null;
313
+ }
314
+ }
@@ -0,0 +1,204 @@
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
+ '&': '&amp;',
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
+ export 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
+ export 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
+ export 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
+ export 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
+ export 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
+ export 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
+ export 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
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Storage Interceptor - Intercepts localStorage and sessionStorage operations
3
+ */
4
+
5
+ import { getCategoryForName } from './pattern-matcher.js';
6
+
7
+ // Upper bound on queued operations awaiting consent replay — unbounded
8
+ // growth would be a memory-exhaustion DoS vector.
9
+ const MAX_QUEUE_SIZE = 200;
10
+
11
+ // Store originals
12
+ let originalLocalStorage = null;
13
+ let originalSessionStorage = null;
14
+
15
+ // Queues for blocked operations
16
+ const localStorageQueue = [];
17
+ const sessionStorageQueue = [];
18
+
19
+ // Reference to consent checker function
20
+ let checkConsent = () => false;
21
+
22
+ /**
23
+ * Set the consent checker function
24
+ */
25
+ export function setConsentChecker(fn) {
26
+ checkConsent = fn;
27
+ }
28
+
29
+ /**
30
+ * Get original localStorage
31
+ */
32
+ export function getOriginalLocalStorage() {
33
+ return originalLocalStorage;
34
+ }
35
+
36
+ /**
37
+ * Get original sessionStorage
38
+ */
39
+ export function getOriginalSessionStorage() {
40
+ return originalSessionStorage;
41
+ }
42
+
43
+ /**
44
+ * Get queued localStorage operations
45
+ */
46
+ export function getLocalStorageQueue() {
47
+ return [...localStorageQueue];
48
+ }
49
+
50
+ /**
51
+ * Get queued sessionStorage operations
52
+ */
53
+ export function getSessionStorageQueue() {
54
+ return [...sessionStorageQueue];
55
+ }
56
+
57
+ /**
58
+ * Clear storage queues
59
+ */
60
+ export function clearStorageQueues() {
61
+ localStorageQueue.length = 0;
62
+ sessionStorageQueue.length = 0;
63
+ }
64
+
65
+ /**
66
+ * Create a proxy for storage API
67
+ */
68
+ function createStorageProxy(storage, queue, storageName) {
69
+ return new Proxy(storage, {
70
+ get(target, prop) {
71
+ if (prop === 'setItem') {
72
+ return (key, value) => {
73
+ const category = getCategoryForName(key);
74
+
75
+ if (checkConsent(category)) {
76
+ target.setItem(key, value);
77
+ } else if (queue.length < MAX_QUEUE_SIZE) {
78
+ queue.push({
79
+ key,
80
+ value,
81
+ category,
82
+ timestamp: Date.now()
83
+ });
84
+ }
85
+ };
86
+ }
87
+
88
+ // Allow all other operations
89
+ const val = target[prop];
90
+ return typeof val === 'function' ? val.bind(target) : val;
91
+ }
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Replay queued storage operations for allowed categories
97
+ */
98
+ export function replayStorage(allowedCategories) {
99
+ // Replay localStorage
100
+ const remainingLocal = [];
101
+ for (const item of localStorageQueue) {
102
+ if (allowedCategories.includes(item.category)) {
103
+ originalLocalStorage?.setItem(item.key, item.value);
104
+ } else {
105
+ remainingLocal.push(item);
106
+ }
107
+ }
108
+ localStorageQueue.length = 0;
109
+ localStorageQueue.push(...remainingLocal);
110
+
111
+ // Replay sessionStorage
112
+ const remainingSession = [];
113
+ for (const item of sessionStorageQueue) {
114
+ if (allowedCategories.includes(item.category)) {
115
+ originalSessionStorage?.setItem(item.key, item.value);
116
+ } else {
117
+ remainingSession.push(item);
118
+ }
119
+ }
120
+ sessionStorageQueue.length = 0;
121
+ sessionStorageQueue.push(...remainingSession);
122
+ }
123
+
124
+ /**
125
+ * Start intercepting storage APIs
126
+ */
127
+ export function interceptStorage() {
128
+ try {
129
+ originalLocalStorage = window.localStorage;
130
+ originalSessionStorage = window.sessionStorage;
131
+
132
+ Object.defineProperty(window, 'localStorage', {
133
+ value: createStorageProxy(originalLocalStorage, localStorageQueue, 'localStorage'),
134
+ configurable: true,
135
+ writable: false
136
+ });
137
+
138
+ Object.defineProperty(window, 'sessionStorage', {
139
+ value: createStorageProxy(originalSessionStorage, sessionStorageQueue, 'sessionStorage'),
140
+ configurable: true,
141
+ writable: false
142
+ });
143
+
144
+ return true;
145
+ } catch (e) {
146
+ console.warn('[Zest] Could not intercept storage APIs:', e);
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Restore original storage APIs
153
+ */
154
+ export function restoreStorage() {
155
+ try {
156
+ if (originalLocalStorage) {
157
+ Object.defineProperty(window, 'localStorage', {
158
+ value: originalLocalStorage,
159
+ configurable: true,
160
+ writable: false
161
+ });
162
+ }
163
+ if (originalSessionStorage) {
164
+ Object.defineProperty(window, 'sessionStorage', {
165
+ value: originalSessionStorage,
166
+ configurable: true,
167
+ writable: false
168
+ });
169
+ }
170
+ } catch (e) {
171
+ console.warn('[Zest] Could not restore storage APIs:', e);
172
+ }
173
+ }