# Consent guide Consumer-facing reference for the `@repo/core-consent` optional core package. Covers the `requiresConsent` manifest field, `withConsent` DI wiring, runtime consent-check pattern, `IConsent` interface, anonymous→authenticated migration, cookie versioning policy, SSR-safe banner loading, and CNIL/EDPB equal-prominence requirements. **Prerequisite:** scaffold the package if it isn't already present: ```bash pnpm turbo gen core-package consent ``` --- ## Declaring `requiresConsent` in the feature manifest Any feature that gates behaviour behind user consent declares the required categories in `feature.manifest.ts`: ```ts // packages//src/feature.manifest.ts export const fooManifest = defineFeature({ name: "foo", requiredCores: ["consent"], requiresConsent: ["analytics", "marketing"], useCases: { trackEvent: { mutates: false, audits: [], publishes: [], consumes: [] }, }, // ... } as const); ``` `requiresConsent` accepts an array of `ConsentCategory` values: | Category | Typical use | | -------------- | ----------------------------------------------- | | `"necessary"` | Session management, CSRF protection (always on) | | `"functional"` | Preferences, language, personalised content | | `"analytics"` | Product analytics, funnels, cohort analysis | | `"marketing"` | Ad targeting, remarketing pixels | | `string & {}` | Custom categories (autocomplete still works) | The ESLint rule `conformance/no-undeclared-consent-check` warns if: - A use-case file calls `consent.isGranted("X")` for a category not in `manifest.requiresConsent`. - `manifest.requiresConsent` lists a category that is never checked in any use case. --- ## Wiring `withConsent` at DI bind time Apply `withConsent` **inside** `withCapture`, **outermost** among the optional wrappers: ```ts // packages//src/di/bind-production.ts import { withSpan, withCapture } from "@repo/core-shared/instrumentation"; import { withConsent } from "@repo/core-consent"; export function bindProductionFoo(ctx: BindProductionContext): void { const { tracer, logger, consent } = ctx; // Full composition order (outermost → innermost): // withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps) fooContainer .bind(FOO_SYMBOLS.ITrackEventUseCase) .toDynamicValue(() => withSpan( tracer, { name: "foo.trackEvent" }, withCapture( logger, { useCase: "foo.trackEvent" }, withConsent(consent, trackEventUseCase({ analytics: ctx.analytics })), ), ), ); assertFeatureConformance( fooContainer, fooManifest, { trackEvent: FOO_SYMBOLS.ITrackEventUseCase }, ctx, ); } ``` `withConsent` attaches the `ConsentChecked` brand at bind time. The boot assertion (`assertFeatureConformance`) verifies that every use case listed under `requiresConsent` in the manifest is wrapped. TypeScript rejects binding an unwrapped factory to a `ConsentChecked`-typed symbol. --- ## Runtime consent-check pattern Inside a use case, call `consent.isGranted(category)` before performing the gated work: ```ts // packages//src/application/use-cases/track-event.use-case.ts import type { IConsent } from "@repo/core-consent"; import { ConsentDeniedError } from "@repo/core-consent/errors"; export function trackEventUseCase(deps: { analytics: IAnalytics; consent: IConsent; }) { return async (input: TrackEventInput): Promise => { if (!deps.consent.isGranted("analytics")) { throw new ConsentDeniedError("analytics"); } await deps.analytics.track(input.event, input.properties); }; } ``` `isGranted` is **synchronous** — it reads the in-memory consent state populated at request initialisation. Do not `await` it. --- ## `IConsent` interface ```ts interface IConsent { /** Synchronous: reads in-memory state. */ isGranted(category: ConsentCategory): boolean; /** Records a consent grant; production impl writes to the audit log. */ grant(category: ConsentCategory, meta?: ConsentGrantMeta): Promise; /** Records a consent withdrawal; production impl writes to the audit log. */ withdraw(category: ConsentCategory): Promise; /** Returns per-category state for all categories the subject has interacted with. */ getCategories(): Promise; } ``` ### Audit trail The production `IConsent` implementation calls `auditLog.record(...)` on every `grant` and `withdraw` call, creating an immutable audit entry with: - `type`: `"consent.granted"` or `"consent.withdrawn"` - `category`: the `ConsentCategory` string - `method`: from `ConsentGrantMeta.method` (e.g., `"banner-accept"`, `"signup-migration"`) - `bannerVersion` / `policyVersion`: from `ConsentGrantMeta` when provided This creates a traceable consent history for regulatory inspection. --- ## Anonymous → authenticated migration When an anonymous visitor accepts the cookie banner, their choices are stored in the `cc_consent` cookie as a comma-separated list of granted categories (e.g., `necessary,analytics`). On sign-up or sign-in, migrate the anonymous consent to the authenticated user record: ```ts // packages/auth/src/application/use-cases/sign-up.use-case.ts import { extractAnonymousConsent, migrateAnonymousConsent, } from "@repo/core-consent/migration"; export function signUpUseCase(deps: { users: IUsersRepository; consent: IConsent; }) { return async (input: SignUpInput): Promise => { const user = await deps.users.create({ email: input.email /* ... */ }); // Migrate consent from the anonymous banner cookie, if present. const cookieState = extractAnonymousConsent(input.cookieHeader ?? ""); await migrateAnonymousConsent({ consent: deps.consent, cookieState, bannerVersion: input.bannerVersion, policyVersion: input.policyVersion, }); return signUpOutputSchema.parse({ userId: user.id }); }; } ``` `migrateAnonymousConsent` calls `consent.grant(category, { method: "signup-migration" })` for each category in the cookie. It is a no-op when `cookieState` is `null`. After migration, delete or expire the `cc_consent` cookie on the client to avoid double-migration on subsequent sign-ins. --- ## Cookie versioning policy The client-side consent state is stored in the `__consent_state` cookie as JSON: ```ts type ConsentCookieState = { _v: number; // schema version (bump when categories change) categories: Record; // category slug → granted }; ``` **When to increment `_v`:** - You add a new non-necessary consent category. - You update the privacy policy in a way that requires renewed consent (e.g., new processing purpose). - You remove a category that users previously consented to and need to inform them of the change. **Migration-on-read:** when `readConsentCookie()` reads a cookie with `_v < current`, treat the consent as `pending` and show the banner again. The user's previous granular choices are discarded because the categories or policy changed. Implement this in your `ConsentProvider` initialisation. The `cc_consent` anonymous cookie (written before authentication) follows the same versioning scheme — pass `bannerVersion` through `extractAnonymousConsent` / `migrateAnonymousConsent` so the version is preserved in the audit log. --- ## SSR-safe banner loading The cookie consent banner is a client-only component (reads `document.cookie`, attaches focus traps). Use `CookieConsentBannerLoader` instead of `CookieConsentBanner` directly to prevent SSR hydration mismatches: ```tsx // apps/web-next/src/app/layout.tsx import { CookieConsentBannerLoader } from "@repo/core-ui/cookie-consent-banner"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} {" "} {/* renders null on server, mounts on client */} ); } ``` `CookieConsentBannerLoader` returns `null` during SSR and mounts the full `CookieConsentBanner` only after hydration, avoiding the `document is not defined` error and preventing layout shift from banner flicker on first load. --- ## CNIL / EDPB equal-prominence requirement The French CNIL and the EDPB both require that the banner offers **equivalent visual weight** to accepting and rejecting consent — a prominent "Accept all" button paired with an equally-prominent "Reject all" button, with no pre-checked non-necessary categories. The default `CookieConsentBanner` implements this: - **Accept all** and **Reject all** buttons are rendered at the same visual prominence (identical `variant` prop). - Non-necessary category checkboxes default to **unchecked**. - ESC key triggers "Reject all" (keyboard-accessible rejection path). - The `required: true` flag on a `CookieCategory` marks it as always-on and renders a disabled, checked checkbox — do not use `required: true` for non-necessary categories. If you customise the banner via render props (`renderActions`, `renderCategoryRow`), you must preserve these guarantees manually. Failing to offer an equivalent "Reject all" path is a CNIL/EDPB violation. --- ## Cross-references - Cookie banner component: `@repo/core-ui` → `CookieConsentBanner`, `CookieConsentBannerLoader` - Anonymous migration: `@repo/core-consent/migration` → `extractAnonymousConsent`, `migrateAnonymousConsent` - Conformance rule: `no-undeclared-consent-check` in `docs/guides/conformance-quickref.md` - Glossary: `ConsentChecked`, `UserConsentState` in `docs/glossary.md` - ADR: ADR-025 (compliance baseline channels)