# 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)
