@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +11 -5
  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
@@ -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
+ }
@@ -0,0 +1,63 @@
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;
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,
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 = JSON.parse(raw) as ComplianceProfileOverride;
50
+ return parsed;
51
+ } catch (e) {
52
+ // Defensiv: ungültiges JSON wird als "kein Override" behandelt. Der
53
+ // set-profile-Handler validiert Zod das Override schon — invalides
54
+ // JSON in der DB ist also nur möglich bei manueller DB-Manipulation
55
+ // oder Migration-Bug. Resolver-Caller darf trotzdem nicht crashen.
56
+ // Operator-Sichtbarkeit via console.warn — Telemetry-Hook spaeter.
57
+ // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
58
+ console.warn(
59
+ `[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${(e as Error).message}`,
60
+ );
61
+ return undefined;
62
+ }
63
+ }
@@ -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;
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,146 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
+ import {
3
+ type ComplianceProfileKey,
4
+ complianceProfileOverrideSchema,
5
+ SELECTABLE_PROFILE_KEYS,
6
+ } from "@cosmicdrift/kumiko-framework/compliance";
7
+ import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
8
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ AccessDeniedError,
11
+ UnprocessableError,
12
+ validationErrorFromZod,
13
+ writeFailure,
14
+ } from "@cosmicdrift/kumiko-framework/errors";
15
+ import { eq } from "drizzle-orm";
16
+ import { z } from "zod";
17
+ import {
18
+ tenantComplianceProfileEntity,
19
+ tenantComplianceProfileTable,
20
+ } from "../schema/profile-selection";
21
+
22
+ const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantComplianceProfileEntity, {
23
+ entityName: "tenant-compliance-profile",
24
+ });
25
+
26
+ // Schema engt sich auf die 3 oeffentlich waehlbaren Profile (Sprint 1.7
27
+ // X1) — minimal-no-region ist Default-Fallback fuer "noch keine Wahl",
28
+ // nicht eine waehlbare Production-Option. Symmetrisch zu
29
+ // SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
30
+ const profileKeySchema = z.enum(
31
+ SELECTABLE_PROFILE_KEYS as readonly [ComplianceProfileKey, ...ComplianceProfileKey[]],
32
+ );
33
+
34
+ // Tenant-Admin setzt Profile-Key + optional Override-JSON.
35
+ //
36
+ // Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
37
+ // wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
38
+ // (modulo aktualisierte Audit-Events im Event-Store).
39
+ //
40
+ // Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
41
+ // anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
42
+ // Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
43
+ // executorUser.tenantId muss = ziel-tenant sein damit der event-store-
44
+ // Stream-Lookup nicht miss → version_conflict gibt (Memory:
45
+ // feedback_event_store_tenant_consistency).
46
+ //
47
+ // Validation:
48
+ // - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
49
+ // - override (optional) muss valides JSON-Object sein
50
+ // - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
51
+ // — verhindert Tippfehler die deepMerge stillschweigend ignoriert
52
+ export const setProfileWrite = defineWriteHandler({
53
+ name: "set-profile",
54
+ schema: z.object({
55
+ profileKey: profileKeySchema,
56
+ override: z.string().nullable().optional(),
57
+ tenantIdOverride: z.string().min(1).optional(),
58
+ }),
59
+ // SystemAdmin kann Profile fuer Customer-Setup setzen (Plattform-
60
+ // Operator-Pfad). TenantAdmin nur fuer eigenen Tenant.
61
+ access: { roles: [ROLES.TenantAdmin, ROLES.SystemAdmin] },
62
+ handler: async (event, ctx) => {
63
+ const tenantOverride = event.payload.tenantIdOverride;
64
+ if (tenantOverride !== undefined && !event.user.roles.includes(ROLES.SystemAdmin)) {
65
+ return writeFailure(
66
+ new AccessDeniedError({
67
+ i18nKey: "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
68
+ details: { reason: "tenant_override_requires_system_admin" },
69
+ }),
70
+ );
71
+ }
72
+ const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId;
73
+ const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
74
+
75
+ // Override-Validation: muss parseables JSON-Object sein UND dem
76
+ // ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
77
+ // mit Top-Level + Sub-Level-Whitelist via .strict()). Tippfehler
78
+ // wie `{ userRights: { weeks: 3 } }` werden hier rejected statt vom
79
+ // deepMerge silent ins Profile gespliced.
80
+ //
81
+ // Errors via writeFailure + Kumiko-Error-Klassen (S1.10 M3) statt
82
+ // throw — landen so mit Path-Detail im response-body statt als
83
+ // generic internal_error.
84
+ if (event.payload.override) {
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(event.payload.override);
88
+ } catch (e) {
89
+ return writeFailure(
90
+ new UnprocessableError("compliance_override_invalid_json", {
91
+ details: {
92
+ reason: "compliance_override_invalid_json",
93
+ parseError: (e as Error).message,
94
+ },
95
+ }),
96
+ );
97
+ }
98
+ const validation = complianceProfileOverrideSchema.safeParse(parsed);
99
+ if (!validation.success) {
100
+ return writeFailure(validationErrorFromZod(validation.error));
101
+ }
102
+ }
103
+
104
+ // Upsert: existierenden Eintrag suchen
105
+ const existing = (await fetchOne(
106
+ ctx.db,
107
+ tenantComplianceProfileTable,
108
+ eq(tenantComplianceProfileTable["tenantId"], tenantId),
109
+ )) as { id: string; version: number } | null;
110
+
111
+ if (existing) {
112
+ const result = await crud.update(
113
+ {
114
+ id: existing.id,
115
+ version: existing.version,
116
+ changes: {
117
+ profileKey: event.payload.profileKey,
118
+ override: event.payload.override ?? null,
119
+ },
120
+ },
121
+ executorUser,
122
+ ctx.db,
123
+ );
124
+ if (!result.isSuccess) return result;
125
+ return {
126
+ isSuccess: true as const,
127
+ data: { profileKey: event.payload.profileKey, isNew: false },
128
+ };
129
+ }
130
+
131
+ const result = await crud.create(
132
+ {
133
+ profileKey: event.payload.profileKey,
134
+ override: event.payload.override ?? null,
135
+ tenantId,
136
+ },
137
+ executorUser,
138
+ ctx.db,
139
+ );
140
+ if (!result.isSuccess) return result;
141
+ return {
142
+ isSuccess: true as const,
143
+ data: { profileKey: event.payload.profileKey, isNew: true },
144
+ };
145
+ },
146
+ });
@@ -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,61 @@
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;
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,
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
+ return JSON.parse(raw) as ComplianceProfileOverride;
54
+ } catch (e) {
55
+ // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
56
+ console.warn(
57
+ `[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${(e as Error).message}`,
58
+ );
59
+ return undefined;
60
+ }
61
+ }
@@ -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;
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
+ }
@@ -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
+ });