Skip to content
PolicyStack
V1
Esc
navigateopen⌘Jpreview
On this page

@policystack/core/consent

Framework-agnostic consent store and the shared concepts every adapter wraps

PolicyStack V1 — current documentation. Supported capabilities and limitations.

Framework-agnostic consent store for Consent. Owns consent state and broadcasts changes to subscribers via a small pub/sub interface that each framework adapter wraps in its own reactivity primitive.

If you’re using a framework, install one of the adapters instead and read this for the shared concepts: react · vue · solid · svelte.

Install

bun add @policystack/core

Quick start

import { createConsentStore } from "@policystack/core/consent";
import { localStorageAdapter } from "@policystack/core/consent/storage/local-storage";

const store = createConsentStore({
	categories: [
		{ key: "essential", label: "Essential", locked: true },
		{ key: "analytics", label: "Analytics" },
		{ key: "marketing", label: "Marketing" },
	],
	adapter: localStorageAdapter(),
});

store.subscribe((state) => render(state));
store.acceptAll();

The store’s surface: getState(), subscribe(), acceptAll(), acceptNecessary(), reject(), toggle(key), save(), setRoute(), has(expr), getConsentRecord(), getPreviousRecord(), refreshJurisdiction(), server. See types.ts for the full shape.

Server rendering (store.server)

getState() and has() read live state, which varies with the environment: a server has no stored record, and timezoneResolver there resolves the host’s timezone rather than the visitor’s. Rendering from live state on the server therefore mismatches the client that hydrates it.

store.server.getState() and store.server.has(expr) return the deterministic pre-consent view instead — undecided, no jurisdiction, conservative opt-in, derived from static config alone. Two stores built from one config agree here whatever adapter or resolver they were given. getState() is frozen and referentially stable, as React’s useSyncExternalStore requires of getServerSnapshot.

The React bindings wire this for you, so consent-driven UI hydrates cleanly with no mounted flag. Custom bindings should render from store.server on the server and during hydration, then switch to live state.

Staged preferences (state.draft)

toggle(key) never changes live consent. It stages the flip in state.draft, and gating (has() / <ConsentGate>), storage, and gated scripts keep reading decisions until save() promotes the draft in one step and stamps decidedAt. Leaving the preferences flow without saving — any setRoute that does not land on "preferences" — discards the draft, so “Back” genuinely abandons unsaved edits and nothing was loaded or persisted in the meantime.

Render preference checkboxes from draft ?? decisions so the panel responds instantly; the framework bindings’ per-category granted accessor does exactly this.

Storage adapters

Decisions persist via a StorageAdapter passed to createConsentStore({ adapter }). Three adapters ship as subpath imports:

  • @policystack/core/consent/storage/local-storage — browser localStorage. Subscribes to storage events for cross-tab sync.
  • @policystack/core/consent/storage/cookiedocument.cookie with a configurable name, domain, and Max-Age. Survives subdomain navigation.
  • @policystack/core/consent/storage/server — header-based read + Set-Cookie write, for SSR runtimes.

Implement the StorageAdapter interface (read, write, clear, optional subscribe) for anything else (IndexedDB, your own backend, etc.).

Storage key

The localStorage and cookie adapters both default to ps_consent, overridable with localStorageAdapter({ key }) and cookieAdapter({ name }). Rather than hardcoding the name server-side, read it off the adapter: cookieAdapter().name.

Before 1.3.0 the default was oc_consent, a leftover from the OpenCookies rebrand. Both adapters still read the old key when the new one is absent, so visitors who already decided are not re-prompted. The fallback is read-only — writes always use ps_consent — with one exception: clear() removes both, so withdrawing consent cannot be undone by the fallback. It is skipped entirely if you pass your own key/name.

On the server, clear consent with getSetCookieHeaders(null), which returns every Set-Cookie header you need to emit including the one expiring the legacy cookie. The singular getSetCookieHeader covers only the canonical cookie.

Jurisdiction

A JurisdictionResolver tells the store which region the visitor is in, so banner defaults can vary (opt-in for EEA/UK, opt-out for US, and so on). The resolved jurisdiction is stored on the consent record and persists across decision changes.

import { createConsentStore, headerResolver } from "@policystack/core/consent";

// Edge runtime (Cloudflare, Vercel, Netlify): read country from request headers.
const store = createConsentStore({
	categories,
	jurisdictionResolver: headerResolver(),
	request, // standard Request, or anything with a Headers instance
});

Four resolvers ship today:

  • headerResolver() reads cf-ipcountry, x-vercel-ip-country, or x-country and normalises the country to a Jurisdiction. Best fit for edge runtimes (Cloudflare, Vercel, Netlify).
  • timezoneResolver() reads Intl.DateTimeFormat().resolvedOptions().timeZone and looks up the country via a bundled IANA → ISO map. Zero network, no IP leak. State-level US jurisdictions (US-CA, US-CO, …) are not derivable from IANA zones — America/Los_Angeles returns "US", not "US-CA".
  • manualResolver(jurisdiction) returns a fixed value — useful for tests and SSR overrides.
  • clientGeoResolver({ endpoint }) fetches a developer-provided endpoint that returns { country, region? }. No IP database is bundled.

There is no default resolver; if you omit jurisdictionResolver, state.jurisdiction stays null and any gpc.applicableJurisdictions filter that requires a known jurisdiction is treated as not matching.

Call store.refreshJurisdiction(req?) to re-resolve (e.g. after client-side navigation in an SSR app). The resolver is otherwise called once per session and cached.

Custom resolver

Implement the JurisdictionResolver interface and reuse countryToJurisdiction for normalisation:

import { type JurisdictionResolver, countryToJurisdiction } from "@policystack/core/consent";

export function ipApiResolver(): JurisdictionResolver {
	return {
		async resolve() {
			const res = await fetch("https://ipapi.co/json/");
			const { country_code } = await res.json();
			return countryToJurisdiction(country_code);
		},
	};
}

Global Privacy Control

Global Privacy Control (GPC) is a browser signal asserting “do not sell or share”. It is legally enforceable under California’s CPRA and the consumer-privacy laws of Colorado, Connecticut, Virginia, and others.

When GPC is asserted, the store sets decisions for opt-out categories to false and stamps state.source = "gpc". GPC is treated as a signal, not a decision: route and decidedAt stay untouched so the banner remains visible and the user can still affirmatively consent (per the W3C GPC draft spec, an explicit user grant overrides the signal). Nothing is persisted to your storage adapter for GPC-only state — getConsentRecord() returns null until the user acts.

The privacy-positive default applies GPC in every jurisdiction with no extra config:

import { createConsentStore } from "@policystack/core/consent";

const store = createConsentStore({ categories });
// Brave (and any browser asserting GPC) starts with all opt-outs denied.

Once a user makes an explicit decision (acceptAll, save, etc.) the resulting record has state.source === "user" and is preserved on reload — applyGPC will not overwrite it.

To scope GPC to the legally-required US states only:

import { GPC_LEGALLY_REQUIRED_JURISDICTIONS, createConsentStore } from "@policystack/core/consent";

const store = createConsentStore({
	categories,
	gpc: { applicableJurisdictions: GPC_LEGALLY_REQUIRED_JURISDICTIONS },
});

The exported list is derived from the jurisdiction capability table and currently covers California, Colorado, Connecticut, Delaware, Maryland, Minnesota, Montana, Nebraska, New Hampshire, New Jersey, Oregon, and Texas. clientGeoResolver preserves all 50 US state codes, so visitors in those states match this scope directly.

A category that should ignore GPC sets respectGPC: false:

const categories = [
	{ key: "essential", label: "Essential", locked: true },
	{ key: "analytics", label: "Analytics", respectGPC: false },
	{ key: "marketing", label: "Marketing" },
];

To disable GPC handling entirely (e.g. you want to display GPC status yourself):

createConsentStore({ categories, gpc: { enabled: false } });

state.source is "default" before any decision, "gpc" after GPC applies, and "user" once the visitor takes any action. Persist this alongside the decisions to keep “the browser said no” distinct from “the user said no” later.

When a decision is persisted via a StorageAdapter, the store serialises it as a versioned ConsentRecord:

type ConsentRecord = {
	schemaVersion: 1;
	decisions: Record<string, boolean>;
	policyVersion: string;
	decidedAt: string; // ISO-8601
	jurisdiction: Jurisdiction | null;
	locale: string;
	source: "banner" | "preferences" | "api" | "import";
};

source records where the decision came from, separately from state.source:

  • "banner" — accepted/rejected from the cookie banner.
  • "preferences" — changed inside the preferences UI.
  • "api" — set via a programmatic call (override with acceptAll({ source: "api" }), etc.).
  • "import" — migrated from a legacy or unrecognised record.

The store infers source from state.route at the moment the decision is taken; pass { source } to any decision action (acceptAll, acceptNecessary, reject, save) to override it. toggle takes no options — it only stages a draft, and the eventual save names the source.

Read the current record via store.getConsentRecord() (or the binding-level useConsent().getConsentRecord()). It returns null until a decision has been recorded.

const store = createConsentStore({
	categories,
	locale: "en-GB", // optional; falls back to navigator.language, then "en"
	adapter: cookieAdapter(),
});

store.acceptAll();
store.getConsentRecord();
// {
//   schemaVersion: 1,
//   decisions: { essential: true, analytics: true, marketing: true },
//   policyVersion: "",
//   decidedAt: "2026-04-29T12:34:56.000Z",
//   jurisdiction: "EEA",
//   locale: "en-GB",
//   source: "banner",
// }

Records produced by older versions of Consent are tolerated on read: missing fields fall back to safe defaults, the legacy source: "user" flag is mapped to "banner", and any other unrecognised legacy source becomes "import". The next user decision rewrites the record in the v1 shape.

GPC alone does not produce a record — the visitor has not made a decision. getConsentRecord() keeps returning null until the user accepts, rejects, or saves their preference changes.

A stored ConsentRecord can become stale: the cookie policy is updated, a new category appears, the visitor moves to a different jurisdiction, or the record simply ages out. Pass a triggers config to declare when the store should re-prompt instead of restoring stored decisions.

const store = createConsentStore({
	categories,
	policyVersion: "v2",
	adapter: cookieAdapter(),
	triggers: {
		policyVersionChanged: true, // config.policyVersion !== record.policyVersion
		categoriesAdded: true, // a category in config is missing from the record
		expiresAfter: "13 months", // older than the duration → re-prompt
		jurisdictionChanged: true, // current jurisdiction differs from the recorded one
	},
});

expiresAfter accepts:

  • a number of milliseconds (60_000);
  • a human-friendly string ("13 months", "30 days", "1 year", "24h", "90s");
  • an ISO 8601 duration ("P13M", "P1Y", "PT24H");
  • null or omitted to never expire.

When any trigger fires, the store invalidates state — route returns to "cookie", decidedAt is cleared, decisions reset to defaults — and exposes the original record on state.repromptReason and store.getPreviousRecord():

const { repromptReason, getPreviousRecord } = useConsent();

if (repromptReason !== null) {
	console.log(`Re-prompting because: ${repromptReason}`);
	console.log("Previous decisions:", getPreviousRecord()?.decisions);
}

repromptReason is one of "policyVersion" | "categoriesAdded" | "expired" | "jurisdiction", in priority order — the first trigger to fire wins. Once the visitor makes a new decision (acceptAll, acceptNecessary, reject, or save), repromptReason clears, getPreviousRecord() returns null, and a fresh record is written via the adapter.

For analytics, the store emits a policystack:reprompt event on globalThis whenever a trigger fires, with event.detail.reason containing the trigger name:

globalThis.addEventListener("policystack:reprompt", (event) => {
	analytics.track("consent_reprompt", { reason: event.detail.reason });
});

Script gating

Third-party tag scripts (GA4, Meta Pixel, PostHog, …) need to be loaded only after the visitor consents to the matching category — but typical site code calls window.gtag(…) from the moment the page boots. gateScript solves that gap: it intercepts pre-consent calls to listed window globals, and once consent is granted it runs the vendor’s snippet bootstrap (init), replays the queued calls into it, and then injects the <script> tag — the same order as the vendor’s documented snippet.

init runs before the script is injected, so it must do what the vendor’s inline snippet does: create the vendor’s own queueing stub and make the initial calls. Vendor scripts like fbevents.js decorate the global that exists when they load and drain its queue — they never create their own.

import { createConsentStore, defineScript, gateScript } from "@policystack/core/consent";

const store = createConsentStore({ categories });

const ga4 = defineScript({
	id: "ga4",
	requires: "analytics",
	src: "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX",
	queue: ["dataLayer.push"],
	init: () => {
		window.dataLayer = window.dataLayer || [];
		window.gtag = function gtag() {
			window.dataLayer.push(arguments);
		};
		window.gtag("js", new Date());
		window.gtag("config", "G-XXXXXXX");
	},
});

gateScript(store, ga4);

gateScript is a free function (rather than a method on the store) so unused script-gating code is tree-shaken out of bundles that never import it. defineScript is a pure identity function; pair it with the snippet above for type-narrowing without dragging in the runtime.

Useful options on the script definition:

  • requires is a ConsentExpr — same shape as store.has. Combine with { and: ["analytics", "marketing"] } for scripts that need multiple categories.
  • queue lists window paths to intercept while gated. Dotted paths like dataLayer.push walk into existing objects (or create them — dataLayer defaults to an array). Pre-consent calls to *.push / *.unshift are mirrored into the underlying array immediately so a script that reads the buffer on load sees the same history.
  • attrs adds attributes to the injected <script> tag (e.g. crossorigin, nonce, integrity).

gateScript returns a dispose(). While the script is still gated, dispose() removes the queue stubs and unsubscribes from the store. Once the script has loaded, dispose() is a no-op — see No auto-revoke below.

No auto-revoke

A loaded script cannot be un-loaded. If consent is later revoked, Consent does not unmount the <script> tag, restore the queue stubs, or re-evaluate the gate. Recommend location.reload() to your users for a clean slate.

Framework consumers should reach for the adapter’s <GatedScript> rather than calling gateScript directly: React, Vue, Solid, and Svelte all bind the provider store to the component lifecycle. React, Vue, and Solid also expose useConsentStore() when gateScripts or another core free function needs the store itself; Svelte callers can inject a pre-created store with setPolicyStackConsentContext({ store }).

For inline JSX gating (e.g. wrapping a <MapWidget /> in a marketing-consent gate) the framework adapters expose <ConsentGate> with the same requires expression shape.

See also

License

Apache-2.0

Last updated on September 6, 2026