@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
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Type definitions for `@freshjuice/zest/headless`.
3
+ *
4
+ * The headless build ships the consent engine without any UI: no Shadow
5
+ * DOM, no styles, no DOM mounting. You bring your own banner / modal /
6
+ * settings markup and call into Zest for the consent state.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import Zest from '@freshjuice/zest/headless';
11
+ *
12
+ * Zest.init({ respectDNT: true, expiration: 365 });
13
+ *
14
+ * if (!Zest.hasConsentDecision()) myBanner.show();
15
+ *
16
+ * acceptBtn.addEventListener('click', () => Zest.acceptAll());
17
+ * rejectBtn.addEventListener('click', () => Zest.rejectAll());
18
+ * ```
19
+ */
20
+
21
+ /** Built-in consent categories. */
22
+ export type ConsentCategory =
23
+ | 'essential'
24
+ | 'functional'
25
+ | 'analytics'
26
+ | 'marketing';
27
+
28
+ /**
29
+ * Per-category boolean consent state. `essential` is always `true` —
30
+ * consent for it cannot be revoked because it covers strictly-necessary
31
+ * processing.
32
+ */
33
+ export type ConsentState =
34
+ & Partial<Record<ConsentCategory, boolean>>
35
+ & { essential: true };
36
+
37
+ /** Snapshot returned by `init()`. */
38
+ export interface InitSnapshot {
39
+ consent: ConsentState;
40
+ hasDecision: boolean;
41
+ dntApplied: boolean;
42
+ }
43
+
44
+ /** Tamper-evident proof of the user's last consent decision. */
45
+ export interface ConsentProof {
46
+ version: string;
47
+ timestamp: number;
48
+ categories: ConsentState;
49
+ }
50
+
51
+ /** Output of `getDNTDetails()`. */
52
+ export interface DNTDetails {
53
+ dnt: boolean;
54
+ gpc: boolean;
55
+ doNotTrack: string | null;
56
+ globalPrivacyControl: boolean;
57
+ }
58
+
59
+ /** Behaviour when DNT / GPC is detected at init time. */
60
+ export type DNTBehavior = 'reject' | 'preselect' | 'ignore';
61
+
62
+ /**
63
+ * Optional consumer callbacks. Each is wrapped in a try/catch internally
64
+ * so a thrown error never breaks the consent pipeline.
65
+ */
66
+ export interface ZestCallbacks {
67
+ onAccept?: (consent: ConsentState) => void;
68
+ onReject?: (consent: ConsentState) => void;
69
+ onChange?: (consent: ConsentState) => void;
70
+ onReady?: (consent: ConsentState) => void;
71
+ }
72
+
73
+ /** Configuration accepted by `init()`. */
74
+ export interface InitOptions {
75
+ /** Respect Do Not Track / Global Privacy Control. Default `true`. */
76
+ respectDNT?: boolean;
77
+ /** What to do when DNT/GPC is on. Default `'reject'`. */
78
+ dntBehavior?: DNTBehavior;
79
+ /** Cookie expiration in days. Default `365`. */
80
+ expiration?: number;
81
+ /** Consumer callbacks. */
82
+ callbacks?: ZestCallbacks;
83
+ /** Anything else — Zest tolerates unknown keys at runtime. */
84
+ [key: string]: unknown;
85
+ }
86
+
87
+ /** Event names emitted on the `window` `document.documentElement`. */
88
+ export interface ZestEvents {
89
+ READY: 'zest:ready';
90
+ CONSENT: 'zest:consent';
91
+ REJECT: 'zest:reject';
92
+ CHANGE: 'zest:change';
93
+ SHOW: 'zest:show';
94
+ HIDE: 'zest:hide';
95
+ }
96
+
97
+ export type ZestEventName = ZestEvents[keyof ZestEvents];
98
+
99
+ /** Detail payload of the consent-change event. */
100
+ export interface ZestEventDetail {
101
+ consent: ConsentState;
102
+ previous?: ConsentState;
103
+ }
104
+
105
+ declare const Zest: {
106
+ /** Initialise the consent engine. Must be called before any other API. */
107
+ init(options?: InitOptions): InitSnapshot;
108
+
109
+ /** Current consent state (clone, safe to mutate). */
110
+ getConsent(): ConsentState;
111
+
112
+ /** Has the user granted consent for `category`? */
113
+ hasConsent(category: ConsentCategory): boolean;
114
+
115
+ /** Has the user made any consent decision yet (accept, reject, or
116
+ * partial)? */
117
+ hasConsentDecision(): boolean;
118
+
119
+ /** Tamper-evident snapshot of the last consent decision. */
120
+ getConsentProof(): ConsentProof | null;
121
+
122
+ /** Grant consent for every category. */
123
+ acceptAll(): ConsentState | null;
124
+
125
+ /** Revoke consent for every non-essential category. */
126
+ rejectAll(): ConsentState | null;
127
+
128
+ /** Set per-category consent. Missing keys are left untouched. */
129
+ updateConsent(
130
+ selections: Partial<Record<ConsentCategory, boolean>>
131
+ ): ConsentState | null;
132
+
133
+ /** Wipe all consent state. Useful for "I changed my mind" flows. */
134
+ reset(): void;
135
+
136
+ /** True if the browser is sending DNT or GPC. */
137
+ isDoNotTrackEnabled(): boolean;
138
+
139
+ /** Why `isDoNotTrackEnabled()` returned what it did. */
140
+ getDNTDetails(): DNTDetails;
141
+
142
+ /** Subscribe to a consent event. Returns an unsubscribe function. */
143
+ on(
144
+ eventName: ZestEventName,
145
+ handler: (event: CustomEvent<ZestEventDetail>) => void
146
+ ): () => void;
147
+
148
+ /** Subscribe once; auto-unsubscribes after the first call. */
149
+ once(
150
+ eventName: ZestEventName,
151
+ handler: (event: CustomEvent<ZestEventDetail>) => void
152
+ ): () => void;
153
+
154
+ /** Constants for `on()` / `once()`. */
155
+ EVENTS: ZestEvents;
156
+
157
+ /** Active configuration after `init()`. */
158
+ getConfig(): InitOptions | null;
159
+ };
160
+
161
+ export default Zest;
162
+
163
+ // Named tree-shake-friendly exports.
164
+ export const init: typeof Zest.init;
165
+ export const acceptAll: typeof Zest.acceptAll;
166
+ export const rejectAll: typeof Zest.rejectAll;
167
+ export const updateConsent: typeof Zest.updateConsent;
168
+ export const reset: typeof Zest.reset;
169
+ export const getConsent: typeof Zest.getConsent;
170
+ export const hasConsent: typeof Zest.hasConsent;
171
+ export const hasConsentDecision: typeof Zest.hasConsentDecision;
172
+ export const getConsentProof: typeof Zest.getConsentProof;
173
+ export const isDoNotTrackEnabled: typeof Zest.isDoNotTrackEnabled;
174
+ export const getDNTDetails: typeof Zest.getDNTDetails;
175
+ export const on: typeof Zest.on;
176
+ export const once: typeof Zest.once;
177
+ export const EVENTS: ZestEvents;
178
+ export const getConfig: typeof Zest.getConfig;
package/src/ui/banner.js CHANGED
@@ -4,30 +4,34 @@
4
4
 
5
5
  import { generateStyles } from './styles.js';
6
6
  import { getCurrentConfig } from '../config/parser.js';
7
+ import { escapeHTML } from '../core/security.js';
7
8
 
8
9
  let bannerElement = null;
9
10
  let shadowRoot = null;
10
11
 
12
+ const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
13
+
11
14
  /**
12
15
  * Create the banner HTML
13
16
  */
14
17
  function createBannerHTML(config) {
15
18
  const labels = config.labels.banner;
16
- const position = config.position || 'bottom';
19
+ const rawPosition = config.position || 'bottom';
20
+ const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
17
21
 
18
22
  return `
19
- <div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
20
- <h2 class="zest-banner__title">${labels.title}</h2>
21
- <p class="zest-banner__description">${labels.description}</p>
23
+ <div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
24
+ <h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
25
+ <p class="zest-banner__description">${escapeHTML(labels.description)}</p>
22
26
  <div class="zest-banner__buttons">
23
27
  <button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
24
- ${labels.acceptAll}
28
+ ${escapeHTML(labels.acceptAll)}
25
29
  </button>
26
30
  <button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
27
- ${labels.rejectAll}
31
+ ${escapeHTML(labels.rejectAll)}
28
32
  </button>
29
33
  <button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
30
- ${labels.settings}
34
+ ${escapeHTML(labels.settings)}
31
35
  </button>
32
36
  </div>
33
37
  </div>
package/src/ui/modal.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { generateStyles } from './styles.js';
6
6
  import { getCurrentConfig } from '../config/parser.js';
7
7
  import { DEFAULT_CATEGORIES } from '../core/categories.js';
8
+ import { escapeHTML, safeUrl } from '../core/security.js';
8
9
 
9
10
  let modalElement = null;
10
11
  let shadowRoot = null;
@@ -16,22 +17,24 @@ let currentSelections = {};
16
17
  function createCategoryHTML(category, isChecked, isRequired) {
17
18
  const disabled = isRequired ? 'disabled' : '';
18
19
  const checked = isChecked ? 'checked' : '';
20
+ const safeId = escapeHTML(category.id);
21
+ const safeLabel = escapeHTML(category.label);
19
22
 
20
23
  return `
21
24
  <div class="zest-category">
22
25
  <div class="zest-category__header">
23
26
  <div class="zest-category__info">
24
- <span class="zest-category__label">${category.label}</span>
25
- <p class="zest-category__description">${category.description}</p>
27
+ <span class="zest-category__label">${safeLabel}</span>
28
+ <p class="zest-category__description">${escapeHTML(category.description)}</p>
26
29
  </div>
27
30
  <label class="zest-toggle">
28
31
  <input
29
32
  type="checkbox"
30
33
  class="zest-toggle__input"
31
- data-category="${category.id}"
34
+ data-category="${safeId}"
32
35
  ${checked}
33
36
  ${disabled}
34
- aria-label="${category.label}"
37
+ aria-label="${safeLabel}"
35
38
  >
36
39
  <span class="zest-toggle__slider"></span>
37
40
  </label>
@@ -55,29 +58,30 @@ function createModalHTML(config, consent) {
55
58
  ))
56
59
  .join('');
57
60
 
58
- const policyLink = config.policyUrl
59
- ? `<a href="${config.policyUrl}" class="zest-link" target="_blank" rel="noopener">Privacy Policy</a>`
61
+ const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
62
+ const policyLink = validatedPolicyUrl
63
+ ? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
60
64
  : '';
61
65
 
62
66
  return `
63
- <div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
67
+ <div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
64
68
  <div class="zest-modal">
65
69
  <div class="zest-modal__header">
66
- <h2 class="zest-modal__title">${labels.title}</h2>
67
- <p class="zest-modal__description">${labels.description} ${policyLink}</p>
70
+ <h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
71
+ <p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
68
72
  </div>
69
73
  <div class="zest-modal__body">
70
74
  ${categoriesHTML}
71
75
  </div>
72
76
  <div class="zest-modal__footer">
73
77
  <button type="button" class="zest-btn zest-btn--primary" data-action="save">
74
- ${labels.save}
78
+ ${escapeHTML(labels.save)}
75
79
  </button>
76
80
  <button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
77
- ${labels.acceptAll}
81
+ ${escapeHTML(labels.acceptAll)}
78
82
  </button>
79
83
  <button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
80
- ${labels.rejectAll}
84
+ ${escapeHTML(labels.rejectAll)}
81
85
  </button>
82
86
  </div>
83
87
  </div>
package/src/ui/styles.js CHANGED
@@ -2,11 +2,18 @@
2
2
  * Styles - Shadow DOM encapsulated CSS with theming
3
3
  */
4
4
 
5
+ import { safeColor, sanitizeCustomStyles } from '../core/security.js';
6
+
7
+ const DEFAULT_ACCENT = '#4F46E5';
8
+
5
9
  /**
6
10
  * Generate CSS with custom properties
7
11
  */
8
12
  export function generateStyles(config) {
9
- const accentColor = config.accentColor || '#4F46E5';
13
+ // Only accept colors that pass strict validation — an unvalidated
14
+ // value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
15
+ const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
16
+ const customCss = sanitizeCustomStyles(config.customStyles);
10
17
 
11
18
  return `
12
19
  :host {
@@ -476,15 +483,29 @@ export function generateStyles(config) {
476
483
  .zest-hidden {
477
484
  display: none !important;
478
485
  }
479
- ${config.customStyles || ''}
486
+ ${customCss}
480
487
  `;
481
488
  }
482
489
 
483
490
  /**
484
- * Adjust color brightness
491
+ * Adjust color brightness. Falls back to the default accent if the input
492
+ * cannot be parsed as a hex color (non-hex inputs pass safeColor but
493
+ * can't be brightness-shifted mathematically).
485
494
  */
486
495
  function adjustColor(hex, percent) {
487
- const num = parseInt(hex.replace('#', ''), 16);
496
+ if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
497
+ hex = DEFAULT_ACCENT;
498
+ }
499
+ let clean = hex.trim().replace('#', '');
500
+ // Expand 3-digit form to 6
501
+ if (clean.length === 3) {
502
+ clean = clean.split('').map(c => c + c).join('');
503
+ }
504
+ // Strip alpha if present
505
+ if (clean.length === 8) clean = clean.slice(0, 6);
506
+ if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
507
+
508
+ const num = parseInt(clean, 16);
488
509
  const amt = Math.round(2.55 * percent);
489
510
  const R = Math.min(255, Math.max(0, (num >> 16) + amt));
490
511
  const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
package/src/ui/widget.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { generateStyles, COOKIE_ICON } from './styles.js';
6
6
  import { getCurrentConfig } from '../config/parser.js';
7
+ import { escapeHTML } from '../core/security.js';
7
8
 
8
9
  let widgetElement = null;
9
10
  let shadowRoot = null;
@@ -13,10 +14,11 @@ let shadowRoot = null;
13
14
  */
14
15
  function createWidgetHTML(config) {
15
16
  const labels = config.labels.widget;
17
+ const safeLabel = escapeHTML(labels.label);
16
18
 
17
19
  return `
18
20
  <div class="zest-widget">
19
- <button type="button" class="zest-widget__btn" aria-label="${labels.label}" title="${labels.label}">
21
+ <button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
20
22
  <span class="zest-widget__icon">${COOKIE_ICON}</span>
21
23
  </button>
22
24
  </div>