# Version 2.0.0

> Headless entry for bring-your-own-UI, new Zest.on/once subscribers, and a defense-in-depth security hardening pass

Source: https://zest.freshjuice.dev/changelog/v2.0.0/
Date: 2026-04-23

Zest reaches v2.0.0 with a new **headless entry** for consumers who want to bring their own UI, new event-subscriber helpers, and a comprehensive security hardening pass. See the [full release on GitHub](https://github.com/freshjuice-dev/zest/releases/tag/v2.0.0).

> **Breaking changes ahead.** If you don't reassign `window.Zest` or read `data-blocked-src` directly, you're fine. See the [Breaking](#breaking) section below.

## Highlights

### Headless Entry

A brand new entry point that ships the consent engine without any UI, translations, or auto-init — ~11 KB gzipped. Ideal if you want to render your own banner/modal with your own CSS.

```javascript
import Zest from '@freshjuice/zest/headless';

Zest.init({
  mode: 'safe',
  callbacks: {
    onAccept: (consent) => console.log('Accepted:', consent)
  }
});

Zest.on('consent', (consent) => {
  if (consent.analytics) initAnalytics();
});
```

The headless build:

- Does **not** auto-initialize — you call `Zest.init(config)` explicitly
- Does **not** define `window.Zest`
- Contains no Shadow DOM UI and no translations
- Adds `Zest.updateConsent(selections)` for saving arbitrary category selections from your custom UI

### New Subscriber API

Both entries now ship `Zest.on()` and `Zest.once()` for cleaner event handling:

```javascript
// Subscribe to events
const unsubscribe = Zest.on('consent', (consent) => {
  console.log('User consented to:', consent);
});

// Fire once and auto-unsubscribe
Zest.once('ready', (consent) => {
  console.log('Zest is ready with:', consent);
});

// Reference event names as constants
Zest.on(Zest.EVENTS.CHANGE, handleChange);
```

## Security Hardening

Every config value that ends up in HTML, CSS, URLs, JSON, or a regex now passes through a dedicated validator/sanitizer.

### High severity

- **XSS via `innerHTML`** in banner, modal, and widget — all interpolated config values (labels, category names, aria-labels) now pass through `escapeHTML()` before rendering. The banner `position` class is additionally validated against an allowlist.
- **`javascript:` URL via `policyUrl`** — now validated with `safeUrl()` (allowlist: `http:` / `https:` / `mailto:` / `tel:` / relative). Link `rel` upgraded to `noopener noreferrer`.
- **Arbitrary CSS injection via `customStyles`** — sanitized by stripping `@import`, `@charset`, `expression()`, `-moz-binding`, external `url()` values, and any selectors targeting the accept/reject buttons (prevents clickjacking via invisible-button attacks). 20 KB hard cap.

### Medium severity

- **ReDoS in user-supplied regex patterns** — `setPatterns()` now routes through `safeRegExp()` which rejects catastrophic-backtracking shapes and caps pattern length at 500 characters.
- **DOM-based script-source tampering** — `replayScripts()` no longer re-reads `data-blocked-src` from the DOM. The internal script queue is now the single source of truth, snapshotted before any DOM mutation.
- **Consent cookie on HTTPS** now carries the `Secure` flag.
- **Cookie JSON schema validation** — parsed cookies are sanitized via `sanitizeConsentPayload()` (allowlisted category keys, forced boolean values, prototype-pollution safe).
- **Overly broad tracker URL matching** — tracker matching is now restricted to hostname (and path prefix for entries containing a slash). The old substring fallback caused false positives on URLs with the domain in query params.
- **CSS injection via `accentColor`** — validated with `safeColor()` (hex / named / `rgb()` / `rgba()` / `hsl()` / `hsla()`).

### Low severity

- `window.Zest` is now defined with `writable: false, configurable: false` and the API object is frozen.
- All user-supplied callbacks are invoked through a safe wrapper — exceptions are logged and swallowed so the consent flow stays consistent.
- Cookie / localStorage / sessionStorage / script replay queues are size-capped (100 / 200 / 200 / 500 entries) to mitigate memory DoS.
- Cookie-interceptor descriptor installed with `configurable: false` so a later-loaded script cannot re-override `document.cookie`.
- Script self-labeling as `data-consent-category="essential"` is now ignored — only `functional`, `analytics`, `marketing` self-labels are honored, and mode-assigned categories take precedence.

## Breaking

- **`data-blocked-src` DOM attribute is no longer written** to blocked `<script>` tags. If you were reading this attribute for debugging, switch to `Zest.getConsentProof()` or subscribe via `Zest.on('consent', …)`.
- **`window.Zest` is now locked** (`writable: false, configurable: false`). Code that replaced or monkey-patched the global will now fail silently. Note: `window.ZestConfig` is **not** locked — the config surface is unchanged.

## Changed

- `src/index.js` refactored to delegate non-UI work to a shared `core-lifecycle.js` module. Public API and behavior unchanged.

## Fixed

- Accent-color CSS now falls back cleanly to the default when a non-hex form (named color, `rgb()`, etc.) is supplied that can't be mathematically brightness-shifted.

## Install

```bash
npm install @freshjuice/zest
```

```javascript
// Full build (banner + modal + widget, auto-init)
import '@freshjuice/zest';

// Headless (BYO UI, manual init)
import Zest from '@freshjuice/zest/headless';
Zest.init({ /* your config */ });
```

Or via CDN:

```html
<script src="https://unpkg.com/@freshjuice/zest@2"></script>
```

