@cosmicdrift/kumiko-bundled-features 0.2.2 → 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 +31 -0
  2. package/package.json +11 -5
  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,57 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
6
+ import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "../resolver";
7
+ import { tenantRetentionOverrideTable } from "../schema/tenant-retention-override";
8
+
9
+ // retention:query:policy-for — Cross-Feature-API fuer den Forget-Flow.
10
+ //
11
+ // user-data-rights-Sprint-2.U5 ruft das pro Entity um zu wissen ob ein
12
+ // Forget mit "delete" oder "anonymize" oder "blockDelete-bis-Frist"
13
+ // laufen soll. Plus Cleanup-Job-Sprint-2.D2b fuer das gleiche.
14
+ //
15
+ // Tenant-Preset-Storage existiert noch nicht (kommt mit S2.D2b ueber
16
+ // einen tenant-config-key). Bis dahin tenantPreset=null — der Resolver
17
+ // liefert dann nur die Entity-Default-Layer + Override.
18
+ //
19
+ // access: openToAll — andere Features im selben Tenant duerfen das
20
+ // abrufen. Keine PII im Result, nur Policy-Metadata.
21
+ export const policyForQuery = defineQueryHandler({
22
+ name: "policy-for",
23
+ schema: z.object({
24
+ entityName: z.string().min(1).max(100),
25
+ }),
26
+ access: { openToAll: true },
27
+ handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
28
+ const entityName = query.payload.entityName;
29
+
30
+ // Layer 3: Tenant-Override aus DB laden (UNIQUE(tenantId, entityName))
31
+ const overrideRow = (await fetchOne(
32
+ ctx.db,
33
+ tenantRetentionOverrideTable,
34
+ eq(tenantRetentionOverrideTable["tenantId"], query.user.tenantId),
35
+ eq(tenantRetentionOverrideTable["entityName"], entityName),
36
+ )) as { config: string | null } | null;
37
+
38
+ const tenantOverride = parseRetentionOverrideOrNull(
39
+ overrideRow?.config ?? null,
40
+ query.user.tenantId,
41
+ "data-retention:policy-for",
42
+ );
43
+
44
+ // Layer 1: Entity-Default aus Registry
45
+ const entityDef = ctx.registry?.getEntity(entityName) ?? null;
46
+
47
+ // Layer 2: Tenant-Preset — Storage kommt mit S2.D2b. Bis dahin null.
48
+ const tenantPreset = null;
49
+
50
+ return resolveRetentionPolicy({
51
+ entityName,
52
+ entityDef,
53
+ tenantPreset,
54
+ tenantOverride,
55
+ });
56
+ },
57
+ });
@@ -0,0 +1,18 @@
1
+ export type {
2
+ EffectiveRetentionPolicy,
3
+ ResolveForTenantArgs,
4
+ ResolveRetentionPolicyArgs,
5
+ RetentionOverride,
6
+ RetentionPreset,
7
+ RetentionPresetKey,
8
+ } from "./feature";
9
+ export {
10
+ createDataRetentionFeature,
11
+ RETENTION_PRESETS,
12
+ resolveRetentionPolicy,
13
+ resolveRetentionPolicyForTenant,
14
+ retentionOverrideSchema,
15
+ SELECTABLE_RETENTION_PRESETS,
16
+ tenantRetentionOverrideEntity,
17
+ tenantRetentionOverrideTable,
18
+ } from "./feature";
@@ -0,0 +1,75 @@
1
+ // keepFor-Format-Parser: "30d" / "10y" / "6m" / "1w" / "24h" → Cutoff-Instant.
2
+ //
3
+ // Nicht millisekunden-praezise — DSGVO-Aufbewahrungspflichten haben
4
+ // Tag-/Monat-Granularitaet. "6m" = 6×30d, "10y" = 10×365d. Cleanup-Job
5
+ // laeuft taeglich, ein paar Tage Differenz beim Cutoff sind akzeptabel.
6
+ //
7
+ // Boot-Validator (S0.2) hat das Format schon gegen /^\d+[hdwmy]$/
8
+ // gecheckt — hier defensiv nochmal validiert fuer Migration-Edge-Cases.
9
+ //
10
+ // Temporal kommt via globalThis (Polyfill in framework-Boot installiert).
11
+ // `getTemporal()` aus framework/time gibt typed Zugriff.
12
+
13
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
14
+
15
+ const KEEP_FOR_PATTERN = /^(\d+)([hdwmy])$/;
16
+
17
+ const UNIT_TO_DAYS: Readonly<Record<string, number>> = {
18
+ d: 1,
19
+ w: 7,
20
+ m: 30,
21
+ y: 365,
22
+ };
23
+
24
+ export class InvalidKeepForError extends Error {
25
+ constructor(spec: string) {
26
+ super(
27
+ `Invalid keepFor format "${spec}" — expected /^\\d+[hdwmy]$/ (e.g. "30d", "10y", "6m", "1w", "24h")`,
28
+ );
29
+ }
30
+ }
31
+
32
+ // Re-export von Temporal.Instant als Type-Alias damit Caller den Type
33
+ // nicht selbst aus globalThis ziehen muessen.
34
+ export type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
35
+
36
+ /**
37
+ * Berechnet den Cutoff-Instant — Rows mit reference < cutoff sind
38
+ * abgelaufen und werden vom Cleanup-Job geraeumt.
39
+ *
40
+ * @param spec keepFor-String wie "30d", "10y", "6m", "1w", "24h"
41
+ * @param now Aktueller Zeitpunkt (advisor-Pattern: injection-Parameter
42
+ * fuer Time-Travel-Tests, kein global Temporal.Now)
43
+ */
44
+ export function computeCutoff(spec: string, now: Instant): Instant {
45
+ const match = KEEP_FOR_PATTERN.exec(spec);
46
+ if (!match) {
47
+ throw new InvalidKeepForError(spec);
48
+ }
49
+ const amount = Number.parseInt(match[1] ?? "0", 10);
50
+ const unit = match[2] ?? "";
51
+
52
+ if (unit === "h") {
53
+ return now.subtract({ hours: amount });
54
+ }
55
+
56
+ const daysFactor = UNIT_TO_DAYS[unit];
57
+ if (daysFactor === undefined) {
58
+ throw new InvalidKeepForError(spec);
59
+ }
60
+ return now.subtract({ hours: amount * daysFactor * 24 });
61
+ }
62
+
63
+ /**
64
+ * Ist referenceTimestamp aelter als der keepFor-Cutoff bei now?
65
+ * Cleanup-Job nutzt das pro Row.
66
+ */
67
+ export function isPastCutoff(args: {
68
+ readonly referenceTimestamp: Instant;
69
+ readonly keepFor: string;
70
+ readonly now: Instant;
71
+ }): boolean {
72
+ const T = getTemporal();
73
+ const cutoff = computeCutoff(args.keepFor, args.now);
74
+ return T.Instant.compare(args.referenceTimestamp, cutoff) < 0;
75
+ }
@@ -0,0 +1,37 @@
1
+ // Zod-Schema fuer RetentionOverride mit .strict() — Sprint 2.D2.5 (M2+M3).
2
+ //
3
+ // Symmetrisch zu compliance-profiles override-schema.ts (S1.9 Z3).
4
+ // Sub-Sprint-Pattern (advisor-pinned): bei jedem User-konfigurierbarem
5
+ // JSON-Override → strict-Zod + enum-validated.
6
+ //
7
+ // Schuetzt vor:
8
+ // - Top-Level-Tippfehler ("keepfor" statt "keepFor") — strict()-Reject
9
+ // - Strategy-Enum-Drift ("delete" statt "hardDelete") — z.enum-Reject
10
+ // - keepFor-Format-Drift ("30days" statt "30d") — regex-Reject
11
+ //
12
+ // Tenant-Override darf alle drei Properties weglassen (Resolver
13
+ // fallback auf Preset/Entity-Default), aber WAS gesetzt ist muss
14
+ // gueltig sein.
15
+
16
+ import { z } from "zod";
17
+
18
+ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
19
+
20
+ const retentionStrategySchema = z.enum(["hardDelete", "softDelete", "anonymize", "blockDelete"]);
21
+
22
+ /**
23
+ * RetentionOverride-Zod-Schema mit .strict() — fuer (a) set-override-
24
+ * Handler-Validation (S2.D3) und (b) DB-Loader im Cleanup-Job (S2.D2b)
25
+ * der invalides JSON aus der config-Spalte loggt + skipt statt mit
26
+ * undefined behavior weiterzumachen.
27
+ */
28
+ export const retentionOverrideSchema = z
29
+ .object({
30
+ keepFor: z
31
+ .string()
32
+ .regex(KEEP_FOR_PATTERN, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y', '6m')")
33
+ .optional(),
34
+ strategy: retentionStrategySchema.optional(),
35
+ reference: z.string().min(1).optional(),
36
+ })
37
+ .strict();
@@ -0,0 +1,72 @@
1
+ // Retention-Presets — vorgefertigte Bündel von per-Entity-Aufbewahrungs-
2
+ // Konfigurationen pro Compliance-Regime. Tenant-Admin wählt EINES (analog
3
+ // zu compliance-profiles); Entity-Default + Tenant-Override liegen als
4
+ // Schichten 1+3 darüber (siehe resolver.ts).
5
+ //
6
+ // Plan-Roadmap docs/plans/datenschutz/core-data-retention.md hat 8 Presets
7
+ // vorgeschlagen. Sprint-2-MVP: 3 Production-Presets + 1 dev-Default,
8
+ // rest folgt on-demand wenn Customer fragt.
9
+ //
10
+ // Presets sind pure Daten — keine Logik. Erweitern = Constant erweitern,
11
+ // kein Code-Eingriff.
12
+
13
+ import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
14
+
15
+ /**
16
+ * Pro Entity-Name gemappte Retention-Policy. Entity-Namen sind der
17
+ * String aus r.entity("name", ...) — kebab-case oder lowercase.
18
+ * Cleanup-Job iteriert alle bekannten Entities; was nicht im Preset
19
+ * steht, fällt auf Entity-Default (Layer 1) zurück.
20
+ */
21
+ export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
22
+
23
+ export type RetentionPresetKey = "default" | "dsgvo-basic" | "dsgvo-hgb" | "swiss-dsg";
24
+
25
+ /**
26
+ * MVP-Set für Sprint 2. Erweiterungen (hipaa, ccpa, aggressive-gdpr,
27
+ * pipeda-default, ca-quebec-l25) kommen on-demand.
28
+ */
29
+ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPreset>> = {
30
+ // Default — keine Auto-Aktion. Nur sinnvoll für Dev/Staging.
31
+ // Production-Tenant der "default" stehen lässt → Cleanup-Job
32
+ // schreibt Audit-Eintrag "skipped: no preset selected".
33
+ default: {},
34
+
35
+ // DSGVO Basic — Datenminimierung ohne Buchhaltungspflichten.
36
+ "dsgvo-basic": {
37
+ auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
38
+ session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
39
+ httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
40
+ },
41
+
42
+ // DSGVO + HGB — deutsche Aufbewahrungspflichten überlagert. Order
43
+ // wird anonymisiert (PII raus, Geschäftsdaten bleiben), Invoice +
44
+ // Booking sind blockDelete bis 10 Jahre, dann Anonymize.
45
+ "dsgvo-hgb": {
46
+ auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
47
+ session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
48
+ httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
49
+ invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
50
+ booking: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
51
+ contract: { keepFor: "6y", strategy: "blockDelete", reference: "createdAt" },
52
+ order: { keepFor: "6y", strategy: "anonymize", reference: "completedAt" },
53
+ },
54
+
55
+ // Schweizer DSG — ähnlich DSGVO mit OR Art. 958f Aufbewahrung.
56
+ "swiss-dsg": {
57
+ auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
58
+ session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
59
+ invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
60
+ },
61
+ };
62
+
63
+ /**
64
+ * Auswählbare Presets für den Onboarding-Banner. "default" ist Migration-
65
+ * Edge-Case und wird nicht angezeigt — Production-Tenants wählen ein
66
+ * echtes Preset.
67
+ */
68
+ export const SELECTABLE_RETENTION_PRESETS: readonly RetentionPresetKey[] = [
69
+ "dsgvo-basic",
70
+ "dsgvo-hgb",
71
+ "swiss-dsg",
72
+ ];
@@ -0,0 +1,50 @@
1
+ // Direkter Resolver-Helper fuer Bulk-Iteration (S2.U5b Cleanup-Runner).
2
+ //
3
+ // `policy-for` Query (handlers/policy-for.query.ts) ist die Cross-Feature-
4
+ // API fuer einzelne Lookups. Der Cleanup-Runner iteriert N Entities × M
5
+ // Tenants — Handler-Roundtrip pro Lookup waere zu teuer + braucht einen
6
+ // HandlerContext. Beide Pfade nutzen denselben `parseRetentionOverrideOrNull`
7
+ // + `resolveRetentionPolicy`, also kein Drift-Risiko.
8
+
9
+ import { type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
10
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { eq } from "drizzle-orm";
12
+ import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
13
+ import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
14
+ import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
15
+
16
+ export interface ResolveForTenantArgs {
17
+ readonly db: DbRunner;
18
+ readonly registry: Registry;
19
+ readonly tenantId: TenantId;
20
+ readonly entityName: string;
21
+ }
22
+
23
+ export async function resolveRetentionPolicyForTenant(
24
+ args: ResolveForTenantArgs,
25
+ ): Promise<EffectiveRetentionPolicy> {
26
+ const overrideRow = (await fetchOne(
27
+ args.db,
28
+ tenantRetentionOverrideTable,
29
+ eq(tenantRetentionOverrideTable["tenantId"], args.tenantId),
30
+ eq(tenantRetentionOverrideTable["entityName"], args.entityName),
31
+ )) as { config: string | null } | null;
32
+
33
+ const tenantOverride = parseRetentionOverrideOrNull(
34
+ overrideRow?.config ?? null,
35
+ args.tenantId,
36
+ "data-retention:resolve-for-tenant",
37
+ );
38
+
39
+ const entityDef = args.registry.getEntity(args.entityName) ?? null;
40
+
41
+ // Layer 2 (Tenant-Preset) kommt mit S2.D2b. Bis dahin null.
42
+ const tenantPreset = null;
43
+
44
+ return resolveRetentionPolicy({
45
+ entityName: args.entityName,
46
+ entityDef,
47
+ tenantPreset,
48
+ tenantOverride,
49
+ });
50
+ }
@@ -0,0 +1,107 @@
1
+ // 3-Schicht-Retention-Resolver.
2
+ //
3
+ // Layer 1: Entity-Default — Feature-Author setzt r.entity({retention})
4
+ // Layer 2: Tenant-Preset — Tenant-Admin wählt Bundle (RETENTION_PRESETS)
5
+ // Layer 3: Tenant-Override — per (tenantId, entityName) JSON-Override
6
+ //
7
+ // Resolver-Reihenfolge:
8
+ // effective = override(tenantId, entityName)
9
+ // ?? preset[tenant.retentionPreset][entityName]
10
+ // ?? entity.retention (Code-Default aus EntityDefinition)
11
+ // ?? null (keine Auto-Aktion)
12
+ //
13
+ // Cleanup-Job in S2.D2 ruft das pro (tenantId, entityName) und
14
+ // entscheidet was zu tun ist (hardDelete / softDelete / anonymize /
15
+ // blockDelete-mit-Frist-Check).
16
+ //
17
+ // Cross-Feature-API r.exposesApi("retention.policyFor") (S2.D3)
18
+ // macht das aus user-data-rights heraus konsumierbar — Forget-Flow
19
+ // fragt blockDelete-Felder ab + anonymisiert sie statt zu löschen.
20
+
21
+ import type { EntityDefinition, RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { RETENTION_PRESETS, type RetentionPresetKey } from "./presets";
23
+
24
+ /**
25
+ * Roh-Override aus der DB-Tabelle (config-Spalte als JSON-String).
26
+ * Nicht das gleiche wie RetentionDef weil hier alles optional ist —
27
+ * Override darf einzelne Properties überschreiben.
28
+ */
29
+ export interface RetentionOverride {
30
+ readonly keepFor?: string;
31
+ readonly strategy?: RetentionDef["strategy"];
32
+ readonly reference?: string;
33
+ }
34
+
35
+ /**
36
+ * Effektive Policy nach Resolver-Lauf. Source dokumentiert WELCHE
37
+ * Schicht den Wert geliefert hat — Audit-Trail für DPO.
38
+ *
39
+ * `override-incomplete` bedeutet: Tenant-Override ist gesetzt, aber
40
+ * füllt keepFor weder selbst noch via Preset-/Entity-Fallback.
41
+ * Cleanup-Job logt eine Warning + skippt — anstatt mit Default-"0d"
42
+ * sofort alles zu löschen.
43
+ */
44
+ export interface EffectiveRetentionPolicy {
45
+ readonly entityName: string;
46
+ readonly policy: RetentionDef | null;
47
+ readonly source: "override" | "preset" | "entity-default" | "none" | "override-incomplete";
48
+ }
49
+
50
+ export interface ResolveRetentionPolicyArgs {
51
+ readonly entityName: string;
52
+ readonly entityDef: EntityDefinition | null;
53
+ readonly tenantPreset: RetentionPresetKey | null;
54
+ readonly tenantOverride: RetentionOverride | null;
55
+ }
56
+
57
+ /**
58
+ * Auswertung der drei Schichten. Pure Function — kein DB-Access, alle
59
+ * Inputs werden vom Caller besorgt (Cleanup-Job aggregiert pro Tenant
60
+ * vorher).
61
+ */
62
+ export function resolveRetentionPolicy(args: ResolveRetentionPolicyArgs): EffectiveRetentionPolicy {
63
+ const { entityName, entityDef, tenantPreset, tenantOverride } = args;
64
+
65
+ // Layer 3: Override wins, aber Override darf Felder weglassen — dann
66
+ // fallen die einzelnen Properties auf Layer 2/1 zurück.
67
+ if (tenantOverride !== null) {
68
+ const baseFromPreset =
69
+ tenantPreset !== null ? (RETENTION_PRESETS[tenantPreset]?.[entityName] ?? null) : null;
70
+ const baseFromEntity = entityDef?.retention ?? null;
71
+ const base = baseFromPreset ?? baseFromEntity;
72
+
73
+ const keepFor = tenantOverride.keepFor ?? base?.keepFor;
74
+ const strategy = tenantOverride.strategy ?? base?.strategy;
75
+
76
+ // keepFor + strategy sind Pflicht für jede aktive Policy. Wenn
77
+ // weder Override noch Base sie liefert, ist das Override semantisch
78
+ // unvollständig — Cleanup-Job soll WARNEN statt mit Default-"0d"
79
+ // sofort alles löschen. Source-Marker dokumentiert das.
80
+ if (keepFor === undefined || strategy === undefined) {
81
+ return { entityName, policy: null, source: "override-incomplete" };
82
+ }
83
+
84
+ const merged: RetentionDef = {
85
+ keepFor,
86
+ strategy,
87
+ reference: tenantOverride.reference ?? base?.reference,
88
+ };
89
+ return { entityName, policy: merged, source: "override" };
90
+ }
91
+
92
+ // Layer 2: Preset
93
+ if (tenantPreset !== null) {
94
+ const fromPreset = RETENTION_PRESETS[tenantPreset]?.[entityName];
95
+ if (fromPreset) {
96
+ return { entityName, policy: fromPreset, source: "preset" };
97
+ }
98
+ }
99
+
100
+ // Layer 1: Entity-Default
101
+ if (entityDef?.retention) {
102
+ return { entityName, policy: entityDef.retention, source: "entity-default" };
103
+ }
104
+
105
+ // Nichts da — Cleanup-Job überspringt diese Entity für diesen Tenant.
106
+ return { entityName, policy: null, source: "none" };
107
+ }
@@ -0,0 +1,47 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createLongTextField,
5
+ createTextField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // tenantRetentionOverride — Layer 3 im 3-Schicht-Resolver.
9
+ //
10
+ // Per (tenantId, entityName) eine optionale Override-Config die das
11
+ // Preset (Layer 2) für genau diese eine Entity überschreibt. Use-Cases:
12
+ // - Anwaltskanzlei in DE: caseFile 6y blockDelete (nicht im Preset)
13
+ // - Pilot-Tenant der länger speichert für Test
14
+ // - Branchenspezifische verkürzte Fristen
15
+ //
16
+ // reason ist Pflicht — Audit für DPO + Aufsichtsbehörde nachvollziehbar.
17
+ //
18
+ // config ist JSON-String mit `{ keepFor, strategy, reference? }`. Zod-
19
+ // Schema validiert beim set-override-Call (S2.D2 ggf. erweitert).
20
+ //
21
+ // Tenant-1-zu-N: pro Tenant beliebig viele Entity-Overrides. UNIQUE-
22
+ // Index auf (tenantId, entityName) damit pro Entity max ein Override.
23
+ export const tenantRetentionOverrideEntity = createEntity({
24
+ table: "read_tenant_retention_overrides",
25
+ fields: {
26
+ entityName: createTextField({
27
+ required: true,
28
+ maxLength: 100,
29
+ allowPlaintext: "is-business-data",
30
+ }),
31
+ config: createLongTextField({
32
+ required: true,
33
+ allowPlaintext: "is-business-data",
34
+ }),
35
+ reason: createTextField({
36
+ required: true,
37
+ maxLength: 500,
38
+ allowPlaintext: "is-business-data",
39
+ }),
40
+ },
41
+ indexes: [{ unique: true, columns: ["tenantId", "entityName"] }],
42
+ });
43
+
44
+ export const tenantRetentionOverrideTable = buildDrizzleTable(
45
+ "tenantRetentionOverride",
46
+ tenantRetentionOverrideEntity,
47
+ );
@@ -24,9 +24,10 @@
24
24
  import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
25
25
  import {
26
26
  access,
27
+ type ConfigAccessor,
27
28
  createTenantConfig,
28
29
  defineFeature,
29
- type HandlerContext,
30
+ type Registry,
30
31
  } from "@cosmicdrift/kumiko-framework/engine";
31
32
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
32
33
 
@@ -36,13 +37,52 @@ const FEATURE_NAME = "file-foundation";
36
37
  // Plugin-Interface — what a Provider-Plugin must implement
37
38
  // =============================================================================
38
39
 
40
+ /**
41
+ * Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
42
+ * fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
43
+ * read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
44
+ * gebraucht werden.
45
+ *
46
+ * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
47
+ * es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
48
+ * `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
49
+ * brechen — und das wuerde NUR mit S3 und nur in production auffallen.
50
+ * Die schmale Surface zwingt Provider zur expliziten Erweiterung
51
+ * (extra-arg) statt silent ctx-feld-ausnutzen.
52
+ *
53
+ * **Felder:**
54
+ * config — fuer tenant-config-reads (bucket/region/endpoint/...)
55
+ * registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
56
+ * secrets — fuer tenant-secret-reads (s3.secretAccessKey)
57
+ * _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
58
+ * der dispatcher das auf die Caller-User-ID; im Worker-Pfad
59
+ * muss der r.job-Wrap das explizit auf eine System-Identity
60
+ * setzen (z.B. "system:user-data-rights:run-export-jobs").
61
+ */
62
+ export type FileProviderContext = {
63
+ readonly config?: ConfigAccessor;
64
+ readonly registry?: Registry;
65
+ readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
66
+ readonly _userId?: string | undefined;
67
+ };
68
+
39
69
  /**
40
70
  * File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
41
71
  * file-provider-azure-blob, ...) registers an implementation via
42
72
  * `r.useExtension("fileProvider", "<name>", { build })`.
73
+ *
74
+ * **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
75
+ * nicht ein voller HandlerContext. Felder ausserhalb der schmalen
76
+ * Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
77
+ * im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
78
+ * Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
79
+ * fliegt zur Runtime im Worker — und der Crash kommt erst in production
80
+ * mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
81
+ * FileProviderContext sind: lieber FileProviderContext explizit erweitern
82
+ * (sichtbarer breaking change) als ctx-cast.
43
83
  */
44
84
  export type FileProviderPlugin = {
45
- readonly build: (ctx: HandlerContext, tenantId: string) => Promise<FileStorageProvider>;
85
+ readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
46
86
  };
47
87
 
48
88
  // =============================================================================
@@ -77,7 +117,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
77
117
  // =============================================================================
78
118
 
79
119
  export async function createFileProviderForTenant(
80
- ctx: HandlerContext,
120
+ ctx: FileProviderContext,
81
121
  tenantId: string,
82
122
  handlerName = "file-foundation:provider-factory",
83
123
  ): Promise<FileStorageProvider> {
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  createFileProviderForTenant,
5
+ type FileProviderContext,
5
6
  type FileProviderPlugin,
6
7
  fileFoundationFeature,
7
8
  } from "./feature";
@@ -17,8 +17,11 @@
17
17
  // **NICHT für Production.** Buffer ist Process-Memory, geht beim
18
18
  // Restart verloren + wächst monoton mit jedem write.
19
19
 
20
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
21
- import { defineFeature, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type {
21
+ FileProviderContext,
22
+ FileProviderPlugin,
23
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
24
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
22
25
  import {
23
26
  createInMemoryFileProvider,
24
27
  type FileStorageProvider,
@@ -63,7 +66,7 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
63
66
  r.requires("file-foundation");
64
67
 
65
68
  const plugin: FileProviderPlugin = {
66
- build: async (_ctx: HandlerContext, tenantId: string): Promise<FileStorageProvider> => {
69
+ build: async (_ctx: FileProviderContext, tenantId: string): Promise<FileStorageProvider> => {
67
70
  // Returnt den per-tenant Storage. Identitätsstabil zwischen calls
68
71
  // damit accumulated state erhalten bleibt.
69
72
  return getOrCreateProviderForTenant(tenantId);
@@ -18,19 +18,17 @@
18
18
  //
19
19
  // **Boot-Dependencies:** config + secrets + file-foundation.
20
20
 
21
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
21
+ import type {
22
+ FileProviderContext,
23
+ FileProviderPlugin,
24
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
22
25
  import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
23
26
  import {
24
27
  requireDefined,
25
28
  requireNonEmpty,
26
29
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
27
30
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
28
- import {
29
- access,
30
- createTenantConfig,
31
- defineFeature,
32
- type HandlerContext,
33
- } from "@cosmicdrift/kumiko-framework/engine";
31
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
34
32
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
35
33
 
36
34
  const FEATURE_NAME = "file-provider-s3";
@@ -89,7 +87,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
89
87
  // Plugin-Registration. entityName "s3" ist was tenants in
90
88
  // file-foundation's `provider` config-key setzen.
91
89
  const plugin: FileProviderPlugin = {
92
- build: async (ctx: HandlerContext, tenantId: string) => buildS3Provider(ctx, tenantId),
90
+ build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId),
93
91
  };
94
92
  r.useExtension("fileProvider", "s3", plugin);
95
93
 
@@ -104,7 +102,7 @@ export const S3_SECRET_ACCESS_KEY = fileProviderS3Feature.exports.secretAccessKe
104
102
  // =============================================================================
105
103
 
106
104
  async function buildS3Provider(
107
- ctx: HandlerContext,
105
+ ctx: FileProviderContext,
108
106
  tenantId: string,
109
107
  ): Promise<FileStorageProvider> {
110
108
  const ctxConfig = ctx.config;
@@ -157,7 +155,7 @@ async function buildS3Provider(
157
155
  });
158
156
  }
159
157
 
160
- async function readSecretAccessKey(ctx: HandlerContext, tenantId: string): Promise<string> {
158
+ async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
161
159
  const secrets = requireSecretsContext(ctx, FEATURE_NAME);
162
160
  const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
163
161
  if (!branded) {