- docs/guides/dsr.md: GDPR Art. 15/16/17/18/20 interface mapping, tRPC router wiring, multi-subject handling, soft vs cascade-hard semantics, DeletionCertificate format and storage requirements - docs/guides/consent.md: requiresConsent manifest field, withConsent DI wiring, runtime isGranted pattern, IConsent audit trail, anonymous→ authenticated migration, cookie _v versioning, SSR-safe banner loading, CNIL/EDPB equal-prominence requirement - docs/compliance/subject-linkage.example.md: SubjectLink kind discriminator with worked support-ticket example (owner submitter + reference assignee) - docs/glossary.md: SubjectLink, DeletionCertificate, UserConsentState, ConsentChecked entries; Manifest definition updated with requiresConsent - CLAUDE.md: lint comment 8→12 conformance rules; conformance section notes requiresConsent; brand composition order updated to full 5-wrapper chain - docs/guides/conformance-quickref.md: requiresConsent field added to manifest table; component-must-have-story, component-must-have-test, atomic-tier-import-direction added to ESLint rules table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
9.6 KiB
Markdown
256 lines
9.6 KiB
Markdown
# 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/<feature>/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/<feature>/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<ITrackEventUseCase>(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/<feature>/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<void> => {
|
|
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<void>;
|
|
/** Records a consent withdrawal; production impl writes to the audit log. */
|
|
withdraw(category: ConsentCategory): Promise<void>;
|
|
/** Returns per-category state for all categories the subject has interacted with. */
|
|
getCategories(): Promise<UserConsentState[]>;
|
|
}
|
|
```
|
|
|
|
### 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<SignUpOutput> => {
|
|
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<string, boolean>; // 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 (
|
|
<html>
|
|
<body>
|
|
{children}
|
|
<CookieConsentBannerLoader />{" "}
|
|
{/* renders null on server, mounts on client */}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
`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)
|