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({ features: ["user-data-rights"] });
r.entity({ name: "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.

Needs a running Postgres and TEST_DATABASE_URL set (e.g. postgres://postgres:postgres@127.0.0.1:5432/postgres, see demo/.env.example).

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 anonymize → authorId=null, title/body remain

runRetentionCleanup is exported for app tests. A hardDelete entity with a file field loses its bytes (and derivatives), its file_refs row and then the row itself; a storage error keeps the row and reports file_delete_failed in skipped. src/retained-docs.ts holds the entity, src/__tests__/retention-files.integration.test.ts drives the run with only public imports:

import { runRetentionCleanup } from "@cosmicdrift/kumiko-bundled-features/data-retention";
import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
const result = await runRetentionCleanup({
db: stack.db,
registry: stack.registry,
tenantId,
preloadedTenantPreset: null,
now: getTemporal()
.Now.instant()
.add({ hours: 31 * 24 }), // keepFor 30d + 1 day
files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
});
// result.hardDeleted === 1, bytes and file_refs row gone
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
//
// How an app domain integrates the GDPR pipeline: one
// (export, delete) hook per entity via EXT_USER_DATA. Forget cron, export ZIP build
// and magic link delivery come entirely from user-data-rights —
// the app author writes only the two hooks per entity.
//
// Demo domain: minimal note service. A note has (id, tenantId, authorId,
// title, body). Pins:
// 1. The export hook delivers note rows as a JSON snippet into the export bundle.
// 2. The delete hook removes note rows during the forget-cleanup cron.
//
// What is not in the recipe (see samples/apps/user-data-rights-demo
// for a complete app):
// - Strategy-aware delete (anonymize vs hardDelete)
// - HTTP endpoints for create/list
// - Compliance profile wiring + cron scheduling
import {
deleteMany,
deriveEntityTableMeta,
selectMany,
updateMany,
} 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: "store_notes",
idType: "uuid",
fields: {
authorId: createTextField({ personal: "ref" }),
title: createTextField({
personal: false,
reason: "is_business_data",
required: true,
maxLength: 200,
}),
// Plaintext on purpose: store_notes is an unmanaged direct-write store, so the
// PII executor never encrypts it. Art. 17 runs through the EXT_USER_DATA
// hook below instead (deletes the author's rows, or unlinks them on anonymize).
body: createTextField({
personal: false,
reason: "covered_by_user_data_hook",
maxLength: 4000,
}),
},
});
// Plain EntityTableMeta, NOT a branded EntityTable: store_notes is a deliberate
// unmanaged direct-write store (r.storeTable below), so the forget hook may
// updateMany/deleteMany it directly — the meta carries no executor-only brand.
export const notesTable = deriveEntityTableMeta("note", noteEntity, { source: "unmanaged" });
export const notesFeature = defineFeature("notes", (r) => {
r.requires({ features: ["user-data-rights"] });
// store_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.storeTable keeps the DDL, opts out of implicit rebuild.
r.storeTable(notesTable, {
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