Skip to content

user-data-rights

Implements GDPR Art. 15 (access / my-audit-log query), Art. 17 (erasure / request-deletion + cancel-deletion, plus the anonymous email-verified request-deletion-by-email + confirm-deletion-by-token flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / restrict-account + lift-restriction), and Art. 20 (portability / async request-export → ZIP via file-foundation, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling r.useExtension(EXT_USER_DATA, "<entity>", { export, delete }) — the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When mail-foundation and a mail-transport-* are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires user, data-retention, compliance-profiles, and sessions.

From recipes-user-data-rights — the smallest working mount:

// 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 { deriveEntityTableMeta } 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({}),
title: createTextField({ required: true, maxLength: 200 }),
body: createTextField({ 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("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

user-data-rights feature preview

What this feature needs to run (Requires, top) and the other features’ commands this feature reads from (uses, top), and the write commands this feature provides (Provides, bottom).

flowchart TB
  n_user_data_rights["user-data-rights"]
  subgraph how_reqs["Requires"]
    n_user["user"]
    n_data_retention["data-retention"]
    n_compliance_profiles["compliance-profiles"]
    n_sessions["sessions"]
  end
  n_uses_compliance_forTenant(("compliance.forTenant"))
  n_uses_retention_policyFor(("retention.policyFor"))
  n_uses_sessions_revokeAllForUser(("sessions.revokeAllForUser"))
  subgraph how_provides["Provides"]
    n_cmd_user_data_rights_write_cancel_deletion(["cancel-deletion"])
    n_cmd_user_data_rights_write_confirm_deletion_by_token(["confirm-deletion-by-token"])
    n_cmd_user_data_rights_write_lift_restriction(["lift-restriction"])
    n_cmd_user_data_rights_write_request_deletion(["request-deletion"])
    n_cmd_user_data_rights_write_request_deletion_by_email(["request-deletion-by-email"])
    n_cmd_user_data_rights_write_request_export(["request-export"])
    n_cmd_more(["+2 more"])
  end
  n_user --> n_user_data_rights
  n_data_retention --> n_user_data_rights
  n_compliance_profiles --> n_user_data_rights
  n_sessions --> n_user_data_rights
  n_uses_compliance_forTenant -->|uses| n_user_data_rights
  n_uses_retention_policyFor -->|uses| n_user_data_rights
  n_uses_sessions_revokeAllForUser -->|uses| n_user_data_rights
  n_user_data_rights --> n_cmd_user_data_rights_write_cancel_deletion
  n_user_data_rights --> n_cmd_user_data_rights_write_confirm_deletion_by_token
  n_user_data_rights --> n_cmd_user_data_rights_write_lift_restriction
  n_user_data_rights --> n_cmd_user_data_rights_write_request_deletion
  n_user_data_rights --> n_cmd_user_data_rights_write_request_deletion_by_email
  n_user_data_rights --> n_cmd_user_data_rights_write_request_export
  n_user_data_rights --> n_cmd_more

Provides — write commands this feature registers (dispatch them through the command bus):

Start with recipes-user-data-rights for a step-by-step walkthrough with runnable code and integration tests.

Per-tenant config keys, set via the tenant-admin UI or a seed. 🔒 = encrypted at rest.

KeyTypeDefaultScopeWho can writeWho can read
tenant-modelselect (single-user | multi-user)multi-usersystemsystemTenantAdmin, Admin, SystemAdmin
  • Exposes API: userDataRights.runForget, userDataRights.runExport
  • Uses API: compliance.forTenant, retention.policyFor, sessions.revokeAllForUser