feat(core-dsr): scaffold package + GDPR DSR interfaces and types

- Add pnpm turbo gen core-package dsr generator template and register
  dsr in CORE_PACKAGE_GENERATORS / choices list
- Run generator to produce packages/core-dsr/ shell
- Define IDataExport (Art. 15/20), IDataDelete (Art. 17),
  IDataRectify (Art. 16), IProcessingRestriction (Art. 18) interfaces
- Add UserDataBundle and DeletionCertificate types in dsr-types.ts
- Ship core-dsr/contexts/user-data.jsonld schema.org JSON-LD @context
- Wire @repo/core-dsr into transpilePackages (web-next)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:32:03 +00:00
parent 86d949294f
commit e378c950a9
24 changed files with 490 additions and 9 deletions

View File

@@ -10,6 +10,7 @@ const nextConfig = {
"@repo/core-audit", "@repo/core-audit",
"@repo/core-cms", "@repo/core-cms",
"@repo/core-consent", "@repo/core-consent",
"@repo/core-dsr",
"@repo/core-shared", "@repo/core-shared",
"@repo/marketing-pages", "@repo/marketing-pages",
"@repo/media", "@repo/media",

View File

@@ -1,18 +1,18 @@
{ {
"generatedAt": "2026-05-19T19:19:27.597Z", "generatedAt": "2026-05-19T19:30:03.273Z",
"commit": "33e3c09", "commit": "86d9492",
"repo": { "repo": {
"statements": 96.74, "statements": 96.74,
"branches": 92.11, "branches": 92.15,
"functions": 96.86, "functions": 96.92,
"lines": 96.74, "lines": 96.74,
"counts": { "counts": {
"lf": 4632, "lf": 4632,
"lh": 4481, "lh": 4481,
"brf": 912, "brf": 917,
"brh": 840, "brh": 845,
"fnf": 287, "fnf": 292,
"fnh": 278 "fnh": 283
} }
}, },
"byPackage": { "byPackage": {
@@ -72,6 +72,20 @@
"fnh": 33 "fnh": 33
} }
}, },
"@repo/core-dsr": {
"statements": 100,
"branches": 100,
"functions": 100,
"lines": 100,
"counts": {
"lf": 0,
"lh": 0,
"brf": 5,
"brh": 5,
"fnf": 5,
"fnh": 5
}
},
"@repo/core-shared": { "@repo/core-shared": {
"statements": 98.03, "statements": 98.03,
"branches": 95.89, "branches": 95.89,

View File

@@ -0,0 +1,33 @@
# @repo/core-dsr
Optional core package providing GDPR Data Subject Rights (DSR) interfaces and implementations. Scaffold via `pnpm turbo gen core-package dsr`.
## Structure
```
src/
data-export.interface.ts # IDataExport — exportSubjectData
data-delete.interface.ts # IDataDelete — deleteSubjectData
data-rectify.interface.ts # IDataRectify — updateSubjectField
processing-restriction.interface.ts # IProcessingRestriction — setRestriction, isRestricted
dsr-types.ts # UserDataBundle, DeletionCertificate, DSR value types
contexts/
user-data.jsonld # schema.org JSON-LD @context
index.ts # Barrel export
```
## Design
Four interfaces map directly to GDPR Articles 1518 + 20:
- `IDataExport` (Art. 15/20) — export a subject's data as `UserDataBundle`
- `IDataDelete` (Art. 17) — soft-delete or cascade-hard-delete subject data; returns `DeletionCertificate`
- `IDataRectify` (Art. 16) — update a specific field for a subject
- `IProcessingRestriction` (Art. 18) — toggle and read the processing restriction flag
Implementations walk `custom.pii`-tagged fields and `custom.subject`-linked collections. Row semantics:
- `kind: "self" | "owner"` → directly owned by the subject (export full; delete hard or soft)
- `kind: "reference"` → references the subject from another entity (export redacted; redact link on delete)
See `docs/architecture/agent-first-workflow-and-conformance.md` for the DI conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "@repo/core-dsr",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,39 @@
{
"@context": {
"@vocab": "https://schema.org/",
"dsr": "https://w3.org/ns/dpv#",
"prov": "https://www.w3.org/ns/prov#",
"subjectId": "identifier",
"exportedAt": "dateCreated",
"format": "encodingFormat",
"data": {
"@id": "prov:hadMember",
"@container": "@index"
},
"asSelf": {
"@id": "dsr:hasPersonalDataHandling",
"@type": "@id"
},
"asReference": {
"@id": "dsr:hasDataSubjectRight",
"@type": "@id"
},
"rowId": "identifier",
"linkedField": "name",
"linkedThrough": {
"@id": "isPartOf",
"@type": "@id"
},
"auditLog": {
"@id": "prov:wasGeneratedBy",
"@type": "@id"
},
"UserDataBundle": "dsr:RightOfAccess",
"SubjectReference": "dsr:DataSubjectRight"
}
}

View File

@@ -0,0 +1,29 @@
import type { DeletionCertificate, DeletionMode } from "./dsr-types";
/**
* GDPR Art. 17 (right to erasure / "right to be forgotten").
*
* Two modes:
*
* - `"soft"` — sets `processingRestrictedAt`, NULLs all `exportable: true`
* PII fields, and redacts `reference`-role linked fields to null. The row
* structure is preserved so other subjects' data in shared rows remains
* intact. Emits one RESTRICT audit entry per affected collection.
*
* - `"cascade-hard"` — hard-deletes `self` and `owner` rows immediately, then
* redacts `reference` fields. Auth-guarded at the procedure layer; must not
* be called from user-facing flows. Emits DELETE audit entries.
*/
export interface IDataDelete {
/**
* Delete or erase all personal data held for the given subject.
*
* @param subjectId - The subject's canonical ID.
* @param mode - Deletion strategy (soft redaction vs hard cascade).
* @returns A signed `DeletionCertificate` linking to the audit log entry.
*/
deleteSubjectData(
subjectId: string,
mode: DeletionMode,
): Promise<DeletionCertificate>;
}

View File

@@ -0,0 +1,22 @@
import type { DsrFormat, UserDataBundle } from "./dsr-types";
/**
* GDPR Art. 15 (right of access) + Art. 20 (right to data portability).
*
* Implementations walk all Payload collections with `custom.subject` linkage,
* segment rows by role (self/owner vs reference), and filter to fields marked
* `exportable: true` in `custom.pii`.
*/
export interface IDataExport {
/**
* Export all personal data held for the given subject.
*
* @param subjectId - The subject's canonical ID (e.g. users.id).
* @param format - "json" for a plain JSON bundle; "json-ld" attaches the
* @context from `contexts/user-data.jsonld`.
*/
exportSubjectData(
subjectId: string,
format: DsrFormat,
): Promise<UserDataBundle>;
}

View File

@@ -0,0 +1,24 @@
/**
* GDPR Art. 16 (right to rectification).
*
* Allows a subject to correct inaccurate personal data held about them.
* Implementations verify the field is PII-tagged before updating and emit a
* RESTRICT audit entry with `reason: "art-16-request"` as the tamper-evident
* record of the correction.
*/
export interface IDataRectify {
/**
* Update a single PII field for the given subject in the specified collection.
*
* @param subjectId - The subject's canonical ID.
* @param collection - Payload collection slug (e.g. "users").
* @param field - Name of the field to update (must be `custom.pii`-tagged).
* @param value - New value; must satisfy the field's Payload field type.
*/
updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void>;
}

View File

@@ -0,0 +1,75 @@
import type { AuditEntry } from "@repo/core-shared/audit";
export type DsrFormat = "json" | "json-ld";
export type DeletionMode = "soft" | "cascade-hard";
export type DeletionReason =
| "art-17-request"
| "admin-expunge"
| "retention-policy";
export type DeletionAction = "deleted" | "redacted" | "pseudonymized";
/** Row reference from a collection where the subject appears as a non-owner link. */
export type SubjectReference = {
rowId: string;
/** Field name in the collection that links to the subject. */
linkedField: string;
/** Slug of the collection containing the reference. */
linkedThrough: string;
};
/** Per-collection data bucket within a UserDataBundle. */
export type CollectionDataBucket = {
/** Rows directly owned by the subject (kind: "self" | "owner"). */
asSelf?: Array<Record<string, unknown>>;
/** Rows referencing the subject without owning the row (kind: "reference"). */
asReference?: SubjectReference[];
};
/**
* GDPR Art. 15/20 export payload.
*
* `data` is keyed by Payload collection slug. asSelf contains exportable-PII-
* filtered rows the subject owns; asReference lists row IDs + link coordinates
* for rows that merely reference the subject.
*/
export type UserDataBundle = {
subjectId: string;
/** ISO 8601 timestamp of when the export was produced. */
exportedAt: string;
format: DsrFormat;
data: Record<string, CollectionDataBucket>;
/** Audit entries scoped to this subject's activity. */
auditLog?: AuditEntry[];
/** JSON-LD @context URI or inline object; populated when format === "json-ld". */
"@context"?: string | Record<string, unknown>;
};
/** Per-collection summary of what the deletion touched. */
export type DeletionAffected = {
collection: string;
rowsAffected: number;
action: DeletionAction;
/** PII field names that were NULLed when action === "redacted". */
fields?: string[];
};
/**
* Immutable proof of a completed GDPR Art. 17 deletion / erasure request.
*
* The `auditEntryId` links back to the audit log entry created at deletion
* time, forming a tamper-evident chain for regulatory inspection.
*/
export type DeletionCertificate = {
/** Subject ID, or "erased-{hash}" if the ID itself was purged. */
subjectId: string;
mode: DeletionMode;
/** ISO 8601 timestamp of the deletion. */
timestamp: string;
reason: DeletionReason;
affected: DeletionAffected[];
/** ID of the audit log entry that recorded this operation. */
auditEntryId: string;
};

View File

@@ -0,0 +1,16 @@
export type {
DsrFormat,
DeletionMode,
DeletionReason,
DeletionAction,
SubjectReference,
CollectionDataBucket,
UserDataBundle,
DeletionAffected,
DeletionCertificate,
} from "./dsr-types";
export type { IDataExport } from "./data-export.interface";
export type { IDataDelete } from "./data-delete.interface";
export type { IDataRectify } from "./data-rectify.interface";
export type { IProcessingRestriction } from "./processing-restriction.interface";

View File

@@ -0,0 +1,25 @@
/**
* GDPR Art. 18 (right to restriction of processing).
*
* Toggles and reads the `processingRestrictedAt` flag on the subject's user
* record. When restricted, downstream use cases should call `isRestricted`
* before processing personal data and short-circuit if true.
*
* Emits RESTRICT / UNRESTRICT audit entries on every state change.
*/
export interface IProcessingRestriction {
/**
* Grant or revoke processing restriction for the given subject.
*
* @param subjectId - The subject's canonical ID.
* @param granted - true to restrict processing; false to lift restriction.
*/
setRestriction(subjectId: string, granted: boolean): Promise<void>;
/**
* Return whether processing is currently restricted for the given subject.
*
* @param subjectId - The subject's canonical ID.
*/
isRestricted(subjectId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});

25
pnpm-lock.yaml generated
View File

@@ -633,6 +633,31 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
packages/core-dsr:
dependencies:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
devDependencies:
"@repo/core-eslint":
specifier: workspace:*
version: link:../core-eslint
"@repo/core-testing":
specifier: workspace:*
version: link:../core-testing
"@repo/core-typescript":
specifier: workspace:*
version: link:../core-typescript
"@vitest/coverage-v8":
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
packages/core-eslint: packages/core-eslint:
dependencies: dependencies:
globals: globals:

View File

@@ -797,11 +797,26 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
}, },
printConsentNextSteps, printConsentNextSteps,
], ],
dsr: () => [
() => {
assertOptionalPackageNotPresent("core-dsr");
return "Guard passed — packages/core-dsr does not exist yet.";
},
...emitTemplateTree("core-package/dsr", "packages/core-dsr"),
() => {
addToTranspilePackages(
"apps/web-next/next.config.mjs",
"@repo/core-dsr",
);
return "Added @repo/core-dsr to transpilePackages.";
},
printDsrNextSteps,
],
}; };
plop.setGenerator("core-package", { plop.setGenerator("core-package", {
description: description:
"Scaffold an optional core package (realtime, events, trpc, ui, audit, analytics, consent)", "Scaffold an optional core package (realtime, events, trpc, ui, audit, analytics, consent, dsr)",
prompts: [ prompts: [
{ {
type: "list", type: "list",
@@ -811,6 +826,7 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
"analytics", "analytics",
"audit", "audit",
"consent", "consent",
"dsr",
"events", "events",
"realtime", "realtime",
"trpc", "trpc",
@@ -1564,6 +1580,25 @@ function printConsentNextSteps(): string {
].join("\n"); ].join("\n");
} }
function printDsrNextSteps(): string {
return [
"─────────────────────────────────────────────────────────────",
"@repo/core-dsr scaffolded into packages/core-dsr/.",
"",
"Next steps:",
"",
" 1. pnpm install # link the new workspace package",
"",
" 2. Implement DSR interfaces and types in packages/core-dsr/src/",
" (see story 06-core-dsr)",
"",
" 3. Add @repo/core-dsr to feature package.json files that need DSR",
"",
" 4. pnpm typecheck && pnpm lint && pnpm test",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
function coreUiComponentActions(a: { function coreUiComponentActions(a: {
tier: "atom" | "molecule" | "organism"; tier: "atom" | "molecule" | "organism";
name: string; name: string;

View File

@@ -0,0 +1,32 @@
# @repo/core-dsr
Optional core package providing GDPR Data Subject Rights (DSR) interfaces and implementations. Scaffold via `pnpm turbo gen core-package dsr`.
## Structure
```
src/
data-export.interface.ts # IDataExport — exportSubjectData
data-delete.interface.ts # IDataDelete — deleteSubjectData
data-rectify.interface.ts # IDataRectify — updateSubjectField
processing-restriction.interface.ts # IProcessingRestriction — setRestriction, isRestricted
dsr-types.ts # UserDataBundle, DeletionCertificate, DSR value types
contexts/
user-data.jsonld # schema.org JSON-LD @context
index.ts # Barrel export
```
## Design
Four interfaces map directly to GDPR Articles 1518 + 20:
- `IDataExport` (Art. 15/20) — export a subject's data as `UserDataBundle`
- `IDataDelete` (Art. 17) — soft-delete or cascade-hard-delete subject data; returns `DeletionCertificate`
- `IDataRectify` (Art. 16) — update a specific field for a subject
- `IProcessingRestriction` (Art. 18) — toggle and read the processing restriction flag
Implementations walk `custom.pii`-tagged fields and `custom.subject`-linked collections. Row semantics:
- `kind: "self" | "owner"` → directly owned by the subject (export full; delete hard or soft)
- `kind: "reference"` → references the subject from another entity (export redacted; redact link on delete)
See `docs/architecture/agent-first-workflow-and-conformance.md` for the DI conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,27 @@
{
"name": "@repo/core-dsr",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,2 @@
// placeholder — populated by story 06-core-dsr
export {};

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});