Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/ps-consent-storage-key.md
Original file line number Diff line number Diff line change
@@ -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` → `<PS.ConsentGate>`) resolving to a PolicyStack package are now recognised. This is purely additive — a bare `<ConsentGate>` with no import still counts, so local wrappers, barrel re-exports and auto-imports keep working and no previously-clean project starts failing.
8 changes: 8 additions & 0 deletions apps/web/content/docs/consent/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion apps/web/content/docs/consent/scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ConsentGate>`
- a JSX element named `<ConsentGate>`, or one imported from a PolicyStack
package under any local name — both `import { ConsentGate as Gate }` →
`<Gate>` and `import * as PS` → `<PS.ConsentGate>` 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
Expand Down
50 changes: 43 additions & 7 deletions packages/core/src/consent/storage/cookie.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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();
});

Expand All @@ -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);
Expand All @@ -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");
});

Expand All @@ -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);
});
Expand All @@ -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=");
});

Expand Down Expand Up @@ -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.
Expand All @@ -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");
});
});
69 changes: 57 additions & 12 deletions packages/core/src/consent/storage/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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");
Expand All @@ -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));
},
Expand Down
Loading
Loading