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.
What the demo does
Section titled “What the demo does”A tiny todo app where each user has private todos. The demo proves that DSGVO requests work end-to-end:
| Article | Endpoint / Runner | What it does |
|---|---|---|
| Art. 15 | user-data-rights:query:my-audit-log | User 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+20 | user-data-rights:write:request-export | ZIP with user-profile + fileRefs + todos + signed download magic-link |
| Art. 17 | user-data-rights:write:request-deletion | Soft-delete with grace period; cron anonymizes user + deletes todos |
| Art. 18 | user-data-rights:write:restrict-account | Auth-middleware blocks logins until lift-restriction |
Architecture in 3 layers
Section titled “Architecture in 3 layers”┌──────────────────────────────────────────────────────────────┐│ 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 │└──────────────────────────────────────────────────────────────┘Demo story as a test
Section titled “Demo story as a test”The most thorough doc is the integration test itself:
bun testsrc/__tests__/user-data-rights-demo.integration.test.ts boots the full
dispatcher + DB and walks through:
- User creates 2 todos
runUserExportreturns a bundle with user + todo entries (todos appear becausetodosFeatureregistered theEXT_USER_DATAhook)request-deletionflips the user toDeletionRequested- After grace expires, the registered
run-forget-cleanupcron 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.
Run locally
Section titled “Run locally”bun kumiko dev # Postgres + Redisbun installcd samples/apps/user-data-rights-demobun dev # → http://localhost:4291| Login | Value |
|---|---|
| URL | http://localhost:4291 |
[email protected] | |
| Password | changeme |
| 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.
Going to production (persistent storage)
Section titled “Going to production (persistent storage)”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 -> fileProviderS3EnvFeatureThen 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/.
Adding your own domain
Section titled “Adding your own domain”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.
Key files
Section titled “Key files”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).
Related samples
Section titled “Related samples”samples/apps/cap-billing-demo— tier-engine + cap-counter + mail- foundation for billing-driven feature gates.
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):
// 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