@cosmicdrift/kumiko-bundled-features 0.115.0 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +6 -6
  2. package/src/config/index.ts +1 -1
  3. package/src/config/table.ts +1 -1
  4. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
  5. package/src/data-retention/feature.ts +3 -3
  6. package/src/data-retention/run-retention-cleanup.ts +102 -19
  7. package/src/delivery/index.ts +5 -1
  8. package/src/delivery/tables.ts +1 -1
  9. package/src/personal-access-tokens/schema/api-token.ts +6 -1
  10. package/src/sessions/schema/user-session.ts +2 -0
  11. package/src/tenant/invitation-table.ts +1 -1
  12. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -1
  13. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -1
  14. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -1
  15. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -1
  16. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -1
  17. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -1
  18. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -1
  19. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +2 -1
  20. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -1
  21. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -1
  22. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +392 -0
  23. package/src/user-data-rights-defaults/feature.ts +58 -1
  24. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
  25. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
  26. package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
  27. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
  28. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +56 -0
  29. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +107 -0
  30. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
  31. package/src/user-data-rights-defaults/index.ts +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.115.0",
3
+ "version": "0.116.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -95,11 +95,11 @@
95
95
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
96
  },
97
97
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.115.0",
99
- "@cosmicdrift/kumiko-framework": "0.115.0",
100
- "@cosmicdrift/kumiko-headless": "0.115.0",
101
- "@cosmicdrift/kumiko-renderer": "0.115.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.115.0",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.116.0",
99
+ "@cosmicdrift/kumiko-framework": "0.116.0",
100
+ "@cosmicdrift/kumiko-headless": "0.116.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.116.0",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.116.0",
103
103
  "@mollie/api-client": "^4.5.0",
104
104
  "@node-rs/argon2": "^2.0.2",
105
105
  "@types/nodemailer": "^8.0.0",
@@ -23,7 +23,7 @@ export {
23
23
  } from "./handlers/readiness.query";
24
24
  export type { AppConfigOverrides, ConfigResolver } from "./resolver";
25
25
  export { buildEnvConfigOverrides, createConfigResolver, validateAppOverrides } from "./resolver";
26
- export { configValuesTable } from "./table";
26
+ export { configValueEntity, configValuesTable } from "./table";
27
27
 
28
28
  // Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
29
29
  // the registry and writes the matching system/tenant/user rows via the
@@ -25,7 +25,7 @@ export const configValueEntity = createEntity({
25
25
  // breaking the null-vs-missing distinction the resolver already draws.
26
26
  value: createTextField({}),
27
27
  // user-scope row: userId populated. tenant- / system-scope: null.
28
- userId: createTextField({}),
28
+ userId: createTextField({ pii: true }),
29
29
  },
30
30
  indexes: [
31
31
  { unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },
@@ -55,10 +55,9 @@ const staleEntity = createEntity({
55
55
  });
56
56
 
57
57
  // blockDelete = Aufbewahrungspflicht (HGB/DSGVO-Presets: invoice/booking/
58
- // contract). Der Cleanup-Job muss diese Rows IGNORIEREN — nur user-forget
59
- // darf sie anonymisieren. Ein Regress im switch-Default oder im blockDelete-
60
- // Case, der sie trotzdem löscht, wäre schwerwiegender als ein verpasstes
61
- // hardDelete (Aufbewahrungspflicht-Verletzung statt nur "zu spät aufgeräumt").
58
+ // contract). Während keepFor muss der Cleanup diese Rows IGNORIEREN (löschen
59
+ // wäre eine Aufbewahrungspflicht-Verletzung); NACH Ablauf laufen die
60
+ // anonymize-Feld-Funktionen Row bleibt, Personen-Bezug raus (fields.ts).
62
61
  const retainedEntity = createEntity({
63
62
  table: "read_c7_retained",
64
63
  fields: {
@@ -67,12 +66,33 @@ const retainedEntity = createEntity({
67
66
  retention: { keepFor: "30d", strategy: "blockDelete" },
68
67
  });
69
68
 
69
+ // anonymize-Strategie: label wird nach Ablauf ueberschrieben, note traegt
70
+ // keine anonymize-Funktion und muss unangetastet bleiben (Geschaeftsdaten).
71
+ const anonEntity = createEntity({
72
+ table: "read_c7_anon",
73
+ fields: {
74
+ label: createTextField({ required: true, anonymize: () => "[ANONYMIZED]" }),
75
+ note: createTextField({ allowPlaintext: "is-business-data" }),
76
+ },
77
+ retention: { keepFor: "30d", strategy: "anonymize" },
78
+ });
79
+
80
+ // anonymize-Strategie OHNE anonymize-Felder — der Runner kann nichts tun und
81
+ // muss das als skip melden statt still nichts zu tun.
82
+ const bareEntity = createEntity({
83
+ table: "read_c7_bare",
84
+ fields: { label: createTextField({ required: true, allowPlaintext: "is-business-data" }) },
85
+ retention: { keepFor: "30d", strategy: "anonymize" },
86
+ });
87
+
70
88
  const c7Feature = defineFeature("c7-retention-fixtures", (r) => {
71
89
  r.entity("c7-widget", widgetEntity);
72
90
  r.entity("c7-gadget", gadgetEntity);
73
91
  r.entity("c7-plain", plainEntity);
74
92
  r.entity("c7-stale", staleEntity);
75
93
  r.entity("c7-retained", retainedEntity);
94
+ r.entity("c7-anon", anonEntity);
95
+ r.entity("c7-bare", bareEntity);
76
96
  });
77
97
 
78
98
  const noopLogger: JobContext["log"] = {
@@ -102,6 +122,8 @@ beforeAll(async () => {
102
122
  plainEntity,
103
123
  staleEntity,
104
124
  retainedEntity,
125
+ anonEntity,
126
+ bareEntity,
105
127
  ]) {
106
128
  await unsafeCreateEntityTable(stack.db, e);
107
129
  }
@@ -144,6 +166,8 @@ beforeEach(async () => {
144
166
  "read_c7_plain",
145
167
  "read_c7_stale",
146
168
  "read_c7_retained",
169
+ "read_c7_anon",
170
+ "read_c7_bare",
147
171
  ]) {
148
172
  await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
149
173
  }
@@ -244,8 +268,9 @@ describe("runRetentionCleanup :: real postgres", () => {
244
268
  expect(await labels("read_c7_widget", T1)).toEqual(["fresh-via-handler"]);
245
269
  });
246
270
 
247
- test("blockDelete: past-cutoff rows are never touched by the cleanup job", async () => {
248
- await seed("read_c7_retained", T1, "should-survive", pastIso);
271
+ test("blockDelete: within keepFor untouched, after expiry anonymized row NEVER deleted", async () => {
272
+ await seed("read_c7_retained", T1, "in-hold", withinIso);
273
+ await seed("read_c7_retained", T1, "hold-expired", pastIso);
249
274
 
250
275
  const result = await runRetentionCleanup({
251
276
  db: stack.db,
@@ -255,9 +280,121 @@ describe("runRetentionCleanup :: real postgres", () => {
255
280
  now,
256
281
  });
257
282
 
258
- expect(await labels("read_c7_retained", T1)).toEqual(["should-survive"]);
283
+ // Beide Rows physisch da — blockDelete darf NIE loeschen.
284
+ expect((await labels("read_c7_retained", T1)).sort()).toEqual(["[ANONYMIZED]", "in-hold"]);
259
285
  expect(result.hardDeleted).toBe(0);
260
286
  expect(result.softDeleted).toBe(0);
261
- expect(result.anonymizeDeferred).not.toContain("c7-retained");
287
+ expect(result.anonymized).toBe(1);
288
+ });
289
+
290
+ test("anonymize: expired row gets field functions applied, other fields + fresh rows untouched", async () => {
291
+ await asRawClient(stack.db).unsafe(
292
+ `INSERT INTO read_c7_anon (tenant_id, label, note, inserted_at) VALUES ($1, 'old-pii', 'business-note', $2::timestamptz)`,
293
+ [T1, pastIso],
294
+ );
295
+ await asRawClient(stack.db).unsafe(
296
+ `INSERT INTO read_c7_anon (tenant_id, label, note, inserted_at) VALUES ($1, 'fresh-pii', 'fresh-note', $2::timestamptz)`,
297
+ [T1, withinIso],
298
+ );
299
+
300
+ const result = await runRetentionCleanup({
301
+ db: stack.db,
302
+ registry: stack.registry,
303
+ tenantId: T1,
304
+ tenantPreset: null,
305
+ now,
306
+ });
307
+
308
+ expect(result.anonymized).toBe(1);
309
+ const rows = (await asRawClient(stack.db).unsafe(
310
+ `SELECT label, note FROM read_c7_anon WHERE tenant_id = $1 ORDER BY note`,
311
+ [T1],
312
+ )) as { label: string; note: string }[];
313
+ expect(rows).toEqual([
314
+ { label: "[ANONYMIZED]", note: "business-note" }, // PII raus, Geschaeftsdaten bleiben
315
+ { label: "fresh-pii", note: "fresh-note" }, // innerhalb keepFor: unangetastet
316
+ ]);
317
+ });
318
+
319
+ test("anonymize is idempotent: second run appends ZERO events (no daily event spam)", async () => {
320
+ await asRawClient(stack.db).unsafe(
321
+ `INSERT INTO read_c7_anon (tenant_id, label, note, inserted_at) VALUES ($1, 'old-pii', 'n', $2::timestamptz)`,
322
+ [T1, pastIso],
323
+ );
324
+
325
+ const first = await runRetentionCleanup({
326
+ db: stack.db,
327
+ registry: stack.registry,
328
+ tenantId: T1,
329
+ tenantPreset: null,
330
+ now,
331
+ });
332
+ expect(first.anonymized).toBe(1);
333
+
334
+ const eventsAfterFirst = (await asRawClient(stack.db).unsafe(
335
+ `SELECT count(*)::int AS n FROM kumiko_events`,
336
+ )) as { n: number }[];
337
+
338
+ const second = await runRetentionCleanup({
339
+ db: stack.db,
340
+ registry: stack.registry,
341
+ tenantId: T1,
342
+ tenantPreset: null,
343
+ now,
344
+ });
345
+ expect(second.anonymized).toBe(0);
346
+
347
+ const eventsAfterSecond = (await asRawClient(stack.db).unsafe(
348
+ `SELECT count(*)::int AS n FROM kumiko_events`,
349
+ )) as { n: number }[];
350
+ expect(eventsAfterSecond[0]?.n).toBe(eventsAfterFirst[0]?.n);
351
+ });
352
+
353
+ test("anonymize converges under batchLimit=1: cursor pages past already-anonymized rows", async () => {
354
+ for (const label of ["a-pii", "b-pii", "c-pii"]) {
355
+ await asRawClient(stack.db).unsafe(
356
+ `INSERT INTO read_c7_anon (tenant_id, label, note, inserted_at) VALUES ($1, $2, $3, $4::timestamptz)`,
357
+ [T1, label, `note-${label}`, pastIso],
358
+ );
359
+ }
360
+
361
+ const run = () =>
362
+ runRetentionCleanup({
363
+ db: stack.db,
364
+ registry: stack.registry,
365
+ tenantId: T1,
366
+ tenantPreset: null,
367
+ now,
368
+ batchLimit: 1,
369
+ });
370
+
371
+ expect((await run()).anonymized).toBe(1);
372
+ expect((await run()).anonymized).toBe(1); // Cursor muss ueber die erledigte Row hinweg
373
+ expect((await run()).anonymized).toBe(1);
374
+ expect((await run()).anonymized).toBe(0); // konvergiert
375
+
376
+ const rows = (await asRawClient(stack.db).unsafe(
377
+ `SELECT label FROM read_c7_anon WHERE tenant_id = $1`,
378
+ [T1],
379
+ )) as { label: string }[];
380
+ expect(rows.map((r) => r.label)).toEqual(["[ANONYMIZED]", "[ANONYMIZED]", "[ANONYMIZED]"]);
381
+ });
382
+
383
+ test("anonymize strategy without anonymize fields → skipped with reason, rows untouched", async () => {
384
+ await seed("read_c7_bare", T1, "survives", pastIso);
385
+
386
+ const result = await runRetentionCleanup({
387
+ db: stack.db,
388
+ registry: stack.registry,
389
+ tenantId: T1,
390
+ tenantPreset: null,
391
+ now,
392
+ });
393
+
394
+ expect(await labels("read_c7_bare", T1)).toEqual(["survives"]);
395
+ expect(result.skipped).toContainEqual({
396
+ entityName: "c7-bare",
397
+ reason: "missing_anonymize_fields",
398
+ });
262
399
  });
263
400
  });
@@ -104,10 +104,10 @@ export function createDataRetentionFeature(): FeatureDefinition {
104
104
  tenantPreset,
105
105
  now: T.Now.instant(),
106
106
  });
107
- if (result.anonymizeDeferred.length > 0 || result.skipped.length > 0) {
108
- // biome-ignore lint/suspicious/noConsole: operator-visibility for deferred/skipped entities
107
+ if (result.skipped.length > 0) {
108
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for skipped entities
109
109
  console.warn(
110
- `[data-retention:retention-cleanup] tenant=${tenantId} anonymizeDeferred=${result.anonymizeDeferred.join(",") || "-"} skipped=${result.skipped.map((s) => `${s.entityName}:${s.reason}`).join(",") || "-"}`,
110
+ `[data-retention:retention-cleanup] tenant=${tenantId} skipped=${result.skipped.map((s) => `${s.entityName}:${s.reason}`).join(",")}`,
111
111
  );
112
112
  }
113
113
  },
@@ -9,13 +9,17 @@
9
9
  // - hardDelete → executor.forget pro Row (Event → rebuild-safe hard purge)
10
10
  // - softDelete → executor.delete pro Row (Event → rebuild-safe soft-delete),
11
11
  // nur auf noch-nicht-geloeschte (isDeleted:false-Filter)
12
- // - anonymize → DEFERRED (siehe unten)
12
+ // - anonymize → executor.update pro Row mit den Werten der per-Feld
13
+ // anonymize-Funktionen; Row bleibt (fields.ts-Contract)
14
+ // - blockDelete → waehrend keepFor unangetastet (Aufbewahrungs-Pflicht;
15
+ // user-forget loest anonymize aus). NACH Ablauf laufen die
16
+ // anonymize-Funktionen — Geschaeftsdaten bleiben, Personen-
17
+ // Bezug raus (fields.ts:44-47).
13
18
  //
14
19
  // Über den Executor statt Batch-deleteMany/updateMany: ein eventloser Batch-Write
15
20
  // auf die (erased) Projektions-Tabelle wird beim Projection-Rebuild
16
21
  // gewischt/resurrektiert — das #648-Loch. Kosten: N Events pro Cleanup statt ein
17
22
  // Batch-Statement (per-Row-Ceiling, Batch-Event-Variante als Follow-up).
18
- // - blockDelete → ignoriert (Aufbewahrungs-Pflicht; user-forget loest anonymize)
19
23
  //
20
24
  // **Schaerfer als soft-delete-cleanup:** dieser Cron hardDeleted LIVE Rows
21
25
  // (keyed auf reference, Default createdAt), nicht bereits-soft-geloeschte.
@@ -23,12 +27,12 @@
23
27
  // reference-Spalte wuerde sonst ein malformed/all-matching WHERE ergeben und
24
28
  // pauschal loeschen.
25
29
  //
26
- // **anonymize deferred:** anonymize behaelt die Row. Ohne Idempotenz-Marker
27
- // wuerde der taegliche UPDATE jede past-cutoff Row endlos neu treffen — genau
28
- // der Full-Table-Scan den die Strategie vermeiden soll (hardDelete begrenzt
29
- // sich selbst, softDelete via isDeleted:false). Kein bundled-Entity nutzt
30
- // zeitgesteuertes anonymize; der user-forget-Flow (run-forget-cleanup) deckt
31
- // anonymize keyed auf userId ab. Follow-up fuer den Marker.
30
+ // **anonymize-Idempotenz ohne Marker-Spalte:** die anonymize-Funktionen sind
31
+ // row-unabhaengig (`() => unknown`) einmal pro Entity ausgewertet ergeben
32
+ // sie die Ziel-Werte. Eine Row die sie schon traegt wird uebersprungen, der
33
+ // taegliche Re-Lauf appended also null Events. Der WHERE matcht anonymisierte
34
+ // Rows weiter (reference bleibt alt), deshalb id-Cursor-Paging statt einer
35
+ // einzelnen Page sonst verstopfen erledigte Rows das batchLimit-Fenster.
32
36
 
33
37
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
34
38
  import {
@@ -76,15 +80,18 @@ export interface RunRetentionCleanupArgs {
76
80
 
77
81
  export interface RetentionCleanupSkip {
78
82
  readonly entityName: string;
79
- readonly reason: "missing_reference_column" | "missing_softdelete_columns";
83
+ readonly reason:
84
+ | "missing_reference_column"
85
+ | "missing_softdelete_columns"
86
+ | "missing_anonymize_fields";
80
87
  }
81
88
 
82
89
  export interface RunRetentionCleanupResult {
83
90
  readonly hardDeleted: number;
84
91
  readonly softDeleted: number;
85
- /** Entities mit anonymize-Strategy deferred (Header). Cron logt sie. */
86
- readonly anonymizeDeferred: readonly string[];
87
- /** Anomalien: Policy referenziert eine Spalte die die Tabelle nicht hat. */
92
+ /** Rows deren anonymize-Feld-Funktionen angewendet wurden (anonymize + abgelaufene blockDelete). */
93
+ readonly anonymized: number;
94
+ /** Anomalien: Policy referenziert eine Spalte die die Tabelle nicht hat, oder anonymize ohne anonymize-Felder. */
88
95
  readonly skipped: readonly RetentionCleanupSkip[];
89
96
  }
90
97
 
@@ -106,6 +113,65 @@ async function purgeMatchingRows(
106
113
  return count;
107
114
  }
108
115
 
116
+ // PiiAnnotations.anonymize — structural view, avoids reaching into the
117
+ // engine's field-type internals from here.
118
+ type AnonymizeCapableField = { readonly anonymize?: () => unknown | Promise<unknown> };
119
+
120
+ // Evaluate the entity's per-field anonymize functions ONCE — they are
121
+ // row-independent by signature, so the result doubles as the idempotency
122
+ // probe: a row already carrying these values needs no event.
123
+ async function resolveAnonymizeTargets(
124
+ fields: Readonly<Record<string, unknown>>,
125
+ ): Promise<Record<string, unknown> | null> {
126
+ const targets: Record<string, unknown> = {};
127
+ for (const [name, field] of Object.entries(fields)) {
128
+ const annot = field as AnonymizeCapableField; // @cast-boundary schema-walk
129
+ if (typeof annot.anonymize === "function") targets[name] = await annot.anonymize();
130
+ }
131
+ return Object.keys(targets).length > 0 ? targets : null;
132
+ }
133
+
134
+ function rowNeedsAnonymize(
135
+ row: Record<string, unknown>,
136
+ targets: Record<string, unknown>,
137
+ ): boolean {
138
+ return Object.entries(targets).some(([field, value]) => row[field] !== value);
139
+ }
140
+
141
+ // Anonymize every matching row via the executor (event → rebuild-safe).
142
+ // id-Cursor-paged because the where keeps matching already-anonymized rows;
143
+ // the diff-check keeps the daily re-run event-free. batchLimit bounds the
144
+ // UPDATES per run, not the scan.
145
+ // ponytail: scans all past-cutoff rows per run — add an anonymized-marker
146
+ // column if a hot entity accumulates millions of held rows.
147
+ async function anonymizeMatchingRows(
148
+ db: DbRunner,
149
+ table: Parameters<typeof selectMany>[1],
150
+ where: WhereObject,
151
+ batchLimit: number,
152
+ targets: Record<string, unknown>,
153
+ op: (id: EntityId, changes: Record<string, unknown>) => Promise<{ readonly isSuccess: boolean }>,
154
+ ): Promise<number> {
155
+ let count = 0;
156
+ let cursor: EntityId | null = null;
157
+ while (count < batchLimit) {
158
+ const pageWhere: WhereObject = cursor === null ? where : { ...where, id: { gt: cursor } };
159
+ const rows = await selectMany<Record<string, unknown>>(db, table, pageWhere, {
160
+ limit: batchLimit,
161
+ orderBy: { col: "id" },
162
+ });
163
+ if (rows.length === 0) break;
164
+ for (const row of rows) {
165
+ cursor = row["id"] as EntityId; // @cast-boundary db-row
166
+ if (!rowNeedsAnonymize(row, targets)) continue;
167
+ const res = await op(cursor, targets);
168
+ if (res.isSuccess) count++;
169
+ if (count >= batchLimit) break;
170
+ }
171
+ }
172
+ return count;
173
+ }
174
+
109
175
  export async function runRetentionCleanup(
110
176
  args: RunRetentionCleanupArgs,
111
177
  ): Promise<RunRetentionCleanupResult> {
@@ -114,7 +180,7 @@ export async function runRetentionCleanup(
114
180
 
115
181
  let hardDeleted = 0;
116
182
  let softDeleted = 0;
117
- const anonymizeDeferred: string[] = [];
183
+ let anonymized = 0;
118
184
  const skipped: RetentionCleanupSkip[] = [];
119
185
 
120
186
  // Retention writes go through the executor (events) so a projection rebuild
@@ -198,16 +264,33 @@ export async function runRetentionCleanup(
198
264
  );
199
265
  break;
200
266
  }
201
- case "anonymize": {
202
- anonymizeDeferred.push(entityName);
203
- break;
204
- }
267
+ // blockDelete = Legal-Hold: rows stay untouched during keepFor (the
268
+ // cutoff-WHERE guarantees that), user-forget anonymizes instead of
269
+ // deleting (run-forget-cleanup maps blockDelete → anonymize). AFTER the
270
+ // hold expires the anonymize-field functions run — business data stays,
271
+ // the person link goes (fields.ts contract). Same executor path as the
272
+ // anonymize strategy, so both cases share it.
273
+ case "anonymize":
205
274
  case "blockDelete": {
206
- // skip: Aufbewahrungs-Pflicht Cleanup ignoriert, user-forget anonymisiert
275
+ const targets = await resolveAnonymizeTargets(entity.fields);
276
+ if (!targets) {
277
+ skipped.push({ entityName, reason: "missing_anonymize_fields" });
278
+ break;
279
+ }
280
+ const executor = createEventStoreExecutor(proj.table, entity, { entityName });
281
+ anonymized += await anonymizeMatchingRows(
282
+ db,
283
+ proj.table,
284
+ where,
285
+ batchLimit,
286
+ targets,
287
+ (id, changes) =>
288
+ executor.update({ id, changes }, systemUser, tdb, { skipOptimisticLock: true }),
289
+ );
207
290
  break;
208
291
  }
209
292
  }
210
293
  }
211
294
 
212
- return { hardDeleted, softDeleted, anonymizeDeferred, skipped };
295
+ return { hardDeleted, softDeleted, anonymized, skipped };
213
296
  }
@@ -15,7 +15,11 @@ export {
15
15
  type RateLimitConfig,
16
16
  } from "./delivery-service";
17
17
  export { createDeliveryFeature } from "./feature";
18
- export { deliveryAttemptsTable, notificationPreferencesTable } from "./tables";
18
+ export {
19
+ deliveryAttemptsTable,
20
+ notificationPreferenceEntity,
21
+ notificationPreferencesTable,
22
+ } from "./tables";
19
23
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing";
20
24
  export type {
21
25
  ChannelContext,
@@ -76,7 +76,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
76
76
  export const notificationPreferenceEntity = createEntity({
77
77
  table: "read_notification_preferences",
78
78
  fields: {
79
- userId: createTextField({ required: true }),
79
+ userId: createTextField({ required: true, pii: true }),
80
80
  notificationType: createTextField({ required: true }), // qualified name or "*"
81
81
  channel: createTextField({ required: true }), // "inApp", "email", "push", or "*"
82
82
  enabled: createBooleanField({ default: true }),
@@ -29,7 +29,12 @@ export const apiTokenEntity = createEntity({
29
29
  maxLength: 36,
30
30
  access: { write: access.privileged },
31
31
  }),
32
- name: createTextField({ required: true, maxLength: 120, access: { write: access.privileged } }),
32
+ name: createTextField({
33
+ required: true,
34
+ maxLength: 120,
35
+ access: { write: access.privileged },
36
+ pii: true,
37
+ }),
33
38
  tokenHash: createTextField({
34
39
  required: true,
35
40
  maxLength: 64,
@@ -56,10 +56,12 @@ export const userSessionEntity = createEntity({
56
56
  ip: createTextField({
57
57
  maxLength: 64,
58
58
  access: { write: access.privileged },
59
+ pii: true,
59
60
  }),
60
61
  userAgent: createTextField({
61
62
  maxLength: 512,
62
63
  access: { write: access.privileged },
64
+ pii: true,
63
65
  }),
64
66
  },
65
67
  });
@@ -70,7 +70,7 @@ export const tenantInvitationEntity = createEntity({
70
70
  default: "pending",
71
71
  }),
72
72
  // userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
73
- invitedBy: createTextField({ required: true }),
73
+ invitedBy: createTextField({ required: true, pii: true }),
74
74
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
75
75
  expiresAt: createTimestampField({ required: true }),
76
76
  },
@@ -36,7 +36,7 @@ import {
36
36
  } from "../../compliance-profiles";
37
37
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
38
38
  import { createFilesFeature } from "../../files";
39
- import { createSessionsFeature } from "../../sessions";
39
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
40
40
  import { tenantMembershipsTable } from "../../tenant";
41
41
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
42
42
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
@@ -127,6 +127,7 @@ beforeAll(async () => {
127
127
  });
128
128
 
129
129
  await unsafeCreateEntityTable(stack.db, userEntity);
130
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
130
131
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
131
132
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
132
133
  await asRawClient(stack.db).unsafe(`
@@ -30,7 +30,7 @@ import { configValuesTable } from "../../config/table";
30
30
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
31
31
  import { fileFoundationFeature } from "../../file-foundation";
32
32
  import { createFilesFeature } from "../../files";
33
- import { createSessionsFeature } from "../../sessions";
33
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
34
34
  import { createUserFeature, userEntity, userTable } from "../../user";
35
35
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
36
36
  import { createUserDataRightsFeature } from "../feature";
@@ -99,6 +99,7 @@ beforeAll(async () => {
99
99
  });
100
100
 
101
101
  await unsafeCreateEntityTable(db, userEntity);
102
+ await unsafeCreateEntityTable(db, userSessionEntity);
102
103
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
103
104
  await unsafePushTables(db, { fileRefsTable, configValuesTable });
104
105
  await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
@@ -34,7 +34,7 @@ import { configValuesTable } from "../../config/table";
34
34
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
35
35
  import { fileFoundationFeature } from "../../file-foundation";
36
36
  import { createFilesFeature } from "../../files";
37
- import { createSessionsFeature } from "../../sessions";
37
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
38
38
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
39
39
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
40
40
  import { createUserDataRightsFeature } from "../feature";
@@ -109,6 +109,7 @@ beforeAll(async () => {
109
109
  });
110
110
 
111
111
  await unsafeCreateEntityTable(db, userEntity);
112
+ await unsafeCreateEntityTable(db, userSessionEntity);
112
113
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
113
114
  await unsafePushTables(db, { fileRefsTable, configValuesTable });
114
115
  await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
@@ -36,7 +36,7 @@ import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
36
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
37
37
  import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
38
38
  import { createFilesFeature } from "../../files";
39
- import { createSessionsFeature } from "../../sessions";
39
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
40
40
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
41
41
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
42
42
  import { createUserDataRightsFeature } from "../feature";
@@ -76,6 +76,7 @@ beforeAll(async () => {
76
76
  db = stack.db;
77
77
 
78
78
  await unsafeCreateEntityTable(db, userEntity);
79
+ await unsafeCreateEntityTable(db, userSessionEntity);
79
80
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
80
81
  await unsafePushTables(db, { fileRefsTable });
81
82
  await asRawClient(db).unsafe(`
@@ -40,7 +40,7 @@ import { configValuesTable } from "../../config/table";
40
40
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
41
41
  import { fileFoundationFeature } from "../../file-foundation";
42
42
  import { createFilesFeature } from "../../files";
43
- import { createSessionsFeature } from "../../sessions";
43
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
44
44
  import { createUserFeature, userEntity, userTable } from "../../user";
45
45
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
46
46
  import { createUserDataRightsFeature } from "../feature";
@@ -117,6 +117,7 @@ beforeAll(async () => {
117
117
  });
118
118
 
119
119
  await unsafeCreateEntityTable(db, userEntity);
120
+ await unsafeCreateEntityTable(db, userSessionEntity);
120
121
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
121
122
  await unsafePushTables(db, { fileRefsTable, configValuesTable });
122
123
  await createEventsTable(db);
@@ -26,7 +26,7 @@ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../..
26
26
  import { createFilesFeature } from "../../files";
27
27
  import { mailFoundationFeature } from "../../mail-foundation";
28
28
  import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
29
- import { createSessionsFeature } from "../../sessions";
29
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
30
30
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
31
31
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
32
32
  import { createUserDataRightsFeature } from "../feature";
@@ -67,6 +67,7 @@ beforeAll(async () => {
67
67
 
68
68
  await unsafePushTables(stack.db, { configValuesTable });
69
69
  await unsafeCreateEntityTable(stack.db, userEntity);
70
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
70
71
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
71
72
  await asRawClient(stack.db).unsafe(`
72
73
  CREATE TABLE IF NOT EXISTS read_tenant_memberships (
@@ -24,7 +24,7 @@ import {
24
24
  tenantComplianceProfileTable,
25
25
  } from "../../compliance-profiles";
26
26
  import { createDataRetentionFeature } from "../../data-retention";
27
- import { createSessionsFeature } from "../../sessions";
27
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
28
28
  import { USER_STATUS, userEntity, userTable } from "../../user";
29
29
  import { createUserFeature } from "../../user/feature";
30
30
  import { createUserDataRightsFeature } from "../feature";
@@ -70,6 +70,7 @@ beforeAll(async () => {
70
70
  ],
71
71
  });
72
72
  await unsafeCreateEntityTable(stack.db, userEntity);
73
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
73
74
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
74
75
  await createEventsTable(stack.db);
75
76
  });
@@ -38,7 +38,7 @@ import {
38
38
  tenantComplianceProfileTable,
39
39
  } from "../../compliance-profiles";
40
40
  import { createDataRetentionFeature } from "../../data-retention";
41
- import { createSessionsFeature } from "../../sessions";
41
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
42
42
  import { tenantMembershipsTable } from "../../tenant";
43
43
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
44
44
  import { createUserDataRightsFeature } from "../feature";
@@ -68,6 +68,7 @@ beforeAll(async () => {
68
68
  await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
69
69
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
70
70
  await unsafeCreateEntityTable(stack.db, userEntity);
71
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
71
72
  await createEventsTable(stack.db);
72
73
  // tenant-membership-table fuer runUserExport's Cross-Tenant-Iteration.
73
74
  // Pattern matched user-data-rights-defaults integration-test.