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 write commands it 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