From 2e7964fac8b88da0b5fabac80c57870a315903ae Mon Sep 17 00:00:00 2001 From: Jamie Davenport <1329874+jamiedavenport@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:54:35 +0100 Subject: [PATCH 1/4] feat(core): default consent storage to ps_consent with a legacy oc_consent read (#161) --- apps/web/content/docs/consent/core.md | 8 +++ .../core/src/consent/storage/cookie.test.ts | 50 ++++++++++++-- packages/core/src/consent/storage/cookie.ts | 69 +++++++++++++++---- .../src/consent/storage/local-storage.test.ts | 69 ++++++++++++++++++- .../core/src/consent/storage/local-storage.ts | 52 ++++++++++---- packages/react/src/consent.ssr.test.tsx | 4 +- 6 files changed, 216 insertions(+), 36 deletions(-) diff --git a/apps/web/content/docs/consent/core.md b/apps/web/content/docs/consent/core.md index b40792b3..ec819183 100644 --- a/apps/web/content/docs/consent/core.md +++ b/apps/web/content/docs/consent/core.md @@ -59,6 +59,14 @@ Decisions persist via a `StorageAdapter` passed to `createConsentStore({ adapter 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. diff --git a/packages/core/src/consent/storage/cookie.test.ts b/packages/core/src/consent/storage/cookie.test.ts index d4616708..ec85570d 100644 --- a/packages/core/src/consent/storage/cookie.test.ts +++ b/packages/core/src/consent/storage/cookie.test.ts @@ -33,7 +33,7 @@ describe("cookieAdapter (browser)", () => { it("round-trips via document.cookie with default name", () => { const adapter = cookieAdapter({ secure: false }); adapter.write(sample); - expect(document.cookie).toContain("oc_consent="); + expect(document.cookie).toContain("ps_consent="); expect(adapter.read()).toEqual(sample); }); @@ -50,7 +50,7 @@ describe("cookieAdapter (browser)", () => { }); it("returns null when cookie is corrupt", () => { - document.cookie = "oc_consent=not-base64-json; Path=/"; + document.cookie = "ps_consent=not-base64-json; Path=/"; expect(cookieAdapter().read()).toBeNull(); }); @@ -61,6 +61,42 @@ describe("cookieAdapter (browser)", () => { expect(adapter.read()).toBeNull(); }); + it("reads a pre-rebrand oc_consent cookie", () => { + const adapter = cookieAdapter({ secure: false }); + document.cookie = `oc_consent=${adapter.serialize(sample)}; Path=/`; + expect(adapter.read()).toEqual(sample); + }); + + it("does not read the legacy cookie when a custom name is set", () => { + const adapter = cookieAdapter({ name: "consent", secure: false }); + document.cookie = `oc_consent=${adapter.serialize(sample)}; Path=/`; + expect(adapter.read()).toBeNull(); + }); + + it("clear() expires the legacy cookie too, so consent is not resurrected", () => { + const adapter = cookieAdapter({ secure: false }); + document.cookie = `oc_consent=${adapter.serialize(sample)}; Path=/`; + adapter.write(sample); + adapter.clear(); + expect(adapter.read()).toBeNull(); + // happy-dom keeps an expired cookie as an empty entry, so assert no value + // survives rather than that the name is gone entirely. + expect(document.cookie).not.toMatch(/oc_consent=[^;\s]/); + }); + + it("getSetCookieHeaders expires both cookies on clear, one on write", () => { + const adapter = cookieAdapter(); + const cleared = adapter.getSetCookieHeaders(null); + expect(cleared).toHaveLength(2); + expect(cleared[1]).toMatch(/^oc_consent=; /); + expect(cleared[1]).toContain("Max-Age=0"); + expect(adapter.getSetCookieHeaders(sample)).toHaveLength(1); + }); + + it("getSetCookieHeaders emits only the custom name when one is set", () => { + expect(cookieAdapter({ name: "consent" }).getSetCookieHeaders(null)).toHaveLength(1); + }); + it("Set-Cookie header includes Path, Max-Age, SameSite, Secure", () => { const adapter = cookieAdapter(); const header = adapter.getSetCookieHeader(sample); @@ -73,7 +109,7 @@ describe("cookieAdapter (browser)", () => { it("clear header has Max-Age=0 and empty value", () => { const adapter = cookieAdapter(); const header = adapter.getSetCookieHeader(null); - expect(header).toMatch(/^oc_consent=; /); + expect(header).toMatch(/^ps_consent=; /); expect(header).toContain("Max-Age=0"); }); @@ -94,7 +130,7 @@ describe("cookieAdapter (Edge / SSR)", () => { it("reads from a request-like object's cookie header", () => { const writer = cookieAdapter(); const value = writer.serialize(sample); - const request = { headers: new Headers({ cookie: `oc_consent=${value}` }) }; + const request = { headers: new Headers({ cookie: `ps_consent=${value}` }) }; const adapter = cookieAdapter({ request }); expect(adapter.read()).toEqual(sample); }); @@ -110,7 +146,7 @@ describe("cookieAdapter (Edge / SSR)", () => { adapter.write(sample); expect(onSetCookie).toHaveBeenCalledOnce(); const header = onSetCookie.mock.calls[0]?.[0] as string; - expect(header).toMatch(/^oc_consent=/); + expect(header).toMatch(/^ps_consent=/); expect(header).toContain("Max-Age="); }); @@ -148,7 +184,7 @@ describe("cookieAdapter (bare value)", () => { it("serialize() matches the value getSetCookieHeader() writes", () => { const adapter = cookieAdapter(); const header = adapter.getSetCookieHeader(sample); - expect(header).toContain(`oc_consent=${adapter.serialize(sample)};`); + expect(header).toContain(`ps_consent=${adapter.serialize(sample)};`); }); // The E2E seeding case from #167: a value set by hand must be readable. @@ -163,7 +199,7 @@ describe("cookieAdapter (bare value)", () => { }); it("name exposes the resolved cookie name", () => { - expect(cookieAdapter().name).toBe("oc_consent"); + expect(cookieAdapter().name).toBe("ps_consent"); expect(cookieAdapter({ name: "consent" }).name).toBe("consent"); }); }); diff --git a/packages/core/src/consent/storage/cookie.ts b/packages/core/src/consent/storage/cookie.ts index 351a3547..c2467b36 100644 --- a/packages/core/src/consent/storage/cookie.ts +++ b/packages/core/src/consent/storage/cookie.ts @@ -24,14 +24,36 @@ export type CookieAdapter = { clear(): void; serialize(record: ConsentRecord): string; deserialize(value: string): ConsentRecord | null; + /** + * The `Set-Cookie` header for the canonical cookie only. Clearing through + * this leaves a pre-rebrand `oc_consent` cookie in place, which the lenient + * read would then resurrect — SSR callers should prefer + * {@link CookieAdapter.getSetCookieHeaders}. + */ getSetCookieHeader(record: ConsentRecord | null): string; + /** + * Every `Set-Cookie` header the caller must emit. Same as + * `getSetCookieHeader` for writes; on clear it also expires the legacy + * cookie. + */ + getSetCookieHeaders(record: ConsentRecord | null): string[]; parse(header: string | null | undefined): ConsentRecord | null; }; const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365; +const DEFAULT_NAME = "ps_consent"; + +// ─── oc_ → ps_ rebrand migration shim (#161) — remove pre-freeze ─── +// Mirrors localStorageAdapter: canonical writes under `ps_consent`, lenient +// reads so pre-rebrand visitors keep their decision. Only consulted when the +// caller did not pick their own name. +const LEGACY_NAME = "oc_consent"; +// ─── end migration shim ─── + export function cookieAdapter(options: CookieAdapterOptions = {}): CookieAdapter { - const name = options.name ?? "oc_consent"; + const name = options.name ?? DEFAULT_NAME; + const legacyName = options.name === undefined ? LEGACY_NAME : null; const path = options.path ?? "/"; const sameSite = options.sameSite ?? "lax"; const secure = options.secure ?? true; @@ -49,19 +71,28 @@ export function cookieAdapter(options: CookieAdapterOptions = {}): CookieAdapter return null; } - function parseCookieHeader(header: string | null | undefined): string | null { + function readCookieValue(header: string | null | undefined, cookieName: string): string | null { if (!header) return null; const parts = header.split(";"); for (const part of parts) { const eq = part.indexOf("="); if (eq === -1) continue; const k = part.slice(0, eq).trim(); - if (k !== name) continue; - return part.slice(eq + 1).trim(); + if (k !== cookieName) continue; + // An expired cookie can linger with an empty value; that carries no + // record, so treat it as absent rather than letting it shadow the + // legacy fallback. + return part.slice(eq + 1).trim() || null; } return null; } + function parseCookieHeader(header: string | null | undefined): string | null { + const current = readCookieValue(header, name); + if (current !== null) return current; + return legacyName === null ? null : readCookieValue(header, legacyName); + } + function decode(value: string | null): ConsentRecord | null { if (value === null) return null; try { @@ -83,8 +114,8 @@ export function cookieAdapter(options: CookieAdapterOptions = {}): CookieAdapter return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } - function buildHeader(value: string, expireMaxAge: number): string { - const parts = [`${name}=${value}`, `Path=${path}`, `Max-Age=${expireMaxAge}`]; + function buildHeader(value: string, expireMaxAge: number, cookieName = name): string { + const parts = [`${cookieName}=${value}`, `Path=${path}`, `Max-Age=${expireMaxAge}`]; if (domain) parts.push(`Domain=${domain}`); parts.push(`SameSite=${capitalize(sameSite)}`); if (secure) parts.push("Secure"); @@ -102,24 +133,38 @@ export function cookieAdapter(options: CookieAdapterOptions = {}): CookieAdapter return buildHeader(encode(record), maxAge); } + function getSetCookieHeaders(record: ConsentRecord | null): string[] { + const headers = [getSetCookieHeader(record)]; + // Expire the pre-rebrand cookie too, or the lenient read would resurrect + // the decision the visitor just withdrew. + if (record === null && legacyName !== null) { + headers.push(buildHeader("", 0, legacyName)); + } + return headers; + } + + function emit(headers: string[]): void { + for (const header of headers) { + setBrowserCookie(header); + if (onSetCookie) onSetCookie(header); + } + } + return { name, read() { return decode(parseCookieHeader(readCookieHeader())); }, write(record) { - const header = getSetCookieHeader(record); - setBrowserCookie(header); - if (onSetCookie) onSetCookie(header); + emit(getSetCookieHeaders(record)); }, clear() { - const header = getSetCookieHeader(null); - setBrowserCookie(header); - if (onSetCookie) onSetCookie(header); + emit(getSetCookieHeaders(null)); }, serialize: encode, deserialize: decode, getSetCookieHeader, + getSetCookieHeaders, parse(header) { return decode(parseCookieHeader(header)); }, diff --git a/packages/core/src/consent/storage/local-storage.test.ts b/packages/core/src/consent/storage/local-storage.test.ts index 759caae8..7685f5fe 100644 --- a/packages/core/src/consent/storage/local-storage.test.ts +++ b/packages/core/src/consent/storage/local-storage.test.ts @@ -27,13 +27,48 @@ describe("localStorageAdapter", () => { const adapter = localStorageAdapter(); adapter.write(sample); expect(adapter.read()).toEqual(sample); - expect(localStorage.getItem("oc_consent")).toBe(JSON.stringify(sample)); + expect(localStorage.getItem("ps_consent")).toBe(JSON.stringify(sample)); }); it("uses a custom key", () => { const adapter = localStorageAdapter({ key: "custom" }); adapter.write(sample); expect(localStorage.getItem("custom")).toBe(JSON.stringify(sample)); + expect(localStorage.getItem("ps_consent")).toBeNull(); + expect(localStorage.getItem("oc_consent")).toBeNull(); + }); + + it("reads a pre-rebrand oc_consent record so visitors are not re-prompted", () => { + localStorage.setItem("oc_consent", JSON.stringify(sample)); + expect(localStorageAdapter().read()).toEqual(sample); + }); + + it("prefers the canonical key over the legacy one", () => { + const legacy: ConsentRecord = { ...sample, policyVersion: "v0" }; + localStorage.setItem("oc_consent", JSON.stringify(legacy)); + localStorage.setItem("ps_consent", JSON.stringify(sample)); + expect(localStorageAdapter().read()).toEqual(sample); + }); + + it("writes to the canonical key and leaves the legacy value untouched", () => { + localStorage.setItem("oc_consent", JSON.stringify(sample)); + const adapter = localStorageAdapter(); + adapter.write(sample); + expect(localStorage.getItem("ps_consent")).toBe(JSON.stringify(sample)); + expect(localStorage.getItem("oc_consent")).toBe(JSON.stringify(sample)); + }); + + it("does not read the legacy key when a custom key is set", () => { + localStorage.setItem("oc_consent", JSON.stringify(sample)); + expect(localStorageAdapter({ key: "custom" }).read()).toBeNull(); + }); + + it("clear() removes the legacy key too, so consent is not resurrected", () => { + localStorage.setItem("oc_consent", JSON.stringify(sample)); + const adapter = localStorageAdapter(); + adapter.write(sample); + adapter.clear(); + expect(adapter.read()).toBeNull(); expect(localStorage.getItem("oc_consent")).toBeNull(); }); @@ -42,7 +77,7 @@ describe("localStorageAdapter", () => { }); it("returns null when stored value is corrupt", () => { - localStorage.setItem("oc_consent", "{not json"); + localStorage.setItem("ps_consent", "{not json"); expect(localStorageAdapter().read()).toBeNull(); }); @@ -90,7 +125,7 @@ describe("localStorageAdapter", () => { expect(unsubscribe).toBeTypeOf("function"); const event = new StorageEvent("storage", { - key: "oc_consent", + key: "ps_consent", newValue: JSON.stringify(sample), }); window.dispatchEvent(event); @@ -107,6 +142,34 @@ describe("localStorageAdapter", () => { unsubscribe?.(); }); + it("notifies subscribers on cross-tab writes to the legacy key", () => { + const adapter = localStorageAdapter(); + const listener = vi.fn(); + const unsubscribe = adapter.subscribe?.(listener); + + window.dispatchEvent( + new StorageEvent("storage", { + key: "oc_consent", + newValue: JSON.stringify(sample), + }), + ); + expect(listener).toHaveBeenCalledWith(sample); + + // Once the canonical key holds a value it wins, so a stale legacy-key + // event must not roll the visitor back. + localStorage.setItem("ps_consent", JSON.stringify(sample)); + listener.mockClear(); + window.dispatchEvent( + new StorageEvent("storage", { + key: "oc_consent", + newValue: JSON.stringify({ ...sample, policyVersion: "v0" }), + }), + ); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe?.(); + }); + it("subscribe returns a no-op unsubscribe when window is unavailable", () => { vi.stubGlobal("window", undefined); const adapter = localStorageAdapter(); diff --git a/packages/core/src/consent/storage/local-storage.ts b/packages/core/src/consent/storage/local-storage.ts index bb63e9a1..4971e51b 100644 --- a/packages/core/src/consent/storage/local-storage.ts +++ b/packages/core/src/consent/storage/local-storage.ts @@ -1,5 +1,17 @@ import type { ConsentRecord } from "../types"; +const DEFAULT_KEY = "ps_consent"; + +// ─── oc_ → ps_ rebrand migration shim (#161) — remove pre-freeze ─── +// Writes are canonical (`ps_consent`); reads stay lenient so visitors who +// decided under the pre-rebrand key are not re-prompted. Only consulted when +// the caller did not pick their own key — a custom-keyed adapter must never +// read the legacy key. `clear()` is the deliberate exception to "never delete": +// it removes both, or a withdrawn decision would be resurrected by the +// fallback read on the very next `read()`. +const LEGACY_KEY = "oc_consent"; +// ─── end migration shim ─── + export type LocalStorageAdapterOptions = { key?: string; }; @@ -12,7 +24,8 @@ export type LocalStorageAdapter = { }; export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): LocalStorageAdapter { - const key = options.key ?? "oc_consent"; + const key = options.key ?? DEFAULT_KEY; + const legacyKey = options.key === undefined ? LEGACY_KEY : null; const memory = new Map(); function getStorage(): Storage | null { @@ -20,7 +33,7 @@ export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): L if (typeof globalThis === "undefined") return null; const ls = (globalThis as { localStorage?: Storage }).localStorage; if (!ls) return null; - const probe = "__oc_probe__"; + const probe = "__ps_probe__"; ls.setItem(probe, "1"); ls.removeItem(probe); return ls; @@ -29,16 +42,24 @@ export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): L } } - function readRaw(): string | null { + function readKey(name: string): string | null { const ls = getStorage(); if (ls) { try { - return ls.getItem(key); + // An empty entry carries no record; treat it as absent rather than + // letting it shadow the legacy fallback below. + return ls.getItem(name) || null; } catch { // fall through to memory } } - return memory.get(key) ?? null; + return memory.get(name) || null; + } + + function readRaw(): string | null { + const current = readKey(key); + if (current !== null) return current; + return legacyKey === null ? null : readKey(legacyKey); } function writeRaw(value: string): void { @@ -56,14 +77,18 @@ export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): L function clearRaw(): void { const ls = getStorage(); - if (ls) { - try { - ls.removeItem(key); - } catch { - // ignore + // The legacy key goes too, otherwise readRaw() would fall back to it and + // resurrect the decision the visitor just withdrew. + for (const name of legacyKey === null ? [key] : [key, legacyKey]) { + if (ls) { + try { + ls.removeItem(name); + } catch { + // ignore + } } + memory.delete(name); } - memory.delete(key); } function decode(raw: string | null): ConsentRecord | null { @@ -93,7 +118,10 @@ export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): L } const handler = (event: Event) => { const e = event as StorageEvent; - if (e.key !== key && e.key !== null) return; + if (e.key !== key && e.key !== legacyKey && e.key !== null) return; + // A legacy-key event is only news if the canonical key is still empty; + // otherwise the canonical value already won and readRaw() reflects it. + if (e.key !== null && e.key === legacyKey && readKey(key) !== null) return; listener(decode(e.newValue ?? readRaw())); }; target.addEventListener("storage", handler); diff --git a/packages/react/src/consent.ssr.test.tsx b/packages/react/src/consent.ssr.test.tsx index 999ed489..4e4bf87a 100644 --- a/packages/react/src/consent.ssr.test.tsx +++ b/packages/react/src/consent.ssr.test.tsx @@ -125,7 +125,7 @@ describe("SSR hydration", () => { // The visitor already decided, so mergeRecord starts the live client // store at route "closed". - localStorage.setItem("oc_consent", JSON.stringify(RETURNING_VISITOR)); + localStorage.setItem("ps_consent", JSON.stringify(RETURNING_VISITOR)); const { container, onRecoverableError, consoleError } = await hydrate( html, @@ -150,7 +150,7 @@ describe("SSR hydration", () => { // No record and an opt-in posture, so the gate is closed on the server. expect(html).not.toContain("Analytics"); - localStorage.setItem("oc_consent", JSON.stringify(RETURNING_VISITOR)); + localStorage.setItem("ps_consent", JSON.stringify(RETURNING_VISITOR)); const { container, onRecoverableError, consoleError } = await hydrate( html, From 0d783e3ec6bc997b5ae51c7d6207dc56da80c017 Mon Sep 17 00:00:00 2001 From: Jamie Davenport <1329874+jamiedavenport@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:54:41 +0100 Subject: [PATCH 2/4] fix(vite): resolve aliased ConsentGate imports in the consent scanner (#161) --- apps/web/content/docs/consent/scanner.md | 4 +- packages/vite/src/consent/ungated.test.ts | 69 +++++++++++++++++++++++ packages/vite/src/consent/ungated.ts | 47 +++++++++++++-- packages/vite/src/consent/visit.ts | 2 +- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/apps/web/content/docs/consent/scanner.md b/apps/web/content/docs/consent/scanner.md index c8551b11..599e0238 100644 --- a/apps/web/content/docs/consent/scanner.md +++ b/apps/web/content/docs/consent/scanner.md @@ -75,7 +75,9 @@ conservative — it favours false negatives over false positives. A hit is treated as gated when any ancestor in its AST path is one of: -- a JSX element named `` +- a JSX element named ``, or one imported from a PolicyStack + package under any local name — both `import { ConsentGate as Gate }` → + `` and `import * as PS` → `` are recognised - an `if` / ternary whose test contains a `.has(...)` call (matches both `consent.has("analytics")` and `cookies.has("session")`) - a function named `acceptAll`, `acceptNecessary`, or any name beginning with diff --git a/packages/vite/src/consent/ungated.test.ts b/packages/vite/src/consent/ungated.test.ts index a206f7a6..98596ee2 100644 --- a/packages/vite/src/consent/ungated.test.ts +++ b/packages/vite/src/consent/ungated.test.ts @@ -32,6 +32,75 @@ function App() { expect(ungated(src)).toHaveLength(0); }); + it("does not flag hits inside an aliased ConsentGate import", () => { + const src = ` +import { ConsentGate as Gate } from "@policystack/react/consent"; +function App() { + return ( + + {(() => { document.cookie = 'a=1'; return null; })()} + + ); +} +`; + expect(ungated(src)).toHaveLength(0); + }); + + it("does not flag hits inside a namespaced ConsentGate", () => { + const src = ` +import * as PS from "@policystack/react/consent"; +function App() { + return ( + + {(() => { document.cookie = 'a=1'; return null; })()} + + ); +} +`; + expect(ungated(src)).toHaveLength(0); + }); + + it("still flags an unresolved alias that is not a PolicyStack gate", () => { + const src = ` +function App() { + return ( + + {(() => { document.cookie = 'a=1'; return null; })()} + + ); +} +`; + expect(ungated(src)).toHaveLength(1); + }); + + it("still flags an alias imported from a non-PolicyStack package", () => { + const src = ` +import { ConsentGate as Gate } from "some-other-lib"; +function App() { + return ( + + {(() => { document.cookie = 'a=1'; return null; })()} + + ); +} +`; + expect(ungated(src)).toHaveLength(1); + }); + + it("does not flag hits inside an aliased Solid gate imported from the package root", () => { + const src = ` +import { ConsentGate as Gate } from "@policystack/solid"; +function App() { + return ( + + {(() => { document.cookie = 'a=1'; return null; })()} + + ); +} +`; + expect(ungated(src)).toHaveLength(0); + }); + it("does not flag hits inside an if (consent.has(...)) gate", () => { const src = ` if (consent.has('analytics')) { diff --git a/packages/vite/src/consent/ungated.ts b/packages/vite/src/consent/ungated.ts index 9ec77263..fe7c50a9 100644 --- a/packages/vite/src/consent/ungated.ts +++ b/packages/vite/src/consent/ungated.ts @@ -1,25 +1,60 @@ -import type { AnyNode } from "./types"; +import type { AnyNode, ImportInfo } from "./types"; const SET_PREFIX_RE = /^set[A-Z_]/; const GATE_HELPER_NAMES = new Set(["acceptAll", "acceptNecessary"]); +const GATE_EXPORT = "ConsentGate"; +const GATE_PACKAGE_PREFIX = "@policystack/"; -export function isGated(parents: AnyNode[]): boolean { +type Imports = Map | undefined; + +export function isGated(parents: AnyNode[], imports?: Imports): boolean { for (let i = parents.length - 1; i >= 0; i--) { const p = parents[i]!; - if (isConsentGateElement(p)) return true; + if (isConsentGateElement(p, imports)) return true; if (isHasCheck(p)) return true; if (isGateHelperFunction(p)) return true; } return false; } -function isConsentGateElement(node: AnyNode): boolean { +/** A local binding that came from a PolicyStack package's `ConsentGate` export. */ +function isGateBinding(local: string, imports: Imports): boolean { + const info = imports?.get(local); + return info?.imported === GATE_EXPORT && info.source.startsWith(GATE_PACKAGE_PREFIX); +} + +/** A local binding for `import * as X from "@policystack/…"`. */ +function isGateNamespaceBinding(local: string, imports: Imports): boolean { + const info = imports?.get(local); + return info?.imported === "*" && info.source.startsWith(GATE_PACKAGE_PREFIX); +} + +function isConsentGateElement(node: AnyNode, imports: Imports): boolean { if (node.type !== "JSXElement") return false; const opening = node.openingElement as AnyNode | undefined; if (!opening) return false; const name = opening.name as AnyNode | undefined; - if (name?.type !== "JSXIdentifier") return false; - return name.name === "ConsentGate"; + if (!name) return false; + + // `` — matched by name alone, with no import required. Local + // wrappers, barrel re-exports and auto-imports all rely on this, so import + // resolution below only ever adds matches. + if (name.type === "JSXIdentifier") { + if (name.name === GATE_EXPORT) return true; + // `import { ConsentGate as Gate }` → ``. + return isGateBinding(name.name as string, imports); + } + + // `import * as PS` → ``. + if (name.type === "JSXMemberExpression") { + const object = name.object as AnyNode | undefined; + const property = name.property as AnyNode | undefined; + if (object?.type !== "JSXIdentifier" || property?.type !== "JSXIdentifier") return false; + if (property.name !== GATE_EXPORT) return false; + return isGateNamespaceBinding(object.name as string, imports); + } + + return false; } function isHasCheck(node: AnyNode): boolean { diff --git a/packages/vite/src/consent/visit.ts b/packages/vite/src/consent/visit.ts index e6f63fd0..a7bea840 100644 --- a/packages/vite/src/consent/visit.ts +++ b/packages/vite/src/consent/visit.ts @@ -39,7 +39,7 @@ export function walk(parsed: ParsedFile, rules: Rule[], registry: VendorRegistry parents, report: (hit: Hit) => { hits.push(hit); - if (!isGated(parents)) { + if (!isGated(parents, ctxBase.imports)) { ungated.push({ file: hit.file, line: hit.line, From 6429e5ad08a8014ce47e99decec173d527094d76 Mon Sep 17 00:00:00 2001 From: Jamie Davenport <1329874+jamiedavenport@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:54:46 +0100 Subject: [PATCH 3/4] chore: add changeset for consent storage keys and scanner gate resolution (#161) --- .changeset/ps-consent-storage-key.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/ps-consent-storage-key.md diff --git a/.changeset/ps-consent-storage-key.md b/.changeset/ps-consent-storage-key.md new file mode 100644 index 00000000..8a4d9b04 --- /dev/null +++ b/.changeset/ps-consent-storage-key.md @@ -0,0 +1,15 @@ +--- +"@policystack/core": minor +"@policystack/vite": minor +--- + +Storage keys drop the pre-rebrand `oc_` prefix, and the consent scanner now resolves aliased `ConsentGate` imports (#161). + +`localStorageAdapter` and `cookieAdapter` both default to `ps_consent` instead of `oc_consent` (and the localStorage probe key is now `__ps_probe__`). **No visitor is re-prompted:** both adapters still read the old key when the new one is absent, so an existing decision keeps loading. The fallback is read-only — writes always go to `ps_consent` — and is skipped entirely when you pass your own `key`/`name`. The one exception is `clear()`, which removes both keys; otherwise the fallback would resurrect a decision the visitor just withdrew. + +Two things to check if you touch the cookie name directly: + +- `cookieAdapter().name` now returns `ps_consent`. Server code that reads the consent cookie should use that property rather than a hardcoded string. +- Clearing consent from SSR should switch to the new `getSetCookieHeaders(record)`, which returns every `Set-Cookie` header to emit — on clear that includes one expiring the legacy cookie. The existing singular `getSetCookieHeader` is unchanged and still covers only the canonical cookie, so clearing through it leaves `oc_consent` behind. + +The Vite consent scanner previously treated a JSX element as a gate only when it was literally named `ConsentGate`, so `import { ConsentGate as Gate }` silently defeated gating detection and correctly-gated code was reported as ungated (a build failure under `mode: "error"`). Aliased imports and namespaced usage (`import * as PS` → ``) resolving to a PolicyStack package are now recognised. This is purely additive — a bare `` with no import still counts, so local wrappers, barrel re-exports and auto-imports keep working and no previously-clean project starts failing. From 1be1df8b1a1d421594a7c3c9411e3f452e57826b Mon Sep 17 00:00:00 2001 From: Jamie Davenport <1329874+jamiedavenport@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:07:11 +0100 Subject: [PATCH 4/4] fix(core): consult the in-memory fallback when a localStorage write was rejected (#161) --- .../src/consent/storage/local-storage.test.ts | 28 +++++++++++++++++++ .../core/src/consent/storage/local-storage.ts | 7 +++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/core/src/consent/storage/local-storage.test.ts b/packages/core/src/consent/storage/local-storage.test.ts index 7685f5fe..ce72dc10 100644 --- a/packages/core/src/consent/storage/local-storage.test.ts +++ b/packages/core/src/consent/storage/local-storage.test.ts @@ -111,6 +111,34 @@ describe("localStorageAdapter", () => { expect(adapter.read()).toBeNull(); }); + it("falls back to in-memory when the probe succeeds but the write is rejected", () => { + // Quota-exceeded shape: the one-byte probe fits, the record does not. The + // storage handle is therefore usable, so reads must still consult memory. + const store = new Map(); + const nearlyFull: Storage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + if (k === "__ps_probe__") { + store.set(k, v); + return; + } + throw new Error("QuotaExceededError"); + }, + removeItem: (k: string) => { + store.delete(k); + }, + clear: () => store.clear(), + key: () => null, + length: 0, + }; + vi.stubGlobal("localStorage", nearlyFull); + const adapter = localStorageAdapter(); + adapter.write(sample); + expect(adapter.read()).toEqual(sample); + adapter.clear(); + expect(adapter.read()).toBeNull(); + }); + it("falls back to in-memory when localStorage is undefined (SSR)", () => { vi.stubGlobal("localStorage", undefined); const adapter = localStorageAdapter(); diff --git a/packages/core/src/consent/storage/local-storage.ts b/packages/core/src/consent/storage/local-storage.ts index 4971e51b..92df51a4 100644 --- a/packages/core/src/consent/storage/local-storage.ts +++ b/packages/core/src/consent/storage/local-storage.ts @@ -47,8 +47,11 @@ export function localStorageAdapter(options: LocalStorageAdapterOptions = {}): L if (ls) { try { // An empty entry carries no record; treat it as absent rather than - // letting it shadow the legacy fallback below. - return ls.getItem(name) || null; + // letting it shadow the legacy fallback below. Fall through to memory + // too: a write can be rejected (quota) while the probe still succeeds, + // which leaves the only copy of the record in the memory map. + const value = ls.getItem(name); + if (value) return value; } catch { // fall through to memory }