@cosmicdrift/kumiko-bundled-features 0.308.0 → 0.310.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.308.0",
3
+ "version": "0.310.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.308.0",
137
- "@cosmicdrift/kumiko-framework": "0.308.0",
138
- "@cosmicdrift/kumiko-headless": "0.308.0",
139
- "@cosmicdrift/kumiko-renderer": "0.308.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.308.0",
141
- "@cosmicdrift/kumiko-types": "0.308.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
137
+ "@cosmicdrift/kumiko-framework": "0.310.0",
138
+ "@cosmicdrift/kumiko-headless": "0.310.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.310.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.310.0",
141
+ "@cosmicdrift/kumiko-types": "0.310.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.308.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.308.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.310.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.310.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -179,4 +179,35 @@ describe("seedAdminGuarded (offlot#114)", () => {
179
179
  expect(await userIds()).toHaveLength(2);
180
180
  expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
181
181
  });
182
+
183
+ test("a KMS without a blind-index key aborts instead of seeding a blind duplicate", async () => {
184
+ await seedAdminGuarded(stack.db, seedOptions);
185
+ resetBlindIndexKeyForTests();
186
+
187
+ await expect(seedAdminGuarded(stack.db, seedOptions)).rejects.toThrow(/blind-index key/);
188
+ expect(await userIds()).toHaveLength(1);
189
+ });
190
+
191
+ test("existing-user branch reconciles emailVerified onto the canonical row", async () => {
192
+ const first = await seedAdminGuarded(stack.db, seedOptions);
193
+ await blankBlindIndex();
194
+
195
+ const second = await seedAdminGuarded(stack.db, { ...seedOptions, emailVerified: true });
196
+
197
+ expect(second.id).toBe(first.id);
198
+ expect(await userIds()).toEqual([first.id]);
199
+ const rows = await selectMany<{ id: string; emailVerified: boolean }>(stack.db, userTable);
200
+ expect(rows.find((r) => r.id === first.id)?.emailVerified).toBe(true);
201
+ });
202
+
203
+ test("without a configured KMS, seeding runs the plaintext path unguarded", async () => {
204
+ resetPiiSubjectKmsForTests();
205
+ resetBlindIndexKeyForTests();
206
+
207
+ const first = await seedAdminGuarded(stack.db, seedOptions);
208
+ const second = await seedAdminGuarded(stack.db, seedOptions);
209
+
210
+ expect(second.id).toBe(first.id);
211
+ expect(await userIds()).toEqual([first.id]);
212
+ });
182
213
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.309.0",
4
+ "type": "fix",
5
+ "title": "Boot admin seed no longer duplicates the admin when the email blind-index lookup misses (fw#3101)",
6
+ "detail": "runProdApp and runDevApp used to call the unguarded `seedAdmin`, whose idempotency check is a plain email fetchOne that the query layer rewrites into `(email = $plaintext OR email_bidx = $hmac)` once a blind-index key is configured. Under KMS encryption `email` holds per-row ciphertext, so only the bidx arm can ever match — and it misses whenever a row's `email_bidx` is NULL (written before the key existed, or after a subject-key erase), silently inserting a second admin account for the same email. Both boot paths now call `seedAdminGuarded`, which under KMS decrypt-scans the active users for the email and reuses the oldest matching row instead of duplicating it. `seedAdminGuarded` also now aborts with a named error if a PII KMS is configured but no blind-index key is — that combination makes the lookup blind by construction — and reconciles `emailVerified: true` onto the existing row it resolves through the decrypt-scan branch, matching the existing-row reconcile `seedUser` already does on its own idempotency hit."
7
+ },
2
8
  {
3
9
  "version": "0.307.0",
4
10
  "type": "improvement",
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import {
17
+ configuredBlindIndexKey,
17
18
  configuredPiiSubjectKms,
18
19
  decryptPiiFieldValues,
19
20
  type LocalKeyKmsAdapter,
@@ -28,7 +29,7 @@ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenan
28
29
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
29
30
  import { USER_STATUS, userTable } from "../user/schema/user";
30
31
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
31
- import { seedUser } from "../user/seeding";
32
+ import { reconcileSeededUserEmailVerified, seedUser } from "../user/seeding";
32
33
 
33
34
  // Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
34
35
  // Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
@@ -257,8 +258,8 @@ export async function seedAdmin(
257
258
  // seedAdminGuarded is a separate, additive boot-seed guard — NOT a change
258
259
  // to seedUser/seedAdmin/provisionSignupAccount, which also run on every
259
260
  // real self-signup request and can't afford a decrypt-scan fallback there.
260
- // Use this only where seedAdmin already runs (dev-boot / sample-server /
261
- // bootstrap scripts), never as a login or signup hot path.
261
+ // It's what runProdApp/runDevApp call for their admin boot-seed; keep it
262
+ // off the login/signup hot path.
262
263
 
263
264
  type ActiveUserRow = {
264
265
  readonly id: string;
@@ -332,10 +333,22 @@ export async function seedAdminGuarded(
332
333
  const kms = configuredPiiSubjectKms();
333
334
  if (kms === undefined) return seedAdmin(db, options);
334
335
 
336
+ // Without a bidx key every row seeded here gets a NULL email_bidx, which
337
+ // neither seedAdmin's lookup nor the partial unique index can ever see.
338
+ if (configuredBlindIndexKey() === undefined) {
339
+ throw new Error(
340
+ "seedAdminGuarded: a PII KMS is configured but no blind-index key — the email lookup would be blind and the seed would insert a duplicate admin. Configure KUMIKO_BLIND_INDEX_KEY.",
341
+ );
342
+ }
343
+
335
344
  const existingId = await findExistingUserIdForEmail(db, kms, options.email);
336
345
  if (existingId === undefined) return seedAdmin(db, options);
337
346
 
338
347
  const by = options.by ?? TestUsers.systemAdmin;
348
+ if (options.emailVerified === true) {
349
+ const existingRow = await fetchOne(db, userTable, { id: existingId });
350
+ if (existingRow) await reconcileSeededUserEmailVerified(db, existingRow, by);
351
+ }
339
352
  for (const m of options.memberships) {
340
353
  await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
341
354
  await seedTenantMembership(db, {
@@ -0,0 +1,454 @@
1
+ // kumiko-framework#3089 — hardDelete deletes a row's file/image/files/images
2
+ // bytes (+ derivatives) and fileRef rows, not just the row itself.
3
+ //
4
+ // Verifies the negative cases where a data-loss/leak bug hides:
5
+ // - fresh rows + other rows' files are NEVER touched
6
+ // - a fileRef SHARED with another row of the same entity survives (bytes +
7
+ // row) even though the row that's being purged references it too
8
+ // - a storage-delete failure fails the row closed (row + fileRef survive,
9
+ // skipped "file_delete_failed") and converges on the next run
10
+ // - no file storage wired at all → row stays, skipped "missing_file_storage"
11
+ // - softDelete/anonymize entities with file fields never touch bytes
12
+ // - the registered cron handler forwards ctx.files, not just direct calls
13
+
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
17
+ import {
18
+ createEntity,
19
+ createFilesField,
20
+ createImageField,
21
+ createSystemUser,
22
+ createTextField,
23
+ defineFeature,
24
+ type JobContext,
25
+ } from "@cosmicdrift/kumiko-framework/engine";
26
+ import {
27
+ createFileContext,
28
+ createInMemoryFileProvider,
29
+ deriveKey,
30
+ type FileStorageProvider,
31
+ fileRefEntity,
32
+ fileRefsTable,
33
+ } from "@cosmicdrift/kumiko-framework/files";
34
+ import {
35
+ setupTestStack,
36
+ type TestStack,
37
+ unsafeCreateEntityTable,
38
+ } from "@cosmicdrift/kumiko-framework/stack";
39
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
40
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
41
+ import { createDataRetentionFeature } from "../feature";
42
+ import { runRetentionCleanup } from "../run-retention-cleanup";
43
+
44
+ // hardDelete entity with BOTH a single (image) and a plural (files) field —
45
+ // covers the column-value path AND the file_refs-lookup path in one entity.
46
+ const docEntity = createEntity({
47
+ table: "read_c8_doc",
48
+ fields: {
49
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
50
+ cover: createImageField(),
51
+ attachments: createFilesField(),
52
+ },
53
+ retention: { keepFor: "30d", strategy: "hardDelete" },
54
+ });
55
+
56
+ // softDelete entity with a file field — the strategy's own path (mark
57
+ // is_deleted) must never touch bytes; only hardDelete does.
58
+ const softDocEntity = createEntity({
59
+ table: "read_c8_soft_doc",
60
+ softDelete: true,
61
+ fields: {
62
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
63
+ cover: createImageField(),
64
+ },
65
+ retention: { keepFor: "30d", strategy: "softDelete" },
66
+ });
67
+
68
+ // anonymize entity with a file field — same guarantee for the anonymize path.
69
+ const anonDocEntity = createEntity({
70
+ table: "read_c8_anon_doc",
71
+ fields: {
72
+ label: {
73
+ ...createTextField({ required: true, personal: false, reason: "test_fixture" }),
74
+ anonymize: () => "[ANONYMIZED]",
75
+ },
76
+ cover: createImageField(),
77
+ },
78
+ retention: { keepFor: "30d", strategy: "anonymize" },
79
+ });
80
+
81
+ const c8Feature = defineFeature("c8-retention-file-fixtures", (r) => {
82
+ r.entity("c8-doc", docEntity);
83
+ r.entity("c8-soft-doc", softDocEntity);
84
+ r.entity("c8-anon-doc", anonDocEntity);
85
+ });
86
+
87
+ const noopLogger: JobContext["log"] = {
88
+ info() {},
89
+ warn() {},
90
+ error() {},
91
+ debug() {},
92
+ child() {
93
+ return noopLogger;
94
+ },
95
+ };
96
+
97
+ const T1 = "33333333-3333-3333-3333-333333333333";
98
+ const T2 = "44444444-4444-4444-4444-444444444444";
99
+
100
+ let stack: TestStack;
101
+ let provider: ReturnType<typeof createInMemoryFileProvider>;
102
+ let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
103
+ let pastIso: string;
104
+ let withinIso: string;
105
+ let fileRefCrud: ReturnType<typeof createEventStoreExecutor>;
106
+
107
+ beforeAll(async () => {
108
+ provider = createInMemoryFileProvider();
109
+ stack = await setupTestStack({
110
+ features: [createDataRetentionFeature(), c8Feature],
111
+ files: { storageProvider: provider },
112
+ });
113
+ for (const e of [docEntity, softDocEntity, anonDocEntity]) {
114
+ await unsafeCreateEntityTable(stack.db, e);
115
+ }
116
+ fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
117
+ now = getTemporal().Now.instant();
118
+ pastIso = now.subtract({ hours: 60 * 24 }).toString();
119
+ withinIso = now.subtract({ hours: 10 * 24 }).toString();
120
+ });
121
+
122
+ afterAll(async () => {
123
+ await stack.cleanup();
124
+ });
125
+
126
+ function filesCtx(p: FileStorageProvider = provider) {
127
+ return createFileContext(() => Promise.resolve(p));
128
+ }
129
+
130
+ async function seedDoc(
131
+ table: string,
132
+ tenantId: string,
133
+ label: string,
134
+ insertedAtIso: string,
135
+ ): Promise<string> {
136
+ const rows = (await asRawClient(stack.db).unsafe(
137
+ `INSERT INTO ${table} (tenant_id, label, inserted_at) VALUES ($1, $2, $3::timestamptz) RETURNING id`,
138
+ [tenantId, label, insertedAtIso],
139
+ )) as { id: string | number }[];
140
+ return String(rows[0]?.id);
141
+ }
142
+
143
+ async function setCover(table: string, rowId: string, fileRefId: string | null): Promise<void> {
144
+ await asRawClient(stack.db).unsafe(`UPDATE ${table} SET cover = $1 WHERE id = $2`, [
145
+ fileRefId,
146
+ rowId,
147
+ ]);
148
+ }
149
+
150
+ async function seedFileRef(args: {
151
+ readonly tenantId: string;
152
+ readonly entityName: string | null;
153
+ readonly entityId: string | null;
154
+ readonly fieldName: string | null;
155
+ readonly key: string;
156
+ }): Promise<string> {
157
+ await provider.write(args.key, new Uint8Array([1, 2, 3]), "application/octet-stream");
158
+ const tdb = createTenantDb(stack.db, args.tenantId, "system");
159
+ const systemUser = createSystemUser(args.tenantId);
160
+ const id = crypto.randomUUID();
161
+ const res = await fileRefCrud.create(
162
+ {
163
+ id,
164
+ storageKey: args.key,
165
+ fileName: "f.bin",
166
+ mimeType: "application/octet-stream",
167
+ size: 3,
168
+ entityType: args.entityName,
169
+ entityId: args.entityId,
170
+ fieldName: args.fieldName,
171
+ },
172
+ systemUser,
173
+ tdb,
174
+ );
175
+ expect(res.isSuccess).toBe(true);
176
+ return id;
177
+ }
178
+
179
+ async function fileRefExists(id: string): Promise<boolean> {
180
+ const rows = await asRawClient(stack.db).unsafe(`SELECT id FROM file_refs WHERE id = $1`, [id]);
181
+ return (rows as unknown[]).length > 0;
182
+ }
183
+
184
+ async function labels(table: string, tenantId: string): Promise<string[]> {
185
+ const rows = (await asRawClient(stack.db).unsafe(
186
+ `SELECT label FROM ${table} WHERE tenant_id = $1 ORDER BY label`,
187
+ [tenantId],
188
+ )) as { label: string }[];
189
+ return rows.map((r) => r.label);
190
+ }
191
+
192
+ beforeEach(async () => {
193
+ for (const t of ["read_c8_doc", "read_c8_soft_doc", "read_c8_anon_doc"]) {
194
+ await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
195
+ }
196
+ await asRawClient(stack.db).unsafe(`DELETE FROM file_refs`);
197
+ provider.clear();
198
+ });
199
+
200
+ describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fileRefs", () => {
201
+ test("expired row: bytes + derivatives + fileRefs (single + plural field) gone; fresh + other-tenant untouched", async () => {
202
+ const expiredId = await seedDoc("read_c8_doc", T1, "expired", pastIso);
203
+ const coverKey = "t1/expired/cover/orig.jpg";
204
+ const coverId = await seedFileRef({
205
+ tenantId: T1,
206
+ entityName: "c8-doc",
207
+ entityId: expiredId,
208
+ fieldName: "cover",
209
+ key: coverKey,
210
+ });
211
+ await setCover("read_c8_doc", expiredId, coverId);
212
+ // Derivative-suffix grammar is "<name>-<16 hex chars>" (variantSuffix),
213
+ // not a bare variant name — see isDerivativeKeyOf/DERIVATIVE_SUFFIX_PATTERN.
214
+ const thumbKey = deriveKey(coverKey, "thumb-0123456789abcdef");
215
+ await provider.write(thumbKey, new Uint8Array([9]), "image/jpeg");
216
+
217
+ const attKey = "t1/expired/attachments/a.pdf";
218
+ const attId = await seedFileRef({
219
+ tenantId: T1,
220
+ entityName: "c8-doc",
221
+ entityId: expiredId,
222
+ fieldName: "attachments",
223
+ key: attKey,
224
+ });
225
+
226
+ const freshId = await seedDoc("read_c8_doc", T1, "fresh", withinIso);
227
+ const freshCoverKey = "t1/fresh/cover/orig.jpg";
228
+ const freshCoverId = await seedFileRef({
229
+ tenantId: T1,
230
+ entityName: "c8-doc",
231
+ entityId: freshId,
232
+ fieldName: "cover",
233
+ key: freshCoverKey,
234
+ });
235
+ await setCover("read_c8_doc", freshId, freshCoverId);
236
+
237
+ const otherTenantId = await seedDoc("read_c8_doc", T2, "expired-t2", pastIso);
238
+ const otherTenantKey = "t2/expired/cover/orig.jpg";
239
+ const otherTenantCoverId = await seedFileRef({
240
+ tenantId: T2,
241
+ entityName: "c8-doc",
242
+ entityId: otherTenantId,
243
+ fieldName: "cover",
244
+ key: otherTenantKey,
245
+ });
246
+ await setCover("read_c8_doc", otherTenantId, otherTenantCoverId);
247
+
248
+ const result = await runRetentionCleanup({
249
+ db: stack.db,
250
+ registry: stack.registry,
251
+ tenantId: T1,
252
+ tenantPreset: null,
253
+ now,
254
+ files: filesCtx(),
255
+ });
256
+
257
+ expect(result.hardDeleted).toBe(1);
258
+ expect(await labels("read_c8_doc", T1)).toEqual(["fresh"]);
259
+
260
+ expect(await provider.exists(coverKey)).toBe(false);
261
+ expect(await provider.exists(thumbKey)).toBe(false);
262
+ expect(await provider.exists(attKey)).toBe(false);
263
+ expect(await fileRefExists(coverId)).toBe(false);
264
+ expect(await fileRefExists(attId)).toBe(false);
265
+
266
+ expect(await provider.exists(freshCoverKey)).toBe(true);
267
+ expect(await fileRefExists(freshCoverId)).toBe(true);
268
+
269
+ expect(await provider.exists(otherTenantKey)).toBe(true);
270
+ expect(await fileRefExists(otherTenantCoverId)).toBe(true);
271
+ expect(await labels("read_c8_doc", T2)).toEqual(["expired-t2"]);
272
+ });
273
+
274
+ test("shared fileRef (same entity, second row) survives even though its owning row is purged", async () => {
275
+ const rowA = await seedDoc("read_c8_doc", T1, "expired-shared", pastIso);
276
+ const rowB = await seedDoc("read_c8_doc", T1, "fresh-shared", withinIso);
277
+ const sharedKey = "t1/shared/cover/orig.jpg";
278
+ const sharedId = await seedFileRef({
279
+ tenantId: T1,
280
+ entityName: "c8-doc",
281
+ entityId: rowA,
282
+ fieldName: "cover",
283
+ key: sharedKey,
284
+ });
285
+ await setCover("read_c8_doc", rowA, sharedId);
286
+ await setCover("read_c8_doc", rowB, sharedId);
287
+
288
+ const result = await runRetentionCleanup({
289
+ db: stack.db,
290
+ registry: stack.registry,
291
+ tenantId: T1,
292
+ tenantPreset: null,
293
+ now,
294
+ files: filesCtx(),
295
+ });
296
+
297
+ expect(result.hardDeleted).toBe(1);
298
+ expect(await labels("read_c8_doc", T1)).toEqual(["fresh-shared"]);
299
+ expect(await provider.exists(sharedKey)).toBe(true);
300
+ expect(await fileRefExists(sharedId)).toBe(true);
301
+ });
302
+
303
+ test("storage-delete failure fails the row closed; skipped + convergence on retry", async () => {
304
+ const rowId = await seedDoc("read_c8_doc", T1, "expired-fails", pastIso);
305
+ const key = "t1/fails/cover/orig.jpg";
306
+ const fileRefId = await seedFileRef({
307
+ tenantId: T1,
308
+ entityName: "c8-doc",
309
+ entityId: rowId,
310
+ fieldName: "cover",
311
+ key,
312
+ });
313
+ await setCover("read_c8_doc", rowId, fileRefId);
314
+
315
+ const failingProvider: FileStorageProvider = {
316
+ ...provider,
317
+ delete: async (k: string) => {
318
+ if (k === key) throw new Error("boom");
319
+ return provider.delete(k);
320
+ },
321
+ };
322
+
323
+ const failed = await runRetentionCleanup({
324
+ db: stack.db,
325
+ registry: stack.registry,
326
+ tenantId: T1,
327
+ tenantPreset: null,
328
+ now,
329
+ files: filesCtx(failingProvider),
330
+ });
331
+
332
+ expect(failed.hardDeleted).toBe(0);
333
+ expect(failed.skipped).toContainEqual({ entityName: "c8-doc", reason: "file_delete_failed" });
334
+ expect(await labels("read_c8_doc", T1)).toEqual(["expired-fails"]);
335
+ expect(await fileRefExists(fileRefId)).toBe(true);
336
+ expect(await provider.exists(key)).toBe(true);
337
+
338
+ const retried = await runRetentionCleanup({
339
+ db: stack.db,
340
+ registry: stack.registry,
341
+ tenantId: T1,
342
+ tenantPreset: null,
343
+ now,
344
+ files: filesCtx(),
345
+ });
346
+ expect(retried.hardDeleted).toBe(1);
347
+ expect(await labels("read_c8_doc", T1)).toEqual([]);
348
+ expect(await fileRefExists(fileRefId)).toBe(false);
349
+ expect(await provider.exists(key)).toBe(false);
350
+ });
351
+
352
+ test("no file storage wired → row stays, skipped missing_file_storage", async () => {
353
+ const rowId = await seedDoc("read_c8_doc", T1, "expired-no-storage", pastIso);
354
+ const key = "t1/no-storage/cover/orig.jpg";
355
+ const fileRefId = await seedFileRef({
356
+ tenantId: T1,
357
+ entityName: "c8-doc",
358
+ entityId: rowId,
359
+ fieldName: "cover",
360
+ key,
361
+ });
362
+ await setCover("read_c8_doc", rowId, fileRefId);
363
+
364
+ const result = await runRetentionCleanup({
365
+ db: stack.db,
366
+ registry: stack.registry,
367
+ tenantId: T1,
368
+ tenantPreset: null,
369
+ now,
370
+ });
371
+
372
+ expect(result.hardDeleted).toBe(0);
373
+ expect(result.skipped).toContainEqual({
374
+ entityName: "c8-doc",
375
+ reason: "missing_file_storage",
376
+ });
377
+ expect(await labels("read_c8_doc", T1)).toEqual(["expired-no-storage"]);
378
+ expect(await fileRefExists(fileRefId)).toBe(true);
379
+ });
380
+
381
+ test("softDelete and anonymize entities with a file field never touch bytes", async () => {
382
+ const softRowId = await seedDoc("read_c8_soft_doc", T1, "soft-expired", pastIso);
383
+ const softKey = "t1/soft/cover/orig.jpg";
384
+ const softFileRefId = await seedFileRef({
385
+ tenantId: T1,
386
+ entityName: "c8-soft-doc",
387
+ entityId: softRowId,
388
+ fieldName: "cover",
389
+ key: softKey,
390
+ });
391
+ await setCover("read_c8_soft_doc", softRowId, softFileRefId);
392
+
393
+ const anonRowId = await seedDoc("read_c8_anon_doc", T1, "anon-expired", pastIso);
394
+ const anonKey = "t1/anon/cover/orig.jpg";
395
+ const anonFileRefId = await seedFileRef({
396
+ tenantId: T1,
397
+ entityName: "c8-anon-doc",
398
+ entityId: anonRowId,
399
+ fieldName: "cover",
400
+ key: anonKey,
401
+ });
402
+ await setCover("read_c8_anon_doc", anonRowId, anonFileRefId);
403
+
404
+ const result = await runRetentionCleanup({
405
+ db: stack.db,
406
+ registry: stack.registry,
407
+ tenantId: T1,
408
+ tenantPreset: null,
409
+ now,
410
+ files: filesCtx(),
411
+ });
412
+
413
+ expect(result.softDeleted).toBe(1);
414
+ expect(result.anonymized).toBe(1);
415
+ expect(await provider.exists(softKey)).toBe(true);
416
+ expect(await fileRefExists(softFileRefId)).toBe(true);
417
+ expect(await provider.exists(anonKey)).toBe(true);
418
+ expect(await fileRefExists(anonFileRefId)).toBe(true);
419
+ });
420
+
421
+ test("the registered job handler forwards ctx.files — bytes gone after the cron runs", async () => {
422
+ const rowId = await seedDoc("read_c8_doc", T1, "expired-via-job", pastIso);
423
+ const key = "t1/via-job/cover/orig.jpg";
424
+ const fileRefId = await seedFileRef({
425
+ tenantId: T1,
426
+ entityName: "c8-doc",
427
+ entityId: rowId,
428
+ fieldName: "cover",
429
+ key,
430
+ });
431
+ await setCover("read_c8_doc", rowId, fileRefId);
432
+
433
+ const job = stack.registry.getJob("data-retention:job:retention-cleanup");
434
+ expect(job).toBeDefined();
435
+ if (!job) return;
436
+
437
+ const ctx: JobContext = {
438
+ db: createTenantDb(stack.db, T1, "tenant", undefined, undefined, undefined, {
439
+ unsafeRaw: { reason: "per-tenant fan-out cleanup using raw helpers" },
440
+ }),
441
+ registry: stack.registry,
442
+ systemUser: { id: "system", tenantId: T1, roles: ["all"] },
443
+ log: noopLogger,
444
+ triggeredBy: null,
445
+ files: filesCtx(),
446
+ ...bridgeStub(),
447
+ };
448
+ await job.handler({}, ctx);
449
+
450
+ expect(await labels("read_c8_doc", T1)).toEqual([]);
451
+ expect(await fileRefExists(fileRefId)).toBe(false);
452
+ expect(await provider.exists(key)).toBe(false);
453
+ });
454
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.309.0",
4
+ "type": "breaking",
5
+ "title": "data-retention hardDelete now purges file bytes + fileRef rows, not just the entity row (fw#3089)",
6
+ "migration": "hardDelete previously deleted only the entity row and left every file/image/files/images field's storage bytes and `file_refs` row behind — a DSGVO Art. 17 gap. As of this release, an expired row's un-shared fileRefs (same tenant, bound to this row, not referenced by another row of the same entity) have their storage bytes deleted (including derivatives/thumbnails), then their `file_refs` row, then the entity row itself. A fileRef still referenced by another row, or bound to a different entity/record, is left untouched. Without a resolvable file-storage provider in the retention cron's job context, an entity with pending file deletions is skipped entirely (`skipped` reason `missing_file_storage`) rather than silently dropping the row with orphaned bytes; a storage-delete failure skips the row too (`file_delete_failed`) and retries on the next run. Before upgrading, a consumer relying on hardDelete NOT touching file storage should: list entities with `retention.strategy: \"hardDelete\"` and at least one file/image/files/images field, count rows already past `keepFor` for each, and confirm the referenced files are safe to delete (not needed elsewhere) before the next cron run."
7
+ },
2
8
  {
3
9
  "version": "0.167.1",
4
10
  "type": "fix",
@@ -109,6 +109,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
109
109
  tenantId,
110
110
  tenantPreset,
111
111
  now: T.Now.instant(),
112
+ files: ctx.files,
112
113
  });
113
114
  if (result.skipped.length > 0) {
114
115
  // biome-ignore lint/suspicious/noConsole: operator-visibility for skipped entities