@freshjuice/zest 1.0.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.
- package/README.md +178 -78
- package/dist/zest.de.js +692 -305
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +692 -305
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +692 -305
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +692 -305
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +692 -305
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.esm.js +2299 -0
- package/dist/zest.headless.esm.js.map +1 -0
- package/dist/zest.headless.esm.min.js +1 -0
- package/dist/zest.it.js +692 -305
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +692 -305
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +692 -305
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +692 -305
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +692 -305
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +692 -305
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +692 -305
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +692 -305
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +692 -305
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +16 -4
- package/src/core/cookie-interceptor.js +20 -5
- package/src/core/known-trackers.js +41 -14
- package/src/core/pattern-matcher.js +20 -5
- package/src/core/script-blocker.js +85 -79
- package/src/core/security.js +204 -0
- package/src/core/storage-interceptor.js +5 -1
- package/src/core-lifecycle.js +192 -0
- package/src/headless.js +133 -0
- package/src/index.js +73 -184
- package/src/storage/consent-store.js +32 -8
- package/src/ui/banner.js +11 -7
- package/src/ui/modal.js +16 -12
- package/src/ui/styles.js +25 -4
- package/src/ui/widget.js +3 -1
|
@@ -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
|
+
'&': '&',
|
|
11
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'"': '"',
|
|
14
|
+
"'": ''',
|
|
15
|
+
'`': '`'
|
|
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
|
+
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { getCategoryForName } from './pattern-matcher.js';
|
|
6
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
|
+
|
|
7
11
|
// Store originals
|
|
8
12
|
let originalLocalStorage = null;
|
|
9
13
|
let originalSessionStorage = null;
|
|
@@ -70,7 +74,7 @@ function createStorageProxy(storage, queue, storageName) {
|
|
|
70
74
|
|
|
71
75
|
if (checkConsent(category)) {
|
|
72
76
|
target.setItem(key, value);
|
|
73
|
-
} else {
|
|
77
|
+
} else if (queue.length < MAX_QUEUE_SIZE) {
|
|
74
78
|
queue.push({
|
|
75
79
|
key,
|
|
76
80
|
value,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core lifecycle - UI-agnostic initialization and consent actions.
|
|
3
|
+
*
|
|
4
|
+
* This module contains everything the main entry (with UI) and the
|
|
5
|
+
* headless entry (no UI) share: interceptor setup, consent load/save,
|
|
6
|
+
* replay, DNT handling, and the events/callbacks fan-out. It intentionally
|
|
7
|
+
* does NOT import anything from `./ui/*` so tree-shakers can drop the UI
|
|
8
|
+
* bundle entirely when only the headless API is used.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { interceptCookies, setConsentChecker as setCookieChecker, replayCookies } from './core/cookie-interceptor.js';
|
|
12
|
+
import { interceptStorage, setConsentChecker as setStorageChecker, replayStorage } from './core/storage-interceptor.js';
|
|
13
|
+
import { startScriptBlocking, setConsentChecker as setScriptChecker, replayScripts } from './core/script-blocker.js';
|
|
14
|
+
import { setPatterns } from './core/pattern-matcher.js';
|
|
15
|
+
import { getCategoryIds } from './core/categories.js';
|
|
16
|
+
import { isDoNotTrackEnabled } from './core/dnt.js';
|
|
17
|
+
import { safeInvoke } from './core/security.js';
|
|
18
|
+
|
|
19
|
+
import { applyConsentSignals } from './integrations/consent-signals.js';
|
|
20
|
+
|
|
21
|
+
import { setConfig } from './config/parser.js';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
loadConsent,
|
|
25
|
+
hasConsent,
|
|
26
|
+
updateConsent,
|
|
27
|
+
acceptAll as storeAcceptAll,
|
|
28
|
+
rejectAll as storeRejectAll,
|
|
29
|
+
resetConsent,
|
|
30
|
+
hasConsentDecision
|
|
31
|
+
} from './storage/consent-store.js';
|
|
32
|
+
import { emitReady, emitConsent, emitReject, emitChange } from './storage/events.js';
|
|
33
|
+
|
|
34
|
+
let initialized = false;
|
|
35
|
+
let currentConfig = null;
|
|
36
|
+
|
|
37
|
+
function checkConsent(category) {
|
|
38
|
+
return hasConsent(category);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function replayAll(categories) {
|
|
42
|
+
replayCookies(categories);
|
|
43
|
+
replayStorage(categories);
|
|
44
|
+
replayScripts(categories);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the non-UI half of init. Returns a snapshot the caller (UI or
|
|
49
|
+
* headless) can use to decide what to do next.
|
|
50
|
+
*/
|
|
51
|
+
export function coreInit(userConfig = {}) {
|
|
52
|
+
if (initialized) {
|
|
53
|
+
return {
|
|
54
|
+
alreadyInitialized: true,
|
|
55
|
+
config: currentConfig,
|
|
56
|
+
consent: loadConsent(),
|
|
57
|
+
hasDecision: hasConsentDecision(),
|
|
58
|
+
dntApplied: false
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
currentConfig = setConfig(userConfig);
|
|
63
|
+
|
|
64
|
+
// Push default-denied state to vendor consent mode APIs BEFORE any
|
|
65
|
+
// third-party script has a chance to fire.
|
|
66
|
+
applyConsentSignals(
|
|
67
|
+
{ essential: true, functional: false, analytics: false, marketing: false },
|
|
68
|
+
currentConfig,
|
|
69
|
+
true
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (currentConfig.patterns) {
|
|
73
|
+
setPatterns(currentConfig.patterns);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setCookieChecker(checkConsent);
|
|
77
|
+
setStorageChecker(checkConsent);
|
|
78
|
+
setScriptChecker(checkConsent);
|
|
79
|
+
|
|
80
|
+
interceptCookies();
|
|
81
|
+
interceptStorage();
|
|
82
|
+
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
83
|
+
|
|
84
|
+
const consent = loadConsent();
|
|
85
|
+
initialized = true;
|
|
86
|
+
|
|
87
|
+
if (hasConsentDecision()) {
|
|
88
|
+
applyConsentSignals(consent, currentConfig, false);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// DNT / GPC handling — if the user signalled opt-out at the browser
|
|
92
|
+
// level and the site opts to respect it, auto-reject before the UI
|
|
93
|
+
// layer ever runs.
|
|
94
|
+
const dntEnabled = isDoNotTrackEnabled();
|
|
95
|
+
let dntApplied = false;
|
|
96
|
+
|
|
97
|
+
if (dntEnabled && currentConfig.respectDNT && currentConfig.dntBehavior !== 'ignore') {
|
|
98
|
+
if (currentConfig.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
99
|
+
const result = storeRejectAll(currentConfig.expiration);
|
|
100
|
+
dntApplied = true;
|
|
101
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
102
|
+
emitReject(result.current);
|
|
103
|
+
emitChange(result.current, result.previous);
|
|
104
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
105
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
emitReady(consent);
|
|
110
|
+
safeInvoke(currentConfig.callbacks?.onReady, consent);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
alreadyInitialized: false,
|
|
114
|
+
config: currentConfig,
|
|
115
|
+
consent,
|
|
116
|
+
hasDecision: hasConsentDecision(),
|
|
117
|
+
dntApplied
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Accept all categories, replay queued items, fire events + callbacks.
|
|
123
|
+
* Returns { current, previous } or null if not yet initialized.
|
|
124
|
+
*/
|
|
125
|
+
export function coreAcceptAll() {
|
|
126
|
+
if (!initialized) return null;
|
|
127
|
+
const result = storeAcceptAll(currentConfig.expiration);
|
|
128
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
129
|
+
replayAll(getCategoryIds());
|
|
130
|
+
emitConsent(result.current, result.previous);
|
|
131
|
+
emitChange(result.current, result.previous);
|
|
132
|
+
safeInvoke(currentConfig.callbacks?.onAccept, result.current);
|
|
133
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reject all non-essential categories, fire events + callbacks.
|
|
139
|
+
*/
|
|
140
|
+
export function coreRejectAll() {
|
|
141
|
+
if (!initialized) return null;
|
|
142
|
+
const result = storeRejectAll(currentConfig.expiration);
|
|
143
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
144
|
+
emitReject(result.current);
|
|
145
|
+
emitChange(result.current, result.previous);
|
|
146
|
+
safeInvoke(currentConfig.callbacks?.onReject);
|
|
147
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Save custom selections and replay only the newly-allowed categories.
|
|
153
|
+
*/
|
|
154
|
+
export function coreUpdateConsent(selections) {
|
|
155
|
+
if (!initialized) return null;
|
|
156
|
+
const result = updateConsent(selections, currentConfig.expiration);
|
|
157
|
+
applyConsentSignals(result.current, currentConfig, false);
|
|
158
|
+
|
|
159
|
+
const newlyAllowed = Object.keys(result.current).filter(
|
|
160
|
+
(cat) => result.current[cat] && !result.previous[cat]
|
|
161
|
+
);
|
|
162
|
+
if (newlyAllowed.length > 0) {
|
|
163
|
+
replayAll(newlyAllowed);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const hasNonEssential = Object.entries(selections || {}).some(
|
|
167
|
+
([cat, val]) => cat !== 'essential' && val
|
|
168
|
+
);
|
|
169
|
+
if (hasNonEssential) {
|
|
170
|
+
emitConsent(result.current, result.previous);
|
|
171
|
+
} else {
|
|
172
|
+
emitReject(result.current);
|
|
173
|
+
}
|
|
174
|
+
emitChange(result.current, result.previous);
|
|
175
|
+
safeInvoke(currentConfig.callbacks?.onChange, result.current);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Clear the consent cookie. The caller is responsible for any UI reset.
|
|
181
|
+
*/
|
|
182
|
+
export function coreReset() {
|
|
183
|
+
resetConsent();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isInitialized() {
|
|
187
|
+
return initialized;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getActiveConfig() {
|
|
191
|
+
return currentConfig;
|
|
192
|
+
}
|
package/src/headless.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zest Headless - consent logic only, zero UI.
|
|
3
|
+
*
|
|
4
|
+
* Use this entry when you want to bring your own banner / modal / settings
|
|
5
|
+
* markup and style it with your own CSS. No Shadow DOM is mounted, no
|
|
6
|
+
* inline stylesheet is injected, and nothing is attached to `window`.
|
|
7
|
+
*
|
|
8
|
+
* Everything you need to wire a custom UI is here:
|
|
9
|
+
*
|
|
10
|
+
* import Zest from '@freshjuice/zest/headless';
|
|
11
|
+
*
|
|
12
|
+
* Zest.init({ mode: 'safe', respectDNT: true });
|
|
13
|
+
*
|
|
14
|
+
* if (!Zest.hasConsentDecision()) {
|
|
15
|
+
* myBanner.show();
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* myAcceptBtn.addEventListener('click', () => Zest.acceptAll());
|
|
19
|
+
* myRejectBtn.addEventListener('click', () => Zest.rejectAll());
|
|
20
|
+
* mySaveBtn.addEventListener('click', () => {
|
|
21
|
+
* Zest.updateConsent({ analytics: true, marketing: false });
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* Zest.on(Zest.EVENTS.CHANGE, (e) => console.log(e.detail.consent));
|
|
25
|
+
*
|
|
26
|
+
* The headless build does NOT auto-initialize. You must call `init()`
|
|
27
|
+
* yourself, so you control exactly when interceptors come online.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
coreInit,
|
|
32
|
+
coreAcceptAll,
|
|
33
|
+
coreRejectAll,
|
|
34
|
+
coreUpdateConsent,
|
|
35
|
+
coreReset,
|
|
36
|
+
isInitialized,
|
|
37
|
+
getActiveConfig
|
|
38
|
+
} from './core-lifecycle.js';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
getConsent,
|
|
42
|
+
hasConsent,
|
|
43
|
+
hasConsentDecision,
|
|
44
|
+
getConsentProof
|
|
45
|
+
} from './storage/consent-store.js';
|
|
46
|
+
|
|
47
|
+
import { isDoNotTrackEnabled, getDNTDetails } from './core/dnt.js';
|
|
48
|
+
|
|
49
|
+
import { EVENTS, on, once } from './storage/events.js';
|
|
50
|
+
|
|
51
|
+
function init(userConfig = {}) {
|
|
52
|
+
const snapshot = coreInit(userConfig);
|
|
53
|
+
// Headless returns the snapshot so callers can decide whether to
|
|
54
|
+
// render their banner / settings UI based on hasDecision / dntApplied.
|
|
55
|
+
return snapshot;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function acceptAll() {
|
|
59
|
+
if (!isInitialized()) {
|
|
60
|
+
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return coreAcceptAll();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rejectAll() {
|
|
67
|
+
if (!isInitialized()) {
|
|
68
|
+
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return coreRejectAll();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function updateConsent(selections) {
|
|
75
|
+
if (!isInitialized()) {
|
|
76
|
+
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return coreUpdateConsent(selections);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reset() {
|
|
83
|
+
coreReset();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const Zest = {
|
|
87
|
+
init,
|
|
88
|
+
|
|
89
|
+
// Consent state
|
|
90
|
+
getConsent,
|
|
91
|
+
hasConsent,
|
|
92
|
+
hasConsentDecision,
|
|
93
|
+
getConsentProof,
|
|
94
|
+
|
|
95
|
+
// Actions
|
|
96
|
+
acceptAll,
|
|
97
|
+
rejectAll,
|
|
98
|
+
updateConsent,
|
|
99
|
+
reset,
|
|
100
|
+
|
|
101
|
+
// DNT introspection
|
|
102
|
+
isDoNotTrackEnabled,
|
|
103
|
+
getDNTDetails,
|
|
104
|
+
|
|
105
|
+
// Events
|
|
106
|
+
on,
|
|
107
|
+
once,
|
|
108
|
+
EVENTS,
|
|
109
|
+
|
|
110
|
+
// Config introspection
|
|
111
|
+
getConfig: getActiveConfig
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default Zest;
|
|
115
|
+
|
|
116
|
+
// Named exports for tree-shake friendly consumers who only need a slice.
|
|
117
|
+
export {
|
|
118
|
+
init,
|
|
119
|
+
acceptAll,
|
|
120
|
+
rejectAll,
|
|
121
|
+
updateConsent,
|
|
122
|
+
reset,
|
|
123
|
+
getConsent,
|
|
124
|
+
hasConsent,
|
|
125
|
+
hasConsentDecision,
|
|
126
|
+
getConsentProof,
|
|
127
|
+
isDoNotTrackEnabled,
|
|
128
|
+
getDNTDetails,
|
|
129
|
+
on,
|
|
130
|
+
once,
|
|
131
|
+
EVENTS,
|
|
132
|
+
getActiveConfig as getConfig
|
|
133
|
+
};
|