@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.65.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 (53) hide show
  1. package/package.json +6 -6
  2. package/src/config/__tests__/write-helpers.test.ts +152 -0
  3. package/src/config/read-redaction.ts +0 -1
  4. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  5. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  6. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  7. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  8. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  9. package/src/custom-fields/db/queries/quota.ts +3 -1
  10. package/src/custom-fields/entity.ts +10 -3
  11. package/src/custom-fields/events.ts +4 -1
  12. package/src/custom-fields/feature.ts +1 -5
  13. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  14. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  15. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  16. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  17. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  18. package/src/custom-fields/wire-for-entity.ts +7 -0
  19. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  20. package/src/files-provider-s3/s3-provider.ts +2 -4
  21. package/src/managed-pages/handlers/set.write.ts +4 -11
  22. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  23. package/src/sessions/feature.ts +16 -3
  24. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  25. package/src/tags/entity.ts +8 -0
  26. package/src/tags/handlers/assign-tag.write.ts +20 -5
  27. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  28. package/src/tags/web/i18n.ts +6 -2
  29. package/src/tags/web/tag-section.tsx +87 -76
  30. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  31. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  32. package/src/tier-engine/entity.ts +8 -0
  33. package/src/tier-engine/feature.ts +49 -9
  34. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  35. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  36. package/src/tier-engine/index.ts +1 -0
  37. package/src/tier-engine/trial.ts +26 -0
  38. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  39. package/src/user-data-rights/constants.ts +48 -0
  40. package/src/user-data-rights/feature.ts +15 -0
  41. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  42. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  43. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  44. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  45. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  46. package/src/user-data-rights/index.ts +3 -0
  47. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  48. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  49. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  50. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  51. package/src/user-data-rights/web/i18n.ts +95 -0
  52. package/src/user-data-rights/web/index.ts +2 -0
  53. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
@@ -0,0 +1,233 @@
1
+ // #494 — ein read_users-Projection-Rebuild darf den Lifecycle-State nicht
2
+ // wegwischen. Die user-Entity ist event-sourced bei CREATE (user.created),
3
+ // aber Lifecycle-Mutationen (restrict/grace-period/cancel/...) waren rohe
4
+ // updateMany OHNE Event. Ein Rebuild replayt damit nur user.created und setzt
5
+ // status zurueck auf den Default (Active) — Datenverlust auf einem DSGVO-Pfad.
6
+ //
7
+ // Diskriminierend: T_create (Stream des user.created — Signup-Tenant) MUSS
8
+ // ungleich T_active (aktiver Tenant zur Lifecycle-Zeit) sein. Nur so wird der
9
+ // Prod-Zustand reproduziert und das Stream-Rescope eingelockt. Ein
10
+ // same-tenant-Test gaebe falsches GREEN: er liefe sogar mit `event.user`
11
+ // durch (gleicher Tenant -> gleicher Stream) und liesse einen Rueckbau auf
12
+ // `event.user` unentdeckt — genau die Naht, an der prod bricht.
13
+
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { randomBytes } from "node:crypto";
16
+ import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
18
+ import { createRegistry, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createProjectionStateTable,
22
+ rebuildProjection,
23
+ } from "@cosmicdrift/kumiko-framework/pipeline";
24
+ import {
25
+ setupTestStack,
26
+ type TestStack,
27
+ TestUsers,
28
+ testTenantId,
29
+ unsafeCreateEntityTable,
30
+ unsafePushTables,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createLateBoundHolder, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
33
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
34
+ import { AuthHandlers } from "../../auth-email-password/constants";
35
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
36
+ import { hashPassword } from "../../auth-email-password/password-hashing";
37
+ import {
38
+ createComplianceProfilesFeature,
39
+ tenantComplianceProfileEntity,
40
+ tenantComplianceProfileTable,
41
+ } from "../../compliance-profiles";
42
+ import { createConfigFeature } from "../../config";
43
+ import { createConfigResolver } from "../../config/resolver";
44
+ import { configValuesTable } from "../../config/table";
45
+ import { createDataRetentionFeature } from "../../data-retention";
46
+ import { createSessionsFeature } from "../../sessions";
47
+ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
48
+ import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
49
+ import { sessionCallbacksFromLateBound } from "../../sessions/testing";
50
+ import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
51
+ import { tenantEntity } from "../../tenant/schema/tenant";
52
+ import { seedTenantMembership } from "../../tenant/seeding";
53
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
54
+ import { UserHandlers } from "../../user/constants";
55
+ import { createUserDataRightsFeature } from "../feature";
56
+ import { backfillUserLifecycleEvents, updateUserLifecycle } from "../lib/update-user-lifecycle";
57
+
58
+ const RESTRICT = "user-data-rights:write:restrict-account";
59
+ const USER_PROJECTION = "user:projection:user-entity";
60
+ // T_create: Stream auf den user.created landet (systemAdmin-Signup-Tenant).
61
+ const T_CREATE: TenantId = testTenantId(1);
62
+ // T_active: aktiver Tenant des Users zur Lifecycle-Zeit — bewusst ungleich.
63
+ const T_ACTIVE: TenantId = testTenantId(2);
64
+
65
+ const ALICE_EMAIL = "alice.rebuild@example.com";
66
+ const ALICE_PW = "alice-pw-long-enough";
67
+
68
+ let stack: TestStack;
69
+ let registry: Registry;
70
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
71
+ const encryptionKey = randomBytes(32).toString("base64");
72
+
73
+ function buildFeatures() {
74
+ return [
75
+ createConfigFeature(),
76
+ createUserFeature(),
77
+ createTenantFeature(),
78
+ createDataRetentionFeature(),
79
+ createComplianceProfilesFeature(),
80
+ createAuthEmailPasswordFeature(),
81
+ createSessionsFeature(),
82
+ createUserDataRightsFeature(),
83
+ ];
84
+ }
85
+
86
+ beforeAll(async () => {
87
+ const encryption = createEncryptionProvider(encryptionKey);
88
+ const resolver = createConfigResolver({ encryption });
89
+ const bound = sessionCallbacksFromLateBound(callbacks);
90
+
91
+ stack = await setupTestStack({
92
+ features: buildFeatures(),
93
+ extraContext: { configResolver: resolver, configEncryption: encryption },
94
+ authConfig: {
95
+ ...bound.asAuthConfig(),
96
+ membershipQuery: "tenant:query:memberships",
97
+ loginHandler: AuthHandlers.login,
98
+ },
99
+ });
100
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
101
+ // Eigene Registry fuer den Rebuild — enthaelt die implicit read_users-Projektion.
102
+ registry = createRegistry(buildFeatures());
103
+
104
+ await unsafeCreateEntityTable(stack.db, userEntity);
105
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
106
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
107
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
108
+ await createEventsTable(stack.db);
109
+ await createProjectionStateTable(stack.db);
110
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await stack.cleanup();
115
+ });
116
+
117
+ beforeEach(async () => {
118
+ await resetTestTables(stack.db, [
119
+ userSessionTable,
120
+ userTable,
121
+ tenantMembershipsTable,
122
+ tenantComplianceProfileTable,
123
+ eventsTable,
124
+ ]);
125
+ });
126
+
127
+ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
128
+ test("Restricted-Status ueberlebt einen Projection-Rebuild (T_create != T_active)", async () => {
129
+ // Diskriminierende Praemisse: user.created landet auf systemAdmin.tenantId
130
+ // (= T_CREATE), Lifecycle laeuft spaeter auf T_ACTIVE. Beide ungleich.
131
+ expect(TestUsers.systemAdmin.tenantId).toBe(T_CREATE);
132
+ expect(T_ACTIVE).not.toBe(T_CREATE);
133
+
134
+ // user.created landet auf T_CREATE (systemAdmin-Signup-Stream).
135
+ const hash = await hashPassword(ALICE_PW);
136
+ const created = await stack.http.writeOk<{ id: string }>(
137
+ UserHandlers.create,
138
+ { email: ALICE_EMAIL, passwordHash: hash, displayName: "Alice" },
139
+ TestUsers.systemAdmin,
140
+ );
141
+ // Mitgliedschaft + Lifecycle auf einem ANDEREN, aktiven Tenant.
142
+ await seedTenantMembership(stack.db, {
143
+ userId: created.id,
144
+ tenantId: T_ACTIVE,
145
+ roles: ["Member"],
146
+ });
147
+
148
+ const aliceActive = { id: created.id, tenantId: T_ACTIVE, roles: ["Member"] };
149
+ const restricted = await stack.http.writeOk<{ status: string }>(RESTRICT, {}, aliceActive);
150
+ expect(restricted.status).toBe(USER_STATUS.Restricted);
151
+
152
+ // Live-Row ist Restricted (sanity).
153
+ const before = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
154
+ status: string;
155
+ }>;
156
+ expect(before[0]?.status).toBe(USER_STATUS.Restricted);
157
+
158
+ // ECHTER Rebuild der read_users-Projektion aus dem Event-Log.
159
+ await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
160
+
161
+ // RED auf aktuellem Code: restrict schrieb roh ohne Event -> der Rebuild
162
+ // replayt nur user.created -> status faellt auf Active zurueck.
163
+ // GREEN nach Stream-Rescope der Lifecycle-Handler.
164
+ const after = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
165
+ status: string;
166
+ }>;
167
+ expect(after[0]?.status).toBe(USER_STATUS.Restricted);
168
+ });
169
+
170
+ test("DeletionRequested + gracePeriodEnd (Date-Spalte) ueberleben den Rebuild", async () => {
171
+ // Direkt via Helper, ohne compliance-profile-Setup — der Fokus ist die
172
+ // Serialisierung der Date-Spalte durch das Event + den Reducer.
173
+ const hash = await hashPassword(ALICE_PW);
174
+ const created = await stack.http.writeOk<{ id: string }>(
175
+ UserHandlers.create,
176
+ { email: "grace.rebuild@example.com", passwordHash: hash, displayName: "Grace" },
177
+ TestUsers.systemAdmin,
178
+ );
179
+
180
+ const T = getTemporal();
181
+ const gracePeriodEnd = T.Now.instant().add({ hours: 24 });
182
+ await updateUserLifecycle(stack.db, created.id, {
183
+ status: USER_STATUS.DeletionRequested,
184
+ gracePeriodEnd,
185
+ });
186
+
187
+ await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
188
+
189
+ const after = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
190
+ status: string;
191
+ gracePeriodEnd: unknown;
192
+ }>;
193
+ expect(after[0]?.status).toBe(USER_STATUS.DeletionRequested);
194
+ // gracePeriodEnd ueberlebt — nicht null nach Replay.
195
+ expect(after[0]?.gracePeriodEnd).not.toBeNull();
196
+ expect(after[0]?.gracePeriodEnd).toBeDefined();
197
+ });
198
+
199
+ // Ehrlicher Spiegel zum Forward-Test: Bestandsdaten, deren Status der ALTE
200
+ // raw-Pfad (ohne Event) gesetzt hat, ueberleben einen Rebuild NICHT — bis der
201
+ // einmalige Backfill ihren Live-State als user.updated ins Event-Log spiegelt.
202
+ test("Bestandsdaten: alt-roh gesetzter Status wird ohne Backfill weggewischt, mit Backfill bewahrt", async () => {
203
+ const hash = await hashPassword(ALICE_PW);
204
+ const created = await stack.http.writeOk<{ id: string }>(
205
+ UserHandlers.create,
206
+ { email: "legacy.rebuild@example.com", passwordHash: hash, displayName: "Legacy" },
207
+ TestUsers.systemAdmin,
208
+ );
209
+
210
+ // Prae-Fix-Zustand simulieren: roher Write OHNE Event.
211
+ await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: created.id });
212
+
213
+ // Ohne Backfill replayt der Rebuild nur user.created -> Status weggewischt.
214
+ await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
215
+ const wiped = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
216
+ status: string;
217
+ }>;
218
+ expect(wiped[0]?.status).toBe(USER_STATUS.Active);
219
+
220
+ // Bestand wieder in den divergenten Live-State bringen (der Rebuild hat ihn
221
+ // auf Active gesetzt) und den Reconcile laufen lassen.
222
+ await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: created.id });
223
+ const backfilled = await backfillUserLifecycleEvents(stack.db);
224
+ expect(backfilled).toBeGreaterThanOrEqual(1);
225
+
226
+ // Jetzt traegt das Event-Log den State -> Rebuild bewahrt ihn.
227
+ await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
228
+ const survived = (await selectMany(stack.db, userTable, { id: created.id })) as Array<{
229
+ status: string;
230
+ }>;
231
+ expect(survived[0]?.status).toBe(USER_STATUS.Restricted);
232
+ });
233
+ });
@@ -0,0 +1,48 @@
1
+ // @runtime client
2
+ // Reine String-Konstanten — client-markiert, damit der PrivacyCenterScreen
3
+ // (web/) sie importieren darf, ohne das runtime-Barrel des Features (und
4
+ // damit dessen Server-/DOM-freien Code) zu ziehen. Runtime-Code
5
+ // (feature.ts) darf client-Dateien ohnehin importieren.
6
+
7
+ export const USER_DATA_RIGHTS_FEATURE = "user-data-rights" as const;
8
+
9
+ // Dormant registriert (kein r.nav im Feature); Apps platzieren ihn via
10
+ // r.nav. Qualifiziert: `user-data-rights:screen:privacy-center`.
11
+ export const PRIVACY_CENTER_SCREEN_ID = "privacy-center" as const;
12
+
13
+ export const UserDataRightsQueries = {
14
+ exportStatus: "user-data-rights:query:export-status",
15
+ myAuditLog: "user-data-rights:query:my-audit-log",
16
+ } as const;
17
+
18
+ export const UserDataRightsHandlers = {
19
+ requestExport: "user-data-rights:write:request-export",
20
+ requestDeletion: "user-data-rights:write:request-deletion",
21
+ cancelDeletion: "user-data-rights:write:cancel-deletion",
22
+ restrictAccount: "user-data-rights:write:restrict-account",
23
+ } as const;
24
+
25
+ // Fremde QN: der Lifecycle-Status (active / deletionRequested / restricted)
26
+ // kommt aus dem user-Feature. Lokal gepinnt statt das user-runtime-Barrel zu
27
+ // importieren (Runtime-Isolation, wie user-profile). Drift-Schutz: der
28
+ // Screen-Test vergleicht gegen UserQueries.me.
29
+ export const USER_ME_QUERY = "user:query:user:me" as const;
30
+
31
+ // Download-Pfad des fertigen Export-Bundles: der dokumentierte UI-Klick-Pfad
32
+ // (r.httpRoute in feature.ts), der per 302 auf die signed Storage-URL
33
+ // weiterleitet. Anchor-navigierbar (Cookie-Auth wird mitgesendet).
34
+ export function userExportByJobPath(jobId: string): string {
35
+ return `/user-export/by-job/${jobId}`;
36
+ }
37
+
38
+ // Client-safe Mirror von EXPORT_JOB_STATUS (schema/export-job.ts ist
39
+ // server-only via Drizzle-Import). Drift-Schutz: der Screen-Test vergleicht
40
+ // gegen die Schema-Originale.
41
+ export const EXPORT_JOB_STATUS = {
42
+ Pending: "pending",
43
+ Running: "running",
44
+ Done: "done",
45
+ Failed: "failed",
46
+ } as const;
47
+
48
+ export type ExportJobStatus = (typeof EXPORT_JOB_STATUS)[keyof typeof EXPORT_JOB_STATUS];
@@ -5,6 +5,7 @@ import {
5
5
  SYSTEM_USER_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { createFileProviderForTenant } from "../file-foundation";
8
+ import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
8
9
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
9
10
  import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
10
11
  import { downloadByJobQuery } from "./handlers/download-by-job.query";
@@ -199,6 +200,20 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
199
200
  r.queryHandler(myAuditLogQuery);
200
201
  r.queryHandler(listDownloadAttemptsQuery);
201
202
 
203
+ // Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
204
+ // protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
205
+ // App platziert ihn im eingeloggten Bereich. Die React-Component kommt
206
+ // client-seitig aus userDataRightsClient() (web/). access openToAll, weil
207
+ // kein App-Rollenname portabel ist; die per-User-Handler erzwingen Auth
208
+ // server-seitig, und der Screen ist ohne r.nav nirgends sichtbar bis die
209
+ // App ihn aktiv im authed-Bereich verlinkt.
210
+ r.screen({
211
+ id: PRIVACY_CENTER_SCREEN_ID,
212
+ type: "custom",
213
+ renderer: { react: { __component: "PrivacyCenterScreen" } },
214
+ access: { openToAll: true },
215
+ });
216
+
202
217
  // r.httpRoute-Wrapper: Magic-Link-Pfad (anonymous) + UI-Klick-Pfad.
203
218
  //
204
219
  // Beide rufen via app.fetch /api/query → wenn success: 302-Redirect
@@ -1,8 +1,9 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
5
  import { USER_STATUS, userTable } from "../../user";
6
+ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
6
7
 
7
8
  // POST /api/user/cancel-deletion (S2.U5).
8
9
  //
@@ -61,19 +62,14 @@ export const cancelDeletionWrite = defineWriteHandler({
61
62
  );
62
63
  }
63
64
 
64
- await updateMany(
65
- ctx.db.raw,
66
- userTable,
67
- {
68
- status: USER_STATUS.Active,
69
- gracePeriodEnd: null,
70
- // #354/1: schließt das replay-after-cancel-Fenster — ein noch
71
- // TTL-gültiges email-Token verifiziert gegen die genullte requestId
72
- // nicht mehr und kann keine zweite Grace-Period armen.
73
- pendingDeletionRequestId: null,
74
- },
75
- { id: event.user.id },
76
- );
65
+ await updateUserLifecycle(ctx.db.raw, event.user.id, {
66
+ status: USER_STATUS.Active,
67
+ gracePeriodEnd: null,
68
+ // #354/1: schließt das replay-after-cancel-Fenster — ein noch
69
+ // TTL-gültiges email-Token verifiziert gegen die genullte requestId
70
+ // nicht mehr und kann keine zweite Grace-Period armen.
71
+ pendingDeletionRequestId: null,
72
+ });
77
73
 
78
74
  // gracePeriodEnd=null im Response symmetrisch zu request-deletion's
79
75
  // ISO-Timestamp — Frontend kann beide Endpoints uniform behandeln.
@@ -1,9 +1,10 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
3
  import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
6
  import { USER_STATUS, userTable } from "../../user";
7
+ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
7
8
 
8
9
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
9
10
 
@@ -57,12 +58,10 @@ export async function startDeletionGracePeriod(
57
58
  const T = getTemporal();
58
59
  const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
59
60
 
60
- await updateMany(
61
- ctx.db.raw,
62
- userTable,
63
- { status: USER_STATUS.DeletionRequested, gracePeriodEnd },
64
- { id: userId },
65
- );
61
+ await updateUserLifecycle(ctx.db.raw, userId, {
62
+ status: USER_STATUS.DeletionRequested,
63
+ gracePeriodEnd,
64
+ });
66
65
 
67
66
  return { ok: true, gracePeriodEnd, userEmail: userRow["email"] ?? "" };
68
67
  }
@@ -1,8 +1,9 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
5
  import { USER_STATUS, userTable } from "../../user";
6
+ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
6
7
 
7
8
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
8
9
  //
@@ -50,7 +51,7 @@ export const liftRestrictionWrite = defineWriteHandler({
50
51
  );
51
52
  }
52
53
 
53
- await updateMany(ctx.db.raw, userTable, { status: USER_STATUS.Active }, { id: event.user.id });
54
+ await updateUserLifecycle(ctx.db.raw, event.user.id, { status: USER_STATUS.Active });
54
55
 
55
56
  return {
56
57
  isSuccess: true as const,
@@ -1,8 +1,9 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
4
  import { USER_STATUS, userTable } from "../../user";
5
5
  import { signDeletionToken } from "../deletion-token";
6
+ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
6
7
 
7
8
  // TTL des Verify-Links. 60 min — lang genug für einen Mail-Roundtrip,
8
9
  // kurz genug dass ein abgefangener Link nicht ewig gültig ist.
@@ -79,12 +80,9 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
79
80
  // user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
80
81
  // nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
81
82
  const requestId = crypto.randomUUID();
82
- await updateMany(
83
- ctx.db.raw,
84
- userTable,
85
- { pendingDeletionRequestId: requestId },
86
- { id: userRow["id"] },
87
- );
83
+ await updateUserLifecycle(ctx.db.raw, userRow["id"], {
84
+ pendingDeletionRequestId: requestId,
85
+ });
88
86
 
89
87
  const { token, expiresAt } = signDeletionToken(
90
88
  userRow["id"],
@@ -1,8 +1,9 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
5
  import { USER_STATUS, userTable } from "../../user";
6
+ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
6
7
 
7
8
  // POST /api/user/restrict (S2.U6) — DSGVO Art. 18 Account-Freeze.
8
9
  // Flippt status=Active → Restricted und revoked alle live sessions
@@ -54,12 +55,7 @@ export const restrictAccountWrite = defineWriteHandler({
54
55
  );
55
56
  }
56
57
 
57
- await updateMany(
58
- ctx.db.raw,
59
- userTable,
60
- { status: USER_STATUS.Restricted },
61
- { id: event.user.id },
62
- );
58
+ await updateUserLifecycle(ctx.db.raw, event.user.id, { status: USER_STATUS.Restricted });
63
59
 
64
60
  // Cross-Feature: alle live sessions revoken — sonst koennte der User
65
61
  // mit existierendem JWT bis zur Token-Expiry weiter schreiben.
@@ -1,5 +1,8 @@
1
1
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
2
2
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
3
+ // #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
4
+ // von read_users-Rebuilds (siehe lib-Doc).
5
+ export { backfillUserLifecycleEvents } from "./lib/update-user-lifecycle";
3
6
  export type {
4
7
  SendExportFailedEmailFn,
5
8
  SendExportReadyEmailFn,
@@ -0,0 +1,100 @@
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { USER_STATUS, userEntity, userTable } from "../../user";
7
+
8
+ // #494 — Lifecycle-Mutationen der user-Entity MUESSEN als `user.updated`-Event
9
+ // laufen. Roh per updateMany geschrieben, wischt ein read_users-Rebuild sie
10
+ // weg (er replayt nur `user.created` -> status faellt auf den Default zurueck;
11
+ // gracePeriodEnd/pendingDeletionRequestId/Deleted gehen verloren = DSGVO-
12
+ // Datenverlust).
13
+ //
14
+ // Das Event MUSS in denselben (tenant_id, aggregate_id)-Stream wie
15
+ // `user.created` landen, sonst splittet das Aggregat ueber Tenants und der
16
+ // Rebuild rekonstruiert nichts. Die user-Entity laeuft `r.systemScope()`, ihre
17
+ // Events landen aber auf einem konkreten Tenant-Stream (siehe
18
+ // auth-email-password/stream-tenant.ts; Root-Cause-Fix tracked in #497). Darum:
19
+ // Rescope auf den Stream-Tenant des Users — den des `user.created`-Events,
20
+ // NICHT `event.user.tenantId` (das ist der aktive Tenant zur Lifecycle-Zeit und
21
+ // kann abweichen).
22
+ const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
+
24
+ // Stream-Tenant = die framework-injizierte tenant_id der read_users-Row. Der
25
+ // Reducer setzt sie aus `user.created`.tenantId, ein Rebuild rekonstruiert sie
26
+ // daraus — sie IST also der Stream-Key des Aggregats. Die Row direkt zu lesen
27
+ // (statt das created-Event zu joinen) deckt auch direkt-geseedete Rows ab.
28
+ async function streamTenantOf(conn: DbRunner, userId: string): Promise<TenantId | null> {
29
+ const row = await fetchOne<{ tenantId?: string }>(conn, userTable, { id: userId });
30
+ // @cast-boundary db-row — tenant_id ist eine TenantId-shaped uuid-Spalte.
31
+ return row?.tenantId ? (row.tenantId as TenantId) : null;
32
+ }
33
+
34
+ // `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
35
+ // Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
36
+ export async function updateUserLifecycle(
37
+ conn: DbRunner,
38
+ userId: string,
39
+ changes: Record<string, unknown>,
40
+ ): Promise<void> {
41
+ const streamTenantId = await streamTenantOf(conn, userId);
42
+ if (!streamTenantId) {
43
+ throw new InternalError({
44
+ message: `read_users row ${userId} has no tenant_id — cannot rescope lifecycle write to its stream tenant`,
45
+ });
46
+ }
47
+
48
+ // Rescope BEIDE Achsen auf den Stream-Tenant: der db-Arg treibt loadById +
49
+ // den Stream-Read, der user-Arg die Event-tenantId + Ownership. Nur beide
50
+ // zusammen halten created + updated auf einem Stream.
51
+ const tenantDb = createTenantDb(conn, streamTenantId, "tenant");
52
+ const result = await userExecutor.update(
53
+ { id: userId, changes },
54
+ createSystemUser(streamTenantId),
55
+ tenantDb,
56
+ { skipOptimisticLock: true },
57
+ );
58
+
59
+ if (!result.isSuccess) {
60
+ throw new InternalError({
61
+ message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
62
+ });
63
+ }
64
+ }
65
+
66
+ // #494 Bestandsdaten-Reconcile: Rows, deren Lifecycle-State der alte
67
+ // raw-updateMany-Pfad gesetzt hat, haben kein `user.updated`-Event — ein
68
+ // Rebuild wuerde sie auf die `user.created`-Defaults zuruecksetzen. Diese
69
+ // Funktion emittiert pro divergenter Row ein `user.updated` mit dem aktuellen
70
+ // Live-State, sodass Event-Log und Live-Tabelle wieder uebereinstimmen.
71
+ // MUSS einmalig ueber den Bestand laufen, BEVOR eine App read_users-Rebuilds
72
+ // re-enabled. Idempotent gegen State (ein zweiter Lauf haengt ein identisches
73
+ // user.updated an — harmlos, last-write-wins beim Replay).
74
+ // ponytail: full read_users-Scan, in JS gefiltert — einmalige Migration, kein
75
+ // Index/Streaming noetig. Bei Millionen-Rows: batchen.
76
+ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<number> {
77
+ const rows = (await selectMany(conn, userTable, {})) as Array<{
78
+ id: string;
79
+ status: string;
80
+ gracePeriodEnd: unknown;
81
+ pendingDeletionRequestId: unknown;
82
+ }>;
83
+
84
+ let backfilled = 0;
85
+ for (const row of rows) {
86
+ const divergent =
87
+ row.status !== USER_STATUS.Active ||
88
+ row.gracePeriodEnd != null ||
89
+ row.pendingDeletionRequestId != null;
90
+ if (!divergent) continue;
91
+
92
+ await updateUserLifecycle(conn, row.id, {
93
+ status: row.status,
94
+ gracePeriodEnd: row.gracePeriodEnd,
95
+ pendingDeletionRequestId: row.pendingDeletionRequestId,
96
+ });
97
+ backfilled++;
98
+ }
99
+ return backfilled;
100
+ }
@@ -32,7 +32,7 @@
32
32
  // gefailten Hooks bleibt im DeletionRequested-Status (next Lauf
33
33
  // retried automatisch).
34
34
 
35
- import { fetchOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
35
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
36
36
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
37
37
  import {
38
38
  EXT_USER_DATA,
@@ -47,6 +47,7 @@ import { resolveRetentionPolicyForTenant } from "../data-retention";
47
47
  import { tenantMembershipsTable } from "../tenant";
48
48
  import { USER_STATUS, userTable } from "../user";
49
49
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup";
50
+ import { updateUserLifecycle } from "./lib/update-user-lifecycle";
50
51
 
51
52
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
52
53
 
@@ -281,7 +282,7 @@ async function processUser(args: {
281
282
  // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
282
283
  // alles, der User bleibt im DeletionRequested-Status, naechster
283
284
  // Run retried.
284
- await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
285
+ await updateUserLifecycle(tx, userId, { status: USER_STATUS.Deleted });
285
286
  txSucceeded = true;
286
287
  });
287
288
  } catch (e) {