@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,334 @@
1
+ // Forget-Cleanup-Runner (S2.U5b) — pure-Function Pipeline.
2
+ //
3
+ // Nach abgelaufener Grace-Period (S2.U5a setzt gracePeriodEnd) iteriert
4
+ // dieser Runner ueber alle User in DeletionRequested-State und triggert
5
+ // die EXT_USER_DATA-delete-Hooks pro Membership-Tenant.
6
+ //
7
+ // **Cross-Tenant-Iteration:** Ein User-Forget-Antrag in Tenant A muss
8
+ // die Daten des Users in ALLEN seinen Tenants entfernen — siehe
9
+ // docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
10
+ //
11
+ // **Strategy-Dispatch:** Pro Entity entscheidet die data-retention-
12
+ // policy (per-Tenant Override moeglich), ob "delete" oder "anonymize"
13
+ // gefahren wird. blockDelete (Aufbewahrungs-Pflicht) ergibt zwingend
14
+ // "anonymize" — Daten-Objekt bleibt physisch da, Personen-Bezug raus.
15
+ //
16
+ // **Per-User-Atomicity (advisor-pinned):** Jeder User wird in einer
17
+ // eigenen Sub-Transaction abgewickelt (db.transaction → SAVEPOINT wenn
18
+ // Outer-Tx aktiv, BEGIN sonst). Folge: ein failing Hook bei User A
19
+ // rollt nur dessen Sub-Tx zurueck, User B + bisherige User-Status-Flips
20
+ // bleiben commit-able. Ohne diese Sub-Tx wuerde der Outer-Dispatcher-Tx
21
+ // (alle writeHandler laufen in `db.transaction(...)`) den ganzen
22
+ // Cleanup-Run beim ersten Hook-Throw zurueckrollen.
23
+ //
24
+ // **Idempotenz:** Hooks sind idempotent designed (siehe
25
+ // engine/extensions/user-data.ts). Doppellauf nach Crash-Recovery muss
26
+ // safe sein. Status-Flip auf "Deleted" am Ende sorgt dafuer, dass next
27
+ // Lauf den User nicht mehr findet.
28
+ //
29
+ // **Error-Handling:** Ein hook der wirft soll den Lauf NICHT stoppen —
30
+ // andere User sollen weiter abgearbeitet werden. Errors werden
31
+ // gesammelt + zurueckgegeben fuer Operator-Visibility. Ein User mit
32
+ // gefailten Hooks bleibt im DeletionRequested-Status (next Lauf
33
+ // retried automatisch).
34
+
35
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
36
+ import {
37
+ EXT_USER_DATA,
38
+ type Registry,
39
+ type TenantId,
40
+ type UserDataDeleteHook,
41
+ type UserDataDeleteStrategy,
42
+ } from "@cosmicdrift/kumiko-framework/engine";
43
+ import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
44
+ import { and, eq, lte } from "drizzle-orm";
45
+ import { resolveRetentionPolicyForTenant } from "../data-retention";
46
+ import { tenantMembershipsTable } from "../tenant";
47
+ import { USER_STATUS, userTable } from "../user";
48
+
49
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
50
+
51
+ /**
52
+ * Notification-Callback fuer den Forget-Cleanup-Pfad (Atom 5b). Pattern
53
+ * matched Atom 5 (Export). Throw bubbelt zum r.job-Wrap; jobs-feature
54
+ * persistiert den failed-Run in jobRunsTable (siehe
55
+ * jobs/__tests__/jobs-feature.integration.ts Scenario 2 — der throw-
56
+ * Pfad eines r.job-handlers wird dort gepinnt).
57
+ *
58
+ * **executedAt:** Zeitpunkt des delete-Flips. Wird als ISO-String
59
+ * uebergeben damit App-Author den frei in Email-Template einbauen kann.
60
+ *
61
+ * **tenantIds:** alle Memberships die der User vor dem Delete hatte.
62
+ * Email-Template kann das nutzen ("dein Account in Tenant X+Y wurde
63
+ * geloescht"). Bei orphan-User (0 Memberships) ist die Liste leer.
64
+ */
65
+ export type SendDeletionExecutedEmailFn = (args: {
66
+ readonly userId: string;
67
+ readonly userEmail: string;
68
+ readonly tenantIds: readonly TenantId[];
69
+ readonly executedAt: string;
70
+ }) => Promise<void>;
71
+
72
+ export interface RunForgetCleanupArgs {
73
+ readonly db: DbRunner;
74
+ readonly registry: Registry;
75
+ /**
76
+ * Now-Injection — Tests koennen den Wert pinnen ohne Date-Mock.
77
+ * Pattern aus data-retention/keep-for.ts (advisor-pinned).
78
+ */
79
+ readonly now: Instant;
80
+
81
+ /** Atom 5b — Email-Notification beim delete-flip. Optional;
82
+ * ohne Callback laeuft Worker still (User hatte schon
83
+ * request-deletion-Email + grace-period-Erinnerung). */
84
+ readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
85
+ }
86
+
87
+ export interface ForgetCleanupError {
88
+ readonly userId: string;
89
+ readonly tenantId: TenantId;
90
+ readonly entityName: string;
91
+ readonly message: string;
92
+ }
93
+
94
+ export interface RunForgetCleanupResult {
95
+ /** User die in diesem Lauf von DeletionRequested → Deleted geflippt wurden. */
96
+ readonly processedUserIds: readonly string[];
97
+ /** Anzahl entity-hook-calls die wirklich gelaufen sind (success oder fail). */
98
+ readonly hookCallsAttempted: number;
99
+ /** Hook-Errors fuer Operator-Visibility. Lauf bricht nicht ab — siehe Header. */
100
+ readonly errors: readonly ForgetCleanupError[];
101
+ }
102
+
103
+ interface HookEntry {
104
+ readonly entityName: string;
105
+ readonly deleteHook: UserDataDeleteHook;
106
+ }
107
+
108
+ export async function runForgetCleanup(
109
+ args: RunForgetCleanupArgs,
110
+ ): Promise<RunForgetCleanupResult> {
111
+ const { db, registry, now, sendDeletionExecutedEmail } = args;
112
+
113
+ // Step 1: Find users with expired grace period.
114
+ // @cast-boundary db-row — drizzle-select gibt Record-Shape zurueck.
115
+ const dueUsers = (await db
116
+ .select({ id: userTable["id"] })
117
+ .from(userTable)
118
+ .where(
119
+ and(
120
+ eq(userTable["status"], USER_STATUS.DeletionRequested),
121
+ lte(userTable["gracePeriodEnd"], now),
122
+ ),
123
+ )) as Array<{ id: string }>;
124
+
125
+ if (dueUsers.length === 0) {
126
+ return { processedUserIds: [], hookCallsAttempted: 0, errors: [] };
127
+ }
128
+
129
+ // Step 2: Sammle alle EXT_USER_DATA-Usages einmalig — Liste der
130
+ // (entityName, deleteHook)-Pairs aller registrierten Provider-Features.
131
+ const usages = registry.getExtensionUsages(EXT_USER_DATA);
132
+ const hookEntries: HookEntry[] = usages
133
+ .map((u): HookEntry | null => {
134
+ const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook };
135
+ return opts.delete ? { entityName: u.entityName, deleteHook: opts.delete } : null;
136
+ })
137
+ .filter((x): x is HookEntry => x !== null);
138
+
139
+ const errors: ForgetCleanupError[] = [];
140
+ const processedUserIds: string[] = [];
141
+ let hookCallsAttempted = 0;
142
+
143
+ // Step 3: Pro User iterieren — eigene Sub-Tx pro User (siehe Header).
144
+ for (const user of dueUsers) {
145
+ const userResult = await processUser({
146
+ db,
147
+ registry,
148
+ userId: user.id,
149
+ hookEntries,
150
+ });
151
+ hookCallsAttempted += userResult.hookCallsAttempted;
152
+ errors.push(...userResult.errors);
153
+ if (userResult.success) {
154
+ processedUserIds.push(user.id);
155
+
156
+ // Atom 5b — Email-Notification nach success-flip. userEmail wurde
157
+ // VOR der Tx gecacht (user-Hook anonymisiert in der Tx).
158
+ //
159
+ // Best-effort: ein Email-Throw fuer User A darf nicht den Batch
160
+ // killen — User A ist bereits geloescht (Sub-Tx committed), und
161
+ // die Users B, C, ... muessen noch verarbeitet werden. Throw waere
162
+ // hier ein Bug: r.job-Wrap markiert den Run failed, retry findet
163
+ // keine User mehr im DeletionRequested+grace-expired-Status (alle
164
+ // schon Deleted) → silent miss. console.warn ist die einzige
165
+ // Operator-Sichtbarkeit — runForgetCleanup-args fuehren AppContext.
166
+ // log aktuell nicht durch (pure-function-Pattern).
167
+ if (sendDeletionExecutedEmail && userResult.userEmailBeforeDelete) {
168
+ try {
169
+ await sendDeletionExecutedEmail({
170
+ userId: user.id,
171
+ userEmail: userResult.userEmailBeforeDelete,
172
+ tenantIds: userResult.tenantIdsBeforeDelete,
173
+ executedAt: now.toString(),
174
+ });
175
+ } catch (err) {
176
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
177
+ console.warn(
178
+ `[user-data-rights:run-forget-cleanup] sendDeletionExecutedEmail failed userId=${user.id} err=${err instanceof Error ? err.message : String(err)}`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return { processedUserIds, hookCallsAttempted, errors };
186
+ }
187
+
188
+ interface ProcessUserResult {
189
+ readonly success: boolean;
190
+ readonly hookCallsAttempted: number;
191
+ readonly errors: readonly ForgetCleanupError[];
192
+ /** Atom 5b: userEmail VOR Tx gecacht (user-Hook anonymisiert in Tx).
193
+ * null wenn user-Row beim Pre-Tx-Lookup nicht (mehr) existiert oder
194
+ * email leer ist. */
195
+ readonly userEmailBeforeDelete: string | null;
196
+ /** Tenant-Memberships VOR Tx — Email-Template kann das nutzen. */
197
+ readonly tenantIdsBeforeDelete: readonly TenantId[];
198
+ }
199
+
200
+ async function processUser(args: {
201
+ db: DbRunner;
202
+ registry: Registry;
203
+ userId: string;
204
+ hookEntries: readonly HookEntry[];
205
+ }): Promise<ProcessUserResult> {
206
+ const { db, registry, userId, hookEntries } = args;
207
+ const errors: ForgetCleanupError[] = [];
208
+ let hookCallsAttempted = 0;
209
+
210
+ // Atom 5b — userEmail VOR der Tx cachen. user-Hook (user-data-rights-
211
+ // defaults) anonymisiert email/displayName/passwordHash IN der Tx.
212
+ // Nach der Tx ist email = "deleted-{id}@{tenant}.example" oder NULL.
213
+ // Memory-cache laesst Atom-5b-Callback nach success-flip den
214
+ // ORIGINAL-email an App-Author-Callback geben.
215
+ // @cast-boundary db-row.
216
+ const userPreTx = (await db
217
+ .select({ email: userTable["email"] })
218
+ .from(userTable)
219
+ .where(eq(userTable["id"], userId))
220
+ .limit(1)) as Array<{ email: string | null }>;
221
+ const userEmailBeforeDelete =
222
+ userPreTx[0]?.email && userPreTx[0].email.length > 0 ? userPreTx[0].email : null;
223
+
224
+ // Memberships fuer diesen User holen — alle Tenants in denen er Mitglied ist.
225
+ // @cast-boundary db-row.
226
+ const memberships = (await db
227
+ .select({ tenantId: tenantMembershipsTable["tenantId"] })
228
+ .from(tenantMembershipsTable)
229
+ .where(eq(tenantMembershipsTable["userId"], userId))) as Array<{
230
+ tenantId: TenantId;
231
+ }>;
232
+ // tenant-Liste fuer Atom 5b Email — Memberships VOR Tx, weil hooks
233
+ // memberships in der Tx loeschen. Orphan-User (0 memberships) liefert
234
+ // [] in Email-args; App-Author-Template kann das case-handlen.
235
+ const tenantIdsBeforeDelete: readonly TenantId[] = memberships.map((m) => m.tenantId);
236
+
237
+ // Edge-Case "0 Memberships": User hat alle Tenants schon verlassen
238
+ // bevor Forget triggerte. Wir laufen den Hook-Loop trotzdem mit einem
239
+ // Pseudo-Tenant — der user-Hook (user-data-rights-defaults) ist
240
+ // tenant-agnostisch und MUSS laufen damit email/displayName/passwordHash
241
+ // anonymisiert werden. Tenant-scoped Hooks (z.B. fileRefDeleteHook)
242
+ // finden im Pseudo-Tenant nichts und sind no-op. Ohne diesen Pfad
243
+ // wuerde status=Deleted gesetzt waehrend Original-PII liegen bleibt
244
+ // — sieht compliant aus, ist es nicht (advisor-Finding S2.U5b.fix1).
245
+ const tenantList: TenantId[] =
246
+ memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
247
+
248
+ // Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
249
+ // nur dieser User zurueck, andere User bleiben commit-fest. Drizzle
250
+ // mappt das in nested-Tx auf SAVEPOINT, in top-level auf BEGIN — die
251
+ // `transaction()`-API ist auf DbRunner uniform.
252
+ //
253
+ // Cast `db as {transaction: ...}` ist eine TS-Limitation: DbRunner ist
254
+ // `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
255
+ // die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
256
+ // PgTransaction haben unterschiedliche Generics). Cast macht das
257
+ // Strukturelle explizit, kein Hack.
258
+ let txSucceeded = false;
259
+ let currentTenantId: TenantId | null = null;
260
+ let currentEntityName: string | null = null;
261
+ try {
262
+ await (
263
+ db as { transaction: (fn: (tx: DbRunner) => Promise<void>) => Promise<void> }
264
+ ).transaction(async (tx) => {
265
+ for (const tenantId of tenantList) {
266
+ currentTenantId = tenantId;
267
+ for (const entry of hookEntries) {
268
+ currentEntityName = entry.entityName;
269
+ const policy = await resolveRetentionPolicyForTenant({
270
+ db: tx,
271
+ registry,
272
+ tenantId,
273
+ entityName: entry.entityName,
274
+ });
275
+ const strategy = policyToStrategy(policy.policy?.strategy ?? null);
276
+
277
+ hookCallsAttempted++;
278
+ await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
279
+ }
280
+ }
281
+
282
+ // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
283
+ // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
284
+ // alles, der User bleibt im DeletionRequested-Status, naechster
285
+ // Run retried.
286
+ await tx
287
+ .update(userTable)
288
+ .set({ status: USER_STATUS.Deleted })
289
+ .where(eq(userTable["id"], userId));
290
+ txSucceeded = true;
291
+ });
292
+ } catch (e) {
293
+ // currentTenantId/currentEntityName tracken den Failing-Hook —
294
+ // Operator sieht "Hook fileRef in Tenant A failed for user X" statt
295
+ // generisches "<sub-transaction>".
296
+ errors.push({
297
+ userId,
298
+ tenantId: currentTenantId ?? ("" as TenantId),
299
+ entityName: currentEntityName ?? "<unknown>",
300
+ message: e instanceof Error ? e.message : String(e),
301
+ });
302
+ }
303
+
304
+ return {
305
+ success: txSucceeded,
306
+ hookCallsAttempted,
307
+ errors,
308
+ userEmailBeforeDelete,
309
+ tenantIdsBeforeDelete,
310
+ };
311
+ }
312
+
313
+ // Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
314
+ // Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
315
+ // tenant-agnostische Hooks (z.B. user) operieren auf der globalen
316
+ // User-Row und ignorieren tenantId.
317
+ const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
318
+
319
+ // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
320
+ // - "anonymize" / "blockDelete" → "anonymize" (Aufbewahrungs-Pflicht
321
+ // blockDelete: Daten muessen physisch bleiben, nur Personen-Bezug raus)
322
+ // - "hardDelete" / "softDelete" / null → "delete" (Default)
323
+ //
324
+ // Eigene Funktion damit Strategie-Drift zwischen retention-strategies
325
+ // und user-data-rights-Hooks an EINER Stelle dokumentiert + getestet
326
+ // werden kann (siehe run-forget-cleanup.test.ts).
327
+ export function policyToStrategy(
328
+ retentionStrategy: "hardDelete" | "softDelete" | "anonymize" | "blockDelete" | null,
329
+ ): UserDataDeleteStrategy {
330
+ if (retentionStrategy === "anonymize" || retentionStrategy === "blockDelete") {
331
+ return "anonymize";
332
+ }
333
+ return "delete";
334
+ }
@@ -0,0 +1,211 @@
1
+ // User-Data-Export-Pipeline (S2.U3) — DSGVO Art. 15 (Auskunft) +
2
+ // Art. 20 (Datenportabilität).
3
+ //
4
+ // Pure Pipeline-Function: ruft alle EXT_USER_DATA-export-Hooks ueber
5
+ // alle Tenant-Memberships eines Users + sammelt das Ergebnis als
6
+ // strukturiertes Bundle.
7
+ //
8
+ // **Async ZIP + Storage (S2.U3-ext) bewusst spaeter:** Diese Foundation
9
+ // gibt das Bundle inline zurueck (JSON-Object). Apps mit grossen
10
+ // File-Mengen brauchen einen Job-Wrap der das Bundle nach S3 / lokalem
11
+ // Storage schreibt + signed-URLs ergibt — kommt drauf wenn ein realer
12
+ // User-Case >10MB Output produziert. Bis dahin reicht inline fuer
13
+ // 99% der Apps (User-Profil + Files-Metadata + Aktivitaeten-History).
14
+ //
15
+ // **Cross-Tenant-Iteration:** Wie beim Forget-Pfad — User-Daten in
16
+ // Tenant A + B kommen in dasselbe Bundle. Plan-Doc:
17
+ // docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
18
+ //
19
+ // **PII-Surface:** Hooks definieren selbst welche Felder ins Bundle
20
+ // landen. user-data-rights-defaults/hooks/user.userdata-hook expose
21
+ // expliziert KEIN passwordHash + KEINE roles (privileged columns).
22
+
23
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
24
+ import {
25
+ EXT_USER_DATA,
26
+ type Registry,
27
+ type TenantId,
28
+ type UserDataExportHook,
29
+ type UserDataExportSnippet,
30
+ } from "@cosmicdrift/kumiko-framework/engine";
31
+ import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
32
+ import { eq } from "drizzle-orm";
33
+ import { tenantMembershipsTable } from "../tenant";
34
+ import { buildFileRefZipPath } from "./zip-path";
35
+
36
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
37
+
38
+ export interface RunUserExportArgs {
39
+ readonly db: DbRunner;
40
+ readonly registry: Registry;
41
+ readonly userId: string;
42
+ readonly now: Instant;
43
+ }
44
+
45
+ export interface UserExportFileRef {
46
+ readonly fileRefId: string;
47
+ readonly storageKey: string;
48
+ readonly fileName: string;
49
+ /** Tenant in dem die Datei haengt — gleicher fileRefId kann nicht ueber Tenants geteilt sein. */
50
+ readonly tenantId: TenantId;
51
+ /**
52
+ * ZIP-internal Pfad unter dem die Datei im Export-ZIP landet. Reader-
53
+ * Tools (Compliance-Audit, Self-Service-Portal) verlinken bundle.json
54
+ * fileRefs[] auf die files/-Pfade ueber dieses Feld. Garantiert
55
+ * path-traversal-frei via sanitizeZipFilename.
56
+ */
57
+ readonly zipPath: string;
58
+ }
59
+
60
+ export interface UserExportTenantSection {
61
+ readonly tenantId: TenantId;
62
+ /** Pro Entity ein Snippet ({entity, rows[]}). Empty wenn Hook null returned. */
63
+ readonly entities: ReadonlyArray<UserDataExportSnippet>;
64
+ }
65
+
66
+ export interface UserExportBundle {
67
+ readonly userId: string;
68
+ /** ISO-8601 Generation-Timestamp — fuer Audit-Trail. */
69
+ readonly generatedAt: string;
70
+ /** Pro Tenant in dem User Mitglied ist eine Section. Orphan-User → tenants=[]. */
71
+ readonly tenants: ReadonlyArray<UserExportTenantSection>;
72
+ /**
73
+ * Flat-Liste aller fileRefs aus allen Tenant-Sections — der spaetere
74
+ * ZIP-Bau-Job iteriert hier durch + zieht Binaries aus dem Storage-
75
+ * Provider. Bis dahin ist das die Stueckliste fuer den Operator.
76
+ */
77
+ readonly fileRefs: ReadonlyArray<UserExportFileRef>;
78
+ }
79
+
80
+ interface HookEntry {
81
+ readonly entityName: string;
82
+ readonly exportHook: UserDataExportHook;
83
+ }
84
+
85
+ /**
86
+ * Pure function: iteriert alle EXT_USER_DATA-Hooks pro Tenant (Cross-
87
+ * Tenant-Memberships) + sammelt Snippets in ein UserExportBundle.
88
+ *
89
+ * **Memory-Footprint:**
90
+ *
91
+ * Der gesamte Storage-Pfad ist streaming-bound:
92
+ * - File-Binaries via provider.readStream → chunk-streaming, skaliert
93
+ * auf beliebige File-Sizes ohne Heap-Spike.
94
+ * - ZIP-Schreiben via provider.writeStream — local nutzt fs.createWriteStream,
95
+ * S3 nutzt lib-storage.Upload (multipart, ~20MB Heap-Bound bei 4
96
+ * concurrent parts).
97
+ *
98
+ * **EINZIGER nicht-streaming Pfad:** das Bundle-Object selbst (bundle.json
99
+ * Inhalt). Es wird komplett in-memory gebaut bevor `bundleToZipEntries`
100
+ * es als ZIP-Entry yieldet. Hooks geben Snippets als Plain-Objects
101
+ * zurueck (siehe UserDataExportSnippet).
102
+ *
103
+ * **Threshold:** Web-App mit ~500 Tabellen-Rows pro User ≈ 500 KB JSON.
104
+ * 50k Rows ≈ 50 MB. 100k+ Rows pro User (z.B. langjaehrige Mietportal-
105
+ * Logs) macht Heap-Druck merkbar.
106
+ *
107
+ * **Wenn das knapp wird:** runUserExport auf AsyncIterable-Form refactoren —
108
+ * Hooks yielden snippets statt Object-Returns; Bundle-Schema bekommt
109
+ * JSON-Lines-Format. bundleToZipEntries wuerde line-by-line streamen.
110
+ * Eigener Sprint, nicht-trivialer Schema-Bruch.
111
+ *
112
+ * **Operator-Signal:** wenn bundle.json im ZIP > 100 MB ist, sollte
113
+ * Telemetry triggern + Schema-Refactor evaluieren.
114
+ */
115
+ export async function runUserExport(args: RunUserExportArgs): Promise<UserExportBundle> {
116
+ const { db, registry, userId, now } = args;
117
+
118
+ // Memberships → Tenant-Liste fuer Hook-Iteration.
119
+ // @cast-boundary db-row.
120
+ const memberships = (await db
121
+ .select({ tenantId: tenantMembershipsTable["tenantId"] })
122
+ .from(tenantMembershipsTable)
123
+ .where(eq(tenantMembershipsTable["userId"], userId))) as Array<{ tenantId: TenantId }>;
124
+
125
+ const tenantList: TenantId[] = memberships.map((m) => m.tenantId);
126
+
127
+ // EXT_USER_DATA-Usages → export-Hook-Liste.
128
+ const usages = registry.getExtensionUsages(EXT_USER_DATA);
129
+ const hookEntries: HookEntry[] = usages
130
+ .map((u): HookEntry | null => {
131
+ const opts = (u.options ?? {}) as { export?: UserDataExportHook };
132
+ return opts.export ? { entityName: u.entityName, exportHook: opts.export } : null;
133
+ })
134
+ .filter((x): x is HookEntry => x !== null);
135
+
136
+ const tenants: UserExportTenantSection[] = [];
137
+ const fileRefs: UserExportFileRef[] = [];
138
+
139
+ for (const tenantId of tenantList) {
140
+ const entities: UserDataExportSnippet[] = [];
141
+ for (const entry of hookEntries) {
142
+ const snippet = await entry.exportHook({ db, tenantId, userId });
143
+ if (snippet === null) continue;
144
+ entities.push(snippet);
145
+ if (snippet.fileRefs) {
146
+ for (const ref of snippet.fileRefs) {
147
+ fileRefs.push({
148
+ ...ref,
149
+ tenantId,
150
+ zipPath: buildFileRefZipPath({
151
+ tenantId,
152
+ fileRefId: ref.fileRefId,
153
+ fileName: ref.fileName,
154
+ }),
155
+ });
156
+ }
157
+ }
158
+ }
159
+ tenants.push({ tenantId, entities });
160
+ }
161
+
162
+ // Edge-Case "0 Memberships": Tenant-agnostische Hooks (z.B. user-Hook)
163
+ // wuerden bei Cross-Tenant-Iteration mehrfach laufen. Bei orphan-User
164
+ // (kein Membership) wuerden sie gar nicht laufen. Loesung wie beim
165
+ // Forget-Runner: einen Sonder-Lauf mit einem Pseudo-Tenant ergaenzen
166
+ // damit die globale User-Row IM Bundle landet. Tenant-scoped Hooks
167
+ // sind no-op.
168
+ //
169
+ // Das Pattern matched run-forget-cleanup.ts; Memory: Cross-Tenant-
170
+ // Konsistenz darf nicht silent kippen wenn Memberships leer sind.
171
+ if (tenantList.length === 0 && hookEntries.length > 0) {
172
+ const orphanEntities: UserDataExportSnippet[] = [];
173
+ for (const entry of hookEntries) {
174
+ const snippet = await entry.exportHook({
175
+ db,
176
+ tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
177
+ userId,
178
+ });
179
+ if (snippet === null) continue;
180
+ orphanEntities.push(snippet);
181
+ if (snippet.fileRefs) {
182
+ for (const ref of snippet.fileRefs) {
183
+ fileRefs.push({
184
+ ...ref,
185
+ tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
186
+ zipPath: buildFileRefZipPath({
187
+ tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
188
+ fileRefId: ref.fileRefId,
189
+ fileName: ref.fileName,
190
+ }),
191
+ });
192
+ }
193
+ }
194
+ }
195
+ if (orphanEntities.length > 0) {
196
+ tenants.push({ tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS, entities: orphanEntities });
197
+ }
198
+ }
199
+
200
+ return {
201
+ userId,
202
+ generatedAt: now.toString(),
203
+ tenants,
204
+ fileRefs,
205
+ };
206
+ }
207
+
208
+ // Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
209
+ // Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
210
+ // Hooks finden hier nichts (no-op).
211
+ const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
@@ -0,0 +1,37 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createTextField,
5
+ createTimestampField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // Audit-Trail invalid Download-Attempts (S2.U7).
9
+ // Schreibt eine Row pro 4xx im download-by-{token,job}-Pfad. DPO erkennt
10
+ // damit Brute-Force / Anomalien (gleiche IP, viele invalid-Versuche).
11
+ // Success-Downloads landen in download-token.lastUsedAt — nicht hier.
12
+ export const downloadAttemptEntity = createEntity({
13
+ table: "read_download_attempts",
14
+ idType: "uuid",
15
+ fields: {
16
+ // notFound | expired | failed | signedUrlNotSupported
17
+ result: createTextField({ required: true, maxLength: 32 }),
18
+ // Welcher Pfad: "token" | "job"
19
+ via: createTextField({ required: true, maxLength: 16 }),
20
+ // Token-Hash (token-Pfad) oder NULL (job-Pfad / unbekannter Token).
21
+ tokenHash: createTextField({ maxLength: 64 }),
22
+ // Job-ID wenn der attempt einen kannte. NULL bei unbekanntem Token.
23
+ jobId: createTextField({}),
24
+ // User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
25
+ attemptedByUserId: createTextField({}),
26
+ ip: createTextField({ maxLength: 64 }),
27
+ userAgent: createTextField({ maxLength: 256 }),
28
+ attemptedAt: createTimestampField({ required: true }),
29
+ },
30
+ // 90d hardDelete: unbounded growth = disk-bomb genau gegen das System
31
+ // das den Brute-Force erkennen soll. Brute-Force-Patterns sind kurzfristig
32
+ // (Stunden bis Tage) — 90d Window deckt forensik-Reviews + DPO-quartal-
33
+ // Audits. Tenant kann via override verlängern (HR-Compliance).
34
+ retention: { keepFor: "90d", strategy: "hardDelete", reference: "attemptedAt" },
35
+ });
36
+
37
+ export const downloadAttemptsTable = buildDrizzleTable("downloadAttempt", downloadAttemptEntity);