@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  4. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  5. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  6. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  7. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  8. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  9. package/src/custom-fields/constants.ts +8 -7
  10. package/src/custom-fields/db/queries/projection.ts +13 -5
  11. package/src/custom-fields/db/queries/retention.ts +20 -6
  12. package/src/custom-fields/executor.ts +10 -0
  13. package/src/custom-fields/feature.ts +32 -39
  14. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  15. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  17. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  18. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  19. package/src/custom-fields/lib/field-access.ts +4 -0
  20. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  21. package/src/custom-fields/run-retention.ts +6 -5
  22. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  23. package/src/custom-fields/web/client-plugin.tsx +2 -0
  24. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  25. package/src/custom-fields/web/i18n.ts +30 -0
  26. package/src/custom-fields/wire-for-entity.ts +1 -1
  27. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  28. package/src/feature-toggles/handlers/set.write.ts +13 -8
  29. package/src/secrets/feature.ts +4 -11
  30. package/src/subscription-stripe/feature.ts +2 -2
  31. package/src/template-resolver/handlers/list.query.ts +12 -10
  32. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  33. package/src/tenant/seeding.ts +3 -3
  34. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  35. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  36. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  37. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  38. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  39. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  40. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -0,0 +1,272 @@
1
+ // DSGVO forget hook-ordering — the custom-fields anonymize strip must run
2
+ // before any host hook that nulls the owner column it filters on, regardless
3
+ // of feature registration order.
4
+ //
5
+ // Two host entities are wired with the OPPOSITE EXT_USER_DATA registration
6
+ // order: one registers the custom-fields strip first (the order that happened
7
+ // to be safe), the other registers the owner-nulling host hook first (the
8
+ // order that exposed the bug). Both run through the real `runForgetCleanup`
9
+ // with strategy=anonymize. With the order-sentinel fix both strip the
10
+ // sensitive jsonb key; without it the host-first entity would leave it behind
11
+ // (the strip matches 0 rows after the owner column is nulled).
12
+ //
13
+ // This drives the full runner on purpose: the existing custom-fields
14
+ // user-data-rights test invokes the strip hook in isolation
15
+ // (getExtensionUsages + direct call) and is structurally blind to ordering.
16
+
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
20
+ import {
21
+ createEntity,
22
+ createTextField,
23
+ defineFeature,
24
+ EXT_USER_DATA,
25
+ type UserDataDeleteHook,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
28
+ import {
29
+ createTestUser,
30
+ resetEventStore,
31
+ setupTestStack,
32
+ type TestStack,
33
+ unsafeCreateEntityTable,
34
+ } from "@cosmicdrift/kumiko-framework/stack";
35
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
+ import { fieldDefinitionEntity } from "../../custom-fields/entity";
37
+ import { createCustomFieldsFeature } from "../../custom-fields/feature";
38
+ import { customFieldsField } from "../../custom-fields/wire-for-entity";
39
+ import { wireCustomFieldsUserDataRightsFor } from "../../custom-fields/wire-user-data-rights";
40
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
41
+ import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
42
+ import { createSessionsFeature } from "../../sessions";
43
+ import { createUserFeature, userEntity } from "../../user";
44
+ import { createUserDataRightsFeature } from "../feature";
45
+ import { runForgetCleanup } from "../run-forget-cleanup";
46
+ import {
47
+ createForgetSeeders,
48
+ nowInstant,
49
+ READ_TENANT_MEMBERSHIPS_DDL,
50
+ } from "./forget-test-helpers";
51
+
52
+ const TENANT = "00000000-0000-4000-8000-0000000000aa";
53
+
54
+ // Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
55
+ // clears the owner" pattern — same shape as file-ref/user host hooks).
56
+ function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
57
+ return async (ctx, strategy) => {
58
+ if (strategy === "delete") {
59
+ await asRawClient(ctx.db).unsafe(
60
+ `DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
61
+ [ctx.userId, ctx.tenantId],
62
+ );
63
+ return;
64
+ }
65
+ await asRawClient(ctx.db).unsafe(
66
+ `UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
67
+ [ctx.userId, ctx.tenantId],
68
+ );
69
+ };
70
+ }
71
+
72
+ interface HostSpec {
73
+ readonly entityName: string;
74
+ readonly tableName: string;
75
+ readonly featureName: string;
76
+ }
77
+
78
+ const CF_FIRST: HostSpec = {
79
+ entityName: "cf-first-prop",
80
+ tableName: "read_dsgvo_cf_first",
81
+ featureName: "dsgvo-cf-first",
82
+ };
83
+ const HOST_FIRST: HostSpec = {
84
+ entityName: "host-first-prop",
85
+ tableName: "read_dsgvo_host_first",
86
+ featureName: "dsgvo-host-first",
87
+ };
88
+
89
+ function makeEntity(tableName: string) {
90
+ return createEntity({
91
+ table: tableName,
92
+ fields: {
93
+ name: createTextField({ required: true }),
94
+ customFields: customFieldsField(),
95
+ },
96
+ });
97
+ }
98
+
99
+ const cfFirstEntity = makeEntity(CF_FIRST.tableName);
100
+ const hostFirstEntity = makeEntity(HOST_FIRST.tableName);
101
+ const cfFirstTable = buildEntityTable(CF_FIRST.entityName, cfFirstEntity);
102
+ const hostFirstTable = buildEntityTable(HOST_FIRST.entityName, hostFirstEntity);
103
+
104
+ // Registration order is the whole point: cfFirst registers the strip BEFORE the
105
+ // owner-nulling host hook; hostFirst registers the host hook FIRST (the order
106
+ // that exposed the bug). The order-sentinel must make both correct.
107
+ const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
108
+ r.entity(CF_FIRST.entityName, cfFirstEntity);
109
+ r.requires("custom-fields");
110
+ wireCustomFieldsUserDataRightsFor(r, {
111
+ entityName: CF_FIRST.entityName,
112
+ entityTable: cfFirstTable,
113
+ userIdColumn: "inserted_by_id",
114
+ });
115
+ r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
116
+ export: async () => null,
117
+ delete: makeHostDeleteHook(CF_FIRST.tableName),
118
+ });
119
+ });
120
+
121
+ const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
122
+ r.entity(HOST_FIRST.entityName, hostFirstEntity);
123
+ r.requires("custom-fields");
124
+ r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
125
+ export: async () => null,
126
+ delete: makeHostDeleteHook(HOST_FIRST.tableName),
127
+ });
128
+ wireCustomFieldsUserDataRightsFor(r, {
129
+ entityName: HOST_FIRST.entityName,
130
+ entityTable: hostFirstTable,
131
+ userIdColumn: "inserted_by_id",
132
+ });
133
+ });
134
+
135
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: TENANT });
136
+ const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000a1";
137
+
138
+ let stack: TestStack;
139
+ // biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
140
+ const seed = (db: unknown) => createForgetSeeders(db as any, { write: async () => {} });
141
+
142
+ beforeAll(async () => {
143
+ stack = await setupTestStack({
144
+ features: [
145
+ createUserFeature(),
146
+ createSessionsFeature(),
147
+ createDataRetentionFeature(),
148
+ createComplianceProfilesFeature(),
149
+ createCustomFieldsFeature(),
150
+ createUserDataRightsFeature(),
151
+ cfFirstFeature,
152
+ hostFirstFeature,
153
+ ],
154
+ });
155
+ await unsafeCreateEntityTable(stack.db, userEntity);
156
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
157
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
158
+ await unsafeCreateEntityTable(stack.db, cfFirstEntity);
159
+ await unsafeCreateEntityTable(stack.db, hostFirstEntity);
160
+ await createEventsTable(stack.db);
161
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
162
+ });
163
+
164
+ afterAll(async () => {
165
+ await stack.cleanup();
166
+ });
167
+
168
+ async function defineSensitiveField(entityName: string, fieldKey: string, sensitive: boolean) {
169
+ await stack.http.writeOk(
170
+ "custom-fields:write:define-tenant-field",
171
+ {
172
+ entityName,
173
+ fieldKey,
174
+ serializedField: { type: "text", sensitive },
175
+ required: false,
176
+ searchable: false,
177
+ displayOrder: 0,
178
+ },
179
+ admin,
180
+ );
181
+ }
182
+
183
+ async function seedAnonymizeOverride(entityName: string) {
184
+ await insertOne(stack.db, tenantRetentionOverrideTable, {
185
+ entityName,
186
+ config: JSON.stringify({ keepFor: "0d", strategy: "anonymize" }),
187
+ reason: "test: force anonymize strategy",
188
+ tenantId: TENANT,
189
+ });
190
+ }
191
+
192
+ async function seedRow(spec: HostSpec, rowId: string) {
193
+ // Inline jsonb literal — binding the JSON as a `$n::jsonb` param double-encodes
194
+ // it into a jsonb *string* scalar (jsonb_typeof='string'), which the strip's
195
+ // object-guard skips. A literal stores a real object, matching what the
196
+ // set-custom-field projection writes in production.
197
+ await asRawClient(stack.db).unsafe(
198
+ `INSERT INTO ${spec.tableName} (id, tenant_id, name, inserted_by_id, custom_fields)
199
+ VALUES ($1, $2, $3, $4, '{"ssn":"123-45-6789","safe":"keep-me"}'::jsonb)`,
200
+ [rowId, TENANT, `Row for ${spec.entityName}`, FORGET_USER],
201
+ );
202
+ }
203
+
204
+ async function readRow(
205
+ spec: HostSpec,
206
+ rowId: string,
207
+ ): Promise<Record<string, unknown> | undefined> {
208
+ const rows = await asRawClient(stack.db).unsafe(
209
+ `SELECT id, inserted_by_id, custom_fields FROM ${spec.tableName} WHERE id = $1`,
210
+ [rowId],
211
+ );
212
+ return (rows as ReadonlyArray<Record<string, unknown>>)[0];
213
+ }
214
+
215
+ beforeEach(async () => {
216
+ await resetEventStore(stack);
217
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${CF_FIRST.tableName}`);
218
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${HOST_FIRST.tableName}`);
219
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
220
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_retention_overrides`);
221
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
222
+ });
223
+
224
+ describe("forget hook-ordering :: redaction runs before owner-nulling regardless of registration order", () => {
225
+ test("both registration orders strip the sensitive jsonb key and null the owner column", async () => {
226
+ // Field defs (sensitive ssn, non-sensitive safe) for both entities.
227
+ for (const spec of [CF_FIRST, HOST_FIRST]) {
228
+ await defineSensitiveField(spec.entityName, "ssn", true);
229
+ await defineSensitiveField(spec.entityName, "safe", false);
230
+ await seedAnonymizeOverride(spec.entityName);
231
+ }
232
+ // Project the field definitions. Assert the pass had no failures: a silent
233
+ // projection failure would leave read_custom_field_definitions empty, make
234
+ // loadSensitiveFieldKeys return [], and turn the strip into a no-op —
235
+ // verifying the test exercises the strip for the right reason.
236
+ const pass = await stack.eventDispatcher?.runOnce();
237
+ expect(pass?.failed ?? 0).toBe(0);
238
+
239
+ const cfRowId = "dddddddd-dddd-4ddd-8ddd-000000000001";
240
+ const hostRowId = "dddddddd-dddd-4ddd-8ddd-000000000002";
241
+ await seedRow(CF_FIRST, cfRowId);
242
+ await seedRow(HOST_FIRST, hostRowId);
243
+
244
+ await seed(stack.db).seedForgetUser(FORGET_USER);
245
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT);
246
+
247
+ const result = await runForgetCleanup({
248
+ db: stack.db,
249
+ registry: stack.registry,
250
+ now: nowInstant(),
251
+ });
252
+
253
+ expect(result.errors).toHaveLength(0);
254
+ expect(result.processedUserIds).toContain(FORGET_USER);
255
+
256
+ for (const [spec, rowId] of [
257
+ [CF_FIRST, cfRowId],
258
+ [HOST_FIRST, hostRowId],
259
+ ] as const) {
260
+ const row = await readRow(spec, rowId);
261
+ // Owner column nulled by the host anonymize hook.
262
+ expect(row?.["inserted_by_id"]).toBeNull();
263
+ const cf = row?.["custom_fields"] as Record<string, unknown>;
264
+ // Sensitive key stripped (the strip ran while the owner column still
265
+ // pointed to the user) — this is the assertion that fails for the
266
+ // host-first entity without the order-sentinel fix.
267
+ expect(cf).not.toHaveProperty("ssn");
268
+ // Non-sensitive key preserved.
269
+ expect(cf["safe"]).toBe("keep-me");
270
+ }
271
+ });
272
+ });
@@ -0,0 +1,86 @@
1
+ // Shared seeders for the forget-cleanup integration tests
2
+ // (file-binary-forget-cleanup + file-binary-forget-failure). Both drive
3
+ // runForgetCleanup against the same fileRef + membership shape — keeping the
4
+ // DDL and seed logic in one place so a file_refs/user-schema change updates
5
+ // both at once instead of drifting.
6
+
7
+ import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
10
+ import { USER_STATUS, userTable } from "../../user";
11
+
12
+ export const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
13
+
14
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
15
+ export const nowInstant = (): Instant => getTemporal().Now.instant();
16
+ export const pastInstant = (): Instant =>
17
+ getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
18
+
19
+ export const READ_TENANT_MEMBERSHIPS_DDL = `
20
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
21
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
22
+ tenant_id UUID NOT NULL,
23
+ user_id TEXT NOT NULL,
24
+ version INTEGER NOT NULL DEFAULT 0,
25
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
26
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ inserted_by_id TEXT,
28
+ modified_by_id TEXT,
29
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
30
+ deleted_at TIMESTAMPTZ,
31
+ deleted_by_id TEXT,
32
+ roles TEXT NOT NULL DEFAULT '[]',
33
+ UNIQUE(user_id, tenant_id)
34
+ )
35
+ `;
36
+
37
+ interface FileWriter {
38
+ write(key: string, bytes: Uint8Array, mimeType: string): Promise<void>;
39
+ }
40
+
41
+ export interface ForgetSeeders {
42
+ seedForgetUser(id: string): Promise<void>;
43
+ seedMembership(userId: string, tenantId: string): Promise<void>;
44
+ seedFile(id: string, tenantId: string, insertedById: string): Promise<string>;
45
+ }
46
+
47
+ // `writer` is taken separately from the feature's storageProvider so the
48
+ // failure test can seed binaries through the real backing store while the
49
+ // feature runs against a delete-failing wrapper.
50
+ export function createForgetSeeders(db: DbConnection, writer: FileWriter): ForgetSeeders {
51
+ return {
52
+ async seedForgetUser(id) {
53
+ await insertOne(db, userTable, {
54
+ id,
55
+ tenantId: TENANT_SYSTEM,
56
+ email: `user-${id}@example.com`,
57
+ passwordHash: "hashed",
58
+ displayName: `User ${id}`,
59
+ locale: "de",
60
+ emailVerified: true,
61
+ roles: '["Member"]',
62
+ status: USER_STATUS.DeletionRequested,
63
+ gracePeriodEnd: pastInstant(),
64
+ });
65
+ },
66
+
67
+ async seedMembership(userId, tenantId) {
68
+ await asRawClient(db).unsafe(
69
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
70
+ VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
71
+ [tenantId, userId],
72
+ );
73
+ },
74
+
75
+ async seedFile(id, tenantId, insertedById) {
76
+ const storageKey = `storage/${id}`;
77
+ await writer.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
78
+ await asRawClient(db).unsafe(
79
+ `INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
80
+ VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
81
+ [id, tenantId, storageKey, `${id}.pdf`, insertedById],
82
+ );
83
+ return storageKey;
84
+ },
85
+ };
86
+ }
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { Temporal } from "temporal-polyfill";
4
5
  import { exportJobsTable } from "../../schema/export-job";
5
6
 
6
7
  export type ExportJobCleanupCandidate = {
@@ -104,8 +104,16 @@ export interface RunForgetCleanupResult {
104
104
  interface HookEntry {
105
105
  readonly entityName: string;
106
106
  readonly deleteHook: UserDataDeleteHook;
107
+ /** Lower runs first. Owner-column-preserving redaction declares a negative
108
+ * order so it precedes owner-nulling hooks on the same entity (see sort below). */
109
+ readonly order: number;
107
110
  }
108
111
 
112
+ // EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
113
+ // owner column it doesn't own must register a lower order so it runs BEFORE any
114
+ // hook that nulls that column. See custom-fields wire-user-data-rights.ts.
115
+ const HOOK_ORDER_DEFAULT = 0;
116
+
109
117
  export async function runForgetCleanup(
110
118
  args: RunForgetCleanupArgs,
111
119
  ): Promise<RunForgetCleanupResult> {
@@ -127,10 +135,18 @@ export async function runForgetCleanup(
127
135
  const usages = registry.getExtensionUsages(EXT_USER_DATA);
128
136
  const hookEntries: HookEntry[] = usages
129
137
  .map((u): HookEntry | null => {
130
- const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook }; // @cast-boundary engine-payload
131
- return opts.delete ? { entityName: u.entityName, deleteHook: opts.delete } : null;
138
+ const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
139
+ if (!opts.delete) return null;
140
+ const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
141
+ return { entityName: u.entityName, deleteHook: opts.delete, order };
132
142
  })
133
- .filter((x): x is HookEntry => x !== null);
143
+ .filter((x): x is HookEntry => x !== null)
144
+ // Order ascending. Array.sort is ES2019-stable, so equal orders keep
145
+ // registration order; correctness here needs only distinct orders, not
146
+ // stability. Guarantees owner-preserving redaction (negative order) runs
147
+ // before owner-nulling hooks on the same entity, independent of feature
148
+ // registration order.
149
+ .sort((a, b) => a.order - b.order);
134
150
 
135
151
  const errors: ForgetCleanupError[] = [];
136
152
  const processedUserIds: string[] = [];
@@ -233,46 +249,40 @@ async function processUser(args: {
233
249
  memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
234
250
 
235
251
  // Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
236
- // nur dieser User zurueck, andere User bleiben commit-fest. Drizzle
237
- // mappt das in nested-Tx auf SAVEPOINT, in top-level auf BEGIN die
238
- // `transaction()`-API ist auf DbRunner uniform.
239
- //
240
- // Cast `db as {transaction: ...}` ist eine TS-Limitation: DbRunner ist
241
- // `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
242
- // die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
243
- // PgTransaction haben unterschiedliche Generics). Cast macht das
244
- // Strukturelle explizit, kein Hack.
252
+ // nur dieser User zurueck, andere User bleiben commit-fest. Die Sub-Tx
253
+ // nestet korrekt: eine Top-Level-Connection oeffnet sie via `.begin`
254
+ // (BEGIN), eine TransactionSql der Fall im Dispatcher, wo jeder
255
+ // writeHandler bereits IN der Outer-Tx laeuft — via `.savepoint`
256
+ // (SAVEPOINT). Siehe runInSubTransaction.
245
257
  let txSucceeded = false;
246
258
  let currentTenantId: TenantId | null = null;
247
259
  let currentEntityName: string | null = null;
248
260
  try {
249
- await (db as { begin: (fn: (tx: DbRunner) => Promise<void>) => Promise<void> }).begin(
250
- async (tx) => {
251
- for (const tenantId of tenantList) {
252
- currentTenantId = tenantId;
253
- for (const entry of hookEntries) {
254
- currentEntityName = entry.entityName;
255
- const policy = await resolveRetentionPolicyForTenant({
256
- db: tx,
257
- registry,
258
- tenantId,
259
- entityName: entry.entityName,
260
- });
261
- const strategy = policyToStrategy(policy.policy?.strategy ?? null);
261
+ await runInSubTransaction(db, async (tx) => {
262
+ for (const tenantId of tenantList) {
263
+ currentTenantId = tenantId;
264
+ for (const entry of hookEntries) {
265
+ currentEntityName = entry.entityName;
266
+ const policy = await resolveRetentionPolicyForTenant({
267
+ db: tx,
268
+ registry,
269
+ tenantId,
270
+ entityName: entry.entityName,
271
+ });
272
+ const strategy = policyToStrategy(policy.policy?.strategy ?? null);
262
273
 
263
- hookCallsAttempted++;
264
- await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
265
- }
274
+ hookCallsAttempted++;
275
+ await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
266
276
  }
277
+ }
267
278
 
268
- // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
269
- // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
270
- // alles, der User bleibt im DeletionRequested-Status, naechster
271
- // Run retried.
272
- await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
273
- txSucceeded = true;
274
- },
275
- );
279
+ // Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
280
+ // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
281
+ // alles, der User bleibt im DeletionRequested-Status, naechster
282
+ // Run retried.
283
+ await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
284
+ txSucceeded = true;
285
+ });
276
286
  } catch (e) {
277
287
  // currentTenantId/currentEntityName tracken den Failing-Hook —
278
288
  // Operator sieht "Hook fileRef in Tenant A failed for user X" statt
@@ -294,6 +304,37 @@ async function processUser(args: {
294
304
  };
295
305
  }
296
306
 
307
+ // Per-user sub-transaction, nesting-aware across both db shapes:
308
+ // - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
309
+ // - TransactionSql (inside the dispatcher's outer tx, where every
310
+ // writeHandler already runs) → `.savepoint` (SAVEPOINT)
311
+ // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
312
+ // threw "is not a function" on every user when invoked through the dispatcher
313
+ // (the cron path) → zero deletions in production, while direct-connection tests
314
+ // stayed green. Selecting the available method makes the sub-tx work in both
315
+ // contexts; on throw the savepoint rolls back just this user (others survive).
316
+ async function runInSubTransaction(
317
+ db: DbRunner,
318
+ fn: (tx: DbRunner) => Promise<void>,
319
+ ): Promise<void> {
320
+ // `db` is already the raw runner (the handler passes ctx.db.raw, the tests a
321
+ // top-level connection) — cast to read the transaction surface directly,
322
+ // without asRawClient (a test-only escape hatch). A top-level connection
323
+ // exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
324
+ // exclusive, so prefer whichever is present.
325
+ const runner = db as {
326
+ begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
327
+ savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
328
+ };
329
+ const open = runner.begin ?? runner.savepoint;
330
+ if (!open) {
331
+ throw new Error(
332
+ "runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
333
+ );
334
+ }
335
+ await open.call(runner, fn);
336
+ }
337
+
297
338
  // Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
298
339
  // Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
299
340
  // tenant-agnostische Hooks (z.B. user) operieren auf der globalen
@@ -11,15 +11,21 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
11
11
  //
12
12
  // Delete-Hook entfernt FileRef-Zeile via factory
13
13
  // `createFileRefDeleteHook(storageProvider)`:
14
- // "delete": storageProvider.delete() pro File (best-effort) + Row hard-delete
14
+ // "delete": storageProvider.delete() pro File + Row hard-delete
15
15
  // "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
16
16
  // koennen weiter zeigen; Personenbezug raus)
17
17
  //
18
- // Storage-Provider-Cleanup ist BEST-EFFORT wenn S3-delete failt,
19
- // log + skip (Cron-Job kann es retry). Memory: Forget-Atomicity-
20
- // Decision aus Sprint-2-Architektur (advisor-pinned): per-Hook
21
- // idempotent, KEIN globaler Rollback wenn ein File-Delete failt,
22
- // bleibt der User-Row trotzdem anonymisiert.
18
+ // Delete-Pfad ist FAIL-CLOSED, sobald ein Provider gewired ist: schlaegt ein
19
+ // binary-delete fehl, wirft der Hook
20
+ // NACH dem Loop — die per-User-Sub-Tx von runForgetCleanup rollt zurueck, der
21
+ // User bleibt DeletionRequested, der naechste Run retried (storageProvider.delete
22
+ // ist idempotent, schon-geloeschte Keys sind no-op). Den Fehler zu schlucken und
23
+ // die Row trotzdem hard-zu-loeschen wuerde Art.-17-Erasure als "done" markieren
24
+ // waehrend die Bytes auf Disk bleiben — eine falsche Compliance-Aussage. Das
25
+ // "KEIN globaler Rollback" der Sprint-2-Atomicity-Decision bleibt gewahrt: nur
26
+ // DIESE User-Sub-Tx rollt zurueck (= der Retry-Mechanismus), andere User des
27
+ // Laufs committen. Der anonymize-Pfad behaelt Row+binary bewusst, hat also
28
+ // nichts zu schlucken.
23
29
  //
24
30
  // `storageProvider` ist optional. App-Author wired es beim
25
31
  // Feature-Mount rein (`createUserDataRightsDefaultsFeature({
@@ -92,6 +98,7 @@ export function createFileRefDeleteHook(
92
98
  tenantId: ctx.tenantId,
93
99
  insertedById: ctx.userId,
94
100
  });
101
+ const failedKeys: string[] = [];
95
102
  for (const row of rows) {
96
103
  const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
97
104
  if (typeof key !== "string" || key.length === 0) continue;
@@ -102,8 +109,16 @@ export function createFileRefDeleteHook(
102
109
  console.warn(
103
110
  `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
104
111
  );
112
+ failedKeys.push(key);
105
113
  }
106
114
  }
115
+ // Fail-closed: abort before the row hard-delete so the sub-tx rolls back
116
+ // and the next forget run retries (delete is idempotent → converges).
117
+ if (failedKeys.length > 0) {
118
+ throw new Error(
119
+ `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
120
+ );
121
+ }
107
122
  } else if (!missingStorageWarned) {
108
123
  missingStorageWarned = true;
109
124
  // biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow