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
7 changes: 7 additions & 0 deletions .changeset/quiet-cookies-describe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@policystack/core": minor
---

Cookie context entries now accept `label`, `description`, and `respectGPC`, and derived consent categories use that metadata directly. Missing copy falls back to the built-in cookie-type dictionary for the policy locale, so preference panels can render `useConsent().categories` without maintaining a separate copy table (#160).

Note that this changes the default copy for categories that do not set `label`/`description`: a derived category's `label` is now the dictionary label (`"Analytics Cookies"`) rather than the capitalised key (`"Analytics"`), and `description` is now the dictionary description (or `""` for a custom category key) rather than `undefined`. Set `label`/`description` in `cookies.context` to keep your existing wording.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
12 changes: 12 additions & 0 deletions apps/web/content/docs/consent/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,24 @@ function CookieBanner() {
);
}

function CookiePreferences() {
const { categories } = useConsent();
return categories.map((category) => (
<label key={category.key}>
{category.label}
{category.description && <span>{category.description}</span>}
</label>
));
}

// Gate third-party code on consent
<ConsentGate requires="analytics">
<GoogleAnalytics />
</ConsentGate>;
```

Category `label`, `description`, and `respectGPC` values come from `cookies.context`. Missing copy uses the built-in cookie-type dictionary for the policy locale, so preference UIs do not need their own category-copy table.

## Features

- **Headless** — no styles, no DOM, no opinions about how your banner looks
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/policy/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ company: {

Setting `phone` is recommended when `jurisdictions` includes `us-ca`. CCPA §1798.130(a)(1) requires businesses to provide two or more designated methods for consumers to submit privacy requests, and (unless you operate exclusively online) one of those methods must be a toll-free number. When `phone` is set, the rendered CCPA supplement appends a "Submitting requests" block listing both methods. Omitting it under `us-ca` emits a validation warning.

The `data` block has two sibling maps: `collected` (category → field labels) and `context` (category → metadata about that category). `defineConfig`'s generic enforces that every key in `collected` has a matching `context` entry with `purpose`, `lawfulBasis`, `retention`, and `provision`. The `cookies` block mirrors the same shape: `cookies.used` lists the categories you enable (with `essential: true` always required), and `cookies.context` declares the Article 6 basis for each enabled category.
The `data` block has two sibling maps: `collected` (category → field labels) and `context` (category → metadata about that category). `defineConfig`'s generic enforces that every key in `collected` has a matching `context` entry with `purpose`, `lawfulBasis`, `retention`, and `provision`. The `cookies` block mirrors the same shape: `cookies.used` lists the categories you enable (with `essential: true` always required), and `cookies.context` declares the Article 6 basis for each enabled category. Cookie context entries can also provide `label`, `description`, and `respectGPC`; these flow into the consent categories exposed by the framework bindings, with missing copy resolved from the built-in dictionary for the configured locale.

### Data Protection Officer

Expand Down
9 changes: 8 additions & 1 deletion apps/web/content/docs/policy/policies/cookies.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ cookies: {
},
context: {
essential: { lawfulBasis: LegalBases.LegalObligation },
analytics: { lawfulBasis: LegalBases.Consent },
analytics: {
lawfulBasis: LegalBases.Consent,
label: "Analytics",
description: "Helps us understand how the site is used.",
respectGPC: true,
},
functional: { lawfulBasis: LegalBases.Consent },
marketing: { lawfulBasis: LegalBases.Consent },
},
Expand All @@ -41,6 +46,8 @@ The consent mechanism (banner / preference panel / withdrawal) is **derived** fr

`cookies.used` always requires `essential: true`; other keys are `boolean` and act as additional categories. Every key in `cookies.used` must have a matching Article 6 basis in `cookies.context[key].lawfulBasis` — `defineConfig` enforces this at type-check time, and the rendered "Cookies and Tracking" section appends the basis to each enabled category.

Each context entry may also set `label`, `description`, and `respectGPC` for the derived consent category. Missing copy falls back field-by-field to the built-in cookie-type dictionary for `locale` (English by default), so a preference panel can render `useConsent().categories` directly. Set `respectGPC: false` only for a category that should remain available when a GPC signal is active.

`defineConfig` also computes a `cookieVersion` — an 8-char hash of the cookie slice of your config — which is printed in the intro paragraph next to the effective date. See [Policy versions](/docs/policy/configuration#policy-versions).

Then render it:
Expand Down
13 changes: 3 additions & 10 deletions apps/web/src/components/CookiePreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,19 @@ const primaryButton =
const secondaryButton =
"inline-flex items-center justify-center border-2 border-black bg-canvas px-4 py-2 text-xs tracking-wide text-ink uppercase hover:bg-ink hover:text-canvas focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black";

const DESCRIPTIONS: Record<string, string> = {
essential:
"Required for the site to work — security, session, and your consent choice itself. Always on.",
analytics:
"Lets us measure which pages are used so we can improve the site. Off until you allow it.",
marketing: "Used to personalise and measure marketing. Off until you allow it.",
};

function CategoryRow({ category }: { category: Category }) {
const { granted, toggle } = useCategory(category.key);
const inputId = `consent-${category.key}`;
const description = category.description ?? DESCRIPTIONS[category.key] ?? "";

return (
<li className="flex items-start justify-between gap-4 border-t-2 border-black py-4 first:border-t-0">
<div className="min-w-0">
<label htmlFor={inputId} className="text-sm tracking-wide text-ink uppercase">
{category.label}
</label>
{description && <p className="mt-1 text-xs text-pretty text-mute">{description}</p>}
{category.description && (
<p className="mt-1 text-xs text-pretty text-mute">{category.description}</p>
)}
</div>
<span className="relative inline-flex h-6 w-12 shrink-0 border-2 border-black">
<input
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/policystack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,23 @@ export default defineConfig({
marketing: false,
},
context: {
essential: { lawfulBasis: LegalBases.LegalObligation },
analytics: { lawfulBasis: LegalBases.Consent },
marketing: { lawfulBasis: LegalBases.Consent },
essential: {
lawfulBasis: LegalBases.LegalObligation,
label: "Essential",
description:
"Required for the site to work — security, session, and your consent choice itself. Always on.",
},
analytics: {
lawfulBasis: LegalBases.Consent,
label: "Analytics",
description:
"Lets us measure which pages are used so we can improve the site. Off until you allow it.",
},
marketing: {
lawfulBasis: LegalBases.Consent,
label: "Marketing",
description: "Used to personalise and measure marketing. Off until you allow it.",
},
},
},
thirdParties: [],
Expand Down
96 changes: 94 additions & 2 deletions packages/core/src/consent/derive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,101 @@ test("locks the essential category", () => {
expect(analytics?.locked).toBe(false);
});

test("capitalizes the category label", () => {
test("falls back to the English cookie-type dictionary", () => {
const config = deriveConsentConfig(policy);
expect(config.categories.find((c) => c.key === "analytics")?.label).toBe("Analytics");
const analytics = config.categories.find((c) => c.key === "analytics");
expect(analytics?.label).toBe("Analytics Cookies");
expect(analytics?.description).toBe(
"Help us understand how visitors interact with our services so we can improve them.",
);
});

test("uses the policy locale for dictionary-backed category copy", () => {
const config = deriveConsentConfig({ ...policy, locale: "fr" });
const analytics = config.categories.find((c) => c.key === "analytics");
expect(analytics?.label).toBe("Cookies d'analyse");
expect(analytics?.description).toBe(
"Nous aident à comprendre comment les visiteurs interagissent avec nos services afin de les améliorer.",
);
});

test("dictionary-backed category copy follows an options.locale override", () => {
const config = deriveConsentConfig({ ...policy, locale: "fr" }, { locale: "de" });
const analytics = config.categories.find((c) => c.key === "analytics");
expect(config.locale).toBe("de");
expect(analytics?.label).toBe("Analyse-Cookies");
});

test("falls back independently around explicit category copy", () => {
const config = deriveConsentConfig({
...policy,
cookies: {
used: { essential: true, analytics: true },
context: {
essential: { lawfulBasis: "legal_obligation", label: "Required" },
analytics: {
lawfulBasis: "consent",
description: "Our measurement cookies.",
},
},
},
});
const essential = config.categories.find((c) => c.key === "essential");
const analytics = config.categories.find((c) => c.key === "analytics");
expect(essential?.label).toBe("Required");
expect(essential?.description).toBe(
"Required for the basic functioning of our services. These cannot be disabled.",
);
expect(analytics?.label).toBe("Analytics Cookies");
expect(analytics?.description).toBe("Our measurement cookies.");
});

test("preserves explicit empty category copy", () => {
const config = deriveConsentConfig({
...policy,
cookies: {
used: { essential: true },
context: {
essential: { lawfulBasis: "legal_obligation", label: "", description: "" },
},
},
});
expect(config.categories[0]?.label).toBe("");
expect(config.categories[0]?.description).toBe("");
});

test("uses the locale fallback for custom category keys", () => {
const config = deriveConsentConfig({
...policy,
locale: "fr",
cookies: {
used: { essential: true, personalization: true },
context: {
essential: { lawfulBasis: "legal_obligation" },
personalization: { lawfulBasis: "consent" },
},
},
});
const personalization = config.categories.find((c) => c.key === "personalization");
expect(personalization?.label).toBe("Cookies personalization");
expect(personalization?.description).toBe("");
});

test("carries both respectGPC values onto derived categories", () => {
const config = deriveConsentConfig({
...policy,
cookies: {
used: { essential: true, analytics: true, marketing: true },
context: {
essential: { lawfulBasis: "legal_obligation" },
analytics: { lawfulBasis: "consent", respectGPC: false },
marketing: { lawfulBasis: "consent", respectGPC: true },
},
},
});
expect(config.categories.find((c) => c.key === "essential")?.respectGPC).toBeUndefined();
expect(config.categories.find((c) => c.key === "analytics")?.respectGPC).toBe(false);
expect(config.categories.find((c) => c.key === "marketing")?.respectGPC).toBe(true);
});

test("returns no categories when policy has no cookies block", () => {
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/consent/derive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { isConsentGated } from "../types";
import type { PolicyStackConfig } from "../types";
import { createT } from "../i18n";
import { resolveCookieTypeMeta } from "../i18n/cookie-type";
import { coerceLocale } from "./locale";
import type { Category, PolicyStackConsentConfig } from "./types";

// Everything in PolicyStackConsentConfig bar `categories` — the runtime-only
Expand All @@ -22,13 +25,23 @@ export function deriveConsentConfig(
): PolicyStackConsentConfig {
const used: Record<string, boolean> = policy.cookies?.used ?? {};
const context = policy.cookies?.context ?? {};
// PS-26: one shared Locale — the policy's canonical Locale flows into the
// consent config so policy text and consent UI agree. An explicit
// options.locale still wins (same override convention as policyVersion).
// The same resolved locale backs the dictionary copy below, so a derived
// category label can never disagree with the locale the config reports.
const locale = options?.locale ?? policy.locale;
const t = createT(coerceLocale(locale ?? "en"));
const categories: Category[] = Object.keys(used)
.filter((key) => used[key])
.map((key) => {
const lawfulBasis = context[key]?.lawfulBasis;
const entry = context[key];
const lawfulBasis = entry?.lawfulBasis;
const fallback = resolveCookieTypeMeta(key, t);
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
label: entry?.label ?? fallback.label,
description: entry?.description ?? fallback.description,
// Gating is the explicit, exhaustive bridge table (§4.1) — not a
// `=== "consent"` string heuristic. `consent` ⇒ gated (not
// locked); every other basis ⇒ locked; a missing basis stays
Expand All @@ -37,6 +50,7 @@ export function deriveConsentConfig(
// resolver and audit keep the full signal.
locked: !isConsentGated(lawfulBasis),
...(lawfulBasis ? { lawfulBasis } : {}),
...(entry?.respectGPC != null ? { respectGPC: entry.respectGPC } : {}),
};
});
const policyVersion = options?.policyVersion ?? policy.cookieVersion;
Expand All @@ -46,10 +60,6 @@ export function deriveConsentConfig(
// actually invalidates stored consent. Callers can still override any
// individual trigger via `options.triggers`.
const triggers = { policyVersionChanged: true, ...options?.triggers };
// PS-26: one shared Locale — the policy's canonical Locale flows into the
// consent config so policy text and consent UI agree. An explicit
// options.locale still wins (same override convention as policyVersion).
const locale = options?.locale ?? policy.locale;
return {
...options,
...(policyVersion ? { policyVersion } : {}),
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/consent/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import type { PolicyStackConsentConfig } from "./types";
// That is not a deprecated *configuration* — it is the runtime default and is
// almost always region-tagged (e.g. "en-US") — so it must not warn. Outlives
// the freeze (navigator.language stays a free string after PS-36).
function coerceLocale(input: string): Locale {
//
// Also used by `deriveConsentConfig` to pick the dictionary behind derived
// category copy: it lands on the same Locale `resolveLocale` would, without
// emitting the deprecation warning twice for one configured locale.
export function coerceLocale(input: string): Locale {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (isLocale(input)) return input;
const primary = input.toLowerCase().split(/[-_]/)[0] ?? "";
return isLocale(primary) ? primary : "en";
Expand Down
13 changes: 2 additions & 11 deletions packages/core/src/documents/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { T } from "../i18n";
import { formatDate } from "../i18n";
import { resolveCookieTypeMeta } from "../i18n/cookie-type";
import { deriveConsentMechanism } from "../normalize";
import { ESSENTIAL_ONLY_COOKIES, type PolicyStackConfig } from "../types";
import { bold, cell, heading, li, link, p, row, section, table, ul } from "./helpers";
Expand Down Expand Up @@ -30,22 +31,12 @@ function buildWhatAreCookies(t: T): DocumentSection {
]);
}

function cookieTypeMeta(key: string, t: T): { label: string; description: string } {
const known = t.cookie.types.labels as Record<
string,
{ label: () => string; description: () => string } | undefined
>;
const entry = known[key];
if (entry) return { label: entry.label(), description: entry.description() };
return t.cookie.types.fallback({ key });
}

function buildTypes(config: PolicyStackConfig, t: T): DocumentSection {
const cookies = config.cookies ?? ESSENTIAL_ONLY_COOKIES;
const types: { label: string; description: string }[] = [];
for (const [key, enabled] of Object.entries(cookies.used)) {
if (!enabled) continue;
types.push(cookieTypeMeta(key, t));
types.push(resolveCookieTypeMeta(key, t));
}

if (types.length === 0) {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/i18n/cookie-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { T } from "./index";

export type CookieTypeMeta = { label: string; description: string };

// Cookie categories may be user-defined, so resolve the four dictionary-backed
// defaults first and use the locale's generic fallback for every other key.
// Shared by policy compilation and consent derivation so their default copy
// cannot drift.
export function resolveCookieTypeMeta(key: string, t: T): CookieTypeMeta {
const known = t.cookie.types.labels as Record<
string,
{ label: () => string; description: () => string } | undefined
>;
// Own-property check, not a bare index: the key is user-authored, so
// `constructor`/`toString` would otherwise resolve up the prototype chain
// to a truthy non-entry and throw instead of taking the fallback.
const entry = Object.hasOwn(known, key) ? known[key] : undefined;
if (entry) return { label: entry.label(), description: entry.description() };
return t.cookie.types.fallback({ key });
}
22 changes: 22 additions & 0 deletions packages/core/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "vite-plus/test";
import { compilePrivacyPolicy } from "../index";
import type { Locale, PolicyStackConfig } from "../types";
import { resolveCookieTypeMeta } from "./cookie-type";
import { de } from "./de";
import { en } from "./en";
import { es } from "./es";
Expand Down Expand Up @@ -58,6 +59,27 @@ test("isLocale accepts registered locales and rejects others", () => {
expect(isLocale(123)).toBe(false);
});

test("resolveCookieTypeMeta reads dictionary entries and falls back on custom keys", () => {
const t = createT("en");
expect(resolveCookieTypeMeta("analytics", t).label).toBe("Analytics Cookies");
expect(resolveCookieTypeMeta("loyalty", t)).toEqual({
label: "Loyalty Cookies",
description: "",
});
});

test("resolveCookieTypeMeta falls back for category keys inherited from Object", () => {
const t = createT("en");
// A user-authored `cookies.used` key can collide with Object.prototype;
// those must take the generic fallback, not resolve up the chain.
for (const key of ["constructor", "toString", "valueOf"]) {
expect(resolveCookieTypeMeta(key, t)).toEqual({
label: `${key.charAt(0).toUpperCase()}${key.slice(1)} Cookies`,
description: "",
});
}
});

test("LOCALES contains every key in dictionaries", () => {
const dictKeys = Object.keys(dictionaries).sort();
const localeKeys = [...LOCALES].sort();
Expand Down
Loading
Loading