@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/package.json +12 -6
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. package/src/user-data-rights-defaults/index.ts +6 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,108 @@
1
+ # @cosmicdrift/kumiko-bundled-features
2
+
3
+ ## 0.2.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 1dbd038: Fix `db.execute is not a function` crash in `createTierEngineFeature`'s
8
+ auto-default-tier postSave-hook when called via the dispatcher path
9
+ (`tenant:write:create`). The hook used `ctx.db as DbConnection` — a
10
+ type-lie. AppContext.db in the inTransaction-phase is a TenantDb, which
11
+ exposes select/insert/update/delete but not execute(). The event-store-
12
+ append (event-store.ts:102) calls `db.execute(sql\`SELECT pg_notify(...)\`)`,
13
+ which crashed at runtime.
14
+
15
+ Fix: typeguard via `if (!("raw" in ctx.db)) return` then use `ctx.db.raw
16
+ as DbConnection` (pattern matched signup-confirm.write.ts:107).
17
+
18
+ Plus: regression integration-test in `tier-engine/__tests__/auto-default-
19
+ tier.integration.ts` covering the dispatcher path (sysadmin →
20
+ tenant:write:create → tier_assignments-row + idempotency on tenant-update).
21
+
22
+ **Known production gap (separate from this fix):** Self-Signup goes through
23
+ `provisionSignupAccount → seedTenant` (event-store-direct), which bypasses
24
+ the dispatcher → postSave-hooks never fire in production self-signup. This
25
+ fix makes the dispatcher path coherent. Real-signup auto-default needs
26
+ follow-up work (either seedTenant fires hooks or signup-confirm calls
27
+ explicit seed-helpers).
28
+
29
+ - @cosmicdrift/kumiko-framework@0.2.3
30
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.3
31
+ - @cosmicdrift/kumiko-renderer@0.2.3
32
+ - @cosmicdrift/kumiko-renderer-web@0.2.3
33
+
34
+ ## 0.2.2
35
+
36
+ ### Patch Changes
37
+
38
+ - 7a7da3e: Re-publish 0.2.1 → 0.2.2 mit korrekt aufgelösten cross-package-Versionen.
39
+ 0.2.1 hatte `workspace:*` als Wert in den dependencies (npm publish ohne
40
+ yarn-pack rewrite), Konsumenten bekamen "Workspace not found".
41
+
42
+ publish-with-oidc.sh nutzt jetzt `yarn pack` (rewrited workspace:\*) +
43
+ `npm publish <tarball>` (OIDC + provenance).
44
+
45
+ - Updated dependencies [7a7da3e]
46
+ - @cosmicdrift/kumiko-framework@0.2.2
47
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.2
48
+ - @cosmicdrift/kumiko-renderer@0.2.2
49
+ - @cosmicdrift/kumiko-renderer-web@0.2.2
50
+
51
+ ## 0.2.1
52
+
53
+ ### Patch Changes
54
+
55
+ - 48b7f6a: CI: switch publish to npm-CLI with OIDC Trusted Publishing + provenance.
56
+ No source changes — verifies the new publish path produces a verified-
57
+ provenance attestation on npmjs.com instead of token-based publish.
58
+ - Updated dependencies [48b7f6a]
59
+ - @cosmicdrift/kumiko-framework@0.2.1
60
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.1
61
+ - @cosmicdrift/kumiko-renderer@0.2.1
62
+ - @cosmicdrift/kumiko-renderer-web@0.2.1
63
+
64
+ ## 0.2.0
65
+
66
+ ### Minor Changes
67
+
68
+ - 6c70b6f: fix(tenant): seedTenant idempotent gegen Event-Store-Projection-Drift.
69
+
70
+ Verhindert version_conflict beim App-Boot wenn Aggregat existiert aber
71
+ Projection-Row fehlt (rebuild-drift, async-lag, manueller DB-Eingriff).
72
+
73
+ ### Patch Changes
74
+
75
+ - Updated dependencies [6c70b6f]
76
+ - @cosmicdrift/kumiko-framework@0.2.0
77
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.0
78
+ - @cosmicdrift/kumiko-renderer@0.2.0
79
+ - @cosmicdrift/kumiko-renderer-web@0.2.0
80
+
81
+ ## 0.1.0
82
+
83
+ ### Minor Changes
84
+
85
+ - 59ba6d7: Initial public release of Kumiko — AI-native backend builder.
86
+
87
+ What ships in 0.1.0:
88
+
89
+ - **Engine** (`@cosmicdrift/kumiko-framework`): `defineFeature`, `r.entity`, `r.writeHandler`, `r.queryHandler`, `r.projection`, `r.multiStreamProjection`, `r.hook`, `r.translations`, `r.crud`, `r.referenceData`, `r.screen`, `r.nav`, `r.authClaims`, full lifecycle pipeline with field-level access checks
90
+ - **Pipeline** (`@cosmicdrift/kumiko-framework`): `createDispatcher`, JWT auth via jose, Zod schema validation, role-based access checks, command/write/query split
91
+ - **DB** (`@cosmicdrift/kumiko-framework`): Drizzle helpers (`buildDrizzleTable`, `applyCursorQuery`), CRUD executor, Postgres dialect, optimistic locking, soft delete, multi-tenant scoping
92
+ - **Event sourcing** (`@cosmicdrift/kumiko-framework`): aggregate streams, single + multi-stream projections, event upcasters, asOf queries, archive support, AsyncDaemon-pattern dispatcher
93
+ - **Bundled features** (`@cosmicdrift/kumiko-bundled-features`): auth-email-password, sessions, tenants, users, jobs, secrets, file-provider-s3, mail-transport-smtp/inmemory, billing-foundation, cap-counter, channel-in-app, delivery, feature-toggles, legal-pages
94
+ - **Renderer** (`@cosmicdrift/kumiko-renderer`, `@cosmicdrift/kumiko-renderer-web`): schema-driven CRUD UI for React + Expo Web, override paths, list debounce, theme tokens
95
+ - **Headless** (`@cosmicdrift/kumiko-headless`): view-models for list/edit screens, locale-aware
96
+ - **Dev server** (`@cosmicdrift/kumiko-dev-server`): `runDevApp`, `runProdApp`, `kumiko-build` for production bundles (client + server), Docker-ready
97
+ - **Realtime** (`@cosmicdrift/kumiko-dispatcher-live`): SSE broadcast across tenants, Redis Pub/Sub backend
98
+ - **CLI** (`bin/kumiko.ts`): interactive dev menu, test runners, check pipeline (Biome + TypeScript + 18 guards + Vitest)
99
+
100
+ This is a pre-1.0 release — APIs may change between minor versions. Breaking changes will be documented per release.
101
+
102
+ ### Patch Changes
103
+
104
+ - Updated dependencies [59ba6d7]
105
+ - @cosmicdrift/kumiko-framework@0.1.0
106
+ - @cosmicdrift/kumiko-dispatcher-live@0.1.0
107
+ - @cosmicdrift/kumiko-renderer@0.1.0
108
+ - @cosmicdrift/kumiko-renderer-web@0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -19,7 +19,9 @@
19
19
  },
20
20
  "exports": {
21
21
  "./audit": "./src/audit/index.ts",
22
+ "./compliance-profiles": "./src/compliance-profiles/index.ts",
22
23
  "./config": "./src/config/index.ts",
24
+ "./data-retention": "./src/data-retention/index.ts",
23
25
  "./jobs": "./src/jobs/index.ts",
24
26
  "./tier-engine": "./src/tier-engine/index.ts",
25
27
  "./cap-counter": "./src/cap-counter/index.ts",
@@ -33,6 +35,9 @@
33
35
  "./file-foundation": "./src/file-foundation/index.ts",
34
36
  "./file-provider-s3": "./src/file-provider-s3/index.ts",
35
37
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
38
+ "./files": "./src/files/index.ts",
39
+ "./user-data-rights": "./src/user-data-rights/index.ts",
40
+ "./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
36
41
  "./tenant": "./src/tenant/index.ts",
37
42
  "./tenant/constants": "./src/tenant/constants.ts",
38
43
  "./tenant/seeding": "./src/tenant/seeding.ts",
@@ -62,11 +67,12 @@
62
67
  },
63
68
  "dependencies": {
64
69
  "@aws-sdk/client-s3": "^3.700.0",
70
+ "@aws-sdk/lib-storage": "^3.700.0",
65
71
  "@aws-sdk/s3-request-presigner": "^3.700.0",
66
- "@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
67
- "@cosmicdrift/kumiko-framework": "workspace:*",
68
- "@cosmicdrift/kumiko-renderer": "workspace:*",
69
- "@cosmicdrift/kumiko-renderer-web": "workspace:*",
72
+ "@cosmicdrift/kumiko-dispatcher-live": "0.2.3",
73
+ "@cosmicdrift/kumiko-framework": "0.2.3",
74
+ "@cosmicdrift/kumiko-renderer": "0.2.3",
75
+ "@cosmicdrift/kumiko-renderer-web": "0.2.3",
70
76
  "@mollie/api-client": "^4.5.0",
71
77
  "@node-rs/argon2": "^2.0.2",
72
78
  "@types/nodemailer": "^8.0.0",
@@ -87,4 +93,4 @@
87
93
  "README.md",
88
94
  "LICENSE"
89
95
  ]
90
- }
96
+ }
@@ -21,6 +21,12 @@ export type AuthUserRow = {
21
21
  // roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
22
22
  // parseRoles() vor dem Merge in die Session.
23
23
  readonly roles?: string | null;
24
+ // user.status (S2.U1) — "active" | "restricted" | "deletion_requested" |
25
+ // "deleted". Login.write.ts blockt Restricted (DSGVO Art. 18) sowie
26
+ // DeletionRequested + Deleted. Untyped string hier weil die Quelle
27
+ // user-feature-internes Enum ist; auth importiert den Constants-Wert
28
+ // an der Verwendungsstelle.
29
+ readonly status?: string | null;
24
30
  };
25
31
 
26
32
  // Returns the narrowed row or null — mirrors findForAuth's contract where
@@ -73,6 +73,17 @@ export const AuthErrors = {
73
73
  // deliberate enumeration trade-off: the lockout event itself is already
74
74
  // observable to the attacker, and legit users benefit from a clear signal.
75
75
  accountLocked: "account_locked",
76
+ // S2.U6 (DSGVO Art. 18) — Account ist im Restricted-Status. Login wird
77
+ // explicit verweigert mit eigenem Code (nicht zu invalid_credentials
78
+ // collapsen) damit UI sagen kann "Account ist aktuell pausiert, hier
79
+ // klicken zum Aufheben". Enumeration-leak akzeptiert: Restriction ist
80
+ // user-initiiert, der User weiss dass sein Konto restricted ist.
81
+ accountRestricted: "account_restricted",
82
+ // Account ist im DeletionRequested- oder Deleted-Status. Anders als
83
+ // Restricted ist das nicht reversibel via Login → wir collapsen auf
84
+ // invalid_credentials damit Forget-Pfad nicht via Login enumerierbar
85
+ // wird (User der "Konto loeschen" geklickt hat soll nicht erneut sehen
86
+ // dass die Email-Adresse noch in der DB existiert).
76
87
  } as const;
77
88
 
78
89
  // Account-lockout defaults — overridable via
@@ -7,7 +7,7 @@ import {
7
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
9
  import { z } from "zod";
10
- import { UserQueries } from "../../user";
10
+ import { USER_STATUS, UserQueries } from "../../user";
11
11
  import { parseAuthUserRow } from "../auth-user-row";
12
12
  import {
13
13
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
@@ -53,6 +53,19 @@ function accountLocked(retryAfterSeconds: number) {
53
53
  );
54
54
  }
55
55
 
56
+ // S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
57
+ // invalid_credentials collapsen) damit UI klar sagen kann "Account ist
58
+ // pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
59
+ // restricted ist (er hat selbst die Restriction gesetzt), also kein
60
+ // Enumeration-Leak.
61
+ function accountRestricted() {
62
+ return writeFailure(
63
+ new UnprocessableError(AuthErrors.accountRestricted, {
64
+ i18nKey: "auth.errors.accountRestricted",
65
+ }),
66
+ );
67
+ }
68
+
56
69
  export type LoginHandlerOptions = {
57
70
  // When true, a valid (email + password) login fails with email_not_verified
58
71
  // if the user row's emailVerified flag is false. Enumeration-leak is
@@ -145,6 +158,23 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
145
158
  return emailNotVerified();
146
159
  }
147
160
 
161
+ // S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
162
+ // nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
163
+ // (siehe lift-restriction.write.ts Header — typisch via Magic-Link
164
+ // oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
165
+ // requirement; ohne den koennte der User mit Login-Sessions trotz
166
+ // Restriction-Flag durchschreiben.
167
+ //
168
+ // DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
169
+ // (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
170
+ // distinct error ist hier safe.
171
+ if (found.status === USER_STATUS.Restricted) {
172
+ return accountRestricted();
173
+ }
174
+ if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
175
+ return invalidCredentials();
176
+ }
177
+
148
178
  // Resolve tenant + roles via the tenant feature's memberships query.
149
179
  // Returns [] if the user has no memberships — MVP: no login without an
150
180
  // invitation, so we refuse with a dedicated error.
@@ -23,6 +23,8 @@ export const defaultTranslations: TranslationsByLocale = {
23
23
  "auth.errors.accountLocked": "Konto vorübergehend gesperrt.",
24
24
  "auth.errors.accountLockedRetry": "Konto gesperrt. Neuer Versuch in {minutes} Minuten.",
25
25
  "auth.errors.emailNotVerified": "E-Mail-Adresse noch nicht bestätigt.",
26
+ "auth.errors.accountRestricted":
27
+ "Konto pausiert (Datenschutz Art. 18). Bitte Pause aufheben um wieder einzuloggen.",
26
28
  "auth.errors.rateLimited": "Zu viele Login-Versuche. Bitte kurz warten.",
27
29
  "auth.errors.invalidBody": "Ungültige Eingabe.",
28
30
  "auth.errors.loginFailed": "Login fehlgeschlagen.",
@@ -116,6 +118,8 @@ export const defaultTranslations: TranslationsByLocale = {
116
118
  "auth.errors.accountLocked": "Account temporarily locked.",
117
119
  "auth.errors.accountLockedRetry": "Account locked. Try again in {minutes} minutes.",
118
120
  "auth.errors.emailNotVerified": "Email address not yet verified.",
121
+ "auth.errors.accountRestricted":
122
+ "Account paused (GDPR Art. 18). Please lift the restriction to sign in again.",
119
123
  "auth.errors.rateLimited": "Too many login attempts. Please wait briefly.",
120
124
  "auth.errors.invalidBody": "Invalid input.",
121
125
  "auth.errors.loginFailed": "Login failed.",
@@ -0,0 +1,88 @@
1
+ # compliance-profiles
2
+
3
+ Tenant-weite DSGVO/Compliance-Profile-Wahl. Pflicht beim Tenant-Onboarding —
4
+ Profile bündelt User-Rights-Grace, Notification-Sprachen, Breach-
5
+ Disclosure, Audit-Retention und Sub-Processor-Anforderungen in eine
6
+ Auswahl.
7
+
8
+ **Status:** Sprint 1 (S1.1 + S1.3). Sub-Processor-Endpoint (S1.4) und
9
+ Onboarding-Banner-API (S1.5) folgen in derselben Sprint-Iteration.
10
+
11
+ ## MVP-Set: 3 Profile
12
+
13
+ - **`eu-dsgvo`** — Foundation-Profile, DSGVO Standard, BlnBDI Berlin
14
+ - **`swiss-dsg`** — extends `eu-dsgvo` mit DE/FR/IT/EN-Sprachen + EDÖB
15
+ - **`de-hr-dsgvo-hgb`** — extends `eu-dsgvo` mit HR-Spezifika (HGB
16
+ 10y-Audit-Retention, Betriebsrat-Notification, 60d-Tenant-Destroy)
17
+
18
+ Plus `minimal-no-region` als Default-Fallback (NICHT auswählbar) bis
19
+ Tenant-Admin eine Wahl trifft.
20
+
21
+ Erweiterungen wie `uk-gdpr`, `ca-pipeda`, `ca-quebec-l25`, `us-ccpa`,
22
+ `hipaa-healthcare` kommen on-demand wenn Customer fragt — der
23
+ `extends`-Mechanismus macht sie zu 30-Zeilen-Adds.
24
+
25
+ ## API
26
+
27
+ ### Queries
28
+
29
+ - `compliance-profiles:query:list-profiles` — `openToAll`. Liefert die
30
+ 3 wählbaren Profile mit Region + Aufsicht + Sprachen, für Onboarding-UI.
31
+ - `compliance-profiles:query:for-tenant` — `openToAll`. Liefert das
32
+ effektive Profile für den aktuellen Tenant inkl. Override (deep-merge).
33
+ Wenn kein Profile gesetzt: `minimal-no-region` + `warning="no-profile-selected"`.
34
+
35
+ ### Writes
36
+
37
+ - `compliance-profiles:write:set-profile` — `roles=[TenantAdmin]`.
38
+ Upsert: setzt `profileKey` (+ optional `override`-JSON) für den
39
+ aktuellen Tenant. Idempotent, zweiter Call updated.
40
+
41
+ ### Cross-Feature (`r.exposesApi`)
42
+
43
+ - `compliance.forTenant` — Marker. Andere Features (Sprint 2
44
+ `user-data-rights`, Sprint 5 `tenant-lifecycle`) rufen den Resolver via
45
+ QN-Pattern (`app.fetch("/api/query")` mit `type=compliance-profiles:query:for-tenant`).
46
+ Boot-Validator checkt dass jeder `r.usesApi("compliance.forTenant")`-
47
+ Caller das Feature in `requires/optionalRequires` hat.
48
+
49
+ ## Override-Semantik
50
+
51
+ `override` wird als JSON-String gespeichert und beim Resolver
52
+ deep-merged auf das gewählte Profile. Atomic-Paths (gracePeriod /
53
+ auskunftFrist / retention / authorityNotificationDeadline /
54
+ tenantDestroyGracePeriod) ersetzen komplett statt rekursiv zu mergen,
55
+ weil sie diskriminierte Union-Objects sind (`{ months } | { years }` vs
56
+ `{ days } | { hours }`).
57
+
58
+ ```typescript
59
+ // Tenant-Admin override:
60
+ {
61
+ "userRights": { "gracePeriod": { "days": 60 } }
62
+ }
63
+ // Effekt auf eu-dsgvo:
64
+ // userRights.gracePeriod = { days: 60 } (overridden)
65
+ // userRights.restrictionAllowed = true (geerbt)
66
+ // userRights.portabilityFormat = ["json"] (geerbt)
67
+ // ...alle anderen userRights-Felder unverändert
68
+ ```
69
+
70
+ ## Architektur-Note
71
+
72
+ Profile-Selection lebt als **separate Entity** (`tenantComplianceProfileEntity`)
73
+ im compliance-profiles-Feature, nicht als config-key im tenant-Feature.
74
+ Begründung in `schema/profile-selection.ts` — kurz: Override ist
75
+ strukturiertes JSON, Profile-Wechsel ist audit-relevant (Event-Store
76
+ liefert das automatisch für Entity-Writes), Plan-Files nennen sie
77
+ explizit als eigene Entity.
78
+
79
+ ## Tests
80
+
81
+ `__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
82
+ `setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
83
+ list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
84
+ Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
85
+ idempotent-Update.
86
+
87
+ Plus Unit-Tests für Profile-Constants + Override-Resolver in
88
+ `framework/src/compliance/__tests__/profiles.test.ts` (16 Tests).
@@ -0,0 +1,308 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import {
4
+ createTestUser,
5
+ setupTestStack,
6
+ type TestStack,
7
+ testTenantId,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
12
+
13
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
14
+ const FOR_TENANT = "compliance-profiles:query:for-tenant";
15
+ const LIST_PROFILES = "compliance-profiles:query:list-profiles";
16
+ const SUB_PROCESSORS = "compliance-profiles:query:sub-processors";
17
+ const NEEDS_PROFILE = "compliance-profiles:query:needs-profile";
18
+
19
+ // S1.8 N5: Isolierten Tenant-Admin pro Test bauen — verhindert
20
+ // Cross-Test-Interferenz uber gemeinsamen Default-tenantId aus
21
+ // TestUsers.admin. Eindeutige numerische ID + parallele tenantId.
22
+ function createIsolatedTenantAdmin(n: number, roles: string[] = ["TenantAdmin"]) {
23
+ return createTestUser({ id: n, tenantId: testTenantId(n), roles });
24
+ }
25
+
26
+ let stack: TestStack;
27
+ let db: DbConnection;
28
+
29
+ const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
30
+ const normalUser = createTestUser({ id: 3, roles: ["Member"] });
31
+
32
+ const feature = createComplianceProfilesFeature();
33
+
34
+ beforeAll(async () => {
35
+ stack = await setupTestStack({ features: [feature] });
36
+ db = stack.db;
37
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
38
+ await createEventsTable(db);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await stack.cleanup();
43
+ });
44
+
45
+ describe("compliance-profiles :: list-profiles", () => {
46
+ test("liefert die 3 wählbaren Profile mit Region + Sprachen", async () => {
47
+ const result = await stack.http.queryOk<{
48
+ profiles: Array<{ key: string; region: string; languages: string[] }>;
49
+ }>(LIST_PROFILES, {}, tenantAdmin);
50
+ expect(result.profiles).toHaveLength(3);
51
+ const keys = result.profiles.map((p) => p.key);
52
+ expect(keys).toEqual(["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb"]);
53
+ const swiss = result.profiles.find((p) => p.key === "swiss-dsg");
54
+ expect(swiss?.region).toBe("CH");
55
+ expect(swiss?.languages).toContain("fr");
56
+ });
57
+
58
+ test("minimal-no-region ist NICHT in der Liste (kein Production-Default)", async () => {
59
+ const result = await stack.http.queryOk<{ profiles: Array<{ key: string }> }>(
60
+ LIST_PROFILES,
61
+ {},
62
+ tenantAdmin,
63
+ );
64
+ expect(result.profiles.find((p) => p.key === "minimal-no-region")).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("compliance-profiles :: for-tenant", () => {
69
+ test("ohne Setting → minimal-no-region + warning=no-profile-selected", async () => {
70
+ const result = await stack.http.queryOk<{
71
+ profile: { key: string };
72
+ warning?: string;
73
+ }>(FOR_TENANT, {}, normalUser);
74
+ expect(result.profile.key).toBe("minimal-no-region");
75
+ expect(result.warning).toBe("no-profile-selected");
76
+ });
77
+ });
78
+
79
+ describe("compliance-profiles :: set-profile", () => {
80
+ test("TenantAdmin kann Profile auf eu-dsgvo setzen", async () => {
81
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
82
+
83
+ const result = await stack.http.queryOk<{
84
+ profile: { key: string; region: string; breach: { authorityContact: string } };
85
+ warning?: string;
86
+ }>(FOR_TENANT, {}, tenantAdmin);
87
+ expect(result.profile.key).toBe("eu-dsgvo");
88
+ expect(result.profile.region).toBe("EU");
89
+ expect(result.profile.breach.authorityContact).toBe("BlnBDI Berlin");
90
+ expect(result.warning).toBeUndefined();
91
+ });
92
+
93
+ test("set-profile ist idempotent — zweiter Call wechselt Profile", async () => {
94
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
95
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "swiss-dsg" }, tenantAdmin);
96
+
97
+ const result = await stack.http.queryOk<{
98
+ profile: { key: string; region: string; breach: { authorityContact: string } };
99
+ }>(FOR_TENANT, {}, tenantAdmin);
100
+ expect(result.profile.key).toBe("swiss-dsg");
101
+ expect(result.profile.breach.authorityContact).toBe("EDÖB Bern");
102
+ });
103
+
104
+ test("set-profile mit Override merged auf base-profile", async () => {
105
+ await stack.http.writeOk(
106
+ SET_PROFILE,
107
+ {
108
+ profileKey: "eu-dsgvo",
109
+ override: JSON.stringify({
110
+ userRights: { gracePeriod: { days: 60 } },
111
+ }),
112
+ },
113
+ tenantAdmin,
114
+ );
115
+
116
+ const result = await stack.http.queryOk<{
117
+ profile: { userRights: { gracePeriod: { days: number }; portabilityFormat: string[] } };
118
+ }>(FOR_TENANT, {}, tenantAdmin);
119
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 60 });
120
+ // Andere userRights-Felder bleiben aus eu-dsgvo
121
+ expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
122
+ });
123
+
124
+ test("Member ohne TenantAdmin-Rolle bekommt 403 beim set-profile", async () => {
125
+ const result = await stack.http.write(SET_PROFILE, { profileKey: "eu-dsgvo" }, normalUser);
126
+ expect(result.status).toBe(403);
127
+ });
128
+
129
+ test("set-profile mit invalid JSON-Override wirft Error", async () => {
130
+ const result = await stack.http.write(
131
+ SET_PROFILE,
132
+ { profileKey: "eu-dsgvo", override: "not-valid-json" },
133
+ tenantAdmin,
134
+ );
135
+ expect(result.status).toBeGreaterThanOrEqual(400);
136
+ });
137
+
138
+ test("set-profile mit Array statt Object als Override wirft Error", async () => {
139
+ const result = await stack.http.write(
140
+ SET_PROFILE,
141
+ { profileKey: "eu-dsgvo", override: JSON.stringify([{ foo: 1 }]) },
142
+ tenantAdmin,
143
+ );
144
+ expect(result.status).toBeGreaterThanOrEqual(400);
145
+ });
146
+
147
+ // S1.7 X1: Schema engt sich auf SELECTABLE_PROFILE_KEYS
148
+ test("set-profile mit minimal-no-region wird abgelehnt (X1)", async () => {
149
+ const result = await stack.http.write(
150
+ SET_PROFILE,
151
+ { profileKey: "minimal-no-region" },
152
+ tenantAdmin,
153
+ );
154
+ expect(result.status).toBeGreaterThanOrEqual(400);
155
+ });
156
+
157
+ // S1.7 X2: Override mit unbekannten Top-Level-Keys
158
+ test("set-profile mit unbekanntem Top-Level-Override-Key wirft Error (X2)", async () => {
159
+ const result = await stack.http.write(
160
+ SET_PROFILE,
161
+ {
162
+ profileKey: "eu-dsgvo",
163
+ override: JSON.stringify({ userrights: { gracePeriod: { days: 60 } } }), // typo lowercase
164
+ },
165
+ tenantAdmin,
166
+ );
167
+ expect(result.status).toBeGreaterThanOrEqual(400);
168
+ });
169
+
170
+ // S1.9 Z3 + S1.10 M3: Override-Sub-Level-Tippfehler (Schema-Strict)
171
+ // mit path-spezifischer Assertion via ValidationError.details.fields.
172
+ test("set-profile mit Sub-Level-Tippfehler liefert validation_error mit Path (Z3+M3)", async () => {
173
+ const err = await stack.http.writeErr(
174
+ SET_PROFILE,
175
+ {
176
+ profileKey: "eu-dsgvo",
177
+ override: JSON.stringify({ userRights: { weeks: 3 } }), // weeks gibt's nicht
178
+ },
179
+ tenantAdmin,
180
+ );
181
+ expect(err.code).toBe("validation_error");
182
+ expect(err.httpStatus).toBe(400);
183
+ const fields = (err.details as { fields: Array<{ path: string }> })?.fields;
184
+ expect(fields).toBeDefined();
185
+ // Schema-strict wirft auf "userRights.weeks" als unrecognized_keys — ODER
186
+ // generelle "userRights" wenn Zod das so reportet. Beide Pfade decken den
187
+ // Bug ab.
188
+ expect(fields?.some((f) => f.path.includes("userRights"))).toBe(true);
189
+ });
190
+
191
+ test("set-profile mit invalid retention-shape liefert validation_error mit Path (Z3+M3)", async () => {
192
+ const err = await stack.http.writeErr(
193
+ SET_PROFILE,
194
+ {
195
+ profileKey: "eu-dsgvo",
196
+ override: JSON.stringify({
197
+ userRights: { gracePeriod: { days: 30, hours: 24 } }, // strict-disjunction
198
+ }),
199
+ },
200
+ tenantAdmin,
201
+ );
202
+ expect(err.code).toBe("validation_error");
203
+ const fields = (err.details as { fields: Array<{ path: string }> })?.fields;
204
+ expect(fields?.some((f) => f.path.includes("gracePeriod"))).toBe(true);
205
+ });
206
+
207
+ // S1.7 F2: SystemAdmin kann Profile setzen
208
+ test("SystemAdmin kann Profile setzen (Plattform-Operator-Pfad)", async () => {
209
+ const sysAdmin = createIsolatedTenantAdmin(50, ["SystemAdmin"]);
210
+ const result = await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, sysAdmin);
211
+ expect(result).toMatchObject({ profileKey: "eu-dsgvo", isNew: true });
212
+ });
213
+
214
+ // S1.7 F3: tenantIdOverride als SystemAdmin → für Customer-Tenant
215
+ test("SystemAdmin kann mit tenantIdOverride für anderen Tenant Profile setzen (F3)", async () => {
216
+ const sysAdmin = createIsolatedTenantAdmin(51, ["SystemAdmin"]);
217
+ const targetTenantAdmin = createIsolatedTenantAdmin(52);
218
+
219
+ await stack.http.writeOk(
220
+ SET_PROFILE,
221
+ { profileKey: "swiss-dsg", tenantIdOverride: targetTenantAdmin.tenantId },
222
+ sysAdmin,
223
+ );
224
+
225
+ const result = await stack.http.queryOk<{ profile: { key: string; region: string } }>(
226
+ FOR_TENANT,
227
+ {},
228
+ targetTenantAdmin,
229
+ );
230
+ expect(result.profile.key).toBe("swiss-dsg");
231
+ expect(result.profile.region).toBe("CH");
232
+ });
233
+
234
+ // S1.7 F3: tenantIdOverride als TenantAdmin → 403
235
+ test("TenantAdmin mit tenantIdOverride bekommt 403 (F3)", async () => {
236
+ const someTenantAdmin = createIsolatedTenantAdmin(53);
237
+ const result = await stack.http.write(
238
+ SET_PROFILE,
239
+ { profileKey: "eu-dsgvo", tenantIdOverride: testTenantId(54) },
240
+ someTenantAdmin,
241
+ );
242
+ expect(result.status).toBe(403);
243
+ });
244
+ });
245
+
246
+ describe("compliance-profiles :: sub-processors (S1.4)", () => {
247
+ test("liefert active + planned Sub-Processors mit Pflicht-Feldern", async () => {
248
+ const result = await stack.http.queryOk<{
249
+ active: Array<{ name: string; region: string; dpa: string; sccRequired?: boolean }>;
250
+ planned: Array<{ name: string; status: string }>;
251
+ total: number;
252
+ generatedAt: string;
253
+ }>(SUB_PROCESSORS, {}, normalUser);
254
+
255
+ expect(result.active.length).toBeGreaterThan(0);
256
+ const hetzner = result.active.find((s) => s.name.includes("Hetzner"));
257
+ expect(hetzner).toBeDefined();
258
+ expect(hetzner?.dpa).toMatch(/^https:\/\//);
259
+ expect(hetzner?.region).toContain("Germany");
260
+
261
+ const cloudflare = result.active.find((s) => s.name.includes("Cloudflare"));
262
+ expect(cloudflare?.sccRequired).toBe(true);
263
+
264
+ expect(result.planned.length).toBeGreaterThan(0);
265
+ expect(result.planned.every((p) => p.status === "planned")).toBe(true);
266
+
267
+ expect(result.total).toBe(result.active.length + result.planned.length);
268
+ expect(result.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
269
+ });
270
+ });
271
+
272
+ describe("compliance-profiles :: needs-profile (S1.5 — Onboarding-Banner)", () => {
273
+ test("Tenant ohne Profile-Wahl → needsSelection=true, reason=no_profile_selected", async () => {
274
+ // Frischer Tenant-Admin (eigener Tenant ID damit kein Profile gesetzt
275
+ // ist — sonst sieht er den Eintrag aus den vorherigen set-profile-Tests).
276
+ const freshTenantAdmin = createIsolatedTenantAdmin(99);
277
+ const result = await stack.http.queryOk<{
278
+ needsSelection: boolean;
279
+ currentProfile: string | null;
280
+ reason?: string;
281
+ }>(NEEDS_PROFILE, {}, freshTenantAdmin);
282
+ expect(result.needsSelection).toBe(true);
283
+ expect(result.currentProfile).toBeNull();
284
+ expect(result.reason).toBe("no_profile_selected");
285
+ });
286
+
287
+ test("Tenant mit eu-dsgvo-Wahl → needsSelection=false", async () => {
288
+ const setupAdmin = createIsolatedTenantAdmin(100);
289
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, setupAdmin);
290
+
291
+ const result = await stack.http.queryOk<{
292
+ needsSelection: boolean;
293
+ currentProfile: string | null;
294
+ }>(NEEDS_PROFILE, {}, setupAdmin);
295
+ expect(result.needsSelection).toBe(false);
296
+ expect(result.currentProfile).toBe("eu-dsgvo");
297
+ });
298
+
299
+ // S1.8 O3: minimal-no-region-Defensiv-Pfad in needs-profile.query.ts
300
+ // entfernt (toter Code nach S1.7 X1 — Zod blockt minimal-no-region).
301
+ // Wenn Sprint 2 einen seedComplianceProfile-Helper bringt der den
302
+ // Migration-Edge-Case einführt, kommt hier ein neuer Test rein.
303
+
304
+ test("Member-Rolle bekommt 403 (Banner ist Admin-only)", async () => {
305
+ const result = await stack.http.query(NEEDS_PROFILE, {}, normalUser);
306
+ expect(result.status).toBe(403);
307
+ });
308
+ });