# Version 2.4.0

> Opt-in geo / jurisdiction gating with a hosted gateway, a "Do Not Sell" notice for US opt-out states, official Astro and Eleventy plugins, and Powered-by-Zest branding

Source: https://cookiezest.com/changelog/v2.4.0/
Date: 2026-06-08

`@freshjuice/zest@2.4.0` teaches Zest *where* the visitor is, so it can show the right consent experience per jurisdiction instead of the same banner to everyone — plus first official framework plugins for Astro and Eleventy. See the [full release on GitHub](https://github.com/freshjuice-dev/zest/releases/tag/v2.4.0).

> **No behavioral change unless you opt in.** With no `geo` key, Zest shows the banner to everyone exactly as before. The only visible default change: a small "Powered by Zest" attribution link on the banner and settings modal — remove it with `branding: false`.

## Highlights

### Added: geo / jurisdiction gating (opt-in)

Zest can now resolve the visitor's location and decide *which* consent experience to present. Off by default; turn it on with a single flag:

```javascript
window.ZestConfig = { geo: true };   // uses the hosted gateway
```

Default behavior once enabled, matching the legal model:

| Jurisdiction | Action | Experience |
|---|---|---|
| GDPR / EEA / UK | `'consent'` | opt-in — stay blocked, show the full banner |
| US state-privacy (CCPA et al.) | `'notice'` | opt-out — allow tracking, show a small "Do Not Sell My Data" link |
| everywhere else | `'allow'` | no banner, release the queues |

**The verdict source is pluggable.** `geo: true` (or `{ provider: 'gateway' }`) uses the hosted **zest-geo** gateway at `https://geo.cookiezest.com/privacy` (Cloudflare Worker, stores nothing). Self-host it via `geo.endpoint`, or supply your own with `geo.resolver()` — e.g. reading a CDN geo header your edge already sets (`CF-IPCountry`, `x-vercel-ip-country`). Every source returns the same shape: `{ isEU, isEEA, isGDPR, isCCPA, isUSPrivacy, regulations[] }`.

```javascript
window.ZestConfig = {
  geo: {
    // provider: 'gateway' | endpoint: 'https://…' | resolver: async () => ({…})
    decide: (geo) => geo.isGDPR ? 'consent' : geo.isUSPrivacy ? 'notice' : 'allow',
    timeout: 1500,        // ms before falling back
    fallback: 'consent'   // fail-closed action on error/timeout
  }
};
```

Action vocabulary: `'consent'` (opt-in banner), `'notice'` (opt-out "Do Not Sell" link), `'allow'` (release, no UI), `'block'` (stay blocked, no UI). A `decide()` return outside this set is clamped to `fallback`.

See the new [Geo Targeting](/docs/geo/) docs page for the full walkthrough.

### Added: "Do Not Sell" notice UI (full build)

A minimal, dismissible Shadow-DOM link shown for the `'notice'` action. Not a consent gate: tracking already runs (opt-out model), the link flips to a full reject. New `labels.notice` (`title`, `optOut`, `dismiss`).

### Added: `onGeo` callback, `zest:geo` event, `Zest.resolveGeo()`

The `onGeo(action, verdict)` callback and `zest:geo` DOM event fire once resolution completes — the primary hook for headless, which renders no UI. Headless can also `await Zest.resolveGeo()` to get the `{ action, verdict }` result directly; `init()` triggers resolution automatically when `geo` is configured.

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

Zest.init({ geo: true });
const { action, verdict } = await Zest.resolveGeo();
```

### Added: Astro integration — `@freshjuice/zest-astro`

Injects the Zest IIFE inline into `<head>` at build time, so interceptors install before any other script — no extra HTTP request, no waterfall. Pass runtime config (including `geo: true`) through the integration options.

```javascript
// astro.config.mjs
import { defineConfig } from 'astro/config';
import zest from '@freshjuice/zest-astro';

export default defineConfig({
  integrations: [
    zest({ language: 'en', config: { theme: 'auto', geo: true, policyUrl: '/privacy' } })
  ]
});
```

### Added: Eleventy (11ty) plugin — `@freshjuice/zest-eleventy`

Auto-injects the Zest IIFE into the `<head>` of every rendered `.html` page. Same before-everything-else guarantee, no extra request. Supports manual placement via a `{% raw %}{% zest %}{% endraw %}` shortcode when `autoInject: false`.

```javascript
// eleventy.config.js
import zest from '@freshjuice/zest-eleventy';

export default function (eleventyConfig) {
  eleventyConfig.addPlugin(zest, {
    language: 'en',
    config: { theme: 'auto', policyUrl: '/privacy' }
  });
}
```

> In both plugins, config is serialized to an inline `window.ZestConfig`, so the serializable geo forms (`geo: true`, `provider`, `endpoint`, `timeout`, `fallback`) work; the function forms (`resolver` / `decide`) do not survive serialization — use them in client-side JS instead.

### Added: "Powered by Zest" branding

A small attribution link (→ [cookiezest.com](https://cookiezest.com)) now appears on the banner and the settings modal. Enabled by default; remove it with `branding: false` (or `data-branding="false"`). Full build only — headless renders no UI.

## Security

Geo verdicts are untrusted input (they cross the network or come from consumer code), so every field is coerced through `sanitizeVerdict()` before any decision — booleans forced, `regulations[]` filtered to short strings and capped, unknown keys dropped (prototype-pollution safe). `geo.endpoint` is validated with `safeUrl()` (http/https only). On failure or timeout, geo fails **closed** to the `fallback` action (default `'consent'`).

## Notes

- **Sync interceptors, async geo.** Interceptors still install synchronously on script eval (trackers stay blocked); geo resolves asynchronously and the UI is held until the verdict lands. In `allow` / `notice` regions trackers are briefly deferred (~the gateway round-trip) before release — correct fail-closed behavior, not a regression.
- **Geo works in headless too** — the resolver lives in the shared core; only the visual notice is full-build-only.
- When `geo` is set, the `init()` snapshot returns `geoPending: true` until the verdict resolves.

## Migration from v2.3

None required. Every existing config keeps working exactly as before. Geo is opt-in. If you don't want the new attribution link, set `branding: false`.

## Install

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

Or with a framework plugin:

```bash
npm install @freshjuice/zest @freshjuice/zest-astro     # Astro
npm install @freshjuice/zest @freshjuice/zest-eleventy  # Eleventy
```

Or via CDN:

```html
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest@2" data-geo="on"></script>
```

