@cosmicdrift/kumiko-bundled-features 0.87.3 → 0.89.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 (35) hide show
  1. package/package.json +6 -6
  2. package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
  3. package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
  4. package/src/data-retention/__tests__/resolver.test.ts +3 -3
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
  6. package/src/data-retention/feature.ts +58 -7
  7. package/src/data-retention/presets.ts +5 -5
  8. package/src/data-retention/resolve-for-tenant.ts +9 -4
  9. package/src/data-retention/resolve-tenant-preset.ts +51 -0
  10. package/src/data-retention/run-retention-cleanup.ts +151 -0
  11. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  12. package/src/legal-pages/feature.ts +22 -10
  13. package/src/mail-foundation/feature.ts +51 -6
  14. package/src/mail-foundation/index.ts +2 -0
  15. package/src/mail-transport-inmemory/feature.ts +6 -3
  16. package/src/mail-transport-smtp/feature.ts +11 -10
  17. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  18. package/src/managed-pages/feature.ts +17 -9
  19. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  20. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  21. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
  22. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  23. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  24. package/src/user-data-rights/email-templates.ts +211 -0
  25. package/src/user-data-rights/feature.ts +110 -21
  26. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  27. package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
  28. package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
  29. package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
  30. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  31. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  32. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  33. package/src/user-data-rights/run-export-jobs.ts +19 -8
  34. package/src/user-data-rights/run-forget-cleanup.ts +11 -1
  35. package/src/user-data-rights/screens.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.87.3",
3
+ "version": "0.89.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>",
@@ -86,11 +86,11 @@
86
86
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
87
87
  },
88
88
  "dependencies": {
89
- "@cosmicdrift/kumiko-dispatcher-live": "0.87.3",
90
- "@cosmicdrift/kumiko-framework": "0.87.3",
91
- "@cosmicdrift/kumiko-headless": "0.87.3",
92
- "@cosmicdrift/kumiko-renderer": "0.87.3",
93
- "@cosmicdrift/kumiko-renderer-web": "0.87.3",
89
+ "@cosmicdrift/kumiko-dispatcher-live": "0.89.0",
90
+ "@cosmicdrift/kumiko-framework": "0.89.0",
91
+ "@cosmicdrift/kumiko-headless": "0.89.0",
92
+ "@cosmicdrift/kumiko-renderer": "0.89.0",
93
+ "@cosmicdrift/kumiko-renderer-web": "0.89.0",
94
94
  "@mollie/api-client": "^4.5.0",
95
95
  "@node-rs/argon2": "^2.0.2",
96
96
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,23 @@
1
+ // Beweist dass der retention-cleanup-Cron mit perTenant-Fan-out in der
2
+ // komponierten Registry landet. Ohne perTenant feuert der Cron global einmal,
3
+ // ctx hat keinen Tenant → der Handler returnt sofort → es wird NICHTS
4
+ // bereinigt (silent no-op). r.job geht durch einen anderen Pfad als der
5
+ // synthetische soft-delete-cleanup-Job, deshalb hier explizit gepinnt.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createDataRetentionFeature } from "../feature";
10
+
11
+ const RETENTION_CLEANUP_JOB = "data-retention:job:retention-cleanup";
12
+
13
+ describe("retention-cleanup cron registration", () => {
14
+ test("perTenant + daily trigger + skip-concurrency ueberleben die Komposition", () => {
15
+ const registry = createRegistry([createDataRetentionFeature()]);
16
+ const job = registry.getJob(RETENTION_CLEANUP_JOB);
17
+
18
+ expect(job).toBeDefined();
19
+ expect(job?.perTenant).toBe(true);
20
+ expect(job?.trigger).toEqual({ cron: "0 3 * * *" });
21
+ expect(job?.concurrency).toBe("skip");
22
+ });
23
+ });
@@ -0,0 +1,57 @@
1
+ // Unit-Test fuer die Preset-Ableitung aus dem Compliance-Profile (Layer 2).
2
+ // Der DB-gebundene "present → derive"-Pfad wird im Integrationstest
3
+ // (retention-cleanup.integration.test.ts) gegen echtes Postgres geprueft;
4
+ // hier nur die pure Map + der Soft-Gate (compliance-profiles nicht gemountet).
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { COMPLIANCE_PROFILES } from "@cosmicdrift/kumiko-framework/compliance";
8
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
9
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { RETENTION_PRESETS } from "../presets";
11
+ import { PROFILE_TO_PRESET, resolveTenantRetentionPreset } from "../resolve-tenant-preset";
12
+
13
+ describe("PROFILE_TO_PRESET map", () => {
14
+ test("deckt jeden ComplianceProfileKey ab", () => {
15
+ for (const key of Object.keys(COMPLIANCE_PROFILES)) {
16
+ expect(PROFILE_TO_PRESET[key as keyof typeof PROFILE_TO_PRESET]).toBeDefined();
17
+ }
18
+ });
19
+
20
+ test("mappt nur auf echte RetentionPreset-Keys", () => {
21
+ for (const preset of Object.values(PROFILE_TO_PRESET)) {
22
+ expect(RETENTION_PRESETS[preset]).toBeDefined();
23
+ }
24
+ });
25
+
26
+ test("swiss-dsg ist namensgleich, EU/DE mappen auf ihr Regime", () => {
27
+ expect(PROFILE_TO_PRESET["swiss-dsg"]).toBe("swiss-dsg");
28
+ expect(PROFILE_TO_PRESET["eu-dsgvo"]).toBe("dsgvo-basic");
29
+ expect(PROFILE_TO_PRESET["de-hr-dsgvo-hgb"]).toBe("dsgvo-hgb");
30
+ expect(PROFILE_TO_PRESET["minimal-no-region"]).toBe("default");
31
+ });
32
+ });
33
+
34
+ describe("resolveTenantRetentionPreset soft-gate", () => {
35
+ test("compliance-profiles nicht gemountet → null (ohne DB-Zugriff)", async () => {
36
+ let dbTouched = false;
37
+ const db = new Proxy(
38
+ {},
39
+ {
40
+ get() {
41
+ dbTouched = true;
42
+ throw new Error("DB must not be touched when compliance-profiles is absent");
43
+ },
44
+ },
45
+ ) as unknown as DbRunner;
46
+ const registry = { getEntity: () => undefined } as unknown as Registry;
47
+
48
+ const preset = await resolveTenantRetentionPreset({
49
+ db,
50
+ registry,
51
+ tenantId: "t1" as TenantId,
52
+ });
53
+
54
+ expect(preset).toBeNull();
55
+ expect(dbTouched).toBe(false);
56
+ });
57
+ });
@@ -188,13 +188,13 @@ describe("resolveRetentionPolicy — Edge-Cases", () => {
188
188
 
189
189
  test("Preset hat eintrag, override hat anderen entityName → override greift NUR für seinen entityName", () => {
190
190
  const result = resolveRetentionPolicy({
191
- entityName: "auditLog",
191
+ entityName: "audit-log",
192
192
  entityDef: null,
193
193
  tenantPreset: "dsgvo-hgb",
194
- tenantOverride: null, // kein override für auditLog
194
+ tenantOverride: null, // kein override für audit-log
195
195
  });
196
196
 
197
- // dsgvo-hgb.auditLog = 1y / hardDelete
197
+ // dsgvo-hgb["audit-log"] = 1y / hardDelete
198
198
  expect(result.source).toBe("preset");
199
199
  expect(result.policy?.keepFor).toBe("1y");
200
200
  });
@@ -0,0 +1,188 @@
1
+ // S2.D2b — retention-cleanup gegen echtes Postgres.
2
+ //
3
+ // Verifiziert NICHT nur "alte Rows weg", sondern vor allem die Negativ-Faelle,
4
+ // in denen sich Datenverlust-Bugs verstecken:
5
+ // - Rows INNERHALB der Retention bleiben
6
+ // - Rows eines ANDEREN Tenants bleiben (perTenant-Scope)
7
+ // - Entity OHNE Policy bleibt unangetastet
8
+ // - Policy mit nicht-existenter reference-Spalte → skip statt Mass-Delete
9
+ //
10
+ // Deckt zudem den createdAt→insertedAt-Alias (Boot-Validator erlaubt
11
+ // "createdAt", die Spalte heisst aber inserted_at) und beide aktiven
12
+ // Strategien (hardDelete batched, softDelete).
13
+
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ unsafeCreateEntityTable,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
23
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
24
+ import { runRetentionCleanup } from "../run-retention-cleanup";
25
+
26
+ // hardDelete, default-reference (createdAt → alias insertedAt → Spalte inserted_at).
27
+ const widgetEntity = createEntity({
28
+ table: "read_c7_widget",
29
+ fields: { label: createTextField({ required: true }) },
30
+ retention: { keepFor: "30d", strategy: "hardDelete" },
31
+ });
32
+
33
+ // softDelete-Strategy → setzt is_deleted/deleted_at (Entity ist softDelete).
34
+ const gadgetEntity = createEntity({
35
+ table: "read_c7_gadget",
36
+ softDelete: true,
37
+ fields: { label: createTextField({ required: true }) },
38
+ retention: { keepFor: "30d", strategy: "softDelete" },
39
+ });
40
+
41
+ // Keine Policy → darf nie angefasst werden.
42
+ const plainEntity = createEntity({
43
+ table: "read_c7_plain",
44
+ fields: { label: createTextField({ required: true }) },
45
+ });
46
+
47
+ // reference="lastSeenAt" ist Boot-valide (Framework-Timestamp-Allowlist), aber
48
+ // die Spalte existiert auf diesem Entity nicht → Guard muss skippen.
49
+ const staleEntity = createEntity({
50
+ table: "read_c7_stale",
51
+ fields: { label: createTextField({ required: true }) },
52
+ retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
53
+ });
54
+
55
+ const c7Feature = defineFeature("c7-retention-fixtures", (r) => {
56
+ r.entity("c7-widget", widgetEntity);
57
+ r.entity("c7-gadget", gadgetEntity);
58
+ r.entity("c7-plain", plainEntity);
59
+ r.entity("c7-stale", staleEntity);
60
+ });
61
+
62
+ const T1 = "11111111-1111-1111-1111-111111111111";
63
+ const T2 = "22222222-2222-2222-2222-222222222222";
64
+
65
+ let stack: TestStack;
66
+ let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
67
+ let pastIso: string;
68
+ let withinIso: string;
69
+
70
+ beforeAll(async () => {
71
+ stack = await setupTestStack({ features: [createDataRetentionFeature(), c7Feature] });
72
+ for (const e of [
73
+ tenantRetentionOverrideEntity,
74
+ widgetEntity,
75
+ gadgetEntity,
76
+ plainEntity,
77
+ staleEntity,
78
+ ]) {
79
+ await unsafeCreateEntityTable(stack.db, e);
80
+ }
81
+ now = getTemporal().Now.instant();
82
+ pastIso = now.subtract({ hours: 60 * 24 }).toString(); // 60d alt → ueber Cutoff
83
+ withinIso = now.subtract({ hours: 10 * 24 }).toString(); // 10d alt → innerhalb
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ async function seed(table: string, tenantId: string, label: string, insertedAtIso: string) {
91
+ await asRawClient(stack.db).unsafe(
92
+ `INSERT INTO ${table} (tenant_id, label, inserted_at) VALUES ($1, $2, $3::timestamptz)`,
93
+ [tenantId, label, insertedAtIso],
94
+ );
95
+ }
96
+
97
+ async function labels(table: string, tenantId: string): Promise<string[]> {
98
+ const rows = (await asRawClient(stack.db).unsafe(
99
+ `SELECT label FROM ${table} WHERE tenant_id = $1 ORDER BY label`,
100
+ [tenantId],
101
+ )) as { label: string }[];
102
+ return rows.map((r) => r.label);
103
+ }
104
+
105
+ async function liveGadgetLabels(tenantId: string): Promise<string[]> {
106
+ const rows = (await asRawClient(stack.db).unsafe(
107
+ `SELECT label FROM read_c7_gadget WHERE tenant_id = $1 AND is_deleted = false ORDER BY label`,
108
+ [tenantId],
109
+ )) as { label: string }[];
110
+ return rows.map((r) => r.label);
111
+ }
112
+
113
+ beforeEach(async () => {
114
+ for (const t of ["read_c7_widget", "read_c7_gadget", "read_c7_plain", "read_c7_stale"]) {
115
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
116
+ }
117
+ });
118
+
119
+ describe("runRetentionCleanup :: real postgres", () => {
120
+ test("hardDelete entfernt nur abgelaufene Rows DES Tenants, behaelt frische + Fremd-Tenant", async () => {
121
+ await seed("read_c7_widget", T1, "expired-t1", pastIso);
122
+ await seed("read_c7_widget", T1, "fresh-t1", withinIso);
123
+ await seed("read_c7_widget", T2, "expired-t2", pastIso);
124
+
125
+ const result = await runRetentionCleanup({
126
+ db: stack.db,
127
+ registry: stack.registry,
128
+ tenantId: T1,
129
+ tenantPreset: null,
130
+ now,
131
+ });
132
+
133
+ expect(result.hardDeleted).toBe(1);
134
+ expect(await labels("read_c7_widget", T1)).toEqual(["fresh-t1"]); // within bleibt
135
+ expect(await labels("read_c7_widget", T2)).toEqual(["expired-t2"]); // Fremd-Tenant bleibt
136
+ });
137
+
138
+ test("softDelete markiert abgelaufene Rows, frische bleiben live", async () => {
139
+ await seed("read_c7_gadget", T1, "expired", pastIso);
140
+ await seed("read_c7_gadget", T1, "fresh", withinIso);
141
+
142
+ const result = await runRetentionCleanup({
143
+ db: stack.db,
144
+ registry: stack.registry,
145
+ tenantId: T1,
146
+ tenantPreset: null,
147
+ now,
148
+ });
149
+
150
+ expect(result.softDeleted).toBe(1);
151
+ expect(await liveGadgetLabels(T1)).toEqual(["fresh"]);
152
+ // Row physisch noch da (nur is_deleted=true).
153
+ expect(await labels("read_c7_gadget", T1)).toEqual(["expired", "fresh"]);
154
+ });
155
+
156
+ test("Entity ohne Policy bleibt komplett unangetastet", async () => {
157
+ await seed("read_c7_plain", T1, "ancient", pastIso);
158
+
159
+ await runRetentionCleanup({
160
+ db: stack.db,
161
+ registry: stack.registry,
162
+ tenantId: T1,
163
+ tenantPreset: null,
164
+ now,
165
+ });
166
+
167
+ expect(await labels("read_c7_plain", T1)).toEqual(["ancient"]);
168
+ });
169
+
170
+ test("fehlende reference-Spalte → skip statt Mass-Delete", async () => {
171
+ await seed("read_c7_stale", T1, "should-survive", pastIso);
172
+
173
+ const result = await runRetentionCleanup({
174
+ db: stack.db,
175
+ registry: stack.registry,
176
+ tenantId: T1,
177
+ tenantPreset: null,
178
+ now,
179
+ });
180
+
181
+ expect(await labels("read_c7_stale", T1)).toEqual(["should-survive"]);
182
+ expect(result.skipped).toContainEqual({
183
+ entityName: "c7-stale",
184
+ reason: "missing_reference_column",
185
+ });
186
+ expect(result.hardDeleted).toBe(0);
187
+ });
188
+ });
@@ -1,5 +1,7 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { policyForQuery } from "./handlers/policy-for.query";
3
+ import { resolveTenantRetentionPreset } from "./resolve-tenant-preset";
4
+ import { runRetentionCleanup } from "./run-retention-cleanup";
3
5
  import { tenantRetentionOverrideEntity } from "./schema/tenant-retention-override";
4
6
 
5
7
  export { retentionOverrideSchema } from "./override-schema";
@@ -13,12 +15,22 @@ export {
13
15
  type ResolveForTenantArgs,
14
16
  resolveRetentionPolicyForTenant,
15
17
  } from "./resolve-for-tenant";
18
+ export {
19
+ type ResolveTenantPresetArgs,
20
+ resolveTenantRetentionPreset,
21
+ } from "./resolve-tenant-preset";
16
22
  export {
17
23
  type EffectiveRetentionPolicy,
18
24
  type ResolveRetentionPolicyArgs,
19
25
  type RetentionOverride,
20
26
  resolveRetentionPolicy,
21
27
  } from "./resolver";
28
+ export {
29
+ type RetentionCleanupSkip,
30
+ type RunRetentionCleanupArgs,
31
+ type RunRetentionCleanupResult,
32
+ runRetentionCleanup,
33
+ } from "./run-retention-cleanup";
22
34
  export {
23
35
  tenantRetentionOverrideEntity,
24
36
  tenantRetentionOverrideTable,
@@ -31,11 +43,12 @@ export {
31
43
  // - Retention-Presets (dsgvo-basic, dsgvo-hgb, swiss-dsg, default)
32
44
  // - tenantRetentionOverride-Entity für per-Tenant Edge-Cases
33
45
  //
34
- // Sprint 2.D2 (kommt):
35
- // - Cleanup-Job mit Batch-Logik
36
- // - Anonymize-Strategy + blockDelete-Frist-Check
46
+ // Sprint 2.D2b (this commit):
47
+ // - retention-cleanup-Cron (perTenant) — hardDelete (batched) + softDelete
48
+ // - Layer-2-Preset aus dem Compliance-Profile abgeleitet (soft-dep)
49
+ // - anonymize deferred (Idempotenz-Marker, siehe run-retention-cleanup.ts)
37
50
  //
38
- // Sprint 2.D3 (kommt):
51
+ // Sprint 2.D3 (done):
39
52
  // - r.exposesApi("retention.policyFor") für user-data-rights
40
53
  //
41
54
  // Cross-Feature-Hinweis: Plan-Roadmap docs/plans/datenschutz/
@@ -58,8 +71,46 @@ export function createDataRetentionFeature(): FeatureDefinition {
58
71
  r.exposesApi("retention.policyFor");
59
72
  r.queryHandler(policyForQuery);
60
73
 
61
- // S2.D2b wird hier den Cleanup-Job registrieren:
62
- // r.job("retention-cleanup", { trigger: { cron: "0 3 * * *" } }, ...)
63
- // + tenant-config-key fuer Preset-Auswahl.
74
+ // S2.D2b autonomer Retention-Cleanup. perTenant-Fan-out (ein Run pro
75
+ // aktivem Tenant, wie soft-delete-cleanup): die job-DB ist NICHT
76
+ // tenant-scoped, deshalb scoped der Runner jeden Delete explizit per
77
+ // tenantId. Ohne diesen Cron werden Retention-Regeln zwar konfiguriert,
78
+ // aber nie ausgefuehrt.
79
+ r.job(
80
+ "retention-cleanup",
81
+ { trigger: { cron: "0 3 * * *" }, perTenant: true, concurrency: "skip" },
82
+ async (_payload, ctx) => {
83
+ if (!ctx.db || !ctx.registry) {
84
+ throw new Error(
85
+ "retention-cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
86
+ );
87
+ }
88
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
89
+ if (tenantId === undefined) {
90
+ // skip: cron fired without a perTenant fan-out tenant — nothing scoped
91
+ return;
92
+ }
93
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
94
+ const cleanupDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
95
+ const tenantPreset = await resolveTenantRetentionPreset({
96
+ db: cleanupDb,
97
+ registry: ctx.registry,
98
+ tenantId,
99
+ });
100
+ const result = await runRetentionCleanup({
101
+ db: cleanupDb,
102
+ registry: ctx.registry,
103
+ tenantId,
104
+ tenantPreset,
105
+ now: T.Now.instant(),
106
+ });
107
+ if (result.anonymizeDeferred.length > 0 || result.skipped.length > 0) {
108
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for deferred/skipped entities
109
+ console.warn(
110
+ `[data-retention:retention-cleanup] tenant=${tenantId} anonymizeDeferred=${result.anonymizeDeferred.join(",") || "-"} skipped=${result.skipped.map((s) => `${s.entityName}:${s.reason}`).join(",") || "-"}`,
111
+ );
112
+ }
113
+ },
114
+ );
64
115
  });
65
116
  }
@@ -34,18 +34,18 @@ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPre
34
34
 
35
35
  // DSGVO Basic — Datenminimierung ohne Buchhaltungspflichten.
36
36
  "dsgvo-basic": {
37
- auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
37
+ "audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
38
38
  session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
39
- httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
39
+ "http-log": { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
40
40
  },
41
41
 
42
42
  // DSGVO + HGB — deutsche Aufbewahrungspflichten überlagert. Order
43
43
  // wird anonymisiert (PII raus, Geschäftsdaten bleiben), Invoice +
44
44
  // Booking sind blockDelete bis 10 Jahre, dann Anonymize.
45
45
  "dsgvo-hgb": {
46
- auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
46
+ "audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
47
47
  session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
48
- httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
48
+ "http-log": { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
49
49
  invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
50
50
  booking: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
51
51
  contract: { keepFor: "6y", strategy: "blockDelete", reference: "createdAt" },
@@ -54,7 +54,7 @@ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPre
54
54
 
55
55
  // Schweizer DSG — ähnlich DSGVO mit OR Art. 958f Aufbewahrung.
56
56
  "swiss-dsg": {
57
- auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
57
+ "audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
58
58
  session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
59
59
  invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
60
60
  },
@@ -10,6 +10,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
11
11
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
13
+ import type { RetentionPresetKey } from "./presets";
13
14
  import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
14
15
  import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
15
16
 
@@ -18,6 +19,13 @@ export interface ResolveForTenantArgs {
18
19
  readonly registry: Registry;
19
20
  readonly tenantId: TenantId;
20
21
  readonly entityName: string;
22
+ /**
23
+ * Layer 2 — Tenant-Preset, vom Caller aufgeloest (retention-cleanup-Cron
24
+ * leitet ihn aus dem Compliance-Profile ab, siehe resolve-tenant-preset.ts).
25
+ * null = kein Preset, Resolver faellt auf Entity-Default (Layer 1) +
26
+ * Tenant-Override (Layer 3) zurueck.
27
+ */
28
+ readonly tenantPreset?: RetentionPresetKey | null;
21
29
  }
22
30
 
23
31
  export async function resolveRetentionPolicyForTenant(
@@ -36,13 +44,10 @@ export async function resolveRetentionPolicyForTenant(
36
44
 
37
45
  const entityDef = args.registry.getEntity(args.entityName) ?? null;
38
46
 
39
- // Layer 2 (Tenant-Preset) kommt mit S2.D2b. Bis dahin null.
40
- const tenantPreset = null;
41
-
42
47
  return resolveRetentionPolicy({
43
48
  entityName: args.entityName,
44
49
  entityDef,
45
- tenantPreset,
50
+ tenantPreset: args.tenantPreset ?? null,
46
51
  tenantOverride,
47
52
  });
48
53
  }
@@ -0,0 +1,51 @@
1
+ // Leitet den Layer-2 Retention-Preset eines Tenants aus seinem Compliance-
2
+ // Profile ab. Der retention-cleanup-Cron ruft das pro fan-out-Tenant.
3
+ //
4
+ // **Soft-Dependency:** data-retention bleibt standalone-mountbar. Nur wenn
5
+ // compliance-profiles mit-gemountet ist (seine Entity registriert), wird ein
6
+ // Preset abgeleitet — sonst null, dann greifen ausschliesslich Entity-Defaults
7
+ // (Layer 1) + per-Entity Tenant-Overrides (Layer 3). Kein r.requires, damit
8
+ // eine App data-retention auch ohne Compliance-Profiles nutzen kann.
9
+ //
10
+ // Die Map ist intentional: Compliance-Profile (Region) und Retention-Preset
11
+ // sind beide um dieselben drei Regimes gebaut (EU/CH/DE-HGB). swiss-dsg ist
12
+ // sogar namensgleich. minimal-no-region → "default" (No-Op-Preset).
13
+
14
+ import type { ComplianceProfileKey } from "@cosmicdrift/kumiko-framework/compliance";
15
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
16
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { resolveProfileForTenant } from "../compliance-profiles";
18
+ import type { RetentionPresetKey } from "./presets";
19
+
20
+ // r.entity-Name aus compliance-profiles/feature.ts — Probe ob das Feature
21
+ // gemountet ist, bevor wir seine Tabelle lesen (sonst wirft fetchOne).
22
+ const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
23
+
24
+ const PROFILE_TO_PRESET: Readonly<Record<ComplianceProfileKey, RetentionPresetKey>> = {
25
+ "eu-dsgvo": "dsgvo-basic",
26
+ "de-hr-dsgvo-hgb": "dsgvo-hgb",
27
+ "swiss-dsg": "swiss-dsg",
28
+ "minimal-no-region": "default",
29
+ } satisfies Readonly<Record<ComplianceProfileKey, RetentionPresetKey>>;
30
+
31
+ export interface ResolveTenantPresetArgs {
32
+ readonly db: DbRunner;
33
+ readonly registry: Registry;
34
+ readonly tenantId: TenantId;
35
+ }
36
+
37
+ export async function resolveTenantRetentionPreset(
38
+ args: ResolveTenantPresetArgs,
39
+ ): Promise<RetentionPresetKey | null> {
40
+ if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
41
+ return null;
42
+ }
43
+ // resolveProfileForTenant liest die 1:1-Profile-Row des Tenants (boot-DB,
44
+ // by tenantId gefiltert) und faellt auf minimal-no-region zurueck wenn der
45
+ // Tenant noch kein Profile gewaehlt hat.
46
+ const effective = await resolveProfileForTenant({ db: args.db, tenantId: args.tenantId });
47
+ return PROFILE_TO_PRESET[effective.profile.key] ?? null;
48
+ }
49
+
50
+ // Exportiert fuer den Unit-Test (Map-Vollstaendigkeit gegen ComplianceProfileKey).
51
+ export { PROFILE_TO_PRESET };