@cosmicdrift/kumiko-bundled-features 0.115.1 → 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.
- package/package.json +6 -6
- package/src/config/index.ts +1 -1
- package/src/config/table.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
- package/src/data-retention/feature.ts +3 -3
- package/src/data-retention/run-retention-cleanup.ts +102 -19
- package/src/delivery/index.ts +5 -1
- package/src/delivery/tables.ts +1 -1
- package/src/personal-access-tokens/schema/api-token.ts +6 -1
- package/src/sessions/schema/user-session.ts +2 -0
- package/src/tenant/invitation-table.ts +1 -1
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +2 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +392 -0
- package/src/user-data-rights-defaults/feature.ts +58 -1
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
- package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +56 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +107 -0
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
- 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.
|
|
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.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "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",
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
package/src/config/table.ts
CHANGED
|
@@ -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).
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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:
|
|
248
|
-
await seed("read_c7_retained", T1, "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
108
|
-
// biome-ignore lint/suspicious/noConsole: operator-visibility for
|
|
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}
|
|
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 →
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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:
|
|
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
|
-
/**
|
|
86
|
-
readonly
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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,
|
|
295
|
+
return { hardDeleted, softDeleted, anonymized, skipped };
|
|
213
296
|
}
|
package/src/delivery/index.ts
CHANGED
|
@@ -15,7 +15,11 @@ export {
|
|
|
15
15
|
type RateLimitConfig,
|
|
16
16
|
} from "./delivery-service";
|
|
17
17
|
export { createDeliveryFeature } from "./feature";
|
|
18
|
-
export {
|
|
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,
|
package/src/delivery/tables.ts
CHANGED
|
@@ -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({
|
|
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.
|