@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3

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 (100) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/package.json +12 -6
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,337 @@
1
+ // userData-Hook Integration-Tests (S2.H1+H2).
2
+ //
3
+ // User-Explicit-Checks aus der Sprint-2-Anfrage:
4
+ // - "alle daten enthalten" (Export-Bundle hat user-Profil + fileRefs)
5
+ // - "PII check in daten" (Forget anonymisiert email/displayName,
6
+ // Export-Bundle hat keine passwordHash/roles)
7
+ // - "exporte + fristen, nach loeschfrist sollte es keine daten mehr
8
+ // haben" (Forget mit strategy=delete entfernt PII; tieferer
9
+ // Frist-Test in S2.U5/S2.T1 wenn Cron-Pipeline da)
10
+ // - "cross data matrix checks" (Cross-Tenant-Isolation: Tenant A's
11
+ // fileRef-Forget beruehrt Tenant B's Files nicht)
12
+
13
+ import {
14
+ setupTestStack,
15
+ type TestStack,
16
+ unsafeCreateEntityTable,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { sql } from "drizzle-orm";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
21
+ import { createDataRetentionFeature } from "../../data-retention";
22
+ import { createFilesFeature } from "../../files";
23
+ import {
24
+ createUserFeature,
25
+ USER_ANONYMIZED_DISPLAY_NAME,
26
+ USER_DELETED_DISPLAY_NAME,
27
+ USER_STATUS,
28
+ userEntity,
29
+ userTable,
30
+ } from "../../user";
31
+ import { createUserDataRightsFeature } from "../../user-data-rights";
32
+ import { createUserDataRightsDefaultsFeature } from "../feature";
33
+ import { fileRefDeleteHook, fileRefExportHook, userDeleteHook, userExportHook } from "../index";
34
+
35
+ let stack: TestStack;
36
+
37
+ const features = [
38
+ createUserFeature(),
39
+ createFilesFeature(),
40
+ createDataRetentionFeature(),
41
+ createComplianceProfilesFeature(),
42
+ createUserDataRightsFeature(),
43
+ createUserDataRightsDefaultsFeature(),
44
+ ];
45
+
46
+ beforeAll(async () => {
47
+ stack = await setupTestStack({ features });
48
+
49
+ // userEntity via Framework-Helper migrieren (kennt softDelete +
50
+ // automatische tenant_id-Spalte — die manuell-CREATE wuerde mit
51
+ // Drizzle-Generated-Queries kollidieren).
52
+ await unsafeCreateEntityTable(stack.db, userEntity);
53
+
54
+ // file_refs ist framework-pgTable (nicht entity-getrieben, S1.5 hat
55
+ // die Schema-Sicht ohne buildDrizzleTable-Auto-Generation). Manuelle
56
+ // CREATE matched die Spalten aus framework/src/files/file-ref-table.ts
57
+ await stack.db.execute(sql`
58
+ CREATE TABLE IF NOT EXISTS file_refs (
59
+ id UUID PRIMARY KEY,
60
+ tenant_id UUID NOT NULL,
61
+ storage_key TEXT NOT NULL,
62
+ file_name TEXT NOT NULL,
63
+ mime_type TEXT NOT NULL,
64
+ size INTEGER NOT NULL,
65
+ entity_type TEXT,
66
+ entity_id TEXT,
67
+ field_name TEXT,
68
+ inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
69
+ inserted_by_id TEXT
70
+ )
71
+ `);
72
+ });
73
+
74
+ afterAll(async () => {
75
+ await stack.cleanup();
76
+ });
77
+
78
+ const TENANT_A = "00000000-0000-4000-8000-00000000000a";
79
+ const TENANT_B = "00000000-0000-4000-8000-00000000000b";
80
+
81
+ // fileRef-IDs muessen UUID sein (file_refs.id ist UUID per S0.1+S1.5).
82
+ // Helper baut zaehlerbasierte UUIDs damit Tests deterministisch.
83
+ function uuid(suffix: number): string {
84
+ return `aaaaaaaa-aaaa-4aaa-8aaa-${suffix.toString(16).padStart(12, "0")}`;
85
+ }
86
+
87
+ async function seedUser(id: string, overrides: Record<string, unknown> = {}): Promise<void> {
88
+ // Drizzle-Insert nutzt Schema (incl. framework-managed tenantId-Spalte).
89
+ // user-Entity ist tenant-agnostisch im Domain-Sinn, aber das DB-
90
+ // Schema hat tenant_id-Spalte automatisch (Framework-Default).
91
+ // Pragmatisch: SYSTEM_TENANT_ID fuer User-Rows in Tests.
92
+ const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
93
+ await stack.db
94
+ .insert(userTable)
95
+ .values({
96
+ id,
97
+ tenantId: SYSTEM_TENANT,
98
+ email: `user-${id}@example.com`,
99
+ passwordHash: "hashed-password",
100
+ displayName: `User ${id}`,
101
+ locale: "de",
102
+ emailVerified: true,
103
+ roles: '["Member"]',
104
+ status: USER_STATUS.Active,
105
+ ...overrides,
106
+ })
107
+ .onConflictDoNothing();
108
+ }
109
+
110
+ async function seedFileRef(
111
+ id: string,
112
+ tenantId: string,
113
+ insertedById: string | null,
114
+ fileName: string,
115
+ ): Promise<void> {
116
+ await stack.db.execute(sql`
117
+ INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
118
+ VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${fileName}, 'application/pdf', 1024, ${insertedById})
119
+ ON CONFLICT (id) DO NOTHING
120
+ `);
121
+ }
122
+
123
+ async function fetchUser(id: string) {
124
+ const result = await stack.db.execute(sql`
125
+ SELECT id, email, display_name, password_hash, status, deleted_at
126
+ FROM read_users WHERE id = ${id}
127
+ `);
128
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute returns any-typed array
129
+ const rows = ((result as any).rows ?? result) as Array<{
130
+ id: string;
131
+ email: string;
132
+ display_name: string;
133
+ password_hash: string | null;
134
+ status: string;
135
+ deleted_at: string | null;
136
+ }>;
137
+ return rows[0] ?? null;
138
+ }
139
+
140
+ async function fetchFileRefs(tenantId: string, insertedById?: string | null) {
141
+ const result =
142
+ insertedById === undefined
143
+ ? await stack.db.execute(sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId}`)
144
+ : insertedById === null
145
+ ? await stack.db.execute(
146
+ sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId} AND inserted_by_id IS NULL`,
147
+ )
148
+ : await stack.db.execute(
149
+ sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId} AND inserted_by_id = ${insertedById}`,
150
+ );
151
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
152
+ return (result as any).rows ?? result;
153
+ }
154
+
155
+ describe("user-data-rights-defaults :: feature loads", () => {
156
+ test("Boot ist clean (5 features in der requires-Chain)", () => {
157
+ expect(stack).toBeDefined();
158
+ });
159
+ });
160
+
161
+ describe("S2.H1 :: userExportHook", () => {
162
+ test("liefert Profil-JSON ohne passwordHash + roles (PII-Check)", async () => {
163
+ await seedUser(uuid(1001), { displayName: "Marc" });
164
+
165
+ const result = await userExportHook({
166
+ db: stack.db,
167
+ tenantId: TENANT_A,
168
+ userId: uuid(1001),
169
+ });
170
+
171
+ expect(result).toBeDefined();
172
+ expect(result?.entity).toBe("user");
173
+ expect(result?.rows).toHaveLength(1);
174
+ const profile = result?.rows[0];
175
+ expect(String(profile?.["email"])).toContain("@example.com");
176
+ expect(profile?.["displayName"]).toBe("Marc");
177
+ expect(profile?.["locale"]).toBe("de");
178
+ // PII-Check: KEINE passwordHash + roles im Bundle
179
+ expect(profile?.["passwordHash"]).toBeUndefined();
180
+ expect(profile?.["roles"]).toBeUndefined();
181
+ expect(profile?.["status"]).toBeUndefined();
182
+ });
183
+
184
+ test("returns null wenn User nicht existiert", async () => {
185
+ const result = await userExportHook({
186
+ db: stack.db,
187
+ tenantId: TENANT_A,
188
+ userId: uuid(1002),
189
+ });
190
+ expect(result).toBeNull();
191
+ });
192
+ });
193
+
194
+ describe("S2.H1 :: userDeleteHook", () => {
195
+ test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
196
+ await seedUser(uuid(1003));
197
+
198
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
199
+
200
+ const row = await fetchUser(uuid(1003));
201
+ expect(row).not.toBeNull();
202
+ if (!row) throw new Error("row should exist");
203
+ expect(row.email).toContain("anonymized.invalid"); // PII raus
204
+ expect(row.email).not.toContain("@example.com"); // urspruengliche email weg
205
+ expect(row.display_name).toBe(USER_DELETED_DISPLAY_NAME);
206
+ expect(row.password_hash).toBeNull();
207
+ expect(row.status).toBe(USER_STATUS.Deleted);
208
+ expect(row.deleted_at).not.toBeNull(); // softDelete-Timestamp gesetzt
209
+ });
210
+
211
+ test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
212
+ await seedUser(uuid(1004));
213
+
214
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
215
+
216
+ const row = await fetchUser(uuid(1004));
217
+ if (!row) throw new Error("row should exist");
218
+ expect(row.email).toContain("anonymized.invalid");
219
+ expect(row.display_name).toBe(USER_ANONYMIZED_DISPLAY_NAME);
220
+ expect(row.status).toBe(USER_STATUS.Active); // NICHT auf deleted
221
+ expect(row.deleted_at).toBeNull(); // KEIN softDelete
222
+ });
223
+
224
+ test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
225
+ await seedUser(uuid(1005));
226
+
227
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
228
+ const afterFirst = await fetchUser(uuid(1005));
229
+ if (!afterFirst) throw new Error("user should exist after first delete");
230
+
231
+ // Zweiter Call: kein Crash + State unverändert
232
+ await expect(
233
+ userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
234
+ ).resolves.toBeUndefined();
235
+
236
+ // State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
237
+ // kein Status-Reset, anonymisierte Werte unverändert.
238
+ const afterSecond = await fetchUser(uuid(1005));
239
+ if (!afterSecond) throw new Error("user should exist after second delete");
240
+ expect(afterSecond.status).toBe(USER_STATUS.Deleted);
241
+ expect(afterSecond.display_name).toBe(USER_DELETED_DISPLAY_NAME);
242
+ expect(afterSecond.password_hash).toBeNull();
243
+ expect(afterSecond.email).toBe(afterFirst.email); // gleicher Wert, nicht "neu anonymisiert"
244
+ });
245
+ });
246
+
247
+ describe("S2.H2 :: fileRefExportHook", () => {
248
+ test("liefert FileRef-Metadata + signed-URL-Liste fuer Sprint-2.U3 ZIP-Bau", async () => {
249
+ await seedFileRef(uuid(101), TENANT_A, "user-files-1", "lebenslauf.pdf");
250
+ await seedFileRef(uuid(102), TENANT_A, "user-files-1", "anschreiben.pdf");
251
+
252
+ const result = await fileRefExportHook({
253
+ db: stack.db,
254
+ tenantId: TENANT_A,
255
+ userId: "user-files-1",
256
+ });
257
+
258
+ expect(result?.entity).toBe("fileRef");
259
+ expect(result?.rows).toHaveLength(2);
260
+ expect(result?.fileRefs).toHaveLength(2);
261
+ const names = result?.fileRefs?.map((f) => f.fileName).sort();
262
+ expect(names).toEqual(["anschreiben.pdf", "lebenslauf.pdf"]);
263
+ });
264
+
265
+ test("returns null wenn User keine Files hat", async () => {
266
+ const result = await fileRefExportHook({
267
+ db: stack.db,
268
+ tenantId: TENANT_A,
269
+ userId: "ghost-user-no-files",
270
+ });
271
+ expect(result).toBeNull();
272
+ });
273
+ });
274
+
275
+ describe("S2.H2 :: fileRefDeleteHook", () => {
276
+ test('strategy="delete" → FileRef-Rows fuer User in Tenant weg', async () => {
277
+ await seedFileRef(uuid(201), TENANT_A, "user-delete-files", "f1.pdf");
278
+ await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
279
+
280
+ await fileRefDeleteHook(
281
+ { db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
282
+ "delete",
283
+ );
284
+
285
+ const remaining = await fetchFileRefs(TENANT_A, "user-delete-files");
286
+ expect(remaining).toHaveLength(0);
287
+ });
288
+
289
+ test('strategy="anonymize" → insertedById=null, Files bleiben', async () => {
290
+ await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
291
+
292
+ await fileRefDeleteHook(
293
+ { db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
294
+ "anonymize",
295
+ );
296
+
297
+ const ownedAfter = await fetchFileRefs(TENANT_A, "user-anon-files");
298
+ expect(ownedAfter).toHaveLength(0); // keiner mehr mit insertedById=user
299
+ const anonymized = await fetchFileRefs(TENANT_A, null);
300
+ const file = anonymized.find((f: { id: string }) => f.id === uuid(203));
301
+ expect(file).toBeDefined();
302
+ expect(file.inserted_by_id).toBeNull();
303
+ });
304
+
305
+ test("Cross-Tenant-Isolation: Tenant A's Forget beruehrt Tenant B's Files nicht (User-explicit)", async () => {
306
+ await seedFileRef(uuid(301), TENANT_A, "shared-user", "tenantA.pdf");
307
+ await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
308
+
309
+ // Tenant A loescht alle Files von "shared-user"
310
+ await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
311
+
312
+ const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
313
+ const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
314
+
315
+ expect(aRemaining).toHaveLength(0); // Tenant A: weg
316
+ expect(bRemaining).toHaveLength(1); // Tenant B: unangetastet
317
+ expect(bRemaining[0]?.file_name).toBe("tenantB.pdf");
318
+ });
319
+
320
+ test("idempotent: zweiter delete-Call crasht nicht UND DB-State bleibt 0 Files", async () => {
321
+ await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
322
+
323
+ await fileRefDeleteHook(
324
+ { db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
325
+ "delete",
326
+ );
327
+ const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
328
+ expect(afterFirst).toHaveLength(0);
329
+
330
+ // Zweiter Call: kein Crash + State weiter 0 Files
331
+ await expect(
332
+ fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
333
+ ).resolves.toBeUndefined();
334
+ const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
335
+ expect(afterSecond).toHaveLength(0);
336
+ });
337
+ });
@@ -0,0 +1,40 @@
1
+ import {
2
+ defineFeature,
3
+ EXT_USER_DATA,
4
+ type FeatureDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
7
+ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
8
+
9
+ // user-data-rights-defaults — Default-Hooks für die Core-Entities
10
+ // `user` (S2.H1) und `fileRef` (S2.H2).
11
+ //
12
+ // Architektur-Entscheidung (S2.H1+H2): user-data-rights selbst kann
13
+ // nicht r.requires("user", "files") + r.useExtension(EXT_USER_DATA, ...)
14
+ // machen weil es selbst Provider von EXT_USER_DATA ist (Boot-Validator
15
+ // lehnt self-extension ab). Lösung: drittes optional-mountbares Feature
16
+ // das requires beide Sources + die useExtension-Calls macht.
17
+ //
18
+ // App-Author kann dieses Feature weglassen wenn er Custom-Hooks
19
+ // stattdessen registrieren will (z.B. "anonymize sollte den User-Row
20
+ // hard-delete" — App-spezifische Compliance-Entscheidung). Default-
21
+ // Implementierung deckt 95% der Apps ab.
22
+ //
23
+ // Pattern matched file-foundation + file-provider-s3 (separate Plugin-
24
+ // Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
25
+ // das circular-requires waere.
26
+ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
27
+ return defineFeature("user-data-rights-defaults", (r) => {
28
+ r.requires("user", "files", "user-data-rights");
29
+
30
+ r.useExtension(EXT_USER_DATA, "user", {
31
+ export: userExportHook,
32
+ delete: userDeleteHook,
33
+ });
34
+
35
+ r.useExtension(EXT_USER_DATA, "fileRef", {
36
+ export: fileRefExportHook,
37
+ delete: fileRefDeleteHook,
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,109 @@
1
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
3
+ import { and, eq } from "drizzle-orm";
4
+
5
+ // userData-Hook fuer fileRef-entity (S2.H2).
6
+ //
7
+ // Export-Hook liefert Metadata aller FileRefs des Users mit Subject-
8
+ // Resolver via insertedById. Storage-Provider-binary-Streams kommen
9
+ // NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
10
+ // gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
11
+ //
12
+ // Delete-Hook entfernt FileRef-Zeile + Storage-Binary. Plan-Roadmap
13
+ // docs/plans/datenschutz/storage-encryption.md hat das Subject-
14
+ // Resolver-Pattern fuer File-Encryption als Sprint 4 — bis dahin:
15
+ // "delete": Row hard-delete + storageProvider.delete() pro File
16
+ // "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
17
+ // koennen weiter zeigen; Personenbezug raus)
18
+ //
19
+ // Storage-Provider-Cleanup ist BEST-EFFORT — wenn S3-delete failt,
20
+ // log + skip (Cron-Job kann es retry). Memory: Forget-Atomicity-
21
+ // Decision aus Sprint-2-Architektur (advisor-pinned): per-Hook
22
+ // idempotent, KEIN globaler Rollback — wenn ein File-Delete failt,
23
+ // bleibt der User-Row trotzdem anonymisiert.
24
+ //
25
+ // Storage-Provider kommt aus dem App-Bootstrap (createBunServer-
26
+ // options.files.storageProvider). Wir greifen darauf via ctx — der
27
+ // Hook-ctx hat aktuell nur db/tenantId/userId, also fuer Storage-
28
+ // Calls braucht es eine Erweiterung. S2.U3 Export-Job-Pipeline regelt
29
+ // das (Job-ctx hat ctx.files.ref(key)). Hier lassen wir Storage-
30
+ // Cleanup als TODO und faellen das in S2.U5 nochmal an.
31
+
32
+ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
33
+ const rawRows = await ctx.db
34
+ .select()
35
+ .from(fileRefsTable)
36
+ .where(
37
+ and(
38
+ eq(fileRefsTable["tenantId"], ctx.tenantId),
39
+ eq(fileRefsTable["insertedById"], ctx.userId),
40
+ ),
41
+ );
42
+
43
+ // @cast-boundary db-row: drizzle liefert insertedAt als Instant
44
+ // (framework-customType). Fuer JSON-Export brauchen wir String —
45
+ // .toString() funktioniert sowohl auf Temporal.Instant als auch
46
+ // Date.
47
+ const rows = rawRows.map((r) => {
48
+ const row = r as Record<string, unknown>;
49
+ return {
50
+ id: String(row["id"]),
51
+ storageKey: String(row["storageKey"]),
52
+ fileName: String(row["fileName"]),
53
+ mimeType: String(row["mimeType"]),
54
+ size: typeof row["size"] === "number" ? row["size"] : 0,
55
+ insertedAt: String(row["insertedAt"] ?? ""),
56
+ };
57
+ });
58
+
59
+ if (rows.length === 0) return null;
60
+
61
+ return {
62
+ entity: "fileRef",
63
+ rows: rows.map((r) => ({
64
+ id: r.id,
65
+ fileName: r.fileName,
66
+ mimeType: r.mimeType,
67
+ size: r.size,
68
+ insertedAt: r.insertedAt,
69
+ })),
70
+ // Plus die fileRefs-Liste die Sprint-2-U3 dann zum Storage-Provider
71
+ // bringt + signed-URLs erzeugt + ins ZIP packt (siehe S1.9-Z1
72
+ // UserDataExportSnippet.fileRefs).
73
+ fileRefs: rows.map((r) => ({
74
+ fileRefId: r.id,
75
+ storageKey: r.storageKey,
76
+ fileName: r.fileName,
77
+ })),
78
+ };
79
+ };
80
+
81
+ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
82
+ if (strategy === "delete") {
83
+ // Hard-delete der FileRef-Rows fuer diesen User in diesem Tenant.
84
+ // Storage-Binary-Cleanup folgt in S2.U5 wenn der Forget-Job-Ctx
85
+ // den Storage-Provider exposed.
86
+ await ctx.db
87
+ .delete(fileRefsTable)
88
+ .where(
89
+ and(
90
+ eq(fileRefsTable["tenantId"], ctx.tenantId),
91
+ eq(fileRefsTable["insertedById"], ctx.userId),
92
+ ),
93
+ );
94
+ } else {
95
+ // anonymize: insertedById=null, FileRef + binary bleiben.
96
+ // Use-case: shared chat-Attachment in einem Multi-User-Channel —
97
+ // Author-Identifikation raus, Datei bleibt fuer andere User
98
+ // sichtbar.
99
+ await ctx.db
100
+ .update(fileRefsTable)
101
+ .set({ insertedById: null })
102
+ .where(
103
+ and(
104
+ eq(fileRefsTable["tenantId"], ctx.tenantId),
105
+ eq(fileRefsTable["insertedById"], ctx.userId),
106
+ ),
107
+ );
108
+ }
109
+ };
@@ -0,0 +1,91 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { eq, sql } from "drizzle-orm";
4
+ import {
5
+ USER_ANONYMIZED_DISPLAY_NAME,
6
+ USER_ANONYMIZED_EMAIL_DOMAIN,
7
+ USER_ANONYMIZED_EMAIL_PREFIX,
8
+ USER_DELETED_DISPLAY_NAME,
9
+ USER_DELETED_EMAIL_PREFIX,
10
+ USER_STATUS,
11
+ userTable,
12
+ } from "../../user";
13
+
14
+ // userData-Hook fuer user-entity (S2.H1).
15
+ //
16
+ // Export-Hook liefert Profil-Daten ohne security-sensitive Felder
17
+ // (passwordHash, roles, status — DSGVO Art. 20 ist Datenportabilitaet
18
+ // fuer das was der User selbst zur Verfuegung gestellt hat, nicht
19
+ // Lifecycle-Metadata oder Authorization-State).
20
+ //
21
+ // Delete-Hook anonymisiert PII + setzt status=deleted. Der eigentliche
22
+ // softDelete-Flag wird ueber drizzle's deletedAt-Column gesetzt; die
23
+ // Row bleibt fuer Audit-Trail erhalten (alle FK-Refs auf user.id
24
+ // bleiben gueltig). DSGVO-konform via PII-Anonymisierung.
25
+ //
26
+ // Strategy:
27
+ // "delete": softDelete + email/displayName/passwordHash leeren,
28
+ // status=deleted (Login geblockt)
29
+ // "anonymize": email/displayName auf Pseudonym, Row bleibt active
30
+ // — fuer Cases wo User-Row als FK noch relevant ist
31
+ // aber PII raus muss (z.B. anonymize mit blockDelete-
32
+ // Frist auf einer FK-target-Entity)
33
+
34
+ export const userExportHook: UserDataExportHook = async (ctx) => {
35
+ const row = (await fetchOne(ctx.db, userTable, eq(userTable["id"], ctx.userId))) as {
36
+ id: string;
37
+ email: string;
38
+ displayName: string;
39
+ locale: string;
40
+ emailVerified: boolean;
41
+ } | null;
42
+
43
+ if (!row) return null;
44
+
45
+ return {
46
+ entity: "user",
47
+ rows: [
48
+ {
49
+ id: row.id,
50
+ email: row.email,
51
+ displayName: row.displayName,
52
+ locale: row.locale,
53
+ emailVerified: row.emailVerified,
54
+ },
55
+ ],
56
+ };
57
+ };
58
+
59
+ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
60
+ // Idempotent: zweiter Call findet die Row schon anonymized + skipt
61
+ // implicit (UPDATE mit gleichen Werten). Memory feedback_event_store_
62
+ // tenant_consistency: ctx.tenantId muss in der user-table-Zeile
63
+ // korrelieren — user.id ist tenant-agnostic (User kann in mehreren
64
+ // Tenants Member sein), kein tenantId-Filter noetig.
65
+
66
+ if (strategy === "delete") {
67
+ await ctx.db
68
+ .update(userTable)
69
+ .set({
70
+ email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
71
+ displayName: USER_DELETED_DISPLAY_NAME,
72
+ passwordHash: null,
73
+ status: USER_STATUS.Deleted,
74
+ deletedAt: sql`now()`,
75
+ })
76
+ .where(eq(userTable["id"], ctx.userId));
77
+ } else {
78
+ // anonymize: PII raus, aber Row bleibt active (damit FK-References
79
+ // weiter aufloesbar sind). Account ist effektiv weiter nutzbar
80
+ // wenn der User sich neu authentifiziert — pragmatisch akzeptabel
81
+ // weil "anonymize" auf user-entity ein seltener Edge-Case ist
82
+ // (typisch hard-delete fuer User).
83
+ await ctx.db
84
+ .update(userTable)
85
+ .set({
86
+ email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
87
+ displayName: USER_ANONYMIZED_DISPLAY_NAME,
88
+ })
89
+ .where(eq(userTable["id"], ctx.userId));
90
+ }
91
+ };
@@ -0,0 +1,6 @@
1
+ export { createUserDataRightsDefaultsFeature } from "./feature";
2
+ export {
3
+ fileRefDeleteHook,
4
+ fileRefExportHook,
5
+ } from "./hooks/file-ref.userdata-hook";
6
+ export { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";