@cosmicdrift/kumiko-bundled-features 0.63.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.
- package/package.json +6 -6
- package/src/config/__tests__/write-helpers.test.ts +152 -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/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/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/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
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.
|
|
@@ -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
|
|
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
|
|
285
|
+
await updateUserLifecycle(tx, userId, { status: USER_STATUS.Deleted });
|
|
285
286
|
txSucceeded = true;
|
|
286
287
|
});
|
|
287
288
|
} catch (e) {
|