@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.
- package/package.json +6 -6
- package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
- package/src/legal-pages/web/client-plugin.ts +9 -10
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
- package/src/text-content/web/client-plugin.tsx +16 -13
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- 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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
package/src/tier-engine/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
ADDED
|
@@ -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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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.
|