@cosmicdrift/kumiko-framework 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 (42) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +4 -3
  3. package/src/auth/__tests__/roles.test.ts +24 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/roles.ts +42 -0
  6. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  7. package/src/compliance/__tests__/profiles.test.ts +308 -0
  8. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  9. package/src/compliance/duration-spec.ts +44 -0
  10. package/src/compliance/index.ts +31 -0
  11. package/src/compliance/override-schema.ts +136 -0
  12. package/src/compliance/profiles.ts +427 -0
  13. package/src/compliance/sub-processors.ts +152 -0
  14. package/src/db/__tests__/big-int-field.test.ts +131 -0
  15. package/src/db/table-builder.ts +18 -1
  16. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  18. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  19. package/src/engine/boot-validator.ts +276 -0
  20. package/src/engine/define-feature.ts +39 -0
  21. package/src/engine/extension-names.ts +105 -0
  22. package/src/engine/extensions/user-data.ts +106 -0
  23. package/src/engine/factories.ts +15 -5
  24. package/src/engine/feature-ast/extractors.ts +40 -0
  25. package/src/engine/feature-ast/parse.ts +6 -0
  26. package/src/engine/feature-ast/patterns.ts +22 -0
  27. package/src/engine/feature-ast/render.ts +14 -0
  28. package/src/engine/index.ts +21 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
  30. package/src/engine/pattern-library/library.ts +36 -0
  31. package/src/engine/schema-builder.ts +8 -0
  32. package/src/engine/types/feature.ts +51 -0
  33. package/src/engine/types/fields.ts +134 -10
  34. package/src/engine/types/index.ts +3 -0
  35. package/src/files/__tests__/read-stream.test.ts +105 -0
  36. package/src/files/__tests__/write-stream.test.ts +233 -0
  37. package/src/files/__tests__/zip-stream.test.ts +357 -0
  38. package/src/files/in-memory-provider.ts +38 -0
  39. package/src/files/index.ts +3 -0
  40. package/src/files/local-provider.ts +58 -1
  41. package/src/files/types.ts +34 -6
  42. package/src/files/zip-stream.ts +251 -0
@@ -0,0 +1,44 @@
1
+ // DurationSpec ↔ SQL-Interval ↔ Temporal.Duration converter.
2
+ //
3
+ // Hintergrund: ComplianceProfile.userRights.gracePeriod ist
4
+ // `{ days: number } | { hours: number }` (Discriminated Union). Caller
5
+ // die das in eine Postgres-`interval`-SQL einsetzen brauchen einen
6
+ // einzigen vertrauenswuerdigen Punkt, sonst springt ein
7
+ // `{ hours: 6 }`-Override stillschweigend auf einen days-Default.
8
+ //
9
+ // Single source of truth: hier. Andere Spec-Forms (months/years) im
10
+ // retention-Pfad gehen ueber den breiteren `keep-for`-Parser, sind hier
11
+ // bewusst nicht abgedeckt — das engt den Type ein und macht die SQL-
12
+ // Renderung total.
13
+
14
+ import { getTemporal } from "../time";
15
+ import type { DurationSpec } from "./profiles";
16
+
17
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
18
+
19
+ // Spec in Millisekunden. Source of truth fuer alle Konvertierungen
20
+ // (Instant-add, optional fuer JS-Datumsrechnung).
21
+ export function durationSpecToMs(spec: DurationSpec): number {
22
+ if ("days" in spec) return spec.days * 24 * 60 * 60 * 1000;
23
+ return spec.hours * 60 * 60 * 1000;
24
+ }
25
+
26
+ // Frist berechnen ohne DB-now() — der App-Server-Clock ist
27
+ // authoritative. Fuer Forget-Grace, Token-TTLs und Frist-Setzungen
28
+ // where eine Toleranz von wenigen ms zwischen App und DB irrelevant
29
+ // ist (Grace-Periods >= 6h, Tokens >= Minuten).
30
+ //
31
+ // Schreibt direkt in `instant()`-customType-Spalten — kein interval-
32
+ // SQL-Fragment, kein Codec-Bypass.
33
+ export function addDurationSpec(now: Instant, spec: DurationSpec): Instant {
34
+ return getTemporal().Instant.fromEpochMilliseconds(
35
+ now.epochMilliseconds + durationSpecToMs(spec),
36
+ );
37
+ }
38
+
39
+ // Lesbare Beschreibung fuer Logs / Error-Messages. Nicht i18n —
40
+ // English-only-Operator-Output.
41
+ export function describeDurationSpec(spec: DurationSpec): string {
42
+ if ("days" in spec) return `${spec.days} day${spec.days === 1 ? "" : "s"}`;
43
+ return `${spec.hours} hour${spec.hours === 1 ? "" : "s"}`;
44
+ }
@@ -0,0 +1,31 @@
1
+ // `@cosmicdrift/kumiko-framework/compliance` — Datenschutz/Compliance-
2
+ // Foundation. Wird von Sprint-1+ Features genutzt (compliance-profiles,
3
+ // data-retention, user-data-rights, ...).
4
+
5
+ export {
6
+ addDurationSpec,
7
+ describeDurationSpec,
8
+ durationSpecToMs,
9
+ } from "./duration-spec";
10
+ export { complianceProfileOverrideSchema } from "./override-schema";
11
+ export type {
12
+ AuthorityNotificationDeadline,
13
+ ComplianceProfile,
14
+ ComplianceProfileKey,
15
+ ComplianceProfileOverride,
16
+ DurationSpec,
17
+ EffectiveComplianceProfile,
18
+ UserNotificationRequiredPolicy,
19
+ } from "./profiles";
20
+ export {
21
+ COMPLIANCE_PROFILES,
22
+ OVERRIDABLE_PROFILE_KEYS,
23
+ resolveComplianceProfile,
24
+ SELECTABLE_PROFILE_KEYS,
25
+ } from "./profiles";
26
+ export type { BundleTier, SubProcessor } from "./sub-processors";
27
+ export {
28
+ getActiveSubProcessors,
29
+ getPlannedSubProcessors,
30
+ KUMIKO_SUB_PROCESSORS,
31
+ } from "./sub-processors";
@@ -0,0 +1,136 @@
1
+ // Zod-Schema fuer ComplianceProfileOverride mit .strict() rekursiv —
2
+ // Sprint 1.9 Z3.
3
+ //
4
+ // Vorher: set-profile.write.ts pruefte nur Top-Level-Keys gegen
5
+ // OVERRIDABLE_PROFILE_KEYS. Sub-Level-Tippfehler (z.B.
6
+ // `{ userRights: { weeks: 3 } }` statt `{ userRights: { gracePeriod: { days: 30 } } }`)
7
+ // kamen durch — der deepMerge spliced das nonsense ins effektive
8
+ // Profile, und ein Caller der `userRights.gracePeriod.days` liest
9
+ // crashed mit `undefined`.
10
+ //
11
+ // Lösung: Zod-Schema das die ComplianceProfile-Struktur in DeepPartial-
12
+ // Form abbildet, mit .strict() auf jedem Object damit unbekannte Keys
13
+ // werfen. Single source of truth wie OVERRIDABLE_PROFILE_KEYS, plus
14
+ // Sub-Level-Coverage.
15
+ //
16
+ // Identifikations-Felder (key, region, label, extends) sind NICHT im
17
+ // Schema — wer die overriden will, würde die Profile-Identitaet
18
+ // zerstören.
19
+
20
+ import { z } from "zod";
21
+
22
+ // DurationSpec: { days } | { hours } — strict bedeutet beide Forms
23
+ // muessen exakt 1 property haben (kein "{ days: 30, hours: 1 }").
24
+ const durationSpecSchema = z.union([
25
+ z.object({ days: z.number().int().nonnegative() }).strict(),
26
+ z.object({ hours: z.number().int().nonnegative() }).strict(),
27
+ ]);
28
+
29
+ // retention.* erlaubt zusaetzlich "months" und "years". Wieder eine
30
+ // strikte Disjunktion.
31
+ const auditRetentionSchema = z.union([
32
+ durationSpecSchema,
33
+ z.object({ months: z.number().int().nonnegative() }).strict(),
34
+ z.object({ years: z.number().int().nonnegative() }).strict(),
35
+ ]);
36
+
37
+ const authorityNotificationDeadlineSchema = z.union([
38
+ durationSpecSchema,
39
+ z.literal("as-soon-as-feasible"),
40
+ z.literal("in-most-expedient-time"),
41
+ z.literal("manual"),
42
+ ]);
43
+
44
+ const userNotificationRequiredSchema = z.union([
45
+ z.literal("if-high-risk"),
46
+ z.literal("if-real-risk-of-significant-harm"),
47
+ z.literal("if-serious-risk-of-injury"),
48
+ z.literal("always-if-encrypted-data-or-pii"),
49
+ z.literal("always-without-undue-delay"),
50
+ z.literal("manual"),
51
+ ]);
52
+
53
+ const userRightsOverrideSchema = z
54
+ .object({
55
+ gracePeriod: durationSpecSchema.optional(),
56
+ restrictionAllowed: z.boolean().optional(),
57
+ objectionAllowed: z.boolean().optional(),
58
+ portabilityFormat: z.array(z.string()).optional(),
59
+ auskunftFrist: durationSpecSchema.optional(),
60
+ employeeAccessRight: z.boolean().optional(),
61
+ explicitConsentForAutomatedDecision: z.boolean().optional(),
62
+ doNotSellRequired: z.boolean().optional(),
63
+ // Async-Export-Pipeline (S2.U3+U4) — TTL Compliance-relevant,
64
+ // Stale/Cleanup Operations-Settings.
65
+ exportDownloadTtl: durationSpecSchema.optional(),
66
+ exportStaleTimeoutMinutes: z.number().int().nonnegative().optional(),
67
+ exportStorageCleanupGraceHours: z.number().int().nonnegative().optional(),
68
+ })
69
+ .strict();
70
+
71
+ const notificationsOverrideSchema = z
72
+ .object({
73
+ languages: z.array(z.string()).optional(),
74
+ languageDefault: z.string().optional(),
75
+ mandatoryBreachNotification: z.boolean().optional(),
76
+ })
77
+ .strict();
78
+
79
+ const breachOverrideSchema = z
80
+ .object({
81
+ authorityNotificationDeadline: authorityNotificationDeadlineSchema.optional(),
82
+ authorityContact: z.string().optional(),
83
+ userNotificationRequired: userNotificationRequiredSchema.optional(),
84
+ worksCouncilNotificationRequired: z.boolean().optional(),
85
+ mandatoryRegisterOfBreaches: z.boolean().optional(),
86
+ })
87
+ .strict();
88
+
89
+ const auditLogOverrideSchema = z
90
+ .object({
91
+ retention: auditRetentionSchema.optional(),
92
+ reportFrequency: z
93
+ .union([
94
+ z.literal("quarterly"),
95
+ z.literal("yearly"),
96
+ z.literal("annual-required"),
97
+ z.literal("manual"),
98
+ ])
99
+ .optional(),
100
+ })
101
+ .strict();
102
+
103
+ const subProcessorOverrideSchema = z
104
+ .object({
105
+ consentRequired: z.boolean().optional(),
106
+ changeNotificationLeadDays: z.number().int().nonnegative().optional(),
107
+ mandatoryBaaWithSubProcessors: z.boolean().optional(),
108
+ worksCouncilApprovalRequired: z.boolean().optional(),
109
+ tierFilter: z.array(z.string()).optional(),
110
+ })
111
+ .strict();
112
+
113
+ const forgetDiscoveryOverrideSchema = z
114
+ .object({
115
+ enabled: z.boolean().optional(),
116
+ mode: z.union([z.literal("manual-redact"), z.literal("auto-redact-strict")]).optional(),
117
+ })
118
+ .strict();
119
+
120
+ /**
121
+ * Komplett-Schema fuer ComplianceProfileOverride. Alle Top-Level- UND
122
+ * Sub-Level-Keys sind gewhitelisted via .strict() — Tippfehler werfen
123
+ * sofort. Set-profile-Handler (Sprint 1.9 Z3) validiert das Override
124
+ * gegen dieses Schema vor dem Persist.
125
+ */
126
+ export const complianceProfileOverrideSchema = z
127
+ .object({
128
+ userRights: userRightsOverrideSchema.optional(),
129
+ notifications: notificationsOverrideSchema.optional(),
130
+ breach: breachOverrideSchema.optional(),
131
+ auditLog: auditLogOverrideSchema.optional(),
132
+ subProcessor: subProcessorOverrideSchema.optional(),
133
+ tenantDestroyGracePeriod: durationSpecSchema.optional(),
134
+ forgetDiscovery: forgetDiscoveryOverrideSchema.optional(),
135
+ })
136
+ .strict();
@@ -0,0 +1,427 @@
1
+ // Compliance-Profiles — DSGVO + sektorspezifische Regelwerke.
2
+ //
3
+ // Tenant-Admin waehlt ein Profile beim Onboarding (Pflicht). Profile
4
+ // buendelt User-Rights (Forget-Grace, Restriction, Auskunftsfrist),
5
+ // Notification-Sprache, Breach-Disclosure, Audit-Log-Retention,
6
+ // Sub-Processor-Anforderungen und Tenant-Destroy-Grace.
7
+ //
8
+ // MVP-Set: 3 Profile + 1 Default-Fallback. Erweiterung via `extends`
9
+ // trivial — neue Laender (uk-gdpr, ca-pipeda, us-ccpa, hipaa-healthcare)
10
+ // kommen on-demand wenn Customer fragt.
11
+ //
12
+ // Edge-Case-Decisions (advisor-pinned 2026-05-06):
13
+ //
14
+ // 1. Default-Fallback: `resolveComplianceProfile(undefined)` returnt
15
+ // `minimal-no-region` mit `warning: "no-profile-selected"`. Caller
16
+ // sieht das und kann Onboarding-Banner triggern. Nie still
17
+ // resolven.
18
+ //
19
+ // 2. extends-Chain-Tiefe: nur 1-Level. de-hr-dsgvo-hgb extends
20
+ // eu-dsgvo, aber eu-dsgvo darf NICHT selbst extends haben. Boot-
21
+ // Validator (Sprint 1.3) prueft.
22
+ //
23
+ // 3. Deep-merge-Semantik vom Override: rekursiv. Override
24
+ // `{ userRights: { gracePeriod: { days: 60 } } }` auf eu-dsgvo
25
+ // laesst andere userRights-Felder unveraendert. Top-Level-Replace
26
+ // gibt es NICHT — Override muss strukturell exakt das Pfad-Tree
27
+ // treffen.
28
+ //
29
+ // 4. Required-Field-Override: Override-Type ist `DeepPartial<...>`.
30
+ // TypeScript verhindert null-Drops zur Compile-Time. Runtime-Cast
31
+ // durch Tenant-Admin-Override-Endpoint laeuft durch Zod-Schema
32
+ // mit allen-Properties-optional (kein nullable).
33
+ //
34
+ // Siehe docs/plans/datenschutz/compliance-profiles.md.
35
+
36
+ import type { BundleTier } from "./sub-processors";
37
+
38
+ // --- Profile-Schema ---
39
+
40
+ export type ComplianceProfileKey =
41
+ | "eu-dsgvo"
42
+ | "swiss-dsg"
43
+ | "de-hr-dsgvo-hgb"
44
+ | "minimal-no-region";
45
+
46
+ export type DurationSpec = { readonly days: number } | { readonly hours: number };
47
+
48
+ export type AuthorityNotificationDeadline =
49
+ | DurationSpec
50
+ | "as-soon-as-feasible"
51
+ | "in-most-expedient-time"
52
+ | "manual";
53
+
54
+ export type UserNotificationRequiredPolicy =
55
+ | "if-high-risk"
56
+ | "if-real-risk-of-significant-harm"
57
+ | "if-serious-risk-of-injury"
58
+ | "always-if-encrypted-data-or-pii"
59
+ | "always-without-undue-delay"
60
+ | "manual";
61
+
62
+ export interface ComplianceProfile {
63
+ readonly key: ComplianceProfileKey;
64
+ readonly region: string;
65
+ readonly label: string;
66
+ readonly extends?: ComplianceProfileKey;
67
+
68
+ readonly userRights: {
69
+ readonly gracePeriod: DurationSpec;
70
+ readonly restrictionAllowed: boolean;
71
+ readonly objectionAllowed: boolean;
72
+ readonly portabilityFormat: readonly string[];
73
+ readonly auskunftFrist: DurationSpec;
74
+ readonly employeeAccessRight?: boolean;
75
+ readonly explicitConsentForAutomatedDecision?: boolean;
76
+ readonly doNotSellRequired?: boolean;
77
+ /**
78
+ * DSGVO Art. 15 + 20 Async-Export — Pipeline-Konfiguration. Spec:
79
+ * docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
80
+ */
81
+ readonly exportDownloadTtl: DurationSpec;
82
+ readonly exportStaleTimeoutMinutes: number;
83
+ readonly exportStorageCleanupGraceHours: number;
84
+ };
85
+
86
+ readonly notifications: {
87
+ readonly languages: readonly string[];
88
+ readonly languageDefault?: string;
89
+ readonly mandatoryBreachNotification: boolean;
90
+ };
91
+
92
+ readonly breach: {
93
+ readonly authorityNotificationDeadline: AuthorityNotificationDeadline;
94
+ readonly authorityContact: string;
95
+ readonly userNotificationRequired: UserNotificationRequiredPolicy;
96
+ readonly worksCouncilNotificationRequired?: boolean;
97
+ readonly mandatoryRegisterOfBreaches?: boolean;
98
+ };
99
+
100
+ readonly auditLog: {
101
+ readonly retention: DurationSpec | { readonly months: number } | { readonly years: number };
102
+ readonly reportFrequency: "quarterly" | "yearly" | "annual-required" | "manual";
103
+ };
104
+
105
+ readonly subProcessor: {
106
+ readonly consentRequired: boolean;
107
+ readonly changeNotificationLeadDays: number;
108
+ readonly mandatoryBaaWithSubProcessors?: boolean;
109
+ readonly worksCouncilApprovalRequired?: boolean;
110
+ readonly tierFilter?: readonly BundleTier[];
111
+ };
112
+
113
+ readonly tenantDestroyGracePeriod: DurationSpec;
114
+
115
+ readonly forgetDiscovery?: {
116
+ readonly enabled: boolean;
117
+ readonly mode?: "manual-redact" | "auto-redact-strict";
118
+ };
119
+ }
120
+
121
+ // --- Profile-Definitions (raw, before extends-Resolution) ---
122
+
123
+ const RAW_PROFILES: Readonly<Record<ComplianceProfileKey, ComplianceProfileRaw>> = {
124
+ "eu-dsgvo": {
125
+ key: "eu-dsgvo",
126
+ region: "EU",
127
+ label: "EU — DSGVO Standard",
128
+ userRights: {
129
+ gracePeriod: { days: 30 },
130
+ restrictionAllowed: true,
131
+ objectionAllowed: true,
132
+ portabilityFormat: ["json"],
133
+ auskunftFrist: { days: 30 },
134
+ exportDownloadTtl: { days: 7 },
135
+ exportStaleTimeoutMinutes: 30,
136
+ exportStorageCleanupGraceHours: 24,
137
+ },
138
+ notifications: {
139
+ languages: ["de", "en"],
140
+ mandatoryBreachNotification: true,
141
+ },
142
+ breach: {
143
+ authorityNotificationDeadline: { hours: 72 },
144
+ authorityContact: "BlnBDI Berlin",
145
+ userNotificationRequired: "if-high-risk",
146
+ },
147
+ auditLog: {
148
+ retention: { months: 24 },
149
+ reportFrequency: "quarterly",
150
+ },
151
+ subProcessor: {
152
+ consentRequired: false,
153
+ changeNotificationLeadDays: 30,
154
+ },
155
+ tenantDestroyGracePeriod: { days: 30 },
156
+ forgetDiscovery: { enabled: false },
157
+ },
158
+
159
+ "swiss-dsg": {
160
+ key: "swiss-dsg",
161
+ region: "CH",
162
+ label: "Schweiz — Bundesgesetz über den Datenschutz (rev. 2023)",
163
+ extends: "eu-dsgvo",
164
+ notifications: {
165
+ languages: ["de", "fr", "it", "en"],
166
+ mandatoryBreachNotification: true,
167
+ },
168
+ breach: {
169
+ authorityNotificationDeadline: { hours: 72 },
170
+ authorityContact: "EDÖB Bern",
171
+ userNotificationRequired: "if-high-risk",
172
+ },
173
+ },
174
+
175
+ "de-hr-dsgvo-hgb": {
176
+ key: "de-hr-dsgvo-hgb",
177
+ region: "DE",
178
+ label: "Deutschland HR — DSGVO + HGB + Personalakten",
179
+ extends: "eu-dsgvo",
180
+ userRights: {
181
+ gracePeriod: { days: 30 },
182
+ restrictionAllowed: true,
183
+ objectionAllowed: true,
184
+ portabilityFormat: ["json"],
185
+ auskunftFrist: { days: 30 },
186
+ employeeAccessRight: true,
187
+ exportDownloadTtl: { days: 7 },
188
+ exportStaleTimeoutMinutes: 30,
189
+ exportStorageCleanupGraceHours: 24,
190
+ },
191
+ notifications: {
192
+ languages: ["de"],
193
+ mandatoryBreachNotification: true,
194
+ },
195
+ breach: {
196
+ authorityNotificationDeadline: { hours: 72 },
197
+ authorityContact: "Landes-Datenschutzbehörde",
198
+ userNotificationRequired: "if-high-risk",
199
+ worksCouncilNotificationRequired: true,
200
+ },
201
+ auditLog: {
202
+ retention: { years: 10 },
203
+ reportFrequency: "yearly",
204
+ },
205
+ subProcessor: {
206
+ consentRequired: false,
207
+ changeNotificationLeadDays: 30,
208
+ worksCouncilApprovalRequired: true,
209
+ },
210
+ tenantDestroyGracePeriod: { days: 60 },
211
+ },
212
+
213
+ "minimal-no-region": {
214
+ key: "minimal-no-region",
215
+ region: "—",
216
+ label: "Minimal — kein Compliance-Profile (NICHT für Production)",
217
+ userRights: {
218
+ gracePeriod: { days: 30 },
219
+ restrictionAllowed: false,
220
+ objectionAllowed: false,
221
+ portabilityFormat: ["json"],
222
+ auskunftFrist: { days: 30 },
223
+ exportDownloadTtl: { days: 7 },
224
+ exportStaleTimeoutMinutes: 30,
225
+ exportStorageCleanupGraceHours: 24,
226
+ },
227
+ notifications: {
228
+ languages: ["en"],
229
+ mandatoryBreachNotification: false,
230
+ },
231
+ breach: {
232
+ authorityNotificationDeadline: "manual",
233
+ authorityContact: "",
234
+ userNotificationRequired: "manual",
235
+ },
236
+ auditLog: {
237
+ retention: { months: 3 },
238
+ reportFrequency: "manual",
239
+ },
240
+ subProcessor: {
241
+ consentRequired: false,
242
+ changeNotificationLeadDays: 30,
243
+ },
244
+ tenantDestroyGracePeriod: { days: 30 },
245
+ },
246
+ };
247
+
248
+ // Raw-Profile (vor extends-Resolution) — `extends`-Profile dürfen
249
+ // Required-Felder weglassen, sie kommen vom Base-Profile dazu.
250
+ type ComplianceProfileRaw = Partial<Omit<ComplianceProfile, "key" | "region" | "label">> & {
251
+ readonly key: ComplianceProfileKey;
252
+ readonly region: string;
253
+ readonly label: string;
254
+ readonly extends?: ComplianceProfileKey;
255
+ };
256
+
257
+ // --- Tenant-Auswählbare Liste (ohne minimal-no-region) ---
258
+
259
+ /**
260
+ * Profile-Schluessel die der Tenant-Admin im Onboarding waehlen darf.
261
+ * `minimal-no-region` ist bewusst NICHT in der Liste — es ist der
262
+ * Default-Fallback fuer "noch keine Wahl getroffen", mit sichtbarer
263
+ * Warning. Production-Tenants sollen ein echtes Profile waehlen.
264
+ */
265
+ export const SELECTABLE_PROFILE_KEYS: readonly ComplianceProfileKey[] = [
266
+ "eu-dsgvo",
267
+ "swiss-dsg",
268
+ "de-hr-dsgvo-hgb",
269
+ ];
270
+
271
+ /**
272
+ * Top-Level-Properties des `ComplianceProfile`-Type, die ein Tenant-
273
+ * Override modifizieren darf. Identifikations-Felder (key, region,
274
+ * label, extends) sind ausgeschlossen — wer die overriden wuerde,
275
+ * würde die Profile-Identität zerstoeren.
276
+ *
277
+ * Single source of truth fuer:
278
+ * - set-profile-Handler-Whitelist (Tippfehler-Reject)
279
+ * - Snapshot-Test gegen Profile-Top-Level (Drift-Guard)
280
+ *
281
+ * Wenn ein neues Top-Level-Property zu `ComplianceProfile` kommt
282
+ * (z.B. `dataSubjectRights` oder `crossBorderTransferRules`), MUSS es
283
+ * hier ergaenzt werden — sonst lehnt set-profile valide Overrides ab.
284
+ * Der Snapshot-Test in profiles.test.ts faengt Drift in beide
285
+ * Richtungen.
286
+ */
287
+ export const OVERRIDABLE_PROFILE_KEYS: ReadonlySet<string> = new Set([
288
+ "userRights",
289
+ "notifications",
290
+ "breach",
291
+ "auditLog",
292
+ "subProcessor",
293
+ "tenantDestroyGracePeriod",
294
+ "forgetDiscovery",
295
+ ]);
296
+
297
+ // --- Extends-Resolver (deep-merge) ---
298
+
299
+ type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
300
+
301
+ export type ComplianceProfileOverride = DeepReadonly<DeepPartial<ComplianceProfile>>;
302
+
303
+ type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
304
+
305
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
306
+ return typeof v === "object" && v !== null && !Array.isArray(v);
307
+ }
308
+
309
+ // Pfade die als ganzes Atom ersetzt werden (NICHT rekursiv gemergt).
310
+ // Notwendig fuer Diskriminierte-Union-Types wo das Patch ein Schwester-
311
+ // Property statt einer Override sein kann — z.B. retention von
312
+ // { months: 24 } auf { years: 10 } wuerde sonst zu { months: 24, years: 10 }
313
+ // werden, semantisch nonsense.
314
+ const ATOMIC_PATHS: ReadonlySet<string> = new Set([
315
+ "userRights.gracePeriod",
316
+ "userRights.auskunftFrist",
317
+ "userRights.exportDownloadTtl",
318
+ "tenantDestroyGracePeriod",
319
+ "breach.authorityNotificationDeadline",
320
+ "auditLog.retention",
321
+ ]);
322
+
323
+ function deepMerge<T extends Record<string, unknown>>(
324
+ base: T,
325
+ patch: Record<string, unknown>,
326
+ path = "",
327
+ ): T {
328
+ const out: Record<string, unknown> = { ...base };
329
+ for (const [k, v] of Object.entries(patch)) {
330
+ if (v === undefined) continue;
331
+ const fullPath = path ? `${path}.${k}` : k;
332
+ const existing = out[k];
333
+ if (ATOMIC_PATHS.has(fullPath)) {
334
+ // Atomic — replace komplett statt rekursiv mergen.
335
+ out[k] = v;
336
+ } else if (isPlainObject(existing) && isPlainObject(v)) {
337
+ out[k] = deepMerge(existing, v, fullPath);
338
+ } else {
339
+ out[k] = v;
340
+ }
341
+ }
342
+ return out as T;
343
+ }
344
+
345
+ /**
346
+ * Resolved ein Profile inklusive `extends`-Auflösung. Wirft wenn die
347
+ * extends-Chain tiefer als 1 Level ist (vermeidet Cycles + macht
348
+ * Boot-Validation einfach).
349
+ *
350
+ * Edge-Case (gepinnt): das `extends`-Target darf selbst KEIN extends
351
+ * haben. Wer doch eine Mehrstufen-Hierarchie braucht, definiert die
352
+ * Stufen explizit (z.B. `de-hr-strict extends eu-dsgvo`, statt
353
+ * `de-hr-strict extends de-hr-dsgvo-hgb extends eu-dsgvo`).
354
+ */
355
+ function resolveExtends(key: ComplianceProfileKey): ComplianceProfile {
356
+ const raw = RAW_PROFILES[key];
357
+ if (!raw.extends) {
358
+ return raw as ComplianceProfile;
359
+ }
360
+
361
+ const base = RAW_PROFILES[raw.extends];
362
+ if (base.extends) {
363
+ throw new Error(
364
+ `Compliance-Profile "${key}" extends "${raw.extends}" which itself extends "${base.extends}" — chain depth >1 not supported. Define a flat extends-hierarchy instead.`,
365
+ );
366
+ }
367
+
368
+ return deepMerge(
369
+ base as Record<string, unknown>,
370
+ raw as unknown as Record<string, unknown>,
371
+ ) as unknown as ComplianceProfile;
372
+ }
373
+
374
+ /**
375
+ * Pre-baked Profile-Liste (extends bereits aufgelöst). Beim Modul-Load
376
+ * einmal berechnet — wirft bei Definition-Fehlern (Cycle, missing target)
377
+ * sofort, nicht erst beim ersten Resolver-Call.
378
+ */
379
+ export const COMPLIANCE_PROFILES: Readonly<Record<ComplianceProfileKey, ComplianceProfile>> =
380
+ Object.fromEntries(
381
+ (Object.keys(RAW_PROFILES) as ComplianceProfileKey[]).map((k) => [k, resolveExtends(k)]),
382
+ ) as Readonly<Record<ComplianceProfileKey, ComplianceProfile>>;
383
+
384
+ // --- Effective-Profile-Resolver ---
385
+
386
+ export interface EffectiveComplianceProfile {
387
+ readonly profile: ComplianceProfile;
388
+ readonly warning?: "no-profile-selected";
389
+ }
390
+
391
+ /**
392
+ * Liefert das effektive Compliance-Profile fuer einen Tenant inklusive
393
+ * Tenant-Override.
394
+ *
395
+ * Edge-Case-Verhalten (gepinnt):
396
+ * - selection=undefined → minimal-no-region + warning="no-profile-selected"
397
+ * - selection=valid + override=undefined → effective profile, kein warning
398
+ * - selection=valid + override → deep-merged effective, kein warning
399
+ *
400
+ * Production-Marker: das frueher hier vorgesehene "minimal-in-production"-
401
+ * warning ist entfallen weil set-profile (Sprint 1.7 X1) minimal-no-region
402
+ * nicht mehr als Tenant-Wahl akzeptiert. Wer Production-spezifisches
403
+ * Verhalten braucht (z.B. Block-bei-Default), addiert den Marker spaeter
404
+ * bei Bedarf.
405
+ */
406
+ export function resolveComplianceProfile(args: {
407
+ readonly selection?: ComplianceProfileKey;
408
+ readonly override?: ComplianceProfileOverride;
409
+ }): EffectiveComplianceProfile {
410
+ if (!args.selection) {
411
+ return {
412
+ profile: COMPLIANCE_PROFILES["minimal-no-region"],
413
+ warning: "no-profile-selected",
414
+ };
415
+ }
416
+
417
+ const base = COMPLIANCE_PROFILES[args.selection];
418
+ if (!args.override) {
419
+ return { profile: base };
420
+ }
421
+
422
+ const merged = deepMerge(
423
+ base as unknown as Record<string, unknown>,
424
+ args.override as Record<string, unknown>,
425
+ ) as unknown as ComplianceProfile;
426
+ return { profile: merged };
427
+ }