@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -12,7 +12,7 @@ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling" as const
12
12
  // Custom event-type für Rolling-Window-Counter. Symmetrisches Paar:
13
13
  // _SHORT — passt zu `r.defineEvent(short, schema)` im Registrar
14
14
  // (Framework prefixt automatisch zu QN)
15
- // _QN — qualifizierte Form für `ctx.appendEventUnsafe({type})`
15
+ // _QN — qualifizierte Form für `ctx.unsafeAppendEvent({type})`
16
16
  // + `events.type`-Spalte + `registry.getEvent(qn)`-Lookup
17
17
  // Beide MÜSSEN konsistent sein (drift-pin im feature-test).
18
18
  export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented" as const;
@@ -118,7 +118,7 @@ export async function enforceCap(
118
118
  .limit(1);
119
119
 
120
120
  const row = rows[0];
121
- const value = row ? (row["value"] as number) : 0;
121
+ const value = row ? (row["value"] as number) : 0; // @cast-boundary db-row
122
122
 
123
123
  if (value >= hardThreshold) {
124
124
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
@@ -46,11 +46,7 @@
46
46
  // config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
47
47
  // vom Framework-Default (Base-Column tenantId).
48
48
 
49
- import {
50
- defineEntityListHandler,
51
- defineFeature,
52
- type FeatureDefinition,
53
- } from "@cosmicdrift/kumiko-framework/engine";
49
+ import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
54
50
  import { CAP_COUNTER_FEATURE, ROLLING_INCREMENTED_EVENT_SHORT } from "./constants";
55
51
  import { capCounterEntity } from "./entity";
56
52
  import { getCounterQuery } from "./handlers/get-counter.query";
@@ -63,11 +59,11 @@ import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
63
59
 
64
60
  const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
65
61
 
66
- export const capCounterFeature: FeatureDefinition = defineFeature(CAP_COUNTER_FEATURE, (r) => {
62
+ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
67
63
  r.entity("cap-counter", capCounterEntity);
68
64
 
69
65
  // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
70
- // das Schema beim Registry; ctx.appendEventUnsafe im Handler nutzt
66
+ // das Schema beim Registry; ctx.unsafeAppendEvent im Handler nutzt
71
67
  // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
72
68
  // "cap-counter:event:rolling-incremented" (siehe
73
69
  // ROLLING_INCREMENTED_EVENT_QN).
@@ -22,7 +22,7 @@ export const getCounterQuery: QueryHandlerDef = {
22
22
  schema: getCounterSchema,
23
23
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
24
24
  handler: async (query, ctx) => {
25
- const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>;
25
+ const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>; // @cast-boundary engine-payload
26
26
 
27
27
  // ctx.db is tenant-scoped; filter by capName + periodStart explicitly.
28
28
  const rows = await ctx.db
@@ -60,11 +60,11 @@ export const incrementRollingCapHandler: WriteHandlerDef = {
60
60
  const payload = event.payload as IncrementRollingPayload;
61
61
  const aggregateId = rollingCapAggregateId(event.user.tenantId, payload.capName);
62
62
 
63
- // appendEventUnsafe — bundled-features-Pfad (apps mit yarn kumiko
63
+ // unsafeAppendEvent — bundled-features-Pfad (apps mit yarn kumiko
64
64
  // codegen kriegen den strict-typed appendEvent-Wrapper). Schema-
65
65
  // Validation läuft trotzdem, weil r.defineEvent das Schema
66
66
  // registriert hat.
67
- await ctx.appendEventUnsafe({
67
+ await ctx.unsafeAppendEvent({
68
68
  aggregateId,
69
69
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
70
70
  type: ROLLING_INCREMENTED_EVENT_QN,
@@ -46,7 +46,7 @@ export const incrementCapHandler: WriteHandlerDef = {
46
46
  // which subsystem incremented.
47
47
  access: { roles: ["SystemAdmin"] },
48
48
  handler: async (event, ctx) => {
49
- const payload = event.payload as IncrementPayload;
49
+ const payload = event.payload as IncrementPayload; // @cast-boundary engine-payload
50
50
  const aggregateId = capCounterAggregateId(
51
51
  event.user.tenantId,
52
52
  payload.capName,
@@ -77,8 +77,8 @@ export const incrementCapHandler: WriteHandlerDef = {
77
77
  // clearer than a possibly-null deref later.
78
78
  throw new Error("cap-counter:increment: row vanished between length-check and read");
79
79
  }
80
- const currentValue = currentRow["value"] as number;
81
- const currentVersion = currentRow["version"] as number;
80
+ const currentValue = currentRow["value"] as number; // @cast-boundary db-row
81
+ const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
82
82
  return executor.update(
83
83
  {
84
84
  id: aggregateId,
@@ -25,7 +25,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
25
25
  schema: markSoftWarnedSchema,
26
26
  access: { roles: ["SystemAdmin"] },
27
27
  handler: async (event, ctx) => {
28
- const payload = event.payload as z.infer<typeof markSoftWarnedSchema>;
28
+ const payload = event.payload as z.infer<typeof markSoftWarnedSchema>; // @cast-boundary engine-payload
29
29
  const aggregateId = capCounterAggregateId(
30
30
  event.user.tenantId,
31
31
  payload.capName,
@@ -42,7 +42,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
42
42
  if (!row) {
43
43
  throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
44
44
  }
45
- const currentVersion = row["version"] as number;
45
+ const currentVersion = row["version"] as number; // @cast-boundary db-row
46
46
 
47
47
  return executor.update(
48
48
  {
@@ -34,7 +34,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
34
34
  template: message.notificationType,
35
35
  variables,
36
36
  });
37
- const subject = (variables["subject"] as string) ?? message.title;
37
+ const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
38
38
 
39
39
  await transport.send({
40
40
  to: address,
@@ -20,7 +20,7 @@ export function createInMemoryTransport(): EmailTransport & {
20
20
  const sent: EmailMessage[] = [];
21
21
  const transport = {
22
22
  sent,
23
- failNext: null as null | { message: string },
23
+ failNext: null as null | { message: string }, // @cast-boundary generic-record
24
24
  async send(message: EmailMessage) {
25
25
  if (transport.failNext) {
26
26
  const err = new Error(transport.failNext.message);
@@ -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
+ });
@@ -0,0 +1,93 @@
1
+ // seedComplianceProfile-Helper-Tests (S1.9 Z2).
2
+ //
3
+ // Beweist:
4
+ // 1. Helper umgeht set-profile-Zod-Engung (kann minimal-no-region
5
+ // setzen für Migration-Edge-Case-Tests in Sprint 2+)
6
+ // 2. Idempotent: zweiter Call mit gleichem tenantId updated den
7
+ // bestehenden Eintrag
8
+ // 3. Override wird als JSON-String persistiert + via for-tenant
9
+ // korrekt zurueckgelesen
10
+
11
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
12
+ import {
13
+ createTestUser,
14
+ setupTestStack,
15
+ type TestStack,
16
+ testTenantId,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
21
+ import { seedComplianceProfile } from "../seeding";
22
+
23
+ const FOR_TENANT = "compliance-profiles:query:for-tenant";
24
+
25
+ let stack: TestStack;
26
+
27
+ const feature = createComplianceProfilesFeature();
28
+
29
+ beforeAll(async () => {
30
+ stack = await setupTestStack({ features: [feature] });
31
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
32
+ await createEventsTable(stack.db);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await stack.cleanup();
37
+ });
38
+
39
+ describe("seedComplianceProfile", () => {
40
+ test("kann eu-dsgvo direkt seeden, for-tenant liefert das Profile", async () => {
41
+ const tenantId = testTenantId(200);
42
+ const user = createTestUser({ id: 200, tenantId, roles: ["TenantAdmin"] });
43
+
44
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
45
+
46
+ const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
47
+ expect(result.profile.key).toBe("eu-dsgvo");
48
+ });
49
+
50
+ test("idempotent: zweiter Call updated den bestehenden Eintrag", async () => {
51
+ const tenantId = testTenantId(201);
52
+ const user = createTestUser({ id: 201, tenantId, roles: ["TenantAdmin"] });
53
+
54
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
55
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "swiss-dsg" });
56
+
57
+ const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
58
+ expect(result.profile.key).toBe("swiss-dsg");
59
+ });
60
+
61
+ test("kann minimal-no-region direkt seeden (Migration-Edge-Case, ohne set-profile-Zod-Engung)", async () => {
62
+ const tenantId = testTenantId(202);
63
+ const user = createTestUser({ id: 202, tenantId, roles: ["TenantAdmin"] });
64
+
65
+ // set-profile (Sprint 1.7 X1) wuerde minimal-no-region rejecten —
66
+ // seedComplianceProfile umgeht das fuer Test-Migration-Szenarien.
67
+ await seedComplianceProfile(stack.db, {
68
+ tenantId,
69
+ profileKey: "minimal-no-region",
70
+ });
71
+
72
+ const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
73
+ expect(result.profile.key).toBe("minimal-no-region");
74
+ });
75
+
76
+ test("Override wird persistiert + im for-tenant deep-merged zurueckgelesen", async () => {
77
+ const tenantId = testTenantId(203);
78
+ const user = createTestUser({ id: 203, tenantId, roles: ["TenantAdmin"] });
79
+
80
+ await seedComplianceProfile(stack.db, {
81
+ tenantId,
82
+ profileKey: "eu-dsgvo",
83
+ override: { userRights: { gracePeriod: { days: 90 } } },
84
+ });
85
+
86
+ const result = await stack.http.queryOk<{
87
+ profile: { userRights: { gracePeriod: { days: number }; portabilityFormat: string[] } };
88
+ }>(FOR_TENANT, {}, user);
89
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 90 });
90
+ // Andere userRights bleiben aus eu-dsgvo
91
+ expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
92
+ });
93
+ });
@@ -0,0 +1,51 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { forTenantQuery } from "./handlers/for-tenant.query";
3
+ import { listProfilesQuery } from "./handlers/list-profiles.query";
4
+ import { needsProfileQuery } from "./handlers/needs-profile.query";
5
+ import { setProfileWrite } from "./handlers/set-profile.write";
6
+ import { subProcessorsQuery } from "./handlers/sub-processors.query";
7
+ import { tenantComplianceProfileEntity } from "./schema/profile-selection";
8
+
9
+ export {
10
+ tenantComplianceProfileEntity,
11
+ tenantComplianceProfileTable,
12
+ } from "./schema/profile-selection";
13
+
14
+ // compliance-profiles — Tenant-weite DSGVO/Compliance-Profile-Wahl.
15
+ //
16
+ // Pflicht beim Tenant-Onboarding (Sprint 1.5 Banner-API). Profile
17
+ // buendelt User-Rights-Grace, Notification-Sprache, Breach-Disclosure,
18
+ // Audit-Retention und Sub-Processor-Anforderungen.
19
+ //
20
+ // Cross-Feature-API: r.exposesApi("compliance.forTenant") — andere
21
+ // Features (user-data-rights in Sprint 2, tenant-lifecycle in Sprint 5)
22
+ // rufen den Profile-Resolver via QN-Pattern (siehe legal-pages →
23
+ // text-content fuer Pattern-Beispiel).
24
+ //
25
+ // Architektur-Note: Profile-Selection lebt als separate Entity
26
+ // (tenantComplianceProfile), nicht als config-key im tenant-Feature.
27
+ // Begruendung in schema/profile-selection.ts.
28
+ export function createComplianceProfilesFeature(): FeatureDefinition {
29
+ return defineFeature("compliance-profiles", (r) => {
30
+ // Standalone — kein r.requires noetig: tenantId kommt aus dem User-
31
+ // Context, Profile-Selection ist eigene Entity, sub-processor-Liste
32
+ // sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
33
+ // r.requires hier rein.
34
+ r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
35
+
36
+ r.exposesApi("compliance.forTenant");
37
+
38
+ const handlers = {
39
+ setProfile: r.writeHandler(setProfileWrite),
40
+ };
41
+
42
+ const queries = {
43
+ forTenant: r.queryHandler(forTenantQuery),
44
+ listProfiles: r.queryHandler(listProfilesQuery),
45
+ subProcessors: r.queryHandler(subProcessorsQuery),
46
+ needsProfile: r.queryHandler(needsProfileQuery),
47
+ };
48
+
49
+ return { handlers, queries };
50
+ });
51
+ }