- 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>
9.6 KiB
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:
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:
// 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 inmanifest.requiresConsent. manifest.requiresConsentlists 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:
// 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:
// 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
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: theConsentCategorystringmethod: fromConsentGrantMeta.method(e.g.,"banner-accept","signup-migration")bannerVersion/policyVersion: fromConsentGrantMetawhen 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:
// 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:
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:
// 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
variantprop). - Non-necessary category checkboxes default to unchecked.
- ESC key triggers "Reject all" (keyboard-accessible rejection path).
- The
required: trueflag on aCookieCategorymarks it as always-on and renders a disabled, checked checkbox — do not userequired: truefor 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-checkindocs/guides/conformance-quickref.md - Glossary:
ConsentChecked,UserConsentStateindocs/glossary.md - ADR: ADR-025 (compliance baseline channels)