@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import { createSystemUser,
|
|
4
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
7
7
|
|
|
@@ -21,16 +21,6 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
|
21
21
|
// kann abweichen).
|
|
22
22
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
23
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
24
|
// `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
|
|
35
25
|
// Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
|
|
36
26
|
export async function updateUserLifecycle(
|
|
@@ -38,20 +28,14 @@ export async function updateUserLifecycle(
|
|
|
38
28
|
userId: string,
|
|
39
29
|
changes: Record<string, unknown>,
|
|
40
30
|
): Promise<void> {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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");
|
|
31
|
+
// user ist systemStream (#497): der Executor-Choke-Point addressiert den
|
|
32
|
+
// Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
|
|
33
|
+
// waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
|
|
34
|
+
// deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
|
|
35
|
+
const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
|
|
52
36
|
const result = await userExecutor.update(
|
|
53
37
|
{ id: userId, changes },
|
|
54
|
-
createSystemUser(
|
|
38
|
+
createSystemUser(SYSTEM_TENANT_ID),
|
|
55
39
|
tenantDb,
|
|
56
40
|
{ skipOptimisticLock: true },
|
|
57
41
|
);
|
|
@@ -410,7 +410,14 @@ async function resolveEffectiveTenantModel(
|
|
|
410
410
|
appTenantModel: TenantUserModel,
|
|
411
411
|
): Promise<TenantUserModel> {
|
|
412
412
|
if (appTenantModel !== "single-user") return "multi-user";
|
|
413
|
-
|
|
413
|
+
// Only "exactly one member vs. more than one" matters here — a LIMIT 2
|
|
414
|
+
// read answers that without pulling every historical membership row.
|
|
415
|
+
const members = await selectMany<{ userId: string }>(
|
|
416
|
+
db,
|
|
417
|
+
tenantMembershipsTable,
|
|
418
|
+
{ tenantId },
|
|
419
|
+
{ limit: 2 },
|
|
420
|
+
);
|
|
414
421
|
return members.length === 1 ? "single-user" : "multi-user";
|
|
415
422
|
}
|
|
416
423
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
|
|
6
6
|
// verbietet test-utils-Import).
|
|
7
7
|
|
|
8
|
-
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
10
10
|
import {
|
|
11
11
|
createStaticLocaleResolver,
|
|
@@ -27,6 +27,12 @@ const stubDispatcher = {
|
|
|
27
27
|
|
|
28
28
|
const ROUTES = { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" };
|
|
29
29
|
|
|
30
|
+
// renderGate mutates the shared happy-dom document's history (570/1) — reset
|
|
31
|
+
// so a later, unrelated test file doesn't inherit this suite's last path.
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
window.history.replaceState({}, "", "/");
|
|
34
|
+
});
|
|
35
|
+
|
|
30
36
|
function renderGate(path: string, gate: ReactNode): ReturnType<typeof within> {
|
|
31
37
|
window.history.replaceState({}, "", path);
|
|
32
38
|
const { container } = render(
|
|
@@ -74,11 +74,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
74
74
|
"userDataRights.privacyCenter.deletion.dialogTitle": "Konto wirklich löschen?",
|
|
75
75
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
76
76
|
"Mit dem Bestätigen startet die Lösch-Frist. Du kannst die Löschung bis zu ihrem Ablauf wieder abbrechen.",
|
|
77
|
-
|
|
78
|
-
"userDataRights.privacyCenter.audit.title": "Aktivitätsprotokoll (Art. 15)",
|
|
79
|
-
"userDataRights.privacyCenter.audit.intro":
|
|
80
|
-
"Die letzten Ereignisse zu deinem Konto über alle Tenants hinweg.",
|
|
81
|
-
"userDataRights.privacyCenter.audit.empty": "Noch keine Ereignisse.",
|
|
82
77
|
},
|
|
83
78
|
en: {
|
|
84
79
|
"userDataRights.deletion.request.title": "Request account deletion",
|
|
@@ -146,10 +141,5 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
146
141
|
"userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
|
|
147
142
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
148
143
|
"Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
|
|
149
|
-
|
|
150
|
-
"userDataRights.privacyCenter.audit.title": "Activity log (Art. 15)",
|
|
151
|
-
"userDataRights.privacyCenter.audit.intro":
|
|
152
|
-
"The most recent events on your account across all tenants.",
|
|
153
|
-
"userDataRights.privacyCenter.audit.empty": "No events yet.",
|
|
154
144
|
},
|
|
155
145
|
};
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// fileRef-Forget beruehrt Tenant B's Files nicht)
|
|
12
12
|
|
|
13
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
-
import { asRawClient
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
16
16
|
import {
|
|
17
17
|
setupTestStack,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
23
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
23
24
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
24
25
|
import { createFilesFeature } from "../../files";
|
|
@@ -80,7 +81,7 @@ async function seedUser(id: string, overrides: Record<string, unknown> = {}): Pr
|
|
|
80
81
|
// Schema hat tenant_id-Spalte automatisch (Framework-Default).
|
|
81
82
|
// Pragmatisch: SYSTEM_TENANT_ID fuer User-Rows in Tests.
|
|
82
83
|
const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
83
|
-
await
|
|
84
|
+
await seedRow(stack.db, userTable, {
|
|
84
85
|
id,
|
|
85
86
|
tenantId: SYSTEM_TENANT,
|
|
86
87
|
email: `user-${id}@example.com`,
|
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type TenantDb,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import {
|
|
8
|
+
createSystemUser,
|
|
9
|
+
type SessionUser,
|
|
10
|
+
type UserDataDeleteHook,
|
|
11
|
+
type UserDataExportHook,
|
|
12
|
+
type UserDataHookCtx,
|
|
13
|
+
type UserDataStorageProvider,
|
|
7
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
15
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
|
|
17
|
+
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
18
|
+
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
19
|
+
// writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
|
|
20
|
+
// per-user forget flows are rare, so per-row events are acceptable.
|
|
21
|
+
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
9
22
|
|
|
10
23
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
11
24
|
//
|
|
@@ -27,7 +40,7 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
27
40
|
//
|
|
28
41
|
// **Zwei Fehlerklassen, bewusst verschieden behandelt:**
|
|
29
42
|
// 1. Resolution schlaegt fehl (kein Provider konfiguriert / configResolver
|
|
30
|
-
// fehlt) → NICHT fail-closed:
|
|
43
|
+
// fehlt) → NICHT fail-closed: Warn pro Aufruf + row-only-delete. Ein
|
|
31
44
|
// fehlkonfigurierter Store darf die Art.-17-Loeschung nicht DAUERHAFT
|
|
32
45
|
// blockieren (sonst haengt jeder User fuer immer in DeletionRequested);
|
|
33
46
|
// der Boot-Guard macht die Fehlkonfiguration sichtbar, Binaries werden
|
|
@@ -94,8 +107,6 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
94
107
|
};
|
|
95
108
|
};
|
|
96
109
|
|
|
97
|
-
let missingStorageWarned = false;
|
|
98
|
-
|
|
99
110
|
// Resolve the per-tenant provider the forget orchestrator injected. A
|
|
100
111
|
// resolution failure (no provider configured / configResolver absent) collapses
|
|
101
112
|
// to `undefined` so the hook degrades to a row-only delete instead of throwing —
|
|
@@ -107,58 +118,95 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
|
|
|
107
118
|
return await ctx.buildStorageProvider(ctx.tenantId);
|
|
108
119
|
} catch {
|
|
109
120
|
// skip: provider unresolvable (not configured) → fall through to row-only
|
|
110
|
-
// delete; warn
|
|
121
|
+
// delete; the warn below gives operator visibility, boot guard catches it.
|
|
111
122
|
return undefined;
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
131
|
-
console.warn(
|
|
132
|
-
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
-
);
|
|
134
|
-
failedKeys.push(key);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Fail-closed: abort before the row hard-delete so the sub-tx rolls back
|
|
138
|
-
// and the next forget run retries (delete is idempotent → converges).
|
|
139
|
-
if (failedKeys.length > 0) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
} else if (!missingStorageWarned) {
|
|
145
|
-
missingStorageWarned = true;
|
|
146
|
-
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
126
|
+
// Delete every row's binary via the provider. Returns the keys whose delete
|
|
127
|
+
// threw — the caller fails closed on a non-empty list so the sub-tx rolls back
|
|
128
|
+
// and the next forget run retries (delete is idempotent → converges).
|
|
129
|
+
async function deleteBinaries(
|
|
130
|
+
rows: readonly Record<string, unknown>[],
|
|
131
|
+
provider: UserDataStorageProvider,
|
|
132
|
+
): Promise<readonly string[]> {
|
|
133
|
+
const failedKeys: string[] = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
const key = row["storageKey"]; // @cast-boundary db-row
|
|
136
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
137
|
+
try {
|
|
138
|
+
await provider.delete(key);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
147
141
|
console.warn(
|
|
148
|
-
|
|
142
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
143
|
+
);
|
|
144
|
+
failedKeys.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return failedKeys;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Null the person-link on every row via the executor (event → rebuild-safe).
|
|
151
|
+
// tdb: the executor needs a TenantDb (loadById → db.fetchOne), not the raw
|
|
152
|
+
// ctx.db runner.
|
|
153
|
+
async function severPersonLink(
|
|
154
|
+
tdb: TenantDb,
|
|
155
|
+
systemUser: SessionUser,
|
|
156
|
+
rows: readonly Record<string, unknown>[],
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const id = row["id"]; // @cast-boundary db-row
|
|
160
|
+
if (typeof id !== "string") continue;
|
|
161
|
+
await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
|
|
162
|
+
skipOptimisticLock: true,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
168
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
169
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
170
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, fileRefsTable, {
|
|
171
|
+
tenantId: ctx.tenantId,
|
|
172
|
+
insertedById: ctx.userId,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (strategy !== "delete") {
|
|
176
|
+
// anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
|
|
177
|
+
// chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
|
|
178
|
+
await severPersonLink(tdb, systemUser, rows);
|
|
179
|
+
// skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const storageProvider = await resolveProvider(ctx);
|
|
184
|
+
if (storageProvider) {
|
|
185
|
+
const failedKeys = await deleteBinaries(rows, storageProvider);
|
|
186
|
+
if (failedKeys.length > 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
149
189
|
);
|
|
150
190
|
}
|
|
151
|
-
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
152
191
|
} else {
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
192
|
+
// No warn-once guard: a forget-cleanup cron runs rarely enough (not a hot
|
|
193
|
+
// path) that logging every occurrence is fine, and an operator who fixes the
|
|
194
|
+
// provider config mid-process sees the warning stop on the very next run —
|
|
195
|
+
// a module-level "warned once" flag would silence it for the rest of the
|
|
196
|
+
// process even after the misconfiguration is corrected.
|
|
197
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
198
|
+
console.warn(
|
|
199
|
+
"[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider — file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
|
|
162
200
|
);
|
|
163
201
|
}
|
|
202
|
+
// Hard-purge each row via the executor forget-verb: emits fileRef.forgotten,
|
|
203
|
+
// which hard-deletes the row even though fileRef is softDelete — and, being an
|
|
204
|
+
// auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
|
|
205
|
+
// The old hard deleteMany was resurrected on rebuild; this closes that Art.17
|
|
206
|
+
// hole without a direct write.
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const id = row["id"]; // @cast-boundary db-row
|
|
209
|
+
if (typeof id !== "string") continue;
|
|
210
|
+
await crud.forget({ id }, systemUser, tdb);
|
|
211
|
+
}
|
|
164
212
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { fetchOne
|
|
2
|
-
import
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createSystemUser,
|
|
5
|
+
type UserDataDeleteHook,
|
|
6
|
+
type UserDataExportHook,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
8
|
import {
|
|
4
9
|
USER_ANONYMIZED_DISPLAY_NAME,
|
|
5
10
|
USER_ANONYMIZED_EMAIL_DOMAIN,
|
|
@@ -7,9 +12,14 @@ import {
|
|
|
7
12
|
USER_DELETED_DISPLAY_NAME,
|
|
8
13
|
USER_DELETED_EMAIL_PREFIX,
|
|
9
14
|
USER_STATUS,
|
|
15
|
+
userEntity,
|
|
10
16
|
userTable,
|
|
11
17
|
} from "../../user";
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
// Forget writes go through the executor (events), not a raw UPDATE: a
|
|
20
|
+
// projection rebuild replays the events, so the anonymization survives it.
|
|
21
|
+
// A direct write would be wiped on rebuild — the Art.17 hole this fixes.
|
|
22
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
13
23
|
|
|
14
24
|
// userData-Hook fuer user-entity (S2.H1).
|
|
15
25
|
//
|
|
@@ -63,27 +73,55 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
63
73
|
// korrelieren — user.id ist tenant-agnostic (User kann in mehreren
|
|
64
74
|
// Tenants Member sein), kein tenantId-Filter noetig.
|
|
65
75
|
|
|
76
|
+
// System actor + skipOptimisticLock: the forget pipeline has no read
|
|
77
|
+
// version and writes privileged identity columns ("system" is privileged).
|
|
78
|
+
// The executor needs a TenantDb (loadById → db.fetchOne); ctx.db is a raw
|
|
79
|
+
// runner, so wrap it. "system" mode = no tenant filter — user is a systemStream
|
|
80
|
+
// entity (tenant-agnostic) and forget must reach the row in any tenant.
|
|
81
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
82
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
83
|
+
|
|
66
84
|
if (strategy === "delete") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
// PII raus + status=deleted (Login geblockt) via Event → Rebuild spielt den
|
|
86
|
+
// Forget mit. KEIN softDelete: user ist systemStream (eine tenant-agnostische
|
|
87
|
+
// Row), der Forget laeuft pro Tenant — wuerde isDeleted gesetzt, faende der
|
|
88
|
+
// loadById (isDeleted:false) die Row beim zweiten Tenant nicht mehr
|
|
89
|
+
// (not_found). status=Deleted blockt Login; die Row bleibt findbar +
|
|
90
|
+
// idempotent ueber mehrere Tenant-Durchlaeufe (wie der alte raw UPDATE).
|
|
91
|
+
await crud.update(
|
|
92
|
+
{
|
|
93
|
+
id: ctx.userId,
|
|
94
|
+
changes: {
|
|
95
|
+
email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
96
|
+
displayName: USER_DELETED_DISPLAY_NAME,
|
|
97
|
+
passwordHash: null,
|
|
98
|
+
status: USER_STATUS.Deleted,
|
|
99
|
+
// deleted_at as an audit timestamp only — NOT isDeleted (see above).
|
|
100
|
+
// Frozen into the event payload, so a rebuild replays the same value.
|
|
101
|
+
deletedAt: Temporal.Now.instant(),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
systemUser,
|
|
105
|
+
tdb,
|
|
106
|
+
{ skipOptimisticLock: true },
|
|
107
|
+
);
|
|
73
108
|
} else {
|
|
74
109
|
// anonymize: PII raus, aber Row bleibt active (damit FK-References
|
|
75
110
|
// weiter aufloesbar sind). Account ist effektiv weiter nutzbar
|
|
76
111
|
// wenn der User sich neu authentifiziert — pragmatisch akzeptabel
|
|
77
112
|
// weil "anonymize" auf user-entity ein seltener Edge-Case ist
|
|
78
113
|
// (typisch hard-delete fuer User).
|
|
79
|
-
await
|
|
80
|
-
ctx.db,
|
|
81
|
-
userTable,
|
|
114
|
+
await crud.update(
|
|
82
115
|
{
|
|
83
|
-
|
|
84
|
-
|
|
116
|
+
id: ctx.userId,
|
|
117
|
+
changes: {
|
|
118
|
+
email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
119
|
+
displayName: USER_ANONYMIZED_DISPLAY_NAME,
|
|
120
|
+
},
|
|
85
121
|
},
|
|
86
|
-
|
|
122
|
+
systemUser,
|
|
123
|
+
tdb,
|
|
124
|
+
{ skipOptimisticLock: true },
|
|
87
125
|
);
|
|
88
126
|
}
|
|
89
127
|
};
|
|
@@ -88,6 +88,15 @@ describe("ProfileScreen", () => {
|
|
|
88
88
|
expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test("Passwort- und Email-Section rendern auf einem echten <form> (Enter-to-submit, Password-Manager-Boundary) (555/1)", async () => {
|
|
92
|
+
const view = renderProfile(activeMe);
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
|
|
95
|
+
});
|
|
96
|
+
expect(view.getByTestId("profile-password").tagName).toBe("FORM");
|
|
97
|
+
expect(view.getByTestId("profile-email").tagName).toBe("FORM");
|
|
98
|
+
});
|
|
99
|
+
|
|
91
100
|
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
92
101
|
const view = renderProfile({
|
|
93
102
|
...activeMe,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
-
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
8
|
import { z } from "zod";
|
|
5
9
|
import { AuthErrors, verifyPassword } from "../../auth-email-password";
|
|
6
|
-
import {
|
|
10
|
+
import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
7
11
|
import { UserProfileErrors } from "../constants";
|
|
8
12
|
|
|
9
13
|
// Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
|
|
@@ -66,11 +70,10 @@ export const changeEmailWrite = defineWriteHandler({
|
|
|
66
70
|
);
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
73
|
+
// user ist systemStream (#497): der Event-Stream liegt deterministisch
|
|
74
|
+
// auf SYSTEM_TENANT_ID. Pre-#497-Streams brauchen einmalig
|
|
75
|
+
// backfillUserStreamTenants (#762).
|
|
76
|
+
const writer = createSystemUser(SYSTEM_TENANT_ID);
|
|
74
77
|
|
|
75
78
|
const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
|
|
76
79
|
id: me.id,
|
|
@@ -59,7 +59,7 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
59
59
|
|
|
60
60
|
function ChangePasswordSection(): ReactNode {
|
|
61
61
|
const t = useTranslation();
|
|
62
|
-
const {
|
|
62
|
+
const { Form, Field, Input, Button } = usePrimitives();
|
|
63
63
|
const dispatcher = useDispatcher();
|
|
64
64
|
const [oldPassword, setOldPassword] = useState("");
|
|
65
65
|
const [newPassword, setNewPassword] = useState("");
|
|
@@ -90,11 +90,12 @@ function ChangePasswordSection(): ReactNode {
|
|
|
90
90
|
|
|
91
91
|
const submitting = status.kind === "submitting";
|
|
92
92
|
return (
|
|
93
|
-
<
|
|
93
|
+
<Form
|
|
94
94
|
testId="profile-password"
|
|
95
95
|
title={t("profile.password.title")}
|
|
96
|
+
onSubmit={onSubmit}
|
|
96
97
|
actions={
|
|
97
|
-
<Button
|
|
98
|
+
<Button type="submit" disabled={submitting} testId="profile-password-submit">
|
|
98
99
|
{t("profile.password.submit")}
|
|
99
100
|
</Button>
|
|
100
101
|
}
|
|
@@ -136,7 +137,7 @@ function ChangePasswordSection(): ReactNode {
|
|
|
136
137
|
/>
|
|
137
138
|
</Field>
|
|
138
139
|
<StatusBanner status={status} />
|
|
139
|
-
</
|
|
140
|
+
</Form>
|
|
140
141
|
);
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -148,7 +149,7 @@ function ChangeEmailSection({
|
|
|
148
149
|
readonly onChanged: () => void;
|
|
149
150
|
}): ReactNode {
|
|
150
151
|
const t = useTranslation();
|
|
151
|
-
const {
|
|
152
|
+
const { Form, Field, Input, Button } = usePrimitives();
|
|
152
153
|
const dispatcher = useDispatcher();
|
|
153
154
|
const [newEmail, setNewEmail] = useState("");
|
|
154
155
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
@@ -192,16 +193,17 @@ function ChangeEmailSection({
|
|
|
192
193
|
|
|
193
194
|
const submitting = status.kind === "submitting";
|
|
194
195
|
return (
|
|
195
|
-
<
|
|
196
|
+
<Form
|
|
196
197
|
testId="profile-email"
|
|
197
198
|
title={t("profile.email.title")}
|
|
199
|
+
onSubmit={onSubmit}
|
|
198
200
|
subtitle={
|
|
199
201
|
<span data-testid="profile-email-current">
|
|
200
202
|
{t("profile.email.current")}: {me.email}
|
|
201
203
|
</span>
|
|
202
204
|
}
|
|
203
205
|
actions={
|
|
204
|
-
<Button
|
|
206
|
+
<Button type="submit" disabled={submitting} testId="profile-email-submit">
|
|
205
207
|
{t("profile.email.submit")}
|
|
206
208
|
</Button>
|
|
207
209
|
}
|
|
@@ -231,7 +233,7 @@ function ChangeEmailSection({
|
|
|
231
233
|
/>
|
|
232
234
|
</Field>
|
|
233
235
|
<StatusBanner status={status} />
|
|
234
|
-
</
|
|
236
|
+
</Form>
|
|
235
237
|
);
|
|
236
238
|
}
|
|
237
239
|
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// user-feature runs with r.systemScope() but events land on a concrete
|
|
2
|
-
// tenant stream. The row.version tracks whichever stream the user's last
|
|
3
|
-
// modification wrote to — and that tenant is NOT discoverable from the
|
|
4
|
-
// row alone.
|
|
5
|
-
//
|
|
6
|
-
// Strategy: prioritize lastActiveTenantId (most likely holds the latest
|
|
7
|
-
// event), fall through to the remaining memberships in insertion order,
|
|
8
|
-
// and let the caller try each one in sequence. The first stream whose
|
|
9
|
-
// version matches row.version wins; the rest are bypassed.
|
|
10
|
-
//
|
|
11
|
-
// This is pragmatic — the real fix is to scope user events to
|
|
12
|
-
// SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
|
|
13
|
-
// framework-level change tracked separately. Until then, "try each
|
|
14
|
-
// tenant the user belongs to" is robust against non-deterministic
|
|
15
|
-
// memberships-query ordering (tenant:query:memberships has no ORDER BY).
|
|
16
|
-
|
|
17
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
-
|
|
19
|
-
export function orderTenantsByPreference(
|
|
20
|
-
memberships: readonly { readonly tenantId: TenantId }[],
|
|
21
|
-
lastActiveTenantId: string | null | undefined,
|
|
22
|
-
): TenantId[] {
|
|
23
|
-
if (memberships.length === 0) return [];
|
|
24
|
-
const ids = memberships.map((m) => m.tenantId);
|
|
25
|
-
if (!lastActiveTenantId) return ids;
|
|
26
|
-
// Move lastActiveTenantId to the front; preserve relative order of rest.
|
|
27
|
-
const preferred = ids.find((id) => id === lastActiveTenantId);
|
|
28
|
-
if (!preferred) return ids;
|
|
29
|
-
const rest = ids.filter((id) => id !== preferred);
|
|
30
|
-
return [preferred, ...rest];
|
|
31
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
|
|
4
|
-
export async function anonymizeDeletedUser(
|
|
5
|
-
db: DbRunner,
|
|
6
|
-
params: {
|
|
7
|
-
readonly email: string;
|
|
8
|
-
readonly displayName: string;
|
|
9
|
-
readonly status: string;
|
|
10
|
-
readonly userId: string;
|
|
11
|
-
},
|
|
12
|
-
): Promise<void> {
|
|
13
|
-
await asRawClient(db).unsafe(
|
|
14
|
-
'UPDATE "read_users" SET email = $1, display_name = $2, password_hash = $3, status = $4, deleted_at = now() WHERE id = $5',
|
|
15
|
-
[params.email, params.displayName, null, params.status, params.userId],
|
|
16
|
-
);
|
|
17
|
-
}
|