Skip to content

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.

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);
}
},
});
});
ArticleWhat happensTrigger
Art. 15 + 20Notes snippet in export ZIP + magic-link emailrunUserExport (via request-export)
Art. 17Notes deleted or anonymized after gracerunForgetCleanup cron
Art. 18Restriction blocks login while activeAuth middleware
OperatorBrute-force detection on magic-link endpointEdge rate-limit + audit log

retention.policyFor("note") resolves per entity:

ProfileStrategyEffect
eu-dsgvo (default)deleteNotes hard-deleted
de-hr-dsgvo-hgbanonymize for HR entitiesauthorId=null, row kept for multi-user refs

The integration test pins both paths.

  1. User creates notes → rows stored with authorId.
  2. request-export queues job → runUserExport bundles user + note rows.
  3. request-deletion → grace period from compliance profile.
  4. runForgetCleanup calls your delete hook with resolved strategy.
Terminal window
bun test src/__tests__/feature.integration.test.ts

Step-by-step proof:

  1. Alice creates 2 notes → export bundle includes note snippet
  2. Deletion + expired grace → runForgetCleanup removes all notes
  3. Strategy anonymizeauthorId=null, title/body remain
This recipeuser-data-rights-demo
ScopeOne hook patternRunnable app + todos + files
BootIntegration test onlybun dev on port 4291
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,
];

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