Skip to content

User-Data-Rights Demo

Sample app showing how a Kumiko app wires user-data-rights (DSGVO Art. 15+17+18+20) into a custom domain — here a tiny todo-list. The domain feature only registers an EXT_USER_DATA hook per entity; export bundling, forget cleanup, restriction and audit-log come from user-data-rights itself.

The sample doubles as living documentation for the pattern: read the integration test top-to-bottom and you’ve understood the contract.

A tiny todo app where each user has private todos. The demo proves that DSGVO requests work end-to-end:

ArticleEndpoint / RunnerWhat it does
Art. 15user-data-rights:query:my-audit-logUser sees own framework events (auth, deletion-request, restriction). Domain entities like todos appear in the export bundle (Art. 20), not the audit-log — only handlers using ctx.appendEvent show up here.
Art. 15+20user-data-rights:write:request-exportZIP with user-profile + fileRefs + todos + signed download magic-link
Art. 17user-data-rights:write:request-deletionSoft-delete with grace period; cron anonymizes user + deletes todos
Art. 18user-data-rights:write:restrict-accountAuth-middleware blocks logins until lift-restriction
┌──────────────────────────────────────────────────────────────┐
│ src/feature.ts │
│ todos:write:create (per-user todo) │
│ todos:query:list (own todos) │
│ r.useExtension(EXT_USER_DATA, "todo", { │
│ export: ctx → { entity:"todo", rows:[...] }, │
│ delete: ctx → DELETE WHERE author_id = userId │
│ }) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ src/run-config.ts │
│ APP_FEATURES = [data-retention, compliance-profiles, │
│ files-foundation, file-provider-inmemory, │
│ files, user-data-rights, │
│ user-data-rights-defaults, todos] │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ bundled-features (no code in the app) │
│ user-data-rights DSGVO pipeline + handlers │
│ user-data-rights-defaults Default-Hooks for user + fileRef │
│ compliance-profiles Region profiles (eu-dsgvo) │
│ data-retention Retention policies │
│ files / file-provider-* File-Refs + Storage │
└──────────────────────────────────────────────────────────────┘

The most thorough doc is the integration test itself:

Terminal window
bun test

src/__tests__/user-data-rights-demo.integration.test.ts boots the full dispatcher + DB and walks through:

  • User creates 2 todos
  • runUserExport returns a bundle with user + todo entries (todos appear because todosFeature registered the EXT_USER_DATA hook)
  • request-deletion flips the user to DeletionRequested
  • After grace expires, the registered run-forget-cleanup cron deletes the todos and anonymizes the user — the framework never had to know about todos specifically

Read the test top-to-bottom — it’s written as a living doc.

Terminal window
bun kumiko dev # Postgres + Redis
bun install
cd samples/apps/user-data-rights-demo
bun dev # → http://localhost:4291
LoginValue
URLhttp://localhost:4291
Email[email protected]
Passwordchangeme
Tenant”User-Data-Rights Demo”

In the browser, use the dispatcher to create a few todos, then call user-data-rights:write:request-export — the worker queues a job and runs the export pipeline. The demo run-config.ts mounts createUserDataRightsFeature() without a sendExportReadyEmail-callback, so no email is sent — you can see the resulting export-job + magic-link token in the DB (read_export_jobs, read_export_download_tokens) and download via the magic-link path manually. To wire real email, pass an inbox callback via the feature options (see source-doc on UserDataRightsOptions.sendExportReadyEmail).

For request-deletion, set the user’s grace_period_end to the past in the DB (or wait the configured grace period) and run the run-forget-cleanup cron job.

The demo mounts file-provider-inmemory so it runs without S3 credentials — but exports live in process memory and are lost on restart (the download then 500s). For a real deploy, swap to the persistent provider in src/run-config.ts:

// import { fileProviderS3EnvFeature } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3-env";
// APP_FEATURES: fileProviderInMemoryFeature -> fileProviderS3EnvFeature

Then set S3_BUCKET / S3_REGION / S3_ACCESS_KEY / S3_SECRET_KEY (plus optional S3_ENDPOINT for Hetzner/S3-compatible stores) and the app override file-foundation:config:provider = "s3-env". Full wiring + prod-verify steps: the wire-gdpr-data-rights runbook in kumiko-platform/docs/runbooks/.

To add another DSGVO-compliant entity to the demo:

// In your feature:
r.useExtension(EXT_USER_DATA, "your-entity", {
export: async (ctx) => {
// ctx.db, ctx.tenantId, ctx.userId
const rows = await ctx.db.select(...).where(authorId = ctx.userId);
return rows.length ? { entity: "your-entity", rows } : null;
},
delete: async (ctx, strategy) => {
if (strategy === "delete") {
await ctx.db.delete(...).where(authorId = ctx.userId);
} else {
// anonymize: keep row, null out PII columns
await ctx.db.update(...).set({ authorId: null }).where(...);
}
},
});

That’s it — your entity is now part of the export bundle and gets cleaned by the forget cron. No changes to user-data-rights needed.

  • src/feature.ts — the todos domain with EXT_USER_DATA hooks. Read this to understand what an app-author needs to write.
  • src/run-config.ts — feature composition (which bundled-features the demo mounts).
  • src/__tests__/user-data-rights-demo.integration.test.ts — the played- out story (create todos → export → request-deletion → forget cron).
  • samples/apps/cap-billing-demo — tier-engine + cap-counter + mail- foundation for billing-driven feature gates.

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):

// kumiko-feature-version: 1
//
// todos — Demo-Domain-Feature, das ueber EXT_USER_DATA in user-data-
// rights integriert. Ein App-Author registriert pro Domain-Entity einen
// (export, delete)-Hook — das war es. Forget-Cron, Export-Bundle und
// DSGVO-Endpoints kommen vollstaendig aus user-data-rights.
//
// Dieses Feature zeigt:
// - r.entity("todo", todoEntity) → Drizzle-Tabelle wird gebaut
// - r.writeHandler("create") → User legt Todo an
// - r.queryHandler("list") → User sieht seine Todos
// - r.useExtension(EXT_USER_DATA, "todo") → Forget + Export integration
//
// Was passiert wenn der User dann request-export aufruft:
// 1. user-data-rights.request-export.write triggert einen Job
// 2. Worker iteriert alle EXT_USER_DATA-Provider (user, fileRef, todo)
// 3. todoExportHook liest alle Rows mit author_id = userId aus
// ALLEN Tenants des Users
// 4. Bundle wird als ZIP an einen signed-Magic-Link gepackt + per
// Email verschickt
//
// Was passiert wenn der User request-deletion aufruft:
// 1. user-data-rights setzt status=DeletionRequested + grace
// 2. Nach Ablauf laeuft der run-forget-cleanup-Cron
// 3. todoDeleteHook DELETEt alle Rows mit author_id = userId
// 4. user wird anonymisiert (display_name="(deleted)", email=null)
import {
buildEntityTableMeta,
deleteMany,
insertOne,
selectMany,
updateMany,
} from "@cosmicdrift/kumiko-framework/db";
import {
createEntity,
createTextField,
defineFeature,
defineQueryHandler,
defineWriteHandler,
EXT_USER_DATA,
type UserDataDeleteHook,
type UserDataExportHook,
} from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
const FEATURE_NAME = "todos";
export const todoEntity = createEntity({
table: "read_todos",
idType: "uuid",
fields: {
// nullable: bei DSGVO-anonymize wird authorId auf null gesetzt
// (Row bleibt, Personenbezug raus). Pattern matched fileRef.
authorId: createTextField({}),
title: createTextField({ required: true, maxLength: 200 }),
body: createTextField({ maxLength: 4000 }),
},
});
// Plain EntityTableMeta, NOT a branded EntityTable: read_todos is a deliberate
// unmanaged direct-write store (r.unmanagedTable below), so the create handler +
// forget hook write it directly — the meta carries no executor-only brand.
export const todosTable = buildEntityTableMeta("todo", todoEntity);
const createSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().max(4000).optional(),
});
const createTodoHandler = defineWriteHandler({
name: "create",
schema: createSchema,
access: { openToAll: true },
handler: async (event, ctx) => {
const id = crypto.randomUUID();
await insertOne(ctx.db, todosTable, {
id,
tenantId: event.user.tenantId,
authorId: event.user.id,
title: event.payload.title,
body: event.payload.body ?? "",
});
return { isSuccess: true as const, data: { id } };
},
});
const listTodosHandler = defineQueryHandler({
name: "list",
schema: z.object({}),
access: { openToAll: true },
handler: async (query, ctx) => {
const rows = await selectMany<{ id: string; title: string; body: string }>(ctx.db, todosTable, {
authorId: query.user.id,
});
return { rows };
},
});
export const todosFeature = defineFeature(FEATURE_NAME, (r) => {
r.requires("user-data-rights");
// read_todos is a direct-write store: the create handler `insertOne`s and
// the forget hook `updateMany`/`deleteMany`s rows WITHOUT emitting lifecycle
// events. Registering it as r.entity would make it a rebuildable implicit
// projection whose replay finds zero todo events and swaps an empty shadow
// over the live table — wiping every todo (and silently un-forgetting
// anonymized rows) on the next projection rebuild (#498). r.unmanagedTable
// keeps the migration DDL but opts the table out of implicit rebuild.
r.unmanagedTable(buildEntityTableMeta("todo", todoEntity), {
reason: "read_side.todos_direct_write",
});
r.writeHandler(createTodoHandler);
r.queryHandler(listTodosHandler);
// EXT_USER_DATA-Hooks: wie todos zu DSGVO-Pipeline beitragen.
// Cross-tenant: Hook wird pro Tenant des Users aufgerufen — wir filtern
// hier on (tenantId, authorId), beide kommen aus dem ctx.
const exportTodos: UserDataExportHook = async (ctx) => {
const rows = await selectMany<{ id: string; title: string; body: string }>(ctx.db, todosTable, {
tenantId: ctx.tenantId,
authorId: ctx.userId,
});
if (rows.length === 0) return null;
return {
entity: "todo",
rows: rows.map((row) => ({
id: String(row.id),
title: row.title ?? "",
body: row.body ?? "",
})),
};
};
// Strategy-aware: bei "anonymize" bleibt die Row (authorId=null) damit
// Multi-User-Refs intakt bleiben; bei "delete" hard-delete. Compliance-
// Profile (DE-HR, Steuer) koennen via retention.strategy=anonymize den
// anonymize-Pfad triggern statt hardDelete. Pattern matched fileRef-hook.
const deleteTodos: UserDataDeleteHook = async (ctx, strategy) => {
const where = { tenantId: ctx.tenantId, authorId: ctx.userId };
if (strategy === "anonymize") {
await updateMany(ctx.db, todosTable, { authorId: null }, where);
} else {
await deleteMany(ctx.db, todosTable, where);
}
};
r.useExtension(EXT_USER_DATA, "todo", {
export: exportTodos,
delete: deleteTodos,
});
// Wire-proof for the read-only GDPR inspector screens: an app opts in by
// navigating the (otherwise inert) bundled screens. SystemAdmin-gated, so
// they surface only for platform operators. This nav is the ONLY wiring an
// app needs — the screens and convention handlers live in user-data-rights.
r.nav({
id: "gdpr-export-jobs",
label: "GDPR · Export Jobs",
screen: "user-data-rights:screen:export-job-list",
access: { roles: ["SystemAdmin"] },
order: 90,
});
r.nav({
id: "gdpr-download-attempts",
label: "GDPR · Download Attempts",
screen: "user-data-rights:screen:download-attempt-list",
access: { roles: ["SystemAdmin"] },
order: 91,
});
});
export const TODO_CREATE_QN = "todos:write:create";
export const TODO_LIST_QN = "todos:query:list";

📄 On GitHub: samples/apps/user-data-rights-demo/src/feature.ts