docs: seed PRD for DSR + consent + cookie banner epic (ADR-025 Epic B)
Implementation seed for ADR-025 Epic B: two new optional cores (@repo/core-dsr with 4 interfaces, @repo/core-consent with IConsent + ConsentChecked brand + requiresConsent manifest field), CookieConsentBanner in core-ui with EU-prominence defaults, subject-linkage types in core-shared, ADR-018 amendment adding 4 new audit action types (CONSENT_GRANT/WITHDRAW + RESTRICT/UNRESTRICT). 15 user stories ordered by the in-epic sequencing hints. Status: approved — ready for pnpm work decompose.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updated_at": "2026-05-18T20:23:30.752Z",
|
||||
"updated_at": "2026-05-19T09:41:27.367Z",
|
||||
"epics": {
|
||||
"binder-wrap-helper": {
|
||||
"status": "done",
|
||||
|
||||
488
docs/work/prds/dsr-consent-and-cookie-banner.prd.md
Normal file
488
docs/work/prds/dsr-consent-and-cookie-banner.prd.md
Normal file
@@ -0,0 +1,488 @@
|
||||
---
|
||||
id: dsr-consent-and-cookie-banner
|
||||
title: DSR + consent abstraction + cookie consent banner — Epic B of ADR-025
|
||||
type: prd
|
||||
status: approved
|
||||
author: danijel
|
||||
created: 2026-05-19T09:36:42Z
|
||||
updated: 2026-05-19T09:41:27.097Z
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Epic A (compliance manifests) shipped declarative PII inventory + retention + sub-processors. A consumer can now answer "what personal data does this system hold?" via `compliance/data-map.yml`. They cannot yet answer "how does a user exercise their GDPR rights against that data?" or "did this user consent to marketing emails?" or "where's the cookie banner that asks?"
|
||||
|
||||
Concretely, three load-bearing surfaces remain missing:
|
||||
|
||||
- **Data Subject Rights (DSR) endpoints** — every EU-bound consumer needs `/api/gdpr/{export,delete,rectify,restrict}` to satisfy GDPR Arts. 15, 16, 17, 18, 20. Without them, the consumer either reinvents the cascade walk over their Payload collections (high failure rate — easy to miss a collection or fail to redact one subject in a multi-subject row) or admin-mediates every request manually (slow, doesn't scale, audit-fragile).
|
||||
- **Consent abstraction** — analytics gating (`analytics.track` from ADR-024) currently has no consent check. Every consumer who ships analytics ends up adding a homegrown consent flag, with no audit trail proving consent was given. Art. 7 requires demonstrable consent; today's template can't demonstrate it.
|
||||
- **Cookie consent UI** — no template surface for first-visit consent collection. Every consumer reinvents the banner, and getting Reject All / Accept All equally prominent (CNIL guidance, EDPB Art. 7 interpretation) is non-obvious. Compliance failures here are the most visible regulator-facing surface.
|
||||
|
||||
ADR-025 settled the strategy: two new optional cores (`@repo/core-dsr`, `@repo/core-consent`) + cookie banner in `core-ui`. This PRD is the implementation seed for Epic B.
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the user-rights surface end-to-end so a downstream consumer can: (1) expose DSR endpoints that walk Epic A's PII tags to export/delete/rectify/restrict any subject's data, (2) declare per-use-case consent requirements that gate analytics + marketing emission with audit-logged proof, (3) drop in a compliant cookie consent banner with EU-prominence defaults. Epic A's PII + retention machinery powers DSR cascade; Epic B's consent gates close the analytics PII loop.
|
||||
|
||||
## In scope
|
||||
|
||||
### `@repo/core-dsr` (new optional core)
|
||||
|
||||
- Scaffolded via `pnpm turbo gen core-package dsr`
|
||||
- Four interfaces:
|
||||
- `IDataExport.exportSubjectData(subjectId, format: "json" | "json-ld"): Promise<UserDataBundle>` — covers Arts. 15 + 20
|
||||
- `IDataDelete.deleteSubjectData(subjectId, mode: "soft" | "cascade-hard"): Promise<DeletionCertificate>` — Art. 17
|
||||
- `IDataRectify.updateSubjectField(subjectId, collection, field, value): Promise<void>` — Art. 16
|
||||
- `IProcessingRestriction.{setRestriction, isRestricted}` — Art. 18
|
||||
- Payload-backed reference impls walking Epic A's `custom.pii` tags and the new collection-level `custom.subject` linkage
|
||||
- Protocol-agnostic handlers in `core-dsr/handlers/` returning normalized `{ status, body, headers }`
|
||||
- tRPC router `core-dsr/dsr.router.ts` consumed by `core-api`'s appRouter
|
||||
- Subject-linkage TypeScript types in `core-shared/payload/`: `SubjectLink`, `SubjectLinkRole`, `CollectionSubject`
|
||||
- Ambient declaration extending Payload's `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
|
||||
- Default schema.org JSON-LD `@context` shipped at `core-dsr/contexts/user-data.jsonld`; consumer-overridable
|
||||
- `DeletionCertificate` type derived from audit entry; returned to caller, not separately persisted
|
||||
|
||||
### `@repo/core-consent` (new optional core)
|
||||
|
||||
- Scaffolded via `pnpm turbo gen core-package consent`
|
||||
- Interface:
|
||||
- `IConsent.{isGranted, grant, withdraw, getCategories}` per the ADR-025 shape
|
||||
- `ConsentCategory` string-literal union with escape hatch (matches `PiiCategory` pattern): `"essential" | "functional" | "analytics" | "marketing" | (string & Record<never, never>)`
|
||||
- `withConsent` wrapper attaching `ConsentChecked` brand at DI bind time (passive — runtime checks live in use case body)
|
||||
- `requiresConsent: ConsentCategory[]` per-use-case manifest field; cross-checked by new ESLint rule `no-undeclared-consent-check`
|
||||
- `assertFeatureConformance` extended to require `ConsentChecked` brand when `requiresConsent.length > 0`
|
||||
- Hybrid storage: `users.consentState: UserConsentState` field (fast `isGranted` reads) + `core-audit` `CONSENT_GRANT`/`CONSENT_WITHDRAW` action entries (legal proof history)
|
||||
- Anonymous → authenticated migration helper `extractAnonymousConsent(cookieHeader)` + `migrateAnonymousConsent({ userId, cookieState })`
|
||||
- Protocol-agnostic handlers in `core-consent/handlers/`
|
||||
- tRPC router `core-consent/consent.router.ts` consumed by `core-api`'s appRouter
|
||||
- React hook `useConsent()` + `<ConsentProvider>` in `core-consent/react`
|
||||
|
||||
### Cookie consent banner in `@repo/core-ui`
|
||||
|
||||
- **Precondition**: `pnpm turbo gen core-package ui` (core-ui not yet scaffolded — directory exists but empty)
|
||||
- `<CookieConsentBanner>` headless component with default UI:
|
||||
- `variant: "modal" | "banner"` prop, default `"modal"`
|
||||
- Granular category toggles (essential/functional/analytics/marketing by default; consumer extends)
|
||||
- Equal-prominence Reject All / Accept All buttons (CNIL + EDPB compliance baked into default visual treatment)
|
||||
- Render-prop overrides for `renderCategoryRow` / `renderActions` / `renderHeader` (default UI works out-of-box; consumer surgically overrides for branding/legal text)
|
||||
- Reads `IConsent` via `useConsent()` hook from `core-consent/react`
|
||||
- Manages pre-signup state in `__consent_state` cookie; emits state-changed callback for analytics
|
||||
- Storybook story doubles as the human-reading-room for compliant cookie UX
|
||||
|
||||
### Conformance + manifest changes
|
||||
|
||||
- New manifest field per use case: `requiresConsent: ConsentCategory[]`
|
||||
- New ESLint rule `no-undeclared-consent-check` at warn severity
|
||||
- `withConsent` wrapper composes innermost — order: `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
|
||||
- Conformance ESLint rule count: 11 → 12
|
||||
|
||||
### Subject linkage on existing collections
|
||||
|
||||
- `auth.users`: `custom.subject` defaults to `{ kind: "self", field: "id" }` (template ships explicit declaration for documentation clarity)
|
||||
- `blog.articles`, `marketing-pages.{site-settings,pages}`, `media.media`, `navigation.header`: no subject linkage needed (no PII fields per Epic A backfill)
|
||||
- Anchor for future per-feature collections that hold PII about users: documented pattern + example in `docs/compliance/subject-linkage.example.md`
|
||||
|
||||
### ADR amendments captured
|
||||
|
||||
- **ADR-018**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
|
||||
- **ADR-024**: `analytics.track` call sites in feature use cases gain a "check consent first" idiom; existing analytics manifest stays unchanged (no `analyticsEvents` re-declaration)
|
||||
- **`PAYLOAD_AUTH_PII_DEFAULTS`** (from Epic A) gains two excluded fields: `processingRestrictedAt`, `consentState`
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/guides/dsr.md` — full DSR cookbook (interfaces, route wiring, multi-subject handling, soft vs hard delete, certificate format)
|
||||
- `docs/guides/consent.md` — consent flow cookbook (manifest field, brand, runtime check pattern, anonymous → authenticated migration)
|
||||
- `docs/glossary.md` — new entries for `SubjectLink`, `DeletionCertificate`, `UserConsentState`, `ConsentChecked` brand
|
||||
- `CLAUDE.md` + `conformance-quickref.md` — rule count bump (11 → 12) + new manifest field documentation
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Pre-launch compliance checklist + fill-in templates** — Epic D
|
||||
- **Security headers middleware + rate-limit primitive + SBOM** — Epic C
|
||||
- **Streaming `IDataExport`** — in-memory only for first pass; streaming v2 when a consumer hits OOM
|
||||
- **REST endpoints** — Epic B exposes only tRPC (matches established pattern); REST wrapping documented for regulators in Epic D
|
||||
- **Per-framework router auto-wiring for cookie banner pageview reset** — banner emits a `consentChanged` callback; consumer wires their router
|
||||
- **Anonymous (pre-signup) consent storage in `users.consentState`** — anonymous lives in the cookie until signup migration
|
||||
- **Strict-mode `ConsentCategory` declaration merging** — string-literal-union escape hatch is sufficient (Q10 of grill)
|
||||
- **`dsr_rectifications` separate audit collection** — rectifications recorded in main audit log via `reason: "art-16-request"` tag (Q4 of grill)
|
||||
- **Brand-treatment of `IProcessingRestriction`** — restriction is binary + rare; consumer calls `isRestricted` where needed without a wrapper (Q5 of grill)
|
||||
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025 (no template ML; revisit when consumer adds automated decisions)
|
||||
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory
|
||||
- **Audit-log full-text export for DSR** — included as `auditLog: AuditEntry[]` in `UserDataBundle` but filtered to subject's own events only (other-subject events stay private)
|
||||
|
||||
## Constraints
|
||||
|
||||
- **ADR-025** — Epic B strategy settled there. Surface deviations require amendment before proceeding.
|
||||
- **ADR-018** — audit action enum amendment captured in this PRD; reuses existing `core-audit` channel for proof-of-consent + restriction events.
|
||||
- **ADR-022** — no library-evaluation traces needed for Epic B itself (no new third-party runtime deps); `@repo/core-dsr` and `@repo/core-consent` are workspace packages.
|
||||
- **ADR-024** — consent gates analytics emission via use case body checks (passive brand pattern); `analytics.track` interface itself unchanged.
|
||||
- **Epic A's deliverables are dependencies**:
|
||||
- `custom.pii` tags drive DSR's PII-walking cascade
|
||||
- `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern is reused for `processingRestrictedAt` + `consentState`
|
||||
- `compliance/data-map.yml` generator output documents what DSR exports
|
||||
- **Generator-first** — both new optional cores scaffolded via `pnpm turbo gen core-package <name>`. `core-ui` also requires scaffold (precondition).
|
||||
- **Manifest-first ordering** — `ConsentCategory` types + `requiresConsent` manifest schema land first; ESLint rule second; runtime wrappers third; use case body migrations last.
|
||||
- **`core-shared` boundary** — subject-linkage types live in `core-shared/payload/` (must-have, every consumer needs the type to read others' collection configs). DSR + consent interfaces stay in their optional cores.
|
||||
- **Optional cores composition** — `core-api`'s appRouter composes the new tRPC routers via the existing `<gen:*>` anchor pattern.
|
||||
- **Consent is template-policy-neutral** — template ships 4 default categories; consumer adds whatever else they need. No template-imposed legal opinion about what requires consent (consumer's DPO decides).
|
||||
- **No `--no-verify`** — every commit passes pre-commit gates; Conventional Commits non-negotiable.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `pnpm turbo gen core-package dsr` and `pnpm turbo gen core-package consent` produce green packages with the documented interfaces.
|
||||
- `pnpm turbo gen core-package ui` produces a green `core-ui` package; `<CookieConsentBanner>` lands in it.
|
||||
- `IDataExport.exportSubjectData("alice", "json")` walks the `users` collection + any `custom.subject`-linked collections, returning a `UserDataBundle` with `data.users.asSelf` containing Alice's row and `data.<other>.asReference` for any rows that linked-field her.
|
||||
- `IDataDelete.deleteSubjectData("alice", "soft")` flips `processingRestrictedAt`, NULLs exportable PII on Alice's `users` row, redacts `assignedTo`-style reference rows to NULL, emits one audit entry per affected collection, returns a `DeletionCertificate`.
|
||||
- 30 days after a soft-delete, Epic A's retention purge job hard-deletes the row via its existing schedule (no new code in Epic B for this — verifies Epic A's interaction).
|
||||
- `IConsent.grant("alice", ["analytics"], { method: "banner", bannerVersion: "v1" })` writes a `CONSENT_GRANT` audit entry AND updates `users.consentState.analytics.granted = true`.
|
||||
- `IConsent.isGranted("alice", "analytics")` returns `true` after the above (reads the cache field).
|
||||
- A use case declaring `requiresConsent: ["analytics"]` but missing the `withConsent` wrapper at bind time fails `assertFeatureConformance` boot check + the `no-undeclared-consent-check` ESLint rule.
|
||||
- A `consent.isGranted(_, "marketing")` call site in a use case whose manifest doesn't declare `"marketing"` in `requiresConsent` fires the ESLint rule.
|
||||
- `<CookieConsentBanner variant="modal">` renders with Reject All / Accept All as equally-sized side-by-side buttons; tab order treats them equally; ARIA labels mirror.
|
||||
- Anonymous user grants consent via banner → cookie stored → signs up → consumer's `signUp` use case calls `migrateAnonymousConsent` → cookie state lands in `users.consentState` + audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
|
||||
- tRPC routers `dsrRouter` + `consentRouter` compose into `core-api`'s appRouter without manual wiring (anchor + barrel pattern).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
|
||||
- `docs/guides/dsr.md` + `docs/guides/consent.md` cover the consumer wiring paths end-to-end including the anonymous → authenticated migration.
|
||||
|
||||
## User stories
|
||||
|
||||
1. As a **downstream consumer**, I want `/api/gdpr/export` to walk every PII-tagged collection and return my user's data in JSON so I satisfy Art. 15 without writing custom collection traversal.
|
||||
2. As a **downstream consumer**, I want `/api/gdpr/delete` to soft-delete a user (immediate flag, 30-day grace, hard-delete via Epic A's purge) so Art. 17 fulfillment is automated.
|
||||
3. As a **downstream consumer**, I want multi-subject rows (e.g. a support ticket with submitter + assignee) to have their linked PII redacted but the row preserved when one subject requests deletion, so the other subject's data remains intact.
|
||||
4. As a **downstream consumer running multi-tenant**, I want `custom.subject` to be declarable per-collection so I model my domain's subject relationships explicitly, not assume one `userId` column.
|
||||
5. As a **downstream consumer**, I want JSON-LD export with schema.org `@context` so a user can port their data to another service (Art. 20) without me writing a portability mapping.
|
||||
6. As a **downstream consumer**, I want `processing_restricted` to be honored across all my normal use cases via `IProcessingRestriction.isRestricted` checks so Art. 18 enforcement is mechanical.
|
||||
7. As a **downstream consumer shipping analytics**, I want `requiresConsent: ["analytics"]` on my manifest to enforce that the use case can't bind without consent-checking wrapper, so Art. 7 demonstrable consent is structurally enforced.
|
||||
8. As a **downstream consumer**, I want `IConsent.grant` to write both the fast-read cache and an audit entry so I have legal proof of consent without slow audit-log queries on every analytics call.
|
||||
9. As a **downstream consumer**, I want a cookie consent banner with EU-compliant default visual treatment (equal-prominence reject/accept) so I don't accidentally ship a CNIL-violating UX.
|
||||
10. As a **downstream consumer**, I want the banner to be a headless component with render-prop overrides so I brand the visuals without forking the legal-compliance logic.
|
||||
11. As an **anonymous user**, I want my consent choices to persist into my account at signup so I don't have to re-consent.
|
||||
12. As a **compliance reviewer**, I want every consent grant/withdrawal to leave an immutable audit entry recording timestamp, banner version, policy version, and method so I can prove demonstrable consent to a regulator.
|
||||
13. As an **AI agent** modifying a use case that calls `analytics.track`, I want `no-undeclared-consent-check` to fire when I call `consent.isGranted("X")` without declaring `"X"` in `requiresConsent`, so consent-event drift is caught at lint time.
|
||||
14. As an **AI agent** scaffolding a new feature, I want `pnpm turbo gen feature` to emit `requiresConsent: []` by default so I declare consent requirements during manifest-first ordering, not as an afterthought.
|
||||
15. As a **DPO doing internal audit**, I want `compliance/data-map.yml` (from Epic A) and the DSR endpoint mapping (from this Epic) to together answer "what data do we hold + how does a subject act on it" without me reading code.
|
||||
|
||||
## Implementation decisions
|
||||
|
||||
### Module surface
|
||||
|
||||
- **`@repo/core-shared` modifications** (must-have package):
|
||||
- New module `core-shared/payload/subject-linkage-types.ts` exporting `SubjectLinkKind` (`"self" | "owner" | "reference"`), `SubjectLink`, `CollectionSubject` (single or array form)
|
||||
- Ambient declaration extending Payload `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
|
||||
- Extension to `PAYLOAD_AUTH_PII_DEFAULTS` (Epic A's auth-managed exclusions): add `processingRestrictedAt` + `consentState` as excluded (security/control material, not PII-export)
|
||||
- Audit action enum (in `core-shared/audit`, used by `core-audit`) gains: `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
|
||||
- **`@repo/core-audit` modifications**:
|
||||
- `IAuditLog.record` accepts the new action types via the extended enum
|
||||
- No new interface methods; existing `eraseSubject` flow handles post-DSR-delete pseudonymization
|
||||
- **`@repo/core-dsr` (new optional core)**:
|
||||
- Four interfaces in `core-dsr/<interface>.interface.ts`
|
||||
- Payload-backed reference impls: `PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`
|
||||
- Recording test doubles in `core-testing`
|
||||
- Protocol-agnostic handlers in `core-dsr/handlers/{export,delete,rectify,restrict}-handler.ts`
|
||||
- tRPC router `core-dsr/dsr.router.ts` exporting `dsrRouter`
|
||||
- JSON-LD context at `core-dsr/contexts/user-data.jsonld` (schema.org-derived)
|
||||
- DI binders `core-dsr/di/{bind-production,bind-dev-seed}.ts`
|
||||
- **`@repo/core-consent` (new optional core)**:
|
||||
- `IConsent` interface in `core-consent/consent.interface.ts`
|
||||
- `ConsentCategory` + `ConsentState` + `UserConsentState` types in `core-consent/consent-types.ts`
|
||||
- `withConsent` wrapper attaching `ConsentChecked` brand at bind time
|
||||
- `ConsentChecked` brand definition added to `core-shared/conformance/brands.ts`
|
||||
- Payload-backed reference impl `PayloadConsent` (reads/writes `users.consentState` + emits `CONSENT_*` audit entries via injected `core-audit`)
|
||||
- Recording test double in `core-testing`
|
||||
- Anonymous migration helpers `extractAnonymousConsent` + `migrateAnonymousConsent`
|
||||
- Protocol-agnostic handlers in `core-consent/handlers/`
|
||||
- tRPC router `core-consent/consent.router.ts` exporting `consentRouter`
|
||||
- React subpath `core-consent/react` with `<ConsentProvider>` + `useConsent()` hook
|
||||
- DI binders `core-consent/di/{bind-production,bind-dev-seed}.ts`
|
||||
- **`@repo/core-ui` (new optional core — precondition scaffold)**:
|
||||
- `pnpm turbo gen core-package ui` lands the package shell first
|
||||
- `<CookieConsentBanner>` component
|
||||
- Default render-prop slots: `renderHeader`, `renderCategoryRow`, `renderActions`
|
||||
- `__consent_state` cookie management (read/write/clear) inside the component for anonymous flow
|
||||
- Storybook story + accessibility tests (axe-core)
|
||||
- **`@repo/core-api` modifications**:
|
||||
- appRouter composes `dsrRouter` + `consentRouter` via existing `<gen:*>` anchor pattern
|
||||
- **`@repo/core-eslint`**:
|
||||
- New rule `no-undeclared-consent-check` (warn severity)
|
||||
- `_manifest-ast.js` parser gains `requiresConsent` field extraction (parallel to `requiresCores`)
|
||||
- **`@repo/core-testing`**:
|
||||
- `RecordingDataExport`, `RecordingDataDelete`, `RecordingDataRectify`, `RecordingProcessingRestriction`, `RecordingConsent` test doubles
|
||||
- **`packages/auth/` modifications**:
|
||||
- `users` collection: explicit `custom.subject = { kind: "self", field: "id" }` declaration
|
||||
- Optional `signUp` use case extension (in template's own auth feature): call `migrateAnonymousConsent` when a cookie is present
|
||||
- **Conformance ESLint rules**: count 11 → 12 (`no-undeclared-consent-check`)
|
||||
- **Brand composition**: order extended to `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
|
||||
- **`assertFeatureConformance`**: extended brand check for `ConsentChecked` when `requiresConsent.length > 0`
|
||||
|
||||
### Subject linkage contract
|
||||
|
||||
```ts
|
||||
// core-shared/payload/subject-linkage-types.ts
|
||||
export type SubjectLinkKind = "self" | "owner" | "reference";
|
||||
|
||||
export type SubjectLink = {
|
||||
field: string; // field name (or "id" for self)
|
||||
kind: SubjectLinkKind;
|
||||
target?: string; // target collection (required when kind !== "self")
|
||||
role?: string; // e.g. "submitter", "assignee" — surfaces in DeletionCertificate
|
||||
};
|
||||
|
||||
export type CollectionSubject = SubjectLink | SubjectLink[];
|
||||
```
|
||||
|
||||
DSR cascade semantics by role:
|
||||
|
||||
| Role | `IDataExport` | `IDataDelete` mode=`soft` | `IDataDelete` mode=`cascade-hard` |
|
||||
| ----------- | -------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------- |
|
||||
| `self` | include row in `asSelf` | NULL exportable fields + set `processingRestrictedAt` | hard-delete row |
|
||||
| `owner` | include row in `asSelf` | NULL exportable fields | hard-delete row |
|
||||
| `reference` | include `{rowId, linkedField, linkedThrough}` in `asReference` | redact the linked field only | redact the linked field only (row preserved) |
|
||||
|
||||
### Interface contracts
|
||||
|
||||
```ts
|
||||
// core-dsr/data-export.interface.ts
|
||||
export type UserDataBundle = {
|
||||
subjectId: string;
|
||||
exportedAt: string; // ISO 8601
|
||||
format: "json" | "json-ld";
|
||||
data: {
|
||||
[collection: string]: {
|
||||
asSelf?: Array<Record<string, unknown>>;
|
||||
asReference?: Array<{
|
||||
rowId: string;
|
||||
linkedField: string;
|
||||
linkedThrough: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
auditLog?: AuditEntry[]; // filtered to subject's own events
|
||||
"@context"?: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface IDataExport {
|
||||
exportSubjectData(
|
||||
subjectId: string,
|
||||
format: "json" | "json-ld",
|
||||
): Promise<UserDataBundle>;
|
||||
}
|
||||
|
||||
// core-dsr/data-delete.interface.ts
|
||||
export type DeletionCertificate = {
|
||||
subjectId: string; // or "erased-{hash}" if already scrubbed
|
||||
mode: "soft" | "cascade-hard";
|
||||
timestamp: string;
|
||||
reason: "art-17-request" | "admin-expunge" | "retention-policy";
|
||||
affected: Array<{
|
||||
collection: string;
|
||||
rowsAffected: number;
|
||||
action: "deleted" | "redacted" | "pseudonymized";
|
||||
fields?: string[]; // when action === "redacted"
|
||||
}>;
|
||||
auditEntryId: string;
|
||||
};
|
||||
|
||||
export interface IDataDelete {
|
||||
deleteSubjectData(
|
||||
subjectId: string,
|
||||
mode: "soft" | "cascade-hard",
|
||||
): Promise<DeletionCertificate>;
|
||||
}
|
||||
|
||||
// core-dsr/data-rectify.interface.ts
|
||||
export interface IDataRectify {
|
||||
updateSubjectField(
|
||||
subjectId: string,
|
||||
collection: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// core-dsr/processing-restriction.interface.ts
|
||||
export interface IProcessingRestriction {
|
||||
setRestriction(subjectId: string, granted: boolean): Promise<void>;
|
||||
isRestricted(subjectId: string): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// core-consent/consent-types.ts
|
||||
export type ConsentCategory =
|
||||
| "essential"
|
||||
| "functional"
|
||||
| "analytics"
|
||||
| "marketing"
|
||||
| (string & Record<never, never>);
|
||||
|
||||
export type ConsentState = {
|
||||
granted: boolean;
|
||||
grantedAt?: string; // ISO 8601
|
||||
withdrawnAt?: string;
|
||||
bannerVersion?: string;
|
||||
policyVersion?: string;
|
||||
method?: "banner" | "settings" | "api" | "signup-migration";
|
||||
};
|
||||
|
||||
export type UserConsentState = Record<ConsentCategory, ConsentState>;
|
||||
|
||||
// core-consent/consent.interface.ts
|
||||
export interface IConsent {
|
||||
isGranted(subjectId: string, category: ConsentCategory): Promise<boolean>;
|
||||
grant(
|
||||
subjectId: string,
|
||||
categories: ConsentCategory[],
|
||||
record: Omit<ConsentState, "granted" | "grantedAt">,
|
||||
): Promise<void>;
|
||||
withdraw(subjectId: string, categories: ConsentCategory[]): Promise<void>;
|
||||
getCategories(subjectId: string): Promise<ConsentCategory[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### Cookie banner contract
|
||||
|
||||
```tsx
|
||||
type CookieConsentBannerProps = {
|
||||
variant?: "modal" | "banner"; // default "modal"
|
||||
categories?: ConsentCategory[]; // default ["essential", "functional", "analytics", "marketing"]
|
||||
defaultEnabled?: ConsentCategory[]; // default ["essential"]
|
||||
privacyPolicyHref?: string;
|
||||
bannerVersion?: string;
|
||||
policyVersion?: string;
|
||||
onConsentChange?: (state: UserConsentState) => void;
|
||||
renderHeader?: (ctx: { variant: "modal" | "banner" }) => ReactNode;
|
||||
renderCategoryRow?: (ctx: {
|
||||
category: ConsentCategory;
|
||||
granted: boolean;
|
||||
toggle: () => void;
|
||||
}) => ReactNode;
|
||||
renderActions?: (ctx: {
|
||||
acceptAll: () => void;
|
||||
rejectAll: () => void;
|
||||
saveSelected: () => void;
|
||||
}) => ReactNode;
|
||||
};
|
||||
```
|
||||
|
||||
Default UI ships:
|
||||
|
||||
- Modal variant: centered, focus-trapped, ESC = "reject all" (legally explicit choice — not silent dismiss)
|
||||
- Banner variant: bottom-of-viewport, position fixed, full-width
|
||||
- Reject All + Accept All: side-by-side, equal size, equal visual weight (no "Accept All" emphasis via color/size)
|
||||
- Save Selected: secondary action
|
||||
|
||||
a11y baseline:
|
||||
|
||||
- WCAG 2.2 AA color contrast
|
||||
- Keyboard navigable (tab/shift-tab/escape)
|
||||
- Screen-reader announcements via ARIA live region
|
||||
- Focus-trap inside modal
|
||||
- axe-core test in Storybook
|
||||
|
||||
### Anonymous → authenticated consent migration
|
||||
|
||||
Flow:
|
||||
|
||||
1. Banner saves anonymous consent to `__consent_state` cookie (SameSite=Lax, Secure, 1-year max-age).
|
||||
2. Consumer's `signUp` use case extracts the cookie via `extractAnonymousConsent(request.headers.cookie)`.
|
||||
3. After user record creation, calls `migrateAnonymousConsent({ userId, cookieState, banneredVersion, policyVersion })`.
|
||||
4. Helper calls `IConsent.grant(userId, cookieState.categories, { method: "signup-migration", bannerVersion, policyVersion })`.
|
||||
5. Audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
|
||||
6. Response sets `Set-Cookie: __consent_state=; Max-Age=0` (clear cookie).
|
||||
|
||||
Helper signatures live in `core-consent`; the template's existing `auth.signUp` use case is the canonical example consumer.
|
||||
|
||||
### tRPC router shapes
|
||||
|
||||
- `dsrRouter.export` → `{ subjectId, format }` → `UserDataBundle`
|
||||
- `dsrRouter.delete` → `{ subjectId, mode }` → `DeletionCertificate`
|
||||
- `dsrRouter.rectify` → `{ subjectId, collection, field, value }` → `void`
|
||||
- `dsrRouter.restrict` → `{ subjectId, granted }` → `void`
|
||||
- `consentRouter.grant` → `{ subjectId, categories, record }` → `void`
|
||||
- `consentRouter.withdraw` → `{ subjectId, categories }` → `void`
|
||||
- `consentRouter.isGranted` → `{ subjectId, category }` → `boolean`
|
||||
- `consentRouter.getCategories` → `{ subjectId }` → `ConsentCategory[]`
|
||||
|
||||
Auth check happens at the procedure level via existing per-feature error middleware pattern (defineErrorMiddleware). Subject-driven calls: `subjectId === ctx.session.user.id`. Admin calls: role check via existing auth feature mechanisms.
|
||||
|
||||
### ADR amendments captured
|
||||
|
||||
- **ADR-018 amendment**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`. The PRD landing this change adds the lines to `core-shared/audit/audit-action.ts` (or wherever the enum lives) and updates `docs/guides/audit-and-compliance.md`. ADR-018's own §"Six action types" wording becomes "ten action types"; an `## Amendments` section at the foot of ADR-018 captures the date + reason.
|
||||
|
||||
### Conformance + manifest impact
|
||||
|
||||
- New per-use-case field: `requiresConsent: ConsentCategory[]` (default `[]`)
|
||||
- New ESLint rule: `no-undeclared-consent-check` (warn)
|
||||
- Brand composition order extended (innermost-to-outermost): `withConsent` → `withAnalytics` → `withAudit` → `withCapture` → `withSpan`
|
||||
- Boot assertion: requires `ConsentChecked` brand when `requiresConsent.length > 0`
|
||||
- New optional cores in `requiredCores` vocabulary: `dsr`, `consent`
|
||||
- `required-cores-installed` ESLint rule auto-detects new optional cores via existing pnpm-workspace.yaml mechanism
|
||||
|
||||
### Endpoint mapping (informative — not code)
|
||||
|
||||
| GDPR Article | tRPC procedure | HTTP path (consumer-mapped) |
|
||||
| ------------------------ | ----------------------------------------- | --------------------------------- |
|
||||
| Art. 15 (access) | `dsrRouter.export({ format: "json" })` | `/api/gdpr/export` |
|
||||
| Art. 16 (rectification) | `dsrRouter.rectify` | `/api/gdpr/rectify` |
|
||||
| Art. 17 (erasure) | `dsrRouter.delete` | `/api/gdpr/delete` |
|
||||
| Art. 18 (restriction) | `dsrRouter.restrict` | `/api/gdpr/restrict` |
|
||||
| Art. 20 (portability) | `dsrRouter.export({ format: "json-ld" })` | `/api/gdpr/export?format=json-ld` |
|
||||
| Art. 21 (objection) | `consentRouter.withdraw` | `/api/consent/withdraw` |
|
||||
| Art. 22 (auto decisions) | _deferred_ per ADR-025 | _deferred_ |
|
||||
| Art. 7 (consent grant) | `consentRouter.grant` | `/api/consent/grant` |
|
||||
|
||||
The mapping is documentation; consumer composes their own HTTP namespace from the tRPC procedures.
|
||||
|
||||
## Testing decisions
|
||||
|
||||
- **Interface impls** (`PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`, `PayloadConsent`): each gets a contract test suite covering: happy path per role/mode, multi-subject row redaction, JSON-LD `@context` correctness, audit emission shape, restriction flag honored on reads, consent state read/write round-trip including audit entry, signup-migration helper.
|
||||
- **Recording doubles**: vitest unit tests asserting captured calls match invocations + payload shape.
|
||||
- **`withConsent` wrapper**: tests asserting `ConsentChecked` brand attached at bind time, brand inspectable via `isConsentChecked` helper, factory invocation passthrough preserved.
|
||||
- **ESLint rule `no-undeclared-consent-check`**: RuleTester fixtures parallel to `no-undeclared-audit.test.js` — passes when call matches manifest, fires on undeclared category, fires on unused declaration (warn), no-op on non-use-case files.
|
||||
- **`assertFeatureConformance` brand check**: synthetic manifest fixture with `requiresConsent: ["analytics"]` but no `withConsent` wrapper at bind site fails with a `ConformanceError` naming the missing `ConsentChecked` brand.
|
||||
- **Cookie banner**:
|
||||
- Storybook story with axe-core a11y pass
|
||||
- React Testing Library: render → click "Reject All" → assert callback fires with all categories `granted: false` except `essential`
|
||||
- Render → toggle analytics → click "Save Selected" → assert callback fires with `analytics.granted: true`
|
||||
- Modal variant focus-trap test
|
||||
- Keyboard nav test (tab order, escape behavior)
|
||||
- **Signup migration**: in `auth.signUp.use-case.test.ts` — mock cookie header → call use case → assert `migrateAnonymousConsent` invoked → assert audit entry shape via `RecordingAuditLog`
|
||||
- **tRPC routers**: integration tests using the existing tRPC test pattern (defineErrorMiddleware passthrough, auth check, response shape)
|
||||
- **Multi-subject scenario**: synthetic Payload collection fixture (`support_tickets`) declared in a unit test with `custom.subject = [{ field: "submittedBy", role: "submitter", kind: "reference" }, { field: "assignedTo", role: "assignee", kind: "reference" }]`. Export for one user returns the linked-field reference; delete redacts only that user's link.
|
||||
- **JSON-LD `@context` correctness**: test asserts emitted JSON-LD parses with a standard schema.org validator (use `jsonld` library in test only, not runtime).
|
||||
- **Coverage**: all new modules join L0 vitest thresholds via `coverage.bands` in their new feature manifests. L1 `pnpm coverage:diff` gates every slice.
|
||||
- **Prior art to mirror**:
|
||||
- `core-audit` interface + DI binder + recording double pattern: `packages/core-audit/src/{audit-log.interface.ts,di/bind-audit.ts}` + `packages/core-testing/src/instrumentation/recording-audit-log.ts`
|
||||
- ESLint rule: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
|
||||
- Brand attachment: `packages/core-shared/src/conformance/{brands.ts,brand-runtime.ts}` + `packages/core-shared/src/instrumentation/with-capture.ts`
|
||||
- tRPC router pattern: any feature's `integrations/api/router.ts`
|
||||
- React subpath: ADR-024's `@repo/core-analytics/react` (`<AnalyticsProvider>` + `useAnalytics()` hook) — shipped this session
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1: Multi-subject `IDataExport` JSON-LD `@context` — separate context per `role` or one context?** — Recommended: **one context**. schema.org's `Role` vocabulary handles role-naming via `roleName`. Simpler than per-role context selection.
|
||||
- **Q2: Should `IDataDelete` mode=`cascade-hard` skip the 30-day grace and execute immediately?** — Recommended: **yes**. Admin-only path (auth check at endpoint level). Soft-delete handles the user-driven 30-day flow; cascade-hard is for "expunge now" scenarios (legal hold release, court order). Document in `docs/guides/dsr.md`.
|
||||
- **Q3: How does `<CookieConsentBanner>` handle SSR — render server-side or client-only?** — Recommended: **client-only with SSR-safe placeholder**. Component reads/writes cookies; server can't reliably synthesize the right initial state. Ship `<CookieConsentBannerLoader>` for SSR placeholder + dynamic-import the actual banner client-side. Document in `docs/guides/consent.md`.
|
||||
- **Q4: Should `IConsent.grant` accept an opaque `ipTruncated` field for audit (matches `core-audit`'s from-where requirement)?** — Recommended: **yes**, but threaded via the handler layer (extracts from request), not via `IConsent`'s own signature. Keep the interface PII-clean; the handler enriches the audit entry with truncated IP from the request context.
|
||||
- **Q5: Does the cookie banner's `__consent_state` cookie shape need to be versioned for forward-compat?** — Recommended: **yes, include `_v: 1` field**. Component reads versioned shape; migrates older versions on read. Documents the migration policy in `docs/guides/consent.md`.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- **REST endpoint scaffolds** — exposed via tRPC only per Q8 of grill; consumer wraps for REST if needed; Epic D docs the wrapping pattern.
|
||||
- **Streaming `IDataExport`** — in-memory `UserDataBundle` for first pass; v2 if a consumer hits OOM (see Q3 of grill).
|
||||
- **`dsr_rectifications` collection for rectification history** — main audit log suffices via `reason: "art-16-request"` tag (Q4 of grill).
|
||||
- **`withRestriction` brand-treatment** — restriction is binary + rare; consumer calls `isRestricted` where needed (Q5 of grill).
|
||||
- **Strict-mode `ConsentCategory` declaration merging** — escape-hatch union is sufficient (Q10 of grill).
|
||||
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025; revisit when consumer adds automated decisions.
|
||||
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory.
|
||||
- **Audit-log full export across subjects (admin-mediated forensics)** — out of DSR's subject-scoped surface.
|
||||
- **Migration tooling for downstream consumers upgrading from a pre-ADR-025 template version** — template hasn't been versioned with consumers; no migration story yet.
|
||||
- **Per-framework router auto-wiring for analytics on consent toggle** — banner emits `onConsentChange` callback; consumer wires (e.g., re-initialize analytics SDK after consent granted).
|
||||
|
||||
## Further notes
|
||||
|
||||
- **Builds on**: ADR-018 (audit channel — amends action enum), ADR-022 (library evaluation policy — no new traces needed for workspace packages), ADR-024 (analytics — gates emission via consent), ADR-025 (strategy umbrella — Epic B implementation seed), Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — provides PII tags + retention purge + `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern).
|
||||
- **Pairs with**: Epic C PRD `security-headers-rate-limit-sbom.prd.md` (independent; dispatcher can interleave); Epic D PRD `compliance-docs-scaffolds.prd.md` (lands after B; consumes DSR + consent route mappings in documentation).
|
||||
- **Sequencing within Epic B**: subject-linkage types + ambient declaration → audit enum amendment → `core-consent` interface + brand + ESLint rule → `core-dsr` interfaces + handlers → tRPC routers + core-api composition → cookie banner (after core-ui scaffolded) → signup-migration helper + auth feature integration → docs.
|
||||
- **Stakeholders**: template authors (most affected — adds 2 optional cores, manifest field, brand, ESLint rule, ADR-018 amendment), downstream EU-bound consumers (positively affected — gain DSR + consent + cookie banner for free), AI agents operating in feature code (positively affected — declarative consent gates with lint-time enforcement), compliance reviewers + DPOs (positively affected — demonstrable consent with audit-logged proof).
|
||||
- **PII boundary clarification**: this PRD does NOT relax ADR-017 §7. Observability surface stays id-only. Consent surface explicitly permits traits within `UserConsentState` (banner version, policy version, method, IP-truncated via handler enrichment) — consumer-policy boundary distinct from observability boundary, documented per ADR-024's PII boundary precedent.
|
||||
Reference in New Issue
Block a user