@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,113 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
3
+ // in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
4
+ // und liefert den treeProvider der Text-Blocks aus der by-tenant Query
5
+ // lädt, nach Slug-Prefix gruppiert und als TreeNode[] emitted.
6
+ //
7
+ // **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
8
+ // werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
9
+ // Trenner landen als Top-Level-Knoten. Beispiele:
10
+ // - "page:index:hero.title" → folder "page", label "index:hero.title"
11
+ // - "imprint" → root-node, label "imprint"
12
+ // V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
13
+ //
14
+ // **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
15
+ // "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
16
+ //
17
+ // **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
18
+ // ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
19
+ // updated-Events propagiert werden.
20
+
21
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
22
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
23
+ import { TextContentQueries } from "../constants";
24
+
25
+ type BlockSummary = {
26
+ readonly slug: string;
27
+ readonly lang: string;
28
+ readonly title: string;
29
+ readonly body: string | null;
30
+ readonly updatedAt: string;
31
+ };
32
+
33
+ type ByTenantResponse = {
34
+ readonly data: { readonly blocks: readonly BlockSummary[] };
35
+ };
36
+
37
+ // Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
38
+ // der Slug keinen Trenner enthält (dann landet er als Root-Node).
39
+ function getFolderName(slug: string): string | undefined {
40
+ const sepIdx = slug.search(/[:/]/);
41
+ if (sepIdx === -1) return undefined;
42
+ return slug.slice(0, sepIdx);
43
+ }
44
+
45
+ function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
46
+ const rootNodes: TreeNode[] = [];
47
+ const folders = new Map<string, TreeNode[]>();
48
+
49
+ for (const block of blocks) {
50
+ const node: TreeNode = {
51
+ label: block.title || block.slug,
52
+ target: {
53
+ featureId: "text-content",
54
+ action: "edit",
55
+ args: { slug: block.slug, lang: block.lang },
56
+ },
57
+ state: block.body ? "filled" : "stub",
58
+ };
59
+
60
+ const folderName = getFolderName(block.slug);
61
+ if (folderName === undefined) {
62
+ rootNodes.push(node);
63
+ } else {
64
+ const existing = folders.get(folderName) ?? [];
65
+ existing.push(node);
66
+ folders.set(folderName, existing);
67
+ }
68
+ }
69
+
70
+ for (const [name, children] of folders) {
71
+ rootNodes.push({
72
+ label: name,
73
+ icon: "folder",
74
+ state: "filled",
75
+ children,
76
+ });
77
+ }
78
+
79
+ return rootNodes;
80
+ }
81
+
82
+ const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
83
+ fetch("/api/query", {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({
87
+ type: TextContentQueries.byTenant,
88
+ payload: {},
89
+ }),
90
+ })
91
+ .then((r) => r.json())
92
+ .then((data: ByTenantResponse) => {
93
+ const nodes = groupBlocksBySlugPrefix(data.data.blocks);
94
+ emit(nodes);
95
+ })
96
+ .catch(() => {
97
+ // V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
98
+ emit([]);
99
+ });
100
+ return () => {};
101
+ };
102
+
103
+ export function textContentClient(): ClientFeatureDefinition {
104
+ return {
105
+ name: "text-content",
106
+ treeProvider,
107
+ treeActions: {
108
+ edit: { args: { slug: "" as string, lang: "" as string } },
109
+ list: {},
110
+ create: { args: { folder: "" as string } },
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des text-content Features.
3
+ // Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
4
+ // konsumiert — die Server-Seite (defineFeature) lebt in
5
+ // `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
6
+ // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
+
8
+ export { textContentClient } from "./client-plugin";
@@ -0,0 +1,118 @@
1
+ // auto-default-tier hook regression test — beweist dass beim
2
+ // `tenant:write:create` der postSave-Hook von createTierEngineFeature
3
+ // fired und automatisch ein tier-assignment-row mit `defaultTier`
4
+ // schreibt.
5
+ //
6
+ // **Warum dieser Test existiert (2026-05-10):**
7
+ // Der auto-default-tier-Hook wurde in Sprint 8a Phase 2 hinzugefügt aber
8
+ // nie direkt getestet. Bei Sprint 8c (Studio-Mount mit auto-default-
9
+ // compliance-companion-hook) flog der Bug auf: `ctx.db as DbConnection`
10
+ // war ein Type-Lie — TenantDb exposed select/insert/update/delete, NICHT
11
+ // execute(). Der event-store-append (event-store.ts:102) ruft
12
+ // `db.execute(sql\`SELECT pg_notify(...)\`)` → TypeError. Fix:
13
+ // `ctx.db.raw as DbConnection` (Pattern aus signup-confirm.write.ts:107).
14
+ //
15
+ // Pin-Verträge:
16
+ // 1. tenant:write:create fired postSave-Hook mit isNew=true
17
+ // 2. Hook erstellt tier-assignment-row im NEUEN tenant (nicht im caller-
18
+ // tenant — Memory `feedback_event_store_tenant_consistency`)
19
+ // 3. Idempotency: tenant-update fired keinen weiteren row
20
+
21
+ import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";
22
+ import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
23
+ import {
24
+ createTestUser,
25
+ setupTestStack,
26
+ type TestStack,
27
+ unsafePushTables,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { eq } from "drizzle-orm";
30
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
31
+ import { configValuesTable } from "../../config";
32
+ import { TenantHandlers, tenantMembershipsTable, tenantTable } from "../../tenant";
33
+ import { userTable } from "../../user";
34
+ import type { TierMap } from "../compose-app";
35
+ import { tierAssignmentEntity } from "../entity";
36
+ import { createTierEngineFeature } from "../feature";
37
+
38
+ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
39
+ free: { features: [], caps: { maxItems: 1 } },
40
+ };
41
+
42
+ const tierAssignmentTable = buildDrizzleTable("tier-assignment", tierAssignmentEntity);
43
+
44
+ const features = composeFeatures(
45
+ [createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP })],
46
+ { includeBundled: true },
47
+ );
48
+
49
+ let stack: TestStack;
50
+ const PLATFORM_TENANT = "00000000-0000-4000-8000-000000000001";
51
+ const sysadmin = createTestUser({
52
+ id: "platform-sysadmin",
53
+ tenantId: PLATFORM_TENANT,
54
+ roles: ["SystemAdmin"],
55
+ });
56
+
57
+ beforeAll(async () => {
58
+ stack = await setupTestStack({ features });
59
+ await unsafePushTables(stack.db, {
60
+ config_values: configValuesTable,
61
+ users: userTable,
62
+ tenants: tenantTable,
63
+ tenant_memberships: tenantMembershipsTable,
64
+ tier_assignments: tierAssignmentTable,
65
+ });
66
+ });
67
+
68
+ afterAll(async () => stack?.cleanup());
69
+
70
+ describe("auto-default-tier postSave hook on tenant-create", () => {
71
+ test("sysadmin creates a new tenant → free tier-assignment-row angelegt", async () => {
72
+ const data = (await stack.http.writeOk<Record<string, unknown>>(
73
+ TenantHandlers.create,
74
+ { key: "test-tenant-1", name: "Test Tenant One" },
75
+ sysadmin,
76
+ ))!;
77
+ const newTenantId = data["id"] as string;
78
+ expect(typeof newTenantId).toBe("string");
79
+
80
+ const rows = await stack.db
81
+ .select()
82
+ .from(tierAssignmentTable)
83
+ .where(eq(tierAssignmentTable["tenantId"], newTenantId));
84
+ expect(rows.length).toBe(1);
85
+ expect((rows[0] as Record<string, unknown>)["tier"]).toBe("free");
86
+ });
87
+
88
+ test("idempotency: tenant-update fired keinen weiteren tier-assignment-row", async () => {
89
+ const created = (await stack.http.writeOk<Record<string, unknown>>(
90
+ TenantHandlers.create,
91
+ { key: "test-tenant-2", name: "Test Tenant Two" },
92
+ sysadmin,
93
+ ))!;
94
+ const tenantId = created["id"] as string;
95
+
96
+ const existing = (await stack.db
97
+ .select()
98
+ .from(tenantTable)
99
+ .where(eq(tenantTable["id"], tenantId))) as Array<{ id: string; version: number }>;
100
+ const currentVersion = existing[0]!.version;
101
+
102
+ await stack.http.writeOk(
103
+ TenantHandlers.update,
104
+ {
105
+ id: tenantId,
106
+ version: currentVersion,
107
+ changes: { name: "Test Tenant Two — Renamed" },
108
+ },
109
+ sysadmin,
110
+ );
111
+
112
+ const rows = await stack.db
113
+ .select()
114
+ .from(tierAssignmentTable)
115
+ .where(eq(tierAssignmentTable["tenantId"], tenantId));
116
+ expect(rows.length).toBe(1);
117
+ });
118
+ });
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
197
197
  // Invalidation: tier-assignment events update the cache.
198
198
  r.entityHook("postSave", "tier-assignment", async (result) => {
199
199
  // result.data has tenantId + tier (after entity-update merge)
200
- const data = result.data as { tenantId?: unknown; tier?: unknown };
200
+ const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
201
201
  // skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
202
202
  // entity-events sind beide fields immer strings; ein malformed-payload
203
203
  // (custom-handler-bug) würde hier silent zum cache-skip führen statt
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
210
210
  );
211
211
  });
212
212
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
213
- const data = payload.data as { tenantId?: unknown };
213
+ const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
214
214
  // skip: gleiche type-guard semantik wie postSave-hook oben.
215
215
  if (typeof data.tenantId !== "string") return;
216
216
  cache.delete(data.tenantId as TenantId);
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
235
235
  "tenant",
236
236
  async (result, ctx) => {
237
237
  // result-shape: kumiko-framework's SaveContext mit isNew + data
238
- const saveResult = result as { isNew?: unknown; data?: unknown };
238
+ const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
239
239
  // skip: nur bei tenant-create (initial) — tenant-updates feuern
240
240
  // auch postSave aber wir wollen kein neues tier-assignment bei
241
241
  // re-keying oder name-update.
242
242
  if (saveResult.isNew !== true) return;
243
- const data = saveResult.data as { id?: unknown };
243
+ const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
244
244
  // skip: defensive type-guard. Tenant-entity hat id zwingend, aber
245
245
  // CrudExecutor's payload-shape ist runtime-unknown.
246
246
  if (typeof data.id !== "string") return;
247
- const newTenantId = data.id as TenantId;
247
+ const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
248
248
  const aggregateId = tierAssignmentAggregateId(newTenantId);
249
249
 
250
250
  // skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
@@ -253,12 +253,22 @@ export function createTierEngineFeature<
253
253
  if (!ctx.db) return;
254
254
 
255
255
  // ctx.db ist im inTransaction-phase eine TenantDb (tenant-scoped
256
- // proxy auf die echte TX). Für event-store-reads (cross-tenant
257
- // stream-lookup via aggregate-id) brauchen wir die rohe TX —
258
- // TenantDb wrapped die echte DbConnection, der select-call
259
- // funktioniert structural identisch. Cast als DbConnection ist
260
- // boundary-cast für event-store-API, kein narrowing-escape.
261
- const rawDb = ctx.db as DbConnection;
256
+ // proxy auf die echte TX). Für event-store-Pfade brauchen wir
257
+ // die rohe DbConnection TenantDb exposes nur select/insert/
258
+ // update/delete, NICHT execute (event-store-append.ts:102 ruft
259
+ // db.execute(sql`SELECT pg_notify(...)`) TypeError sonst).
260
+ // Pattern matched signup-confirm.write.ts:107 (.raw), nicht
261
+ // `as DbConnection` das ist Type-Lie der erst beim ersten
262
+ // .execute()-Call crashed.
263
+ //
264
+ // AppContext.db ist union (DbConnection | TenantDb). Im
265
+ // inTransaction-phase garantiert TenantDb — der dispatcher
266
+ // wrapped vorher (siehe pipeline/dispatcher.ts createTenantDb-
267
+ // Aufruf). TypeGuard via `"raw" in ...` ist robuster als
268
+ // `as TenantDb` gegen future refactor.
269
+ // skip: defensive — sollte im inTransaction nie greifen.
270
+ if (!("raw" in ctx.db)) return;
271
+ const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
262
272
 
263
273
  // Idempotency: stream-existence-check vor create. Pattern aus
264
274
  // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
@@ -266,7 +276,7 @@ export function createTierEngineFeature<
266
276
  const [streamRow] = (await rawDb
267
277
  .select({ v: maxFn(eventsTable.version) })
268
278
  .from(eventsTable)
269
- .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
279
+ .where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
270
280
  // skip: idempotency — aggregate-stream existiert schon (re-replay
271
281
  // nach projection-rebuild oder hook-retry). create() würde
272
282
  // version_conflict werfen + tenant-create rollback'n. Pattern aus
@@ -326,7 +336,7 @@ export function createTierEngineFeature<
326
336
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
327
337
  // Bedürfnis entsteht.
328
338
  type AssignmentRow = { tenantId: string; tier: string };
329
- const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
339
+ const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
330
340
  for (const row of rows) {
331
341
  cache.set(
332
342
  row.tenantId as TenantId,
@@ -0,0 +1,39 @@
1
+ // Drift-Guard fuer USER_STATUS (S2.D2.5 N1).
2
+ //
3
+ // Plus: USER_STATUS_OPTIONS wird nicht direkt exportiert (private im
4
+ // schema/user.ts), wird aber via createSelectField in der Entity
5
+ // referenziert. Wenn USER_STATUS-Object erweitert wird ohne dass das
6
+ // Tuple synchron mitwaechst, faengt der Test es ab — entity.fields.status
7
+ // liefert die options-Liste.
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { USER_STATUS, userEntity } from "../schema/user";
11
+
12
+ describe("USER_STATUS — Drift-Guard (S2.D2.5 N1)", () => {
13
+ test("Snapshot-Vergleich: USER_STATUS-Object und entity.fields.status.options synchron", () => {
14
+ const objectValues = Object.values(USER_STATUS).sort();
15
+ // entity.fields.status ist createSelectField — options ist die Tuple
16
+ const statusField = userEntity.fields["status"] as { options: readonly string[] };
17
+ const optionValues = [...statusField.options].sort();
18
+
19
+ expect(optionValues).toEqual(objectValues);
20
+ });
21
+
22
+ test("USER_STATUS-Snapshot — explizit zu updaten bei Aenderungen", () => {
23
+ expect(USER_STATUS).toMatchInlineSnapshot(`
24
+ {
25
+ "Active": "active",
26
+ "Deleted": "deleted",
27
+ "DeletionRequested": "deletionRequested",
28
+ "Restricted": "restricted",
29
+ }
30
+ `);
31
+ });
32
+
33
+ test("USER_STATUS-Werte sind camelCase (Convention fuer status-Strings)", () => {
34
+ // Erwartung: alle Werte starten mit lowercase und enthalten kein Leerzeichen
35
+ for (const value of Object.values(USER_STATUS)) {
36
+ expect(value).toMatch(/^[a-z][a-zA-Z]*$/);
37
+ }
38
+ });
39
+ });
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
30
30
  const condition =
31
31
  query.payload.email !== undefined
32
32
  ? eq(userTable["email"], query.payload.email)
33
- : eq(userTable["id"], query.payload.id as string);
33
+ : eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
34
34
 
35
35
  const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
36
36
  return (row as DbRow) ?? null;
package/src/user/index.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  export { UserCommandSchemas } from "./command-schemas";
2
2
  export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
3
3
  export { createUserFeature } from "./feature";
4
- export { userEntity, userTable } from "./schema/user";
4
+ export type { UserStatus } from "./schema/user";
5
+ export {
6
+ USER_ANONYMIZED_DISPLAY_NAME,
7
+ USER_ANONYMIZED_EMAIL_DOMAIN,
8
+ USER_ANONYMIZED_EMAIL_PREFIX,
9
+ USER_DELETED_DISPLAY_NAME,
10
+ USER_DELETED_EMAIL_PREFIX,
11
+ USER_STATUS,
12
+ userEntity,
13
+ userTable,
14
+ } from "./schema/user";
@@ -3,9 +3,54 @@ import {
3
3
  access,
4
4
  createBooleanField,
5
5
  createEntity,
6
+ createSelectField,
6
7
  createTextField,
8
+ createTimestampField,
7
9
  } from "@cosmicdrift/kumiko-framework/engine";
8
10
 
11
+ /**
12
+ * User-Lifecycle-Status (S2.U1). Single source of truth — Auth-Middleware
13
+ * (S2.U6), Forget-Job (S2.U5) und Restriction-Handler nutzen diese
14
+ * Constants statt Magic-Strings (Memory feedback_role_naming_drift —
15
+ * gleiches Pattern wie ROLES.SystemAdmin).
16
+ */
17
+ export const USER_STATUS = {
18
+ Active: "active",
19
+ Restricted: "restricted",
20
+ DeletionRequested: "deletionRequested",
21
+ Deleted: "deleted",
22
+ } as const;
23
+
24
+ export type UserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS];
25
+
26
+ /**
27
+ * Anonymize-Display-Strings fuer userDeleteHook (S2.H1). Constants statt
28
+ * Magic-Strings damit i18n-Mapping moeglich + drift-fest. Default-DE,
29
+ * App-Author kann via i18n-System uebersetzen wenn gewuenscht.
30
+ */
31
+ export const USER_DELETED_DISPLAY_NAME = "[Geloescht]";
32
+ export const USER_ANONYMIZED_DISPLAY_NAME = "[Anonymisiert]";
33
+
34
+ /**
35
+ * Email-Pseudonyme nach Forget. `<prefix>-<userId>@anonymized.invalid`
36
+ * — der userId-Suffix ist als Pseudo-Audit-Marker fuer Operator
37
+ * (Tracing-fall) erlaubt; user-id selbst ist UUID, kein PII.
38
+ * `.invalid`-TLD ist RFC2606-reserviert — niemals deliverbare Email.
39
+ */
40
+ export const USER_DELETED_EMAIL_PREFIX = "deleted";
41
+ export const USER_ANONYMIZED_EMAIL_PREFIX = "anonymized";
42
+ export const USER_ANONYMIZED_EMAIL_DOMAIN = "anonymized.invalid";
43
+
44
+ // Tuple form fuer createSelectField (erfordert non-empty readonly tuple).
45
+ // Object.values(USER_STATUS) waere string[] — statisches Tuple ist
46
+ // type-sicher.
47
+ const USER_STATUS_OPTIONS = [
48
+ USER_STATUS.Active,
49
+ USER_STATUS.Restricted,
50
+ USER_STATUS.DeletionRequested,
51
+ USER_STATUS.Deleted,
52
+ ] as const;
53
+
9
54
  // User entity — tenant-agnostic. A single user can belong to multiple tenants
10
55
  // via tenantMemberships. No tenantId column on this table.
11
56
  export const userEntity = createEntity({
@@ -63,6 +108,37 @@ export const userEntity = createEntity({
63
108
  default: "[]",
64
109
  access: { write: access.privileged },
65
110
  }),
111
+
112
+ // S2.U1: User-Lifecycle-Status für user-data-rights (Sprint 2).
113
+ // - "active": Normaler State, alle Operationen erlaubt
114
+ // - "restricted": Art. 18 Restriction — Auth-Middleware blockiert
115
+ // Schreib-Endpoints, Read bleibt erlaubt damit
116
+ // User das Banner sieht + lift-restriction klicken kann
117
+ // - "deletionRequested": delete-account aufgerufen, gracePeriodEnd
118
+ // gesetzt, User kann via cancel-deletion zurueck
119
+ // auf "active". Auth-Middleware blockiert wie
120
+ // "restricted".
121
+ // - "deleted": Forget executed nach Grace, Row anonymisiert via
122
+ // softDelete. Auth-Middleware blockt Login.
123
+ //
124
+ // Schreibrecht privileged: nur die request-deletion / restrict / lift /
125
+ // execute-forget-Handler (alle SYSTEM-context) duerfen status flippen.
126
+ status: createSelectField({
127
+ required: true,
128
+ default: USER_STATUS.Active,
129
+ options: USER_STATUS_OPTIONS,
130
+ access: { write: access.privileged },
131
+ }),
132
+
133
+ // Wann darf der pending-Forget tatsaechlich ausgefuehrt werden?
134
+ // Cron-Job in user-data-rights checkt taeglich gracePeriodEnd < now()
135
+ // und triggert dann die EXT_USER_DATA-Hooks. NULL solange kein
136
+ // Forget pending — wird beim delete-account-Call gesetzt
137
+ // (= now() + Compliance-Profile.userRights.gracePeriod), beim
138
+ // cancel-deletion zurueckgesetzt.
139
+ gracePeriodEnd: createTimestampField({
140
+ access: { write: access.privileged },
141
+ }),
66
142
  },
67
143
  });
68
144
 
@@ -57,7 +57,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
57
57
  const tdb = createTenantDb(db, by.tenantId, "system");
58
58
 
59
59
  const existing = await fetchOne(db, userTable, eq(userTable["email"], options.email));
60
- if (existing) return existing["id"] as string;
60
+ if (existing) return existing["id"] as string; // @cast-boundary db-row
61
61
 
62
62
  const result = await userExecutor.create(
63
63
  {
@@ -86,7 +86,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
86
86
  // als ehrlicher Throw rauskommt statt downstream als undefined-Bug.
87
87
  function extractId(data: unknown, who: string): string {
88
88
  if (typeof data === "object" && data !== null && "id" in data) {
89
- const id = (data as { id: unknown }).id;
89
+ const id = (data as { id: unknown }).id; // @cast-boundary engine-bridge
90
90
  if (typeof id === "string") return id;
91
91
  }
92
92
  throw new Error(`${who}: executor.create result has no string id (got ${JSON.stringify(data)})`);