@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  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/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. 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; // @cast-boundary db-runner
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
+ } satisfies Readonly<Record<string, number>>;
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
+ } satisfies Readonly<Record<RetentionPresetKey, RetentionPreset>>;
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; // @cast-boundary db-runner
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
+ );
@@ -31,7 +31,7 @@ export function createDeliveryFeature(): FeatureDefinition {
31
31
  table: deliveryAttemptsTable,
32
32
  apply: {
33
33
  [DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
34
- const p = event.payload as z.infer<typeof deliveryAttemptSchema>;
34
+ const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
35
35
  // PK = aggregateId — replaying the same event twice conflicts on
36
36
  // the PK rather than silently duplicating the log row.
37
37
  await tx.insert(deliveryAttemptsTable).values({
@@ -41,7 +41,6 @@ export function createDeliveryTestContext(
41
41
  _notifyFactory:
42
42
  (user: { id: number; tenantId: TenantId }, tenantId: TenantId) =>
43
43
  (notificationType: string, notifyOptions: Record<string, unknown>) =>
44
- // @cast-boundary engine-bridge — generic test-helper → typed entity-specific notify()
45
- deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId),
44
+ deliveryService.notify(notificationType, notifyOptions as never, user as never, tenantId), // @cast-boundary engine-bridge
46
45
  };
47
46
  }
@@ -137,7 +137,7 @@ export async function upsertPreference(
137
137
  // minor driver-version shifts without drifting wide.
138
138
  function isUniqueViolation(err: unknown): boolean {
139
139
  if (typeof err !== "object" || err === null) return false;
140
- const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown };
140
+ const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
141
141
  if (e.code === "23505") return true;
142
142
  if (e.cause && typeof e.cause === "object" && e.cause.code === "23505") return true;
143
143
  if (typeof e.message === "string" && e.message.includes("23505")) return true;
@@ -78,7 +78,7 @@ export function createFeatureTogglesFeature(options: FeatureTogglesOptions): Fea
78
78
  // (validated on append). Shallow-cast to a typed shape rather
79
79
  // than re-parsing — the payload round-trips through JSON and is
80
80
  // fixed at the source.
81
- const payload = event.payload as { featureName: string; enabled: boolean };
81
+ const payload = event.payload as { featureName: string; enabled: boolean }; // @cast-boundary engine-payload
82
82
  options.getRuntime().apply(payload.featureName, payload.enabled);
83
83
  },
84
84
  },
@@ -12,7 +12,7 @@ export const listQuery = defineQueryHandler({
12
12
  access: { roles: ["SystemAdmin", "Admin"] },
13
13
  handler: async (_event, ctx) => {
14
14
  type Row = typeof globalFeatureStateTable.$inferSelect;
15
- const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[];
15
+ const rows = (await ctx.db.select().from(globalFeatureStateTable)) as Row[]; // @cast-boundary db-row
16
16
  return {
17
17
  items: rows.map((r) => ({
18
18
  featureName: r.featureName,
@@ -21,7 +21,7 @@ export const registeredQuery = defineQueryHandler({
21
21
  featureName: globalFeatureStateTable.featureName,
22
22
  enabled: globalFeatureStateTable.enabled,
23
23
  })
24
- .from(globalFeatureStateTable)) as OverrideRow[];
24
+ .from(globalFeatureStateTable)) as OverrideRow[]; // @cast-boundary db-row
25
25
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
26
26
 
27
27
  // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
@@ -31,7 +31,14 @@ export const registeredQuery = defineQueryHandler({
31
31
  // dokumentiert in DispatcherOptions.effectiveFeatures.
32
32
  const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
33
33
 
34
- const items = [];
34
+ const items: Array<{
35
+ name: string;
36
+ toggleable: boolean;
37
+ default: boolean | null;
38
+ override: boolean | null;
39
+ requires: readonly string[];
40
+ effective: boolean | null;
41
+ }> = [];
35
42
  for (const feature of ctx.registry.features.values()) {
36
43
  const toggleable = feature.toggleableDefault !== undefined;
37
44
  const override = overrides.get(feature.name);
@@ -71,7 +71,7 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
71
71
  .select()
72
72
  .from(globalFeatureStateTable)
73
73
  .where(eq(globalFeatureStateTable.featureName, featureName))
74
- .limit(1)) as StateRow[];
74
+ .limit(1)) as StateRow[]; // @cast-boundary db-row
75
75
 
76
76
  const previousEnabled = existing?.enabled ?? null;
77
77
 
@@ -123,11 +123,11 @@ export function createSetWriteHandler(getRuntime: () => GlobalFeatureToggleRunti
123
123
  // and filtering by payload.featureName is trivial at query time.
124
124
  // This mirrors how `config` handles the same constraint for
125
125
  // its config-changed events.
126
- // appendEventUnsafe — bundled-features ohne lokalen Wrapper. Apps
126
+ // unsafeAppendEvent — bundled-features ohne lokalen Wrapper. Apps
127
127
  // mit `yarn kumiko codegen` kriegen `.kumiko/define.ts` als strict-
128
128
  // path; bundled-features bleibt bei der unsafe-Variante. Schema-
129
129
  // Validation läuft trotzdem via r.defineEvent("toggle-set", ...).
130
- await ctx.appendEventUnsafe({
130
+ await ctx.unsafeAppendEvent({
131
131
  aggregateId: SYSTEM_TENANT_ID,
132
132
  aggregateType: FEATURE_TOGGLE_AGGREGATE_TYPE,
133
133
  type: FEATURE_TOGGLE_SET_EVENT_NAME,
@@ -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> {
@@ -97,7 +137,7 @@ export async function createFileProviderForTenant(
97
137
  await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
98
138
  FEATURE_NAME,
99
139
  "provider",
100
- ) as string;
140
+ ) as string; // @cast-boundary engine-payload
101
141
  if (provider.length === 0) {
102
142
  const usages = ctx.registry.getExtensionUsages("fileProvider");
103
143
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
@@ -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";