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
@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.
No behavioral change unless you opt in. With no
geokey, 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 withbranding: 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:
window.ZestConfig = { geo: true }; // uses the hosted gatewayDefault 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[] }.
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 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.
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.
// 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 {% zest %} shortcode when autoInject: false.
// 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) 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/noticeregions 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
geois set, theinit()snapshot returnsgeoPending: trueuntil 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
npm install @freshjuice/zestOr with a framework plugin:
npm install @freshjuice/zest @freshjuice/zest-astro # Astro
npm install @freshjuice/zest @freshjuice/zest-eleventy # EleventyOr via CDN:
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest@2" data-geo="on"></script> 