user-data-rights
GDPR-compliant domain wiring in ~5 lines: r.useExtension(EXT_USER_DATA, …)
hooks any entity into the forget cron, export ZIP, and magic-link pipeline.
You write two hooks per entity — the framework runs the rest.
What it shows
Section titled “What it shows”A minimal notes domain with GDPR Art. 15+17+20 integrated without hand-written forget logic:
defineFeature("notes", (r) => { r.requires("user-data-rights"); r.entity("note", noteEntity);
r.useExtension(EXT_USER_DATA, "note", { export: async (ctx) => ({ entity: "note", rows: await ctx.db.select().from(notesTable) .where(and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId))), }), delete: async (ctx, strategy) => { const where = and(eq(notesTable.tenantId, ctx.tenantId), eq(notesTable.authorId, ctx.userId)); if (strategy === "anonymize") { await ctx.db.update(notesTable).set({ authorId: null }).where(where); } else { await ctx.db.delete(notesTable).where(where); } }, });});What runs automatically
Section titled “What runs automatically”| Article | What happens | Trigger |
|---|---|---|
| Art. 15 + 20 | Notes snippet in export ZIP + magic-link email | runUserExport (via request-export) |
| Art. 17 | Notes deleted or anonymized after grace | runForgetCleanup cron |
| Art. 18 | Restriction blocks login while active | Auth middleware |
| Operator | Brute-force detection on magic-link endpoint | Edge rate-limit + audit log |
Strategy-aware delete
Section titled “Strategy-aware delete”retention.policyFor("note") resolves per entity:
| Profile | Strategy | Effect |
|---|---|---|
eu-dsgvo (default) | delete | Notes hard-deleted |
de-hr-dsgvo-hgb | anonymize for HR entities | authorId=null, row kept for multi-user refs |
The integration test pins both paths.
- User creates notes → rows stored with
authorId. request-exportqueues job →runUserExportbundles user + note rows.request-deletion→ grace period from compliance profile.runForgetCleanupcalls yourdeletehook with resolved strategy.
bun test src/__tests__/feature.integration.test.tsStep-by-step proof:
- Alice creates 2 notes → export bundle includes note snippet
- Deletion + expired grace →
runForgetCleanupremoves all notes - Strategy
anonymize→authorId=null, title/body remain
Full app vs this recipe
Section titled “Full app vs this recipe”| This recipe | user-data-rights-demo | |
|---|---|---|
| Scope | One hook pattern | Runnable app + todos + files |
| Boot | Integration test only | bun dev on port 4291 |
Dependencies
Section titled “Dependencies”import { createUserDataRightsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";import { createUserDataRightsDefaultsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights-defaults";import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
export const features = [ createDataRetentionFeature(), createComplianceProfilesFeature(), createUserDataRightsFeature(), createUserDataRightsDefaultsFeature(), notesFeature,];Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// User-Data-Rights — Recipe//// Wie eine App-Domain DSGVO-Pipeline integriert: pro Entity einen// (export, delete)-Hook ueber EXT_USER_DATA. Forget-Cron, Export-ZIP-Bau// und Magic-Link-Versand kommen vollstaendig aus user-data-rights —// App-Author schreibt nur die zwei Hooks pro Entity.//// Demo-Domain: minimaler Note-Service. Note hat (id, tenantId, authorId,// title, body). Pinst:// 1. Hook export liefert Note-Rows als JSON-Snippet ins Export-Bundle.// 2. Hook delete entfernt Note-Rows beim Forget-Cleanup-Cron.//// Was nicht im Recipe ist (siehe samples/apps/user-data-rights-demo// fuer eine vollstaendige App):// - Strategy-aware delete (anonymize vs hardDelete)// - HTTP-Endpoints fuer create/list// - Compliance-Profile-Wiring + Cron-Scheduling
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createTextField, defineFeature, EXT_USER_DATA, type UserDataDeleteHook, type UserDataExportHook,} from "@cosmicdrift/kumiko-framework/engine";
export const noteEntity = createEntity({ table: "read_notes", idType: "uuid", fields: { authorId: createTextField({}), title: createTextField({ required: true, maxLength: 200 }), body: createTextField({ maxLength: 4000 }), },});
// Plain EntityTableMeta, NOT a branded EntityTable: read_notes is a deliberate// unmanaged direct-write store (r.unmanagedTable below), so the forget hook may// updateMany/deleteMany it directly — the meta carries no executor-only brand.export const notesTable = buildEntityTableMeta("note", noteEntity);
export const notesFeature = defineFeature("notes", (r) => { r.requires("user-data-rights"); // read_notes is a direct-write store: the forget hook below `updateMany`/ // `deleteMany`s rows WITHOUT emitting lifecycle events. r.entity would make // it a rebuildable implicit projection whose replay finds zero note events // and wipes the table (or un-forgets anonymized rows) on the next rebuild // (#498). r.unmanagedTable keeps the DDL, opts out of implicit rebuild. r.unmanagedTable(buildEntityTableMeta("note", noteEntity), { reason: "read_side.notes_direct_write", });
const exportNotes: UserDataExportHook = async (ctx) => { const rows = await selectMany(ctx.db, notesTable, { tenantId: ctx.tenantId, authorId: ctx.userId, }); if (rows.length === 0) return null; return { entity: "note", rows: rows.map((row) => ({ id: String(row.id), title: row.title ?? "", body: row.body ?? "", })), }; };
const deleteNotes: UserDataDeleteHook = async (ctx, strategy) => { const where = { tenantId: ctx.tenantId, authorId: ctx.userId }; if (strategy === "anonymize") { await updateMany(ctx.db, notesTable, { authorId: null }, where); } else { await deleteMany(ctx.db, notesTable, where); } };
r.useExtension(EXT_USER_DATA, "note", { export: exportNotes, delete: deleteNotes, });});📄 On GitHub: samples/recipes/user-data-rights/src/feature.ts