@cosmicdrift/kumiko-framework 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 (42) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/package.json +3 -2
  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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.2.3
4
+
3
5
  ## 0.2.2
4
6
 
5
7
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,6 +28,7 @@
28
28
  "runtime": "runtime"
29
29
  },
30
30
  "exports": {
31
+ "./compliance": "./src/compliance/index.ts",
31
32
  "./engine": "./src/engine/index.ts",
32
33
  "./engine/types": "./src/engine/types/index.ts",
33
34
  "./errors": "./src/errors/index.ts",
@@ -73,7 +74,7 @@
73
74
  "zod": "^4.3.6"
74
75
  },
75
76
  "devDependencies": {
76
- "@cosmicdrift/kumiko-dispatcher-live": "0.2.2",
77
+ "@cosmicdrift/kumiko-dispatcher-live": "0.2.3",
77
78
  "@types/uuid": "^11.0.0",
78
79
  "bun-types": "^1.2.9",
79
80
  "drizzle-kit": "^0.31.0",
@@ -0,0 +1,24 @@
1
+ // Snapshot-Tests fuer ROLES — faengt stille Drift ab.
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { ROLES } from "../roles";
5
+
6
+ describe("ROLES constants", () => {
7
+ test("Snapshot — explizit zu updaten bei Aenderungen", () => {
8
+ expect(ROLES).toMatchInlineSnapshot(`
9
+ {
10
+ "DataProtectionOfficer": "DataProtectionOfficer",
11
+ "Member": "Member",
12
+ "SystemAdmin": "SystemAdmin",
13
+ "TenantAdmin": "TenantAdmin",
14
+ "TenantOwner": "TenantOwner",
15
+ }
16
+ `);
17
+ });
18
+
19
+ test("ROLES-Werte sind identisch zu den Keys (keine Drift im Mapping)", () => {
20
+ for (const [key, value] of Object.entries(ROLES)) {
21
+ expect(value).toBe(key);
22
+ }
23
+ });
24
+ });
@@ -0,0 +1,7 @@
1
+ // `@cosmicdrift/kumiko-framework/auth` — Auth-Foundation (Roles, Token-
2
+ // Helpers, Session-Types). Wird von Bundled-Features (auth-email-password,
3
+ // tenant, user) und Datenschutz-Sprints (S1+ user-data-rights, S5 tenant-
4
+ // lifecycle, S6 legal-hold) genutzt.
5
+
6
+ export type { Role } from "./roles";
7
+ export { ROLES } from "./roles";
@@ -0,0 +1,42 @@
1
+ // Zentrale Role-Konstanten der Plattform.
2
+ //
3
+ // Hintergrund: Bundled-Features driften zwischen "Admin" (text-content,
4
+ // secrets, ai-foundation, file-provider-s3) und "TenantAdmin" (tenant-
5
+ // handler, publicstatus, platform). App-Builder fallen in diese Falle.
6
+ // Diese Datei ist die Single Source of Truth — Bundled-Features
7
+ // migrieren schrittweise auf die ROLES-Constants in den Sprint-
8
+ // Touchpoints, an denen sie ohnehin angefasst werden.
9
+ //
10
+ // Canonical-Names:
11
+ // TenantOwner — Volle Tenant-Hoheit, einzige Rolle die
12
+ // Tenant-Destroy triggern darf.
13
+ // TenantAdmin — Tenant-Konfiguration + User-Management.
14
+ // Bestehender String "Admin" wird hierauf
15
+ // migriert.
16
+ // DataProtectionOfficer — DPO; setzt Legal-Holds, sieht Authority-
17
+ // Audit-Stream auch im silentMode (Sprint 6).
18
+ // SystemAdmin — Plattform-Operator (NICHT Tenant-scoped).
19
+ // Authority-Export, KMS-Recovery, Cross-
20
+ // Tenant-Setup. Matched den bestehenden
21
+ // "SystemAdmin"-String in text-content,
22
+ // secrets, auth-email-password, etc.
23
+ // Member — Standard-Mitglied eines Tenants ohne
24
+ // Admin-Rechte.
25
+
26
+ /**
27
+ * Plattform-weit standardisierte Role-Namen. Alle Datenschutz-Sprints
28
+ * (1+) nutzen ausschliesslich diese Constants statt String-Literale.
29
+ */
30
+ export const ROLES = {
31
+ TenantOwner: "TenantOwner",
32
+ TenantAdmin: "TenantAdmin",
33
+ DataProtectionOfficer: "DataProtectionOfficer",
34
+ SystemAdmin: "SystemAdmin",
35
+ Member: "Member",
36
+ } as const;
37
+
38
+ /**
39
+ * Type-Union aller bekannten Role-Namen (Compile-Time-Check fuer
40
+ * Handler-Access-Rules + Ownership-Maps).
41
+ */
42
+ export type Role = (typeof ROLES)[keyof typeof ROLES];
@@ -0,0 +1,72 @@
1
+ // Unit-Tests fuer DurationSpec-Helpers (S2.U5a.fix2).
2
+ //
3
+ // Pinst beide Discriminated-Union-Branches (`{days}` + `{hours}`) plus
4
+ // Edge-Cases die in den Integration-Tests nicht auftauchen (0-Werte,
5
+ // Singular/Plural). Der Bug aus dem U5a-Review (`{hours: 6}` fiel auf
6
+ // 30d-Default) wird hier zentral verhindert.
7
+
8
+ import { ensureTemporalPolyfill, getTemporal } from "@cosmicdrift/kumiko-framework/time";
9
+ import { beforeAll, describe, expect, test } from "vitest";
10
+ import { addDurationSpec, describeDurationSpec, durationSpecToMs } from "../duration-spec";
11
+
12
+ beforeAll(async () => {
13
+ await ensureTemporalPolyfill();
14
+ });
15
+
16
+ describe("durationSpecToMs", () => {
17
+ test("days → days * 86_400_000", () => {
18
+ expect(durationSpecToMs({ days: 30 })).toBe(30 * 24 * 60 * 60 * 1000);
19
+ expect(durationSpecToMs({ days: 1 })).toBe(24 * 60 * 60 * 1000);
20
+ });
21
+
22
+ test("hours → hours * 3_600_000", () => {
23
+ expect(durationSpecToMs({ hours: 6 })).toBe(6 * 60 * 60 * 1000);
24
+ expect(durationSpecToMs({ hours: 72 })).toBe(72 * 60 * 60 * 1000);
25
+ });
26
+
27
+ test("0-Werte ergeben 0", () => {
28
+ expect(durationSpecToMs({ days: 0 })).toBe(0);
29
+ expect(durationSpecToMs({ hours: 0 })).toBe(0);
30
+ });
31
+ });
32
+
33
+ describe("addDurationSpec", () => {
34
+ test("days addiert exakt zu Instant.epochMilliseconds", () => {
35
+ const T = getTemporal();
36
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
37
+ const t1 = addDurationSpec(t0, { days: 30 });
38
+ expect(t1.epochMilliseconds - t0.epochMilliseconds).toBe(30 * 24 * 60 * 60 * 1000);
39
+ });
40
+
41
+ test("hours addiert exakt zu Instant.epochMilliseconds", () => {
42
+ const T = getTemporal();
43
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
44
+ const t1 = addDurationSpec(t0, { hours: 6 });
45
+ expect(t1.epochMilliseconds - t0.epochMilliseconds).toBe(6 * 60 * 60 * 1000);
46
+ });
47
+
48
+ // Regression-Guard fuer den U5a-Bug: vorher fiel `{hours: 6}` auf
49
+ // `30 * 86_400_000`-Default zurueck. Wenn jemand den Branch wieder
50
+ // verliert, faellt dieser Test sofort um.
51
+ test("hours-Branch ist NICHT auf days-Default mappable (U5a-Regression)", () => {
52
+ const T = getTemporal();
53
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
54
+ const tHours = addDurationSpec(t0, { hours: 6 });
55
+ const tDaysDefault = addDurationSpec(t0, { days: 30 });
56
+ expect(tHours.epochMilliseconds).not.toBe(tDaysDefault.epochMilliseconds);
57
+ });
58
+ });
59
+
60
+ describe("describeDurationSpec", () => {
61
+ test("days mit Pluralisierung", () => {
62
+ expect(describeDurationSpec({ days: 30 })).toBe("30 days");
63
+ expect(describeDurationSpec({ days: 1 })).toBe("1 day");
64
+ expect(describeDurationSpec({ days: 0 })).toBe("0 days");
65
+ });
66
+
67
+ test("hours mit Pluralisierung", () => {
68
+ expect(describeDurationSpec({ hours: 72 })).toBe("72 hours");
69
+ expect(describeDurationSpec({ hours: 1 })).toBe("1 hour");
70
+ expect(describeDurationSpec({ hours: 0 })).toBe("0 hours");
71
+ });
72
+ });
@@ -0,0 +1,308 @@
1
+ // Tests fuer Compliance-Profile-Constants + extends-Resolver.
2
+ //
3
+ // Edge-Cases (advisor-pinned 2026-05-06):
4
+ // 1. Default-Fallback bei selection=undefined → minimal-no-region + warning
5
+ // 2. extends-Chain max 1 Level (Cycle-/Tiefe-Schutz)
6
+ // 3. Deep-merge-Semantik beim Override (rekursiv, Top-Level-Replace nicht)
7
+ // 4. Override darf einzelne Felder gezielt setzen ohne Required-Drops
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { complianceProfileOverrideSchema } from "../override-schema";
11
+ import {
12
+ COMPLIANCE_PROFILES,
13
+ OVERRIDABLE_PROFILE_KEYS,
14
+ resolveComplianceProfile,
15
+ SELECTABLE_PROFILE_KEYS,
16
+ } from "../profiles";
17
+
18
+ // Identifikations-Keys die ein Override NICHT modifizieren darf — sie
19
+ // definieren die Profile-Identität. Werden vom Drift-Test ausgeschlossen.
20
+ const IDENTIFICATION_KEYS = new Set(["key", "region", "label", "extends"]);
21
+
22
+ describe("COMPLIANCE_PROFILES — Pre-baked", () => {
23
+ test("eu-dsgvo hat alle Required-Felder", () => {
24
+ const p = COMPLIANCE_PROFILES["eu-dsgvo"];
25
+ expect(p.key).toBe("eu-dsgvo");
26
+ expect(p.region).toBe("EU");
27
+ expect(p.userRights.gracePeriod).toEqual({ days: 30 });
28
+ expect(p.notifications.languages).toContain("de");
29
+ expect(p.breach.authorityContact).toBe("BlnBDI Berlin");
30
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 30 });
31
+ });
32
+
33
+ test("swiss-dsg extends eu-dsgvo — übernimmt Base-Felder", () => {
34
+ const p = COMPLIANCE_PROFILES["swiss-dsg"];
35
+ expect(p.key).toBe("swiss-dsg");
36
+ expect(p.region).toBe("CH");
37
+ // Override: andere Sprachen + andere Aufsicht
38
+ expect(p.notifications.languages).toEqual(["de", "fr", "it", "en"]);
39
+ expect(p.breach.authorityContact).toBe("EDÖB Bern");
40
+ // Geerbt von eu-dsgvo
41
+ expect(p.userRights.gracePeriod).toEqual({ days: 30 });
42
+ expect(p.userRights.restrictionAllowed).toBe(true);
43
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 30 });
44
+ });
45
+
46
+ test("de-hr-dsgvo-hgb extends eu-dsgvo — HR-Override greift, Base bleibt", () => {
47
+ const p = COMPLIANCE_PROFILES["de-hr-dsgvo-hgb"];
48
+ expect(p.region).toBe("DE");
49
+ expect(p.userRights.employeeAccessRight).toBe(true);
50
+ expect(p.breach.worksCouncilNotificationRequired).toBe(true);
51
+ expect(p.subProcessor.worksCouncilApprovalRequired).toBe(true);
52
+ expect(p.auditLog.retention).toEqual({ years: 10 });
53
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 60 });
54
+ // Geerbt: portabilityFormat aus eu-dsgvo bleibt
55
+ expect(p.userRights.portabilityFormat).toEqual(["json"]);
56
+ });
57
+
58
+ test("minimal-no-region ist eigenständig (kein extends)", () => {
59
+ const p = COMPLIANCE_PROFILES["minimal-no-region"];
60
+ expect(p.region).toBe("—");
61
+ expect(p.notifications.mandatoryBreachNotification).toBe(false);
62
+ expect(p.breach.authorityNotificationDeadline).toBe("manual");
63
+ });
64
+ });
65
+
66
+ describe("SELECTABLE_PROFILE_KEYS", () => {
67
+ test('enthält 3 Profile, ohne "minimal-no-region" (kein Production-Default)', () => {
68
+ expect(SELECTABLE_PROFILE_KEYS).toEqual(["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb"]);
69
+ expect(SELECTABLE_PROFILE_KEYS).not.toContain("minimal-no-region");
70
+ });
71
+ });
72
+
73
+ describe("OVERRIDABLE_PROFILE_KEYS — Drift-Guard (S1.8 N2)", () => {
74
+ // Wenn ein neues Top-Level-Property zu ComplianceProfile kommt
75
+ // (oder eines wegfaellt), muss OVERRIDABLE_PROFILE_KEYS synchron
76
+ // gehalten werden — sonst lehnt set-profile valide Overrides ab
77
+ // ODER akzeptiert ungueltige. Dieser Test detektiert den Drift in
78
+ // beide Richtungen anhand des voll aufgeloesten eu-dsgvo-Profile.
79
+ test("Override-Whitelist matched die uebrigen Top-Level-Keys von eu-dsgvo (minus Identifikations-Felder)", () => {
80
+ const allTopLevelKeys = new Set(Object.keys(COMPLIANCE_PROFILES["eu-dsgvo"]));
81
+ for (const idKey of IDENTIFICATION_KEYS) {
82
+ allTopLevelKeys.delete(idKey);
83
+ }
84
+ const expected = [...allTopLevelKeys].sort();
85
+ const actual = [...OVERRIDABLE_PROFILE_KEYS].sort();
86
+ expect(actual).toEqual(expected);
87
+ });
88
+
89
+ test("Identifikations-Keys (key, region, label, extends) sind NICHT in OVERRIDABLE_PROFILE_KEYS", () => {
90
+ for (const idKey of IDENTIFICATION_KEYS) {
91
+ expect(OVERRIDABLE_PROFILE_KEYS.has(idKey)).toBe(false);
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("complianceProfileOverrideSchema — Schema↔Profile-Konsistenz (S1.10 M2)", () => {
97
+ // Wenn Profile-Type erweitert wird (neues Sub-Property in userRights /
98
+ // breach / auditLog / etc.) ohne dass override-schema.ts mitgepflegt
99
+ // wird, fail dieser Test. Prinzip: ein voll aufgelöstes Profile ist
100
+ // ein triviales valides Override-Object — wenn Schema das ablehnt, ist
101
+ // entweder Schema oder Profile out of sync.
102
+ test("Schema akzeptiert ein vollständig aufgelöstes Profile als Override (Drift-Guard)", () => {
103
+ // Identifikations-Felder rausnehmen weil die nicht override-bar sind.
104
+ const fullProfile = COMPLIANCE_PROFILES["eu-dsgvo"];
105
+ const {
106
+ key: _key,
107
+ region: _region,
108
+ label: _label,
109
+ extends: _extends,
110
+ ...overridable
111
+ } = fullProfile;
112
+
113
+ const result = complianceProfileOverrideSchema.safeParse(overridable);
114
+ if (!result.success) {
115
+ throw new Error(
116
+ `Schema↔Profile-Drift: vollständig aufgelöstes eu-dsgvo wird vom Override-Schema abgelehnt. ` +
117
+ `Issue at "${result.error.issues[0]?.path.join(".")}": ${result.error.issues[0]?.message}. ` +
118
+ "Entweder ComplianceProfile-Type erweitert ohne Schema-Update, oder Schema strikter als Profile-Type.",
119
+ );
120
+ }
121
+ expect(result.success).toBe(true);
122
+ });
123
+
124
+ test("Schema akzeptiert auch swiss-dsg + de-hr-dsgvo-hgb (extends-Profile mit Override-Feldern)", () => {
125
+ for (const key of ["swiss-dsg", "de-hr-dsgvo-hgb"] as const) {
126
+ const fullProfile = COMPLIANCE_PROFILES[key];
127
+ const {
128
+ key: _key,
129
+ region: _region,
130
+ label: _label,
131
+ extends: _extends,
132
+ ...overridable
133
+ } = fullProfile;
134
+ const result = complianceProfileOverrideSchema.safeParse(overridable);
135
+ expect(result.success, `Schema lehnt voll aufgelöstes ${key} ab`).toBe(true);
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("resolveComplianceProfile — Default-Fallback", () => {
141
+ test("selection=undefined → minimal-no-region + warning=no-profile-selected", () => {
142
+ const result = resolveComplianceProfile({});
143
+ expect(result.profile.key).toBe("minimal-no-region");
144
+ expect(result.warning).toBe("no-profile-selected");
145
+ });
146
+
147
+ test("selection=minimal-no-region (DB-Edge-Case) → kein warning, fallback aktiv", () => {
148
+ // Sprint 1.7 X1: minimal-no-region ist ueber set-profile nicht mehr
149
+ // setzbar. Wer den State trotzdem in der DB hat (Migration, Direct-
150
+ // Insert) bekommt das minimal-Profile zurueck — der needs-profile-
151
+ // Banner-Endpoint markiert ihn separat als "needsSelection=true".
152
+ // Resolver selber zieht keine warning, weil "explicit selection".
153
+ const result = resolveComplianceProfile({ selection: "minimal-no-region" });
154
+ expect(result.profile.key).toBe("minimal-no-region");
155
+ expect(result.warning).toBeUndefined();
156
+ });
157
+
158
+ test("selection=eu-dsgvo + kein override → keine warning", () => {
159
+ const result = resolveComplianceProfile({ selection: "eu-dsgvo" });
160
+ expect(result.profile.key).toBe("eu-dsgvo");
161
+ expect(result.warning).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe("resolveComplianceProfile — Override deep-merge", () => {
166
+ test("Override gracePeriod.days überschreibt rekursiv, andere userRights bleiben", () => {
167
+ const result = resolveComplianceProfile({
168
+ selection: "eu-dsgvo",
169
+ override: {
170
+ userRights: { gracePeriod: { days: 60 } },
171
+ },
172
+ });
173
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 60 });
174
+ // Andere userRights-Felder unverändert
175
+ expect(result.profile.userRights.restrictionAllowed).toBe(true);
176
+ expect(result.profile.userRights.objectionAllowed).toBe(true);
177
+ expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
178
+ expect(result.profile.userRights.auskunftFrist).toEqual({ days: 30 });
179
+ });
180
+
181
+ test("Override breach.authorityContact ändert nur diesen einen Pfad", () => {
182
+ const result = resolveComplianceProfile({
183
+ selection: "eu-dsgvo",
184
+ override: {
185
+ breach: { authorityContact: "Hamburgischer DSB" },
186
+ },
187
+ });
188
+ expect(result.profile.breach.authorityContact).toBe("Hamburgischer DSB");
189
+ // Rest des breach-Objects unverändert
190
+ expect(result.profile.breach.authorityNotificationDeadline).toEqual({ hours: 72 });
191
+ expect(result.profile.breach.userNotificationRequired).toBe("if-high-risk");
192
+ });
193
+
194
+ test("Override mehrerer Top-Level-Pfade gleichzeitig", () => {
195
+ const result = resolveComplianceProfile({
196
+ selection: "swiss-dsg",
197
+ override: {
198
+ userRights: { gracePeriod: { days: 45 } },
199
+ tenantDestroyGracePeriod: { days: 90 },
200
+ },
201
+ });
202
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 45 });
203
+ expect(result.profile.tenantDestroyGracePeriod).toEqual({ days: 90 });
204
+ // swiss-dsg extends eu-dsgvo war drin
205
+ expect(result.profile.region).toBe("CH");
206
+ expect(result.profile.breach.authorityContact).toBe("EDÖB Bern");
207
+ });
208
+
209
+ test("Atomic path: retention {months} → {years} ersetzt komplett (kein object-merge)", () => {
210
+ // Bug-Regression: deepMerge auf Diskriminierten-Union-Objects
211
+ // wuerde sonst { months: 24, years: 10 } produzieren — semantisch
212
+ // Nonsense, retention ist EINE Wahl.
213
+ const result = resolveComplianceProfile({
214
+ selection: "eu-dsgvo",
215
+ override: {
216
+ auditLog: { retention: { years: 10 } },
217
+ },
218
+ });
219
+ expect(result.profile.auditLog.retention).toEqual({ years: 10 });
220
+ // reportFrequency darf NICHT mit ersetzt werden — auditLog ist
221
+ // nicht atomic, nur retention darunter.
222
+ expect(result.profile.auditLog.reportFrequency).toBe("quarterly");
223
+ });
224
+
225
+ test("Atomic path: gracePeriod {days} → {hours} ersetzt komplett", () => {
226
+ const result = resolveComplianceProfile({
227
+ selection: "eu-dsgvo",
228
+ override: {
229
+ userRights: { gracePeriod: { hours: 24 } },
230
+ },
231
+ });
232
+ expect(result.profile.userRights.gracePeriod).toEqual({ hours: 24 });
233
+ expect(result.profile.userRights.gracePeriod).not.toHaveProperty("days");
234
+ });
235
+
236
+ test("Array-Property im Override ersetzt komplett (Top-Level-Replace, kein concat)", () => {
237
+ const result = resolveComplianceProfile({
238
+ selection: "eu-dsgvo",
239
+ override: {
240
+ notifications: { languages: ["en"] },
241
+ },
242
+ });
243
+ // Replace, nicht append: ["de", "en"] wird zu ["en"]
244
+ expect(result.profile.notifications.languages).toEqual(["en"]);
245
+ // Andere notifications-Felder bleiben
246
+ expect(result.profile.notifications.mandatoryBreachNotification).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe("Profile-Definition-Snapshots — fängt Drift ab", () => {
251
+ test("eu-dsgvo voll-resolved", () => {
252
+ expect(COMPLIANCE_PROFILES["eu-dsgvo"]).toMatchInlineSnapshot(`
253
+ {
254
+ "auditLog": {
255
+ "reportFrequency": "quarterly",
256
+ "retention": {
257
+ "months": 24,
258
+ },
259
+ },
260
+ "breach": {
261
+ "authorityContact": "BlnBDI Berlin",
262
+ "authorityNotificationDeadline": {
263
+ "hours": 72,
264
+ },
265
+ "userNotificationRequired": "if-high-risk",
266
+ },
267
+ "forgetDiscovery": {
268
+ "enabled": false,
269
+ },
270
+ "key": "eu-dsgvo",
271
+ "label": "EU — DSGVO Standard",
272
+ "notifications": {
273
+ "languages": [
274
+ "de",
275
+ "en",
276
+ ],
277
+ "mandatoryBreachNotification": true,
278
+ },
279
+ "region": "EU",
280
+ "subProcessor": {
281
+ "changeNotificationLeadDays": 30,
282
+ "consentRequired": false,
283
+ },
284
+ "tenantDestroyGracePeriod": {
285
+ "days": 30,
286
+ },
287
+ "userRights": {
288
+ "auskunftFrist": {
289
+ "days": 30,
290
+ },
291
+ "exportDownloadTtl": {
292
+ "days": 7,
293
+ },
294
+ "exportStaleTimeoutMinutes": 30,
295
+ "exportStorageCleanupGraceHours": 24,
296
+ "gracePeriod": {
297
+ "days": 30,
298
+ },
299
+ "objectionAllowed": true,
300
+ "portabilityFormat": [
301
+ "json",
302
+ ],
303
+ "restrictionAllowed": true,
304
+ },
305
+ }
306
+ `);
307
+ });
308
+ });
@@ -0,0 +1,139 @@
1
+ // Snapshot-Tests fuer KUMIKO_SUB_PROCESSORS. Fangen still-Drift ab —
2
+ // jede Aenderung an der Liste muss bewusst durch ein Snapshot-Update
3
+ // gehen, damit niemand versehentlich einen Sub-Processor entfernt
4
+ // oder hinzufuegt ohne dass die DPA/Tenant-Notification-Pipeline das
5
+ // mitbekommt.
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import {
9
+ getActiveSubProcessors,
10
+ getPlannedSubProcessors,
11
+ KUMIKO_SUB_PROCESSORS,
12
+ } from "../sub-processors";
13
+
14
+ describe("KUMIKO_SUB_PROCESSORS", () => {
15
+ test("Liste ist nicht leer (Plattform hat mindestens Hetzner+Cloudflare)", () => {
16
+ expect(KUMIKO_SUB_PROCESSORS.length).toBeGreaterThan(0);
17
+ });
18
+
19
+ test("Alle Eintraege haben Pflicht-Felder (name, purpose, region, dpa, addedAt, appliesTo)", () => {
20
+ for (const sp of KUMIKO_SUB_PROCESSORS) {
21
+ expect(sp.name).toBeTruthy();
22
+ expect(sp.purpose).toBeTruthy();
23
+ expect(sp.region).toBeTruthy();
24
+ expect(sp.dpa).toMatch(/^https?:\/\//);
25
+ expect(sp.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
26
+ expect(sp.appliesTo.length).toBeGreaterThan(0);
27
+ }
28
+ });
29
+
30
+ test("US-Sub-Processors haben sccRequired: true (DSGVO Art. 44+ Drittland)", () => {
31
+ const usProcessors = KUMIKO_SUB_PROCESSORS.filter(
32
+ (sp) => sp.region.includes("US") && !sp.region.startsWith("EU"),
33
+ );
34
+ for (const sp of usProcessors) {
35
+ expect(
36
+ sp.sccRequired,
37
+ `Sub-Processor "${sp.name}" with US-region should have sccRequired: true`,
38
+ ).toBe(true);
39
+ }
40
+ });
41
+
42
+ test("Snapshot — explizit zu updaten bei jeder Liste-Aenderung", () => {
43
+ const summary = KUMIKO_SUB_PROCESSORS.map((sp) => ({
44
+ name: sp.name,
45
+ region: sp.region,
46
+ status: sp.status ?? "active",
47
+ appliesTo: sp.appliesTo,
48
+ sccRequired: sp.sccRequired ?? false,
49
+ optInOnly: sp.optInOnly ?? false,
50
+ }));
51
+ expect(summary).toMatchInlineSnapshot(`
52
+ [
53
+ {
54
+ "appliesTo": [
55
+ "all-tiers",
56
+ ],
57
+ "name": "Hetzner Online GmbH",
58
+ "optInOnly": false,
59
+ "region": "EU (Germany)",
60
+ "sccRequired": false,
61
+ "status": "active",
62
+ },
63
+ {
64
+ "appliesTo": [
65
+ "all-tiers",
66
+ ],
67
+ "name": "Cloudflare, Inc.",
68
+ "optInOnly": false,
69
+ "region": "Global (US-headquartered)",
70
+ "sccRequired": true,
71
+ "status": "active",
72
+ },
73
+ {
74
+ "appliesTo": [
75
+ "standard",
76
+ "business",
77
+ "enterprise",
78
+ ],
79
+ "name": "Sendinblue SAS (Brevo)",
80
+ "optInOnly": false,
81
+ "region": "EU (France)",
82
+ "sccRequired": false,
83
+ "status": "active",
84
+ },
85
+ {
86
+ "appliesTo": [
87
+ "all-tiers",
88
+ ],
89
+ "name": "Heinlein Hosting (Mailbox.org)",
90
+ "optInOnly": false,
91
+ "region": "EU (Germany)",
92
+ "sccRequired": false,
93
+ "status": "active",
94
+ },
95
+ {
96
+ "appliesTo": [
97
+ "business",
98
+ "enterprise",
99
+ ],
100
+ "name": "Anthropic PBC",
101
+ "optInOnly": true,
102
+ "region": "US",
103
+ "sccRequired": true,
104
+ "status": "planned",
105
+ },
106
+ {
107
+ "appliesTo": [
108
+ "all-tiers",
109
+ ],
110
+ "name": "Stripe, Inc.",
111
+ "optInOnly": false,
112
+ "region": "Global (US-headquartered)",
113
+ "sccRequired": true,
114
+ "status": "planned",
115
+ },
116
+ ]
117
+ `);
118
+ });
119
+ });
120
+
121
+ describe("getActiveSubProcessors / getPlannedSubProcessors", () => {
122
+ test("active + planned partitionieren die Gesamt-Liste vollstaendig", () => {
123
+ const active = getActiveSubProcessors();
124
+ const planned = getPlannedSubProcessors();
125
+ expect(active.length + planned.length).toBe(KUMIKO_SUB_PROCESSORS.length);
126
+ });
127
+
128
+ test("kein Sub-Processor in active hat status=planned", () => {
129
+ for (const sp of getActiveSubProcessors()) {
130
+ expect(sp.status).not.toBe("planned");
131
+ }
132
+ });
133
+
134
+ test("alle planned-Eintraege haben status=planned", () => {
135
+ for (const sp of getPlannedSubProcessors()) {
136
+ expect(sp.status).toBe("planned");
137
+ }
138
+ });
139
+ });
@@ -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
+ }