@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,64 @@
1
+ import {
2
+ type ComplianceProfileKey,
3
+ type ComplianceProfileOverride,
4
+ type EffectiveComplianceProfile,
5
+ resolveComplianceProfile,
6
+ } from "@cosmicdrift/kumiko-framework/compliance";
7
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
8
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { eq } from "drizzle-orm";
10
+ import { z } from "zod";
11
+ import { tenantComplianceProfileTable } from "../schema/profile-selection";
12
+
13
+ // Liefert das effektive Compliance-Profile fuer den aktuellen Tenant.
14
+ // Macht den exposesApi-Marker aus feature.ts mit echtem Inhalt.
15
+ //
16
+ // Default-Verhalten (Edge-Case-Decision aus S1.1): kein Profile-
17
+ // Eintrag → minimal-no-region + warning="no-profile-selected".
18
+ // Caller (z.B. user-data-rights in Sprint 2) sieht das warning und
19
+ // kann Onboarding-Banner triggern.
20
+ export const forTenantQuery = defineQueryHandler({
21
+ name: "for-tenant",
22
+ schema: z.object({}),
23
+ access: { openToAll: true },
24
+ handler: async (query, ctx): Promise<EffectiveComplianceProfile> => {
25
+ const row = (await fetchOne(
26
+ ctx.db,
27
+ tenantComplianceProfileTable,
28
+ eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
29
+ )) as { profileKey: string; override: string | null } | null; // @cast-boundary db-runner
30
+
31
+ if (!row) {
32
+ return resolveComplianceProfile({});
33
+ }
34
+
35
+ const override = parseOverride(row.override, query.user.tenantId);
36
+ return resolveComplianceProfile({
37
+ selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
38
+ override,
39
+ });
40
+ },
41
+ });
42
+
43
+ function parseOverride(
44
+ raw: string | null,
45
+ tenantId: string,
46
+ ): ComplianceProfileOverride | undefined {
47
+ if (!raw || raw.trim() === "") return undefined;
48
+ try {
49
+ const parsed: unknown = JSON.parse(raw);
50
+ return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
51
+ } catch (e: unknown) {
52
+ const reason = e instanceof Error ? e.message : String(e);
53
+ // Defensiv: ungültiges JSON wird als "kein Override" behandelt. Der
54
+ // set-profile-Handler validiert Zod das Override schon — invalides
55
+ // JSON in der DB ist also nur möglich bei manueller DB-Manipulation
56
+ // oder Migration-Bug. Resolver-Caller darf trotzdem nicht crashen.
57
+ // Operator-Sichtbarkeit via console.warn — Telemetry-Hook spaeter.
58
+ // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
59
+ console.warn(
60
+ `[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${reason}`,
61
+ );
62
+ return undefined;
63
+ }
64
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ COMPLIANCE_PROFILES,
3
+ type ComplianceProfile,
4
+ type ComplianceProfileKey,
5
+ SELECTABLE_PROFILE_KEYS,
6
+ } from "@cosmicdrift/kumiko-framework/compliance";
7
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { z } from "zod";
9
+
10
+ // Liefert alle waehlbaren Compliance-Profile fuer das Tenant-Onboarding.
11
+ // Pure In-Memory-Read der Constants — keine DB-Abfrage. Kein Caching
12
+ // noetig (modulo Pre-Boot bereits aufgeloest).
13
+ //
14
+ // Filtert minimal-no-region raus — das ist Default-Fallback, nicht
15
+ // auswählbar (Production soll explizite Wahl treffen).
16
+ export const listProfilesQuery = defineQueryHandler({
17
+ name: "list-profiles",
18
+ schema: z.object({}),
19
+ access: { openToAll: true },
20
+ handler: async (): Promise<{ profiles: readonly ComplianceProfileSummary[] }> => {
21
+ return {
22
+ profiles: SELECTABLE_PROFILE_KEYS.map(toSummary),
23
+ };
24
+ },
25
+ });
26
+
27
+ interface ComplianceProfileSummary {
28
+ readonly key: ComplianceProfileKey;
29
+ readonly region: string;
30
+ readonly label: string;
31
+ readonly authorityContact: string;
32
+ readonly languages: readonly string[];
33
+ }
34
+
35
+ function toSummary(key: ComplianceProfileKey): ComplianceProfileSummary {
36
+ const p: ComplianceProfile = COMPLIANCE_PROFILES[key];
37
+ return {
38
+ key: p.key,
39
+ region: p.region,
40
+ label: p.label,
41
+ authorityContact: p.breach.authorityContact,
42
+ languages: p.notifications.languages,
43
+ };
44
+ }
@@ -0,0 +1,56 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
+ import type { ComplianceProfileKey } from "@cosmicdrift/kumiko-framework/compliance";
3
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
4
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { eq } from "drizzle-orm";
6
+ import { z } from "zod";
7
+ import { tenantComplianceProfileTable } from "../schema/profile-selection";
8
+
9
+ // Onboarding-Banner-Trigger fuer Tenant-Admin.
10
+ //
11
+ // Sprint 1.5 — minimaler API-Endpoint, UI-Banner kommt in einem
12
+ // spaeteren UI-Sprint. Reine Read-Query: gibt es einen Eintrag in
13
+ // tenantComplianceProfile fuer den aktuellen Tenant?
14
+ //
15
+ // Wenn nein → Tenant-Admin muss Profile waehlen (Pflicht beim
16
+ // Onboarding). Bis zur Wahl laeuft minimal-no-region mit warning,
17
+ // das in der Tenant-Dashboard-Banner sichtbar gemacht werden soll.
18
+ //
19
+ // Access: TenantAdmin only — der Banner ist nur fuer Tenant-Admins
20
+ // relevant, nicht fuer normale Member.
21
+ export const needsProfileQuery = defineQueryHandler({
22
+ name: "needs-profile",
23
+ schema: z.object({}),
24
+ access: { roles: [ROLES.TenantAdmin] },
25
+ handler: async (query, ctx): Promise<NeedsProfileResponse> => {
26
+ const row = (await fetchOne(
27
+ ctx.db,
28
+ tenantComplianceProfileTable,
29
+ eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
30
+ )) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
31
+
32
+ if (!row) {
33
+ return {
34
+ needsSelection: true,
35
+ currentProfile: null,
36
+ reason: "no_profile_selected",
37
+ };
38
+ }
39
+
40
+ // S1.7 X1: minimal-no-region ist via set-profile (Zod) nicht mehr
41
+ // setzbar. Wenn Sprint 2 einen seedComplianceProfile-Helper liefert
42
+ // der den Migration-Edge-Case einführt, kommt hier wieder ein
43
+ // defensiver Pfad rein — bis dahin: jeder existierende Eintrag ist
44
+ // ein bewusst gewähltes Production-Profile.
45
+ return {
46
+ needsSelection: false,
47
+ currentProfile: row.profileKey,
48
+ };
49
+ },
50
+ });
51
+
52
+ interface NeedsProfileResponse {
53
+ readonly needsSelection: boolean;
54
+ readonly currentProfile: ComplianceProfileKey | null;
55
+ readonly reason?: "no_profile_selected";
56
+ }
@@ -0,0 +1,144 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
+ import {
3
+ complianceProfileOverrideSchema,
4
+ SELECTABLE_PROFILE_KEYS,
5
+ } from "@cosmicdrift/kumiko-framework/compliance";
6
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
7
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ AccessDeniedError,
10
+ UnprocessableError,
11
+ validationErrorFromZod,
12
+ writeFailure,
13
+ } from "@cosmicdrift/kumiko-framework/errors";
14
+ import { eq } from "drizzle-orm";
15
+ import { z } from "zod";
16
+ import {
17
+ tenantComplianceProfileEntity,
18
+ tenantComplianceProfileTable,
19
+ } from "../schema/profile-selection";
20
+
21
+ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantComplianceProfileEntity, {
22
+ entityName: "tenant-compliance-profile",
23
+ });
24
+
25
+ // Schema engt sich auf die 3 oeffentlich waehlbaren Profile (Sprint 1.7
26
+ // X1) — minimal-no-region ist Default-Fallback fuer "noch keine Wahl",
27
+ // nicht eine waehlbare Production-Option. Symmetrisch zu
28
+ // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
29
+ const profileKeySchema = z.enum(SELECTABLE_PROFILE_KEYS);
30
+
31
+ // Tenant-Admin setzt Profile-Key + optional Override-JSON.
32
+ //
33
+ // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
34
+ // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
35
+ // (modulo aktualisierte Audit-Events im Event-Store).
36
+ //
37
+ // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
38
+ // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
39
+ // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
40
+ // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
41
+ // Stream-Lookup nicht miss → version_conflict gibt (Memory:
42
+ // feedback_event_store_tenant_consistency).
43
+ //
44
+ // Validation:
45
+ // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
46
+ // - override (optional) muss valides JSON-Object sein
47
+ // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
48
+ // — verhindert Tippfehler die deepMerge stillschweigend ignoriert
49
+ export const setProfileWrite = defineWriteHandler({
50
+ name: "set-profile",
51
+ schema: z.object({
52
+ profileKey: profileKeySchema,
53
+ override: z.string().nullable().optional(),
54
+ tenantIdOverride: z.string().min(1).optional(),
55
+ }),
56
+ // SystemAdmin kann Profile fuer Customer-Setup setzen (Plattform-
57
+ // Operator-Pfad). TenantAdmin nur fuer eigenen Tenant.
58
+ access: { roles: [ROLES.TenantAdmin, ROLES.SystemAdmin] },
59
+ handler: async (event, ctx) => {
60
+ const tenantOverride = event.payload.tenantIdOverride;
61
+ if (tenantOverride !== undefined && !event.user.roles.includes(ROLES.SystemAdmin)) {
62
+ return writeFailure(
63
+ new AccessDeniedError({
64
+ i18nKey: "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
65
+ details: { reason: "tenant_override_requires_system_admin" },
66
+ }),
67
+ );
68
+ }
69
+ const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId; // @cast-boundary engine-payload
70
+ const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
71
+
72
+ // Override-Validation: muss parseables JSON-Object sein UND dem
73
+ // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
74
+ // mit Top-Level + Sub-Level-Whitelist via .strict()). Tippfehler
75
+ // wie `{ userRights: { weeks: 3 } }` werden hier rejected statt vom
76
+ // deepMerge silent ins Profile gespliced.
77
+ //
78
+ // Errors via writeFailure + Kumiko-Error-Klassen (S1.10 M3) statt
79
+ // throw — landen so mit Path-Detail im response-body statt als
80
+ // generic internal_error.
81
+ if (event.payload.override) {
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(event.payload.override);
85
+ } catch (e: unknown) {
86
+ const parseError = e instanceof Error ? e.message : String(e);
87
+ return writeFailure(
88
+ new UnprocessableError("compliance_override_invalid_json", {
89
+ details: {
90
+ reason: "compliance_override_invalid_json",
91
+ parseError,
92
+ },
93
+ }),
94
+ );
95
+ }
96
+ const validation = complianceProfileOverrideSchema.safeParse(parsed);
97
+ if (!validation.success) {
98
+ return writeFailure(validationErrorFromZod(validation.error));
99
+ }
100
+ }
101
+
102
+ // Upsert: existierenden Eintrag suchen
103
+ const existing = (await fetchOne(
104
+ ctx.db,
105
+ tenantComplianceProfileTable,
106
+ eq(tenantComplianceProfileTable["tenantId"], tenantId),
107
+ )) as { id: string; version: number } | null; // @cast-boundary db-runner
108
+
109
+ if (existing) {
110
+ const result = await crud.update(
111
+ {
112
+ id: existing.id,
113
+ version: existing.version,
114
+ changes: {
115
+ profileKey: event.payload.profileKey,
116
+ override: event.payload.override ?? null,
117
+ },
118
+ },
119
+ executorUser,
120
+ ctx.db,
121
+ );
122
+ if (!result.isSuccess) return result;
123
+ return {
124
+ isSuccess: true as const,
125
+ data: { profileKey: event.payload.profileKey, isNew: false },
126
+ };
127
+ }
128
+
129
+ const result = await crud.create(
130
+ {
131
+ profileKey: event.payload.profileKey,
132
+ override: event.payload.override ?? null,
133
+ tenantId,
134
+ },
135
+ executorUser,
136
+ ctx.db,
137
+ );
138
+ if (!result.isSuccess) return result;
139
+ return {
140
+ isSuccess: true as const,
141
+ data: { profileKey: event.payload.profileKey, isNew: true },
142
+ };
143
+ },
144
+ });
@@ -0,0 +1,43 @@
1
+ import {
2
+ getActiveSubProcessors,
3
+ getPlannedSubProcessors,
4
+ KUMIKO_SUB_PROCESSORS,
5
+ type SubProcessor,
6
+ } from "@cosmicdrift/kumiko-framework/compliance";
7
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
9
+ import { z } from "zod";
10
+
11
+ // Public Sub-Processor-Liste — anonymous accessible (Memory:
12
+ // project_anonymous_access). Matched die DSGVO Art. 28(2) Pflicht
13
+ // dass die Liste der Auftragsverarbeiter oeffentlich einsehbar ist
14
+ // (typisch verlinkt aus Datenschutzerklaerung + AVV).
15
+ //
16
+ // Format: JSON mit getrennten active/planned-Sektionen. Tenant-Admins
17
+ // kriegen Notification ueber Aenderungen via Cron-Job (Sprint 1.5+
18
+ // oder S9 compliance-as-product). RSS-Feed kommt in S9.
19
+ //
20
+ // Zwei Auflistungen statt einer flachen Liste:
21
+ // - `active`: aktuell eingesetzte Sub-Processors
22
+ // - `planned`: bekannte zukünftige Sub-Processors (Tenant-Admin-
23
+ // Lead-Time bevor sie aktiv werden — typisch bei AI/Stripe)
24
+ export const subProcessorsQuery = defineQueryHandler({
25
+ name: "sub-processors",
26
+ schema: z.object({}),
27
+ access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
28
+ handler: async (): Promise<SubProcessorListResponse> => {
29
+ return {
30
+ active: [...getActiveSubProcessors()],
31
+ planned: [...getPlannedSubProcessors()],
32
+ generatedAt: getTemporal().Now.instant().toString(),
33
+ total: KUMIKO_SUB_PROCESSORS.length,
34
+ };
35
+ },
36
+ });
37
+
38
+ interface SubProcessorListResponse {
39
+ readonly active: readonly SubProcessor[];
40
+ readonly planned: readonly SubProcessor[];
41
+ readonly generatedAt: string;
42
+ readonly total: number;
43
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createComplianceProfilesFeature,
3
+ tenantComplianceProfileEntity,
4
+ tenantComplianceProfileTable,
5
+ } from "./feature";
6
+ export { resolveProfileForTenant } from "./resolve-for-tenant";
@@ -0,0 +1,63 @@
1
+ // Direkter Resolver-Helper fuer Bulk-Iteration ohne dispatcher-Roundtrip.
2
+ //
3
+ // `for-tenant`-Query (handlers/for-tenant.query.ts) ist die Cross-Feature-
4
+ // API fuer Handler-Pfade. Worker (S2.U3 Atom 3b) lebt im JobContext
5
+ // ohne `queryAs` — braucht direkten DB-Lookup + resolveComplianceProfile.
6
+ //
7
+ // Pattern matched data-retention's `resolveRetentionPolicyForTenant`.
8
+ // Beide Pfade nutzen `resolveComplianceProfile` aus framework/compliance,
9
+ // also kein Drift zwischen Query-API und Worker-Pfad.
10
+
11
+ import {
12
+ type ComplianceProfileKey,
13
+ type ComplianceProfileOverride,
14
+ type EffectiveComplianceProfile,
15
+ resolveComplianceProfile,
16
+ } from "@cosmicdrift/kumiko-framework/compliance";
17
+ import { type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
18
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { eq } from "drizzle-orm";
20
+ import { tenantComplianceProfileTable } from "./schema/profile-selection";
21
+
22
+ export interface ResolveProfileForTenantArgs {
23
+ readonly db: DbRunner;
24
+ readonly tenantId: TenantId;
25
+ }
26
+
27
+ export async function resolveProfileForTenant(
28
+ args: ResolveProfileForTenantArgs,
29
+ ): Promise<EffectiveComplianceProfile> {
30
+ const row = (await fetchOne(
31
+ args.db,
32
+ tenantComplianceProfileTable,
33
+ eq(tenantComplianceProfileTable["tenantId"], args.tenantId),
34
+ )) as { profileKey: string; override: string | null } | null; // @cast-boundary db-runner
35
+
36
+ if (!row) {
37
+ return resolveComplianceProfile({});
38
+ }
39
+
40
+ const override = parseOverride(row.override, args.tenantId);
41
+ return resolveComplianceProfile({
42
+ selection: row.profileKey as ComplianceProfileKey, // @cast-boundary engine-payload
43
+ override,
44
+ });
45
+ }
46
+
47
+ function parseOverride(
48
+ raw: string | null,
49
+ tenantId: string,
50
+ ): ComplianceProfileOverride | undefined {
51
+ if (!raw || raw.trim() === "") return undefined;
52
+ try {
53
+ const parsed: unknown = JSON.parse(raw);
54
+ return parsed as ComplianceProfileOverride; // @cast-boundary engine-payload
55
+ } catch (e: unknown) {
56
+ const reason = e instanceof Error ? e.message : String(e);
57
+ // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
58
+ console.warn(
59
+ `[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${reason}`,
60
+ );
61
+ return undefined;
62
+ }
63
+ }
@@ -0,0 +1,52 @@
1
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createLongTextField,
5
+ createSelectField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // Tenant-1-zu-1: Pro Tenant genau eine Profile-Wahl.
9
+ //
10
+ // Architektur-Entscheidung (2026-05-06): Profile-Selection lebt als
11
+ // separate Entity im compliance-profiles-Feature, NICHT als config-key
12
+ // im tenant-Feature. Begruendung:
13
+ // (a) override ist strukturiertes JSON, config-key-Pattern (timezone/
14
+ // locale) ist key-value-flach
15
+ // (b) Profile-Wechsel ist audit-relevant — Event-Store gibt das
16
+ // automatisch fuer Entity-Writes
17
+ // (c) Plan-Files in docs/plans/datenschutz/compliance-profiles.md
18
+ // nennen sie explizit als tenantComplianceProfile-Entity
19
+ //
20
+ // Wer in 6 Monaten zweifelt warum nicht config-key: siehe oben.
21
+ //
22
+ // override als JSON-String in longText: kein dedizierter jsonField-Typ
23
+ // im Framework; embedded hat festes Schema, das hier dynamisch ist.
24
+ // Zod-Validation beim set-profile-Handler stellt Schema-Konformitaet
25
+ // sicher.
26
+ export const tenantComplianceProfileEntity = createEntity({
27
+ table: "read_tenant_compliance_profiles",
28
+ fields: {
29
+ profileKey: createSelectField({
30
+ required: true,
31
+ options: ["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb", "minimal-no-region"] as const,
32
+ }),
33
+ // override: JSON-String mit Partial-ComplianceProfile. NULL/leer
34
+ // bedeutet "Default-Profile, keine Override". Validiert beim
35
+ // set-profile-Handler via Zod.
36
+ override: createLongTextField({
37
+ allowPlaintext: "is-business-data",
38
+ }),
39
+ },
40
+ indexes: [
41
+ // Pro Tenant nur EIN Profile-Datensatz. Boot-Validator-Comment in
42
+ // EntityIndexDef warnt vor single-column-tenantId-Index als redundant
43
+ // — UNIQUE-Constraint ist hier aber semantisch noetig (1:1-Relation)
44
+ // und nicht nur Performance-Hint, daher explizit deklariert.
45
+ { unique: true, columns: ["tenantId"] },
46
+ ],
47
+ });
48
+
49
+ export const tenantComplianceProfileTable = buildDrizzleTable(
50
+ "tenantComplianceProfile",
51
+ tenantComplianceProfileEntity,
52
+ );
@@ -0,0 +1,96 @@
1
+ // Test-Helper für compliance-profiles. Legt einen Profile-Eintrag direkt
2
+ // über den Event-Store-Executor an — gleicher Pfad wie der echte
3
+ // set-profile-Handler, aber ohne Zod-Schema-Engung (akzeptiert
4
+ // minimal-no-region für Migration-Edge-Case-Tests) und ohne Access-
5
+ // Check. Idempotent: zweiter Call mit gleichem tenantId updated.
6
+ //
7
+ // Sprint 2 user-data-rights nutzt das fuer Test-Setup ("user kann
8
+ // Daten exportieren mit profile X" — pro Test ein frischer Tenant +
9
+ // Profile-Wahl in einem Helper-Call).
10
+ //
11
+ // Pattern matched seedTextBlock aus text-content.
12
+
13
+ import type {
14
+ ComplianceProfileKey,
15
+ ComplianceProfileOverride,
16
+ } from "@cosmicdrift/kumiko-framework/compliance";
17
+ import {
18
+ createEventStoreExecutor,
19
+ createTenantDb,
20
+ type DbConnection,
21
+ fetchOne,
22
+ } from "@cosmicdrift/kumiko-framework/db";
23
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { eq } from "drizzle-orm";
26
+ import {
27
+ tenantComplianceProfileEntity,
28
+ tenantComplianceProfileTable,
29
+ } from "./schema/profile-selection";
30
+
31
+ const executor = createEventStoreExecutor(
32
+ tenantComplianceProfileTable,
33
+ tenantComplianceProfileEntity,
34
+ { entityName: "tenant-compliance-profile" },
35
+ );
36
+
37
+ export type SeedComplianceProfileOptions = {
38
+ readonly tenantId: TenantId;
39
+ readonly profileKey: ComplianceProfileKey;
40
+ readonly override?: ComplianceProfileOverride;
41
+ readonly by?: SessionUser;
42
+ };
43
+
44
+ export async function seedComplianceProfile(
45
+ db: DbConnection,
46
+ opts: SeedComplianceProfileOptions,
47
+ ): Promise<{ id: string | number }> {
48
+ // user.tenantId muss === opts.tenantId sein damit Event-Store-Stream
49
+ // + Projection im selben Tenant-Bucket landen (Memory:
50
+ // feedback_event_store_tenant_consistency).
51
+ const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
52
+ const tdb = createTenantDb(db, opts.tenantId, "system");
53
+ const overrideJson = opts.override !== undefined ? JSON.stringify(opts.override) : null;
54
+
55
+ const existing = (await fetchOne(
56
+ db,
57
+ tenantComplianceProfileTable,
58
+ eq(tenantComplianceProfileTable["tenantId"], opts.tenantId),
59
+ )) as { id: string; version: number } | null; // @cast-boundary db-runner
60
+
61
+ if (existing) {
62
+ const result = await executor.update(
63
+ {
64
+ id: existing.id,
65
+ version: existing.version,
66
+ changes: { profileKey: opts.profileKey, override: overrideJson },
67
+ },
68
+ by,
69
+ tdb,
70
+ );
71
+ if (!result.isSuccess) {
72
+ throw new Error(`seedComplianceProfile update failed: ${JSON.stringify(result)}`);
73
+ }
74
+ return { id: existing.id };
75
+ }
76
+
77
+ const result = await executor.create(
78
+ {
79
+ profileKey: opts.profileKey,
80
+ override: overrideJson,
81
+ tenantId: opts.tenantId,
82
+ },
83
+ by,
84
+ tdb,
85
+ );
86
+ if (!result.isSuccess) {
87
+ throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
88
+ }
89
+ // @cast-boundary db-row: executor.create-result enthält die inserted
90
+ // Row als Record<string, unknown>; id ist nach INSERT garantiert.
91
+ const data = result.data as { id?: string };
92
+ if (data.id === undefined) {
93
+ throw new Error("seedComplianceProfile: executor.create did not return an id");
94
+ }
95
+ return { id: data.id };
96
+ }
@@ -179,7 +179,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
179
179
 
180
180
  const result = new Map<string, ConfigRow>();
181
181
  for (const row of rows) {
182
- const r = row as ConfigRow;
182
+ const r = row as ConfigRow; // @cast-boundary db-row
183
183
  // Higher specificity wins: user > tenant > system. Under the ES
184
184
  // schema system rows carry SYSTEM_TENANT_ID instead of NULL, so the
185
185
  // "tenant set" check compares against the sentinel rather than null.
@@ -0,0 +1,49 @@
1
+ // Boot-Smoke-Test (S2.D2.5 M4) — verifiziert dass das data-retention-
2
+ // Feature im setupTestStack hochfaehrt + Entity-Schema valide ist.
3
+ //
4
+ // Tiefere Integration (Cleanup-Job mit DB-Operations + Strategy-Dispatch
5
+ // + Cron-Trigger) kommt in S2.D2b. Dieser Smoke-Test fanngt frueh ab
6
+ // ob Boot-Validation oder Entity-Definition gebrochen ist — pre-S2.D2b
7
+ // Sicherheitsnetz.
8
+
9
+ import {
10
+ setupTestStack,
11
+ type TestStack,
12
+ unsafeCreateEntityTable,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
15
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
16
+
17
+ let stack: TestStack;
18
+
19
+ const feature = createDataRetentionFeature();
20
+
21
+ beforeAll(async () => {
22
+ stack = await setupTestStack({ features: [feature] });
23
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
24
+ });
25
+
26
+ afterAll(async () => {
27
+ await stack.cleanup();
28
+ });
29
+
30
+ describe("data-retention :: feature-definition smoke", () => {
31
+ test("Feature laedt clean (Boot-Validation passed)", () => {
32
+ // setupTestStack hat das Feature im beforeAll geladen — wenn Boot-
33
+ // Validation fehlschlaegt, waere dieser Block nie gelaufen.
34
+ expect(stack).toBeDefined();
35
+ expect(feature.name).toBe("data-retention");
36
+ });
37
+
38
+ test("tenantRetentionOverride-Entity ist registriert", () => {
39
+ expect(feature.entities["tenant-retention-override"]).toBeDefined();
40
+ });
41
+
42
+ test("Entity-Definition hat UNIQUE(tenantId, entityName) als 1:1-Constraint", () => {
43
+ const entity = feature.entities["tenant-retention-override"];
44
+ const indexes = entity?.indexes ?? [];
45
+ const uniqueIndex = indexes.find((i) => i.unique === true);
46
+ expect(uniqueIndex).toBeDefined();
47
+ expect(uniqueIndex?.columns).toEqual(["tenantId", "entityName"]);
48
+ });
49
+ });