@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  3. package/src/config/__tests__/write-helpers.test.ts +152 -0
  4. package/src/config/handlers/readiness.query.ts +1 -0
  5. package/src/config/read-redaction.ts +0 -1
  6. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  7. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  8. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  9. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  11. package/src/custom-fields/db/queries/quota.ts +3 -1
  12. package/src/custom-fields/entity.ts +10 -3
  13. package/src/custom-fields/events.ts +4 -1
  14. package/src/custom-fields/feature.ts +1 -5
  15. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  17. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  18. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  19. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  20. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
  21. package/src/custom-fields/wire-for-entity.ts +7 -0
  22. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  23. package/src/files-provider-s3/s3-provider.ts +2 -4
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
  25. package/src/legal-pages/web/client-plugin.ts +9 -10
  26. package/src/managed-pages/handlers/set.write.ts +4 -11
  27. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  28. package/src/sessions/feature.ts +16 -3
  29. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  30. package/src/tags/entity.ts +8 -0
  31. package/src/tags/handlers/assign-tag.write.ts +20 -5
  32. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  33. package/src/tags/web/i18n.ts +6 -2
  34. package/src/tags/web/tag-section.tsx +87 -76
  35. package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
  36. package/src/text-content/web/client-plugin.tsx +16 -13
  37. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  38. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  39. package/src/tier-engine/entity.ts +8 -0
  40. package/src/tier-engine/feature.ts +49 -9
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  43. package/src/tier-engine/index.ts +1 -0
  44. package/src/tier-engine/trial.ts +26 -0
  45. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  46. package/src/user-data-rights/constants.ts +48 -0
  47. package/src/user-data-rights/feature.ts +15 -0
  48. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  49. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  50. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  51. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  52. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  53. package/src/user-data-rights/index.ts +3 -0
  54. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  55. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  56. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  57. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  58. package/src/user-data-rights/web/i18n.ts +95 -0
  59. package/src/user-data-rights/web/index.ts +2 -0
  60. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
@@ -62,6 +62,7 @@ import {
62
62
  type TierResolverPlugin,
63
63
  } from "@cosmicdrift/kumiko-framework/engine";
64
64
  import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
65
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
65
66
  import { z } from "zod";
66
67
  import { tierAssignmentAggregateId } from "./aggregate-id";
67
68
  import type { TierMap } from "./compose-app";
@@ -70,6 +71,7 @@ import { tierAssignmentEntity } from "./entity";
70
71
  import { getActiveTierQuery } from "./handlers/active-tier.query";
71
72
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
72
73
  import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
74
+ import { isTrialActive, type TrialPolicy } from "./trial";
73
75
 
74
76
  // Drizzle-table for the tier-assignment-entity. Built once at module-load
75
77
  // from the entity definition — same shape buildEntityTable would produce
@@ -115,6 +117,15 @@ export type CreateTierEngineOptions<TCaps extends Readonly<Record<string, unknow
115
117
  * oder eigene resolution-logic nutzen (legacy-pattern).
116
118
  */
117
119
  readonly tierMap?: TierMap<TCaps>;
120
+
121
+ /**
122
+ * Optionale Trial-Phase: jeder Tenant bekommt für `durationHours` ab seinem
123
+ * Anlage-Datum zusätzlich die Features von `trial.tier` freigeschaltet,
124
+ * danach fällt er automatisch auf sein gespeichertes Tier zurück. Erfordert
125
+ * `tierMap` (der Trial-Tier muss ein Key sein). Zeit-abgeleitet aus
126
+ * inserted_at — kein Stored-Flag, kein Scheduler.
127
+ */
128
+ readonly trial?: TrialPolicy;
118
129
  };
119
130
 
120
131
  /**
@@ -242,6 +253,17 @@ export function createTierEngineFeature<
242
253
  // Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
243
254
  const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
244
255
 
256
+ // Trial-State: tenantId → inserted_at als epochMilliseconds (Anlage-Datum
257
+ // der Assignment ≈ Signup, rebuild-stabil). Trial wird at-resolve-time aus
258
+ // (jetzt vs startedAt + durationHours) berechnet, NICHT gecacht — anders als
259
+ // das Feature-Set ändert sich der Trial-Status mit der Zeit. trialFeatures
260
+ // ist die fixe Feature-Menge des Trial-Tiers (einmal aufgelöst).
261
+ const trialClock = new Map<TenantId, number>();
262
+ const trialFeatures: ReadonlySet<string> = opts.trial
263
+ ? featuresForTier(tierMap, opts.trial.tier)
264
+ : new Set();
265
+ const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
266
+
245
267
  // set-tenant-tier schreibt direkt über den Executor → der postSave-Hook
246
268
  // unten feuert dabei NICHT. Diese Funktion repliziert den Cache-Update
247
269
  // des Hooks, damit ein manueller Grant das effektive Feature-Set sofort
@@ -249,6 +271,9 @@ export function createTierEngineFeature<
249
271
  // Semantik wie der Hook.
250
272
  onTierAssigned.fn = (tenantId, tier) => {
251
273
  cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
274
+ // Trial-Uhr nur setzen, wenn unbekannt: ein manueller Grant ändert nicht
275
+ // das Signup-Datum eines bestehenden Tenants (build() hat es bereits).
276
+ if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
252
277
  };
253
278
 
254
279
  // Invalidation: tier-assignment events update the cache.
@@ -261,10 +286,12 @@ export function createTierEngineFeature<
261
286
  // throwing — der lifecycle-pipeline darf nicht durch hook-fehler
262
287
  // blocken (afterCommit-pattern, side-effect-best-effort).
263
288
  if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
264
- cache.set(
265
- data.tenantId as TenantId,
266
- mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)),
267
- );
289
+ const tenantId = data.tenantId as TenantId;
290
+ cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
291
+ // Erstes Assignment eines Tenants = Signup → Trial-Uhr startet jetzt
292
+ // (inserted_at der frisch erzeugten Row ≈ now). Spätere Tier-Wechsel
293
+ // lassen die Uhr unberührt, sonst würde ein Upgrade das Fenster verlängern.
294
+ if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
268
295
  });
269
296
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
270
297
  const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
@@ -385,15 +412,18 @@ export function createTierEngineFeature<
385
412
  // typischerweise <100k tenants — single-pass scan akzeptabel.
386
413
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
387
414
  // Bedürfnis entsteht.
388
- type AssignmentRow = { tenantId: string; tier: string };
415
+ type AssignmentRow = { tenantId: string; tier: string; insertedAt: Temporal.Instant };
389
416
  const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
390
417
  for (const row of rows) {
391
418
  cache.set(
392
419
  row.tenantId as TenantId,
393
420
  mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
394
421
  );
422
+ trialClock.set(row.tenantId as TenantId, row.insertedAt.epochMilliseconds);
395
423
  }
396
424
 
425
+ const trial = opts.trial;
426
+
397
427
  // Synchronous resolver-callback for dispatcher hot-path.
398
428
  return (tenantId: TenantId): ReadonlySet<string> => {
399
429
  // Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
@@ -401,15 +431,25 @@ export function createTierEngineFeature<
401
431
  if (tenantId === SYSTEM_TENANT_ID) {
402
432
  return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
403
433
  }
404
- const cached = cache.get(tenantId);
405
- if (cached !== undefined) return cached;
406
434
  // Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
407
435
  // boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
408
436
  // ist least-privileged — typisch Free-Tier-features. Memory
409
437
  // `feedback_security_default_on`: secure-by-default.
410
438
  const fallbackTier = opts.defaultTier;
411
- if (fallbackTier === undefined) return computedAlwaysOn;
412
- return mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier));
439
+ const base =
440
+ cache.get(tenantId) ??
441
+ (fallbackTier === undefined
442
+ ? computedAlwaysOn
443
+ : mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)));
444
+ // Trial: innerhalb des Fensters ab Signup zusätzlich die Trial-Tier-
445
+ // Features. Zeit-abgeleitet → pro Request geprüft, nie gecacht.
446
+ if (trial !== undefined) {
447
+ const startedMs = trialClock.get(tenantId);
448
+ if (startedMs !== undefined && isTrialActive(startedMs, nowMs(), trial.durationHours)) {
449
+ return mergeAlwaysOn(base, trialFeatures);
450
+ }
451
+ }
452
+ return base;
413
453
  };
414
454
  },
415
455
  };
@@ -6,7 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { z } from "zod";
9
- import { tierAssignmentEntity } from "../entity";
9
+ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
10
10
 
11
11
  // Liest das Tier-Assignment eines BELIEBIGEN Tenants (cross-tenant) für den
12
12
  // tier-admin-Screen. SystemAdmin-only. get-active-tier liest nur den eigenen
@@ -15,14 +15,6 @@ import { tierAssignmentEntity } from "../entity";
15
15
 
16
16
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
17
17
 
18
- type TierAssignmentRow = {
19
- readonly id: string;
20
- readonly version: number;
21
- readonly tier: string;
22
- readonly source: string | null;
23
- readonly tenantId: string;
24
- };
25
-
26
18
  export const getTenantTierQuery = defineQueryHandler({
27
19
  name: "get-tenant-tier",
28
20
  schema: z.object({ tenantId: z.string().min(1) }),
@@ -8,7 +8,7 @@ import {
8
8
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { z } from "zod";
10
10
  import { tierAssignmentAggregateId } from "../aggregate-id";
11
- import { tierAssignmentEntity } from "../entity";
11
+ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
12
12
 
13
13
  // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
14
14
  // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
@@ -39,14 +39,6 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
39
39
  entityName: "tier-assignment",
40
40
  });
41
41
 
42
- type TierAssignmentRow = {
43
- readonly id: string;
44
- readonly version: number;
45
- readonly tier: string;
46
- readonly source: string | null;
47
- readonly tenantId: string;
48
- };
49
-
50
42
  export type SetTenantTierOptions = {
51
43
  /** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
52
44
  * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
@@ -24,3 +24,4 @@ export {
24
24
  createTierEngineFeature,
25
25
  tierEngineFeature,
26
26
  } from "./feature";
27
+ export { isTrialActive, type TrialPolicy } from "./trial";
@@ -0,0 +1,26 @@
1
+ // Trial-Phase: ein neuer Tenant bekommt für eine Karenzzeit ab seinem
2
+ // Anlage-Datum (inserted_at der tier-assignment-Row — rebuild-stabil aus dem
3
+ // Create-Event) zusätzlich die Features eines höheren Tiers, unabhängig vom
4
+ // gespeicherten Tier. Rein zeit-abgeleitet: kein Stored-Flag, kein Scheduler,
5
+ // automatischer Ablauf. Die App definiert die Policy (welcher Tier, wie lange);
6
+ // die tier-engine wendet sie im Resolver an.
7
+
8
+ export interface TrialPolicy {
9
+ // Tier, dessen Features während der Trial-Phase zusätzlich freigeschaltet
10
+ // werden (muss ein Key der tierMap sein, sonst greift kein Feature).
11
+ readonly tier: string;
12
+ // Länge der Trial-Phase ab inserted_at, in Stunden (720 = 30 Tage). Stunden
13
+ // statt Tage: Temporal.Instant kennt keine Kalender-Tage, 720h ist die
14
+ // ehrliche, DST-unabhängige Dauer.
15
+ readonly durationHours: number;
16
+ }
17
+
18
+ // Reine Millis-Arithmetik auf epochMilliseconds (die beide Seiten — Projektions-
19
+ // Row und Now — liefern). Kein Date, keine TZ.
20
+ export function isTrialActive(
21
+ startedAtEpochMs: number,
22
+ nowEpochMs: number,
23
+ durationHours: number,
24
+ ): boolean {
25
+ return nowEpochMs < startedAtEpochMs + durationHours * 3_600_000;
26
+ }
@@ -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.