@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.287.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.
@@ -15,7 +15,13 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
15
15
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
17
17
  import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
18
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
18
+ import {
19
+ createEntity,
20
+ createFileField,
21
+ createImageField,
22
+ defineFeature,
23
+ SYSTEM_TENANT_ID,
24
+ } from "@cosmicdrift/kumiko-framework/engine";
19
25
  import {
20
26
  createInMemoryFileProvider,
21
27
  deriveKey,
@@ -29,7 +35,9 @@ import {
29
35
  } from "@cosmicdrift/kumiko-framework/stack";
30
36
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
31
37
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
38
+ import { createConfigFeature } from "../../config";
32
39
  import { createDataRetentionFeature } from "../../data-retention";
40
+ import { fileFoundationFeature } from "../../file-foundation";
33
41
  import { createFilesFeature } from "../../files";
34
42
  import { createSessionsFeature } from "../../sessions";
35
43
  import {
@@ -46,15 +54,50 @@ import { fileRefDeleteHook, fileRefExportHook, userDeleteHook, userExportHook }
46
54
 
47
55
  let stack: TestStack;
48
56
 
57
+ // #3005 fixture: a business entity with file fields covering the three
58
+ // field-annotation shapes fileRefDeleteHook must branch on — explicitly
59
+ // non-personal (dealer's own business data), personal (self), and no
60
+ // annotation at all (the conservative "no annotation" case).
61
+ const vehicleEntity = createEntity({
62
+ table: "test_vehicles",
63
+ fields: {
64
+ dealerPhoto: createImageField({
65
+ personal: false,
66
+ reason: "dealer_business_data_test_fixture",
67
+ }),
68
+ driverSelfie: createImageField({ personal: "self" }),
69
+ unannotatedDoc: createFileField(),
70
+ },
71
+ });
72
+
73
+ const vehicleFeature = defineFeature("testVehicleFileFields", (r) => {
74
+ r.entity("vehicle", vehicleEntity);
75
+ });
76
+
77
+ // The vehicle fixture's image/file fields make server.ts's boot guard require
78
+ // a mounted file-storage provider (registryDeclaresFileFields). The hook
79
+ // tests below never resolve a provider through the registry — each passes
80
+ // its own `buildStorageProvider` directly — so this one only exists to
81
+ // satisfy that boot check.
82
+ const bootGuardFileProvider = createInMemoryFileProvider();
83
+ const testFileProviderFeature = defineFeature("testFileProviderBootGuard", (r) => {
84
+ r.requires("file-foundation");
85
+ r.useExtension("fileProvider", "test", { build: async () => bootGuardFileProvider });
86
+ });
87
+
49
88
  const features = [
50
89
  createUserFeature(),
90
+ createConfigFeature(),
51
91
  createFilesFeature(),
92
+ fileFoundationFeature,
93
+ testFileProviderFeature,
52
94
  createDataRetentionFeature(),
53
95
  createComplianceProfilesFeature(),
54
96
  authFoundationFeature,
55
97
  createSessionsFeature(),
56
98
  createUserDataRightsFeature(),
57
99
  createUserDataRightsDefaultsFeature(),
100
+ vehicleFeature,
58
101
  ];
59
102
 
60
103
  beforeAll(async () => {
@@ -119,6 +162,35 @@ async function seedFileRef(
119
162
  );
120
163
  }
121
164
 
165
+ // #3005: seeds a fileRef attached to an entity/field, for testing the
166
+ // per-row PII-vs-business-data decision in fileRefDeleteHook.
167
+ async function seedFileRefWithField(
168
+ id: string,
169
+ tenantId: string,
170
+ insertedById: string,
171
+ entityType: string | null,
172
+ fieldName: string | null,
173
+ fileName: string,
174
+ ): Promise<void> {
175
+ await asRawClient(stack.db).unsafe(
176
+ `
177
+ INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, entity_type, entity_id, field_name, inserted_by_id)
178
+ VALUES ($1, $2, $3, $4, 'image/jpeg', 1024, $5, $6, $7, $8)
179
+ ON CONFLICT (id) DO NOTHING
180
+ `,
181
+ [
182
+ id,
183
+ tenantId,
184
+ `storage/${id}`,
185
+ fileName,
186
+ entityType,
187
+ entityType !== null ? "1" : null,
188
+ fieldName,
189
+ insertedById,
190
+ ],
191
+ );
192
+ }
193
+
122
194
  async function fetchUser(id: string) {
123
195
  const result = await asRawClient(stack.db).unsafe(
124
196
  `
@@ -409,6 +481,131 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
409
481
  });
410
482
  });
411
483
 
484
+ describe("S2.H2 :: fileRefDeleteHook — per-row PII decision (issue #3005)", () => {
485
+ test('strategy="delete" — field explicitly non-personal (dealer business data): binary + row survive, insertedById=null', async () => {
486
+ const userId = "user-dealer-photo";
487
+ await seedFileRefWithField(
488
+ uuid(601),
489
+ TENANT_A,
490
+ userId,
491
+ "vehicle",
492
+ "dealerPhoto",
493
+ "vehicle-front.jpg",
494
+ );
495
+
496
+ await fileRefDeleteHook(
497
+ {
498
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
499
+ registry: stack.registry,
500
+ tenantId: TENANT_A,
501
+ userId,
502
+ },
503
+ "delete",
504
+ );
505
+
506
+ const ownedAfter = await fetchFileRefs(TENANT_A, userId);
507
+ expect(ownedAfter).toHaveLength(0);
508
+ const anonymized = await fetchFileRefs(TENANT_A, null);
509
+ const row = anonymized.find((f: { id: string }) => f.id === uuid(601));
510
+ expect(row).toBeDefined();
511
+ expect(row.inserted_by_id).toBeNull();
512
+ });
513
+
514
+ test('strategy="delete" — field marked personal (self): binary + row are gone', async () => {
515
+ const userId = "user-driver-selfie";
516
+ await seedFileRefWithField(
517
+ uuid(602),
518
+ TENANT_A,
519
+ userId,
520
+ "vehicle",
521
+ "driverSelfie",
522
+ "selfie.jpg",
523
+ );
524
+
525
+ await fileRefDeleteHook(
526
+ {
527
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
528
+ registry: stack.registry,
529
+ tenantId: TENANT_A,
530
+ userId,
531
+ },
532
+ "delete",
533
+ );
534
+
535
+ const remaining = await fetchFileRefs(TENANT_A);
536
+ expect(remaining.find((f: { id: string }) => f.id === uuid(602))).toBeUndefined();
537
+ });
538
+
539
+ test('strategy="delete" — unattached upload (entityType/fieldName both null): hard-deleted', async () => {
540
+ const userId = "user-unattached-hard";
541
+ await seedFileRefWithField(uuid(603), TENANT_A, userId, null, null, "loose-file.pdf");
542
+
543
+ await fileRefDeleteHook(
544
+ {
545
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
546
+ registry: stack.registry,
547
+ tenantId: TENANT_A,
548
+ userId,
549
+ },
550
+ "delete",
551
+ );
552
+
553
+ const remaining = await fetchFileRefs(TENANT_A);
554
+ expect(remaining.find((f: { id: string }) => f.id === uuid(603))).toBeUndefined();
555
+ });
556
+
557
+ test('strategy="delete" — field exists but carries no annotation: anonymized, not hard-deleted', async () => {
558
+ const userId = "user-unannotated-field";
559
+ await seedFileRefWithField(uuid(604), TENANT_A, userId, "vehicle", "unannotatedDoc", "doc.pdf");
560
+
561
+ await fileRefDeleteHook(
562
+ {
563
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
564
+ registry: stack.registry,
565
+ tenantId: TENANT_A,
566
+ userId,
567
+ },
568
+ "delete",
569
+ );
570
+
571
+ const ownedAfter = await fetchFileRefs(TENANT_A, userId);
572
+ expect(ownedAfter).toHaveLength(0);
573
+ const anonymized = await fetchFileRefs(TENANT_A, null);
574
+ const row = anonymized.find((f: { id: string }) => f.id === uuid(604));
575
+ expect(row).toBeDefined();
576
+ expect(row.inserted_by_id).toBeNull();
577
+ });
578
+
579
+ test('strategy="delete" — entityType not resolvable in the registry: anonymized, never a silent hard-delete', async () => {
580
+ const userId = "user-unresolvable-entity";
581
+ await seedFileRefWithField(
582
+ uuid(605),
583
+ TENANT_A,
584
+ userId,
585
+ "no-such-entity",
586
+ "somefield",
587
+ "orphaned.pdf",
588
+ );
589
+
590
+ await fileRefDeleteHook(
591
+ {
592
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
593
+ registry: stack.registry,
594
+ tenantId: TENANT_A,
595
+ userId,
596
+ },
597
+ "delete",
598
+ );
599
+
600
+ const ownedAfter = await fetchFileRefs(TENANT_A, userId);
601
+ expect(ownedAfter).toHaveLength(0);
602
+ const anonymized = await fetchFileRefs(TENANT_A, null);
603
+ const row = anonymized.find((f: { id: string }) => f.id === uuid(605));
604
+ expect(row).toBeDefined();
605
+ expect(row.inserted_by_id).toBeNull();
606
+ });
607
+ });
608
+
412
609
  describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue #2461)", () => {
413
610
  async function seedFileRefWithKey(
414
611
  id: string,
@@ -2,6 +2,8 @@ import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-fra
2
2
  import { derivativeListPrefix, isDerivativeKeyOf } from "@cosmicdrift/kumiko-framework/derivatives";
3
3
  import {
4
4
  createSystemUser,
5
+ type FieldDefinition,
6
+ type Registry,
5
7
  type SessionUser,
6
8
  type UserDataDeleteHook,
7
9
  type UserDataExportHook,
@@ -28,10 +30,18 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
28
30
  // NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
29
31
  // gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
30
32
  //
31
- // Delete-Hook entfernt FileRef-Zeile + Binary:
32
- // "delete": storageProvider.delete() pro File + Row hard-delete
33
- // "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
34
- // koennen weiter zeigen; Personenbezug raus)
33
+ // Delete-hook removes the fileRef row + binary:
34
+ // "delete": storageProvider.delete() per file + row hard-delete — but
35
+ // ONLY for rows whose field is marked as PII of the person
36
+ // (isPersonalFileRow, #3005). All other rows (business/
37
+ // tenant data, no resolvable field, no annotation) go
38
+ // through severPersonLink instead, even when the entity
39
+ // strategy is "delete" — the uploader axis (insertedById) is
40
+ // not the subject axis.
41
+ // "anonymize": insertedById=null, row + binary survive (FK refs can still
42
+ // point at it; person-link removed) — applies to ALL rows
43
+ // when the entity strategy itself is already anonymize (e.g.
44
+ // blockDelete retention), regardless of the field.
35
45
  //
36
46
  // **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
37
47
  // `ctx.buildStorageProvider(ctx.tenantId)` — der Forget-Orchestrator
@@ -124,6 +134,46 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
124
134
  }
125
135
  }
126
136
 
137
+ // Per-row Art.17 decision (kumiko-framework#3005): whether THIS file's field
138
+ // carries personal data of the forgotten person, or is business/tenant data
139
+ // that merely loses its uploader-attribution. insertedById names who
140
+ // UPLOADED the file, not whose data it is — a dealer's vehicle photo
141
+ // uploaded by an employee is the dealer's business data, not the employee's
142
+ // PII. The field's own personal-annotation decides:
143
+ // - pii / userOwned / recordOwned → the field's content IS personal data
144
+ // of a person (self, owner-referenced, or the record's own subject) →
145
+ // hard-delete path.
146
+ // - anything else (explicit `personal: false`, `tenantOwned`,
147
+ // `subjectRef`, or no annotation at all) → not personal-to-a-person
148
+ // content → sever the uploader link only, keep row + binary.
149
+ function isPersonalPiiField(field: FieldDefinition | undefined): boolean {
150
+ if (!field) return false;
151
+ return (
152
+ ("pii" in field && field.pii === true) ||
153
+ ("userOwned" in field && field.userOwned !== undefined) ||
154
+ ("recordOwned" in field && field.recordOwned === true)
155
+ );
156
+ }
157
+
158
+ // Three cases the field-lookup itself can't resolve (issue #3005's rule for
159
+ // the non-resolvable cases):
160
+ // 1. Unattached upload (entityType/fieldName both null) → hard-delete: a
161
+ // file with no entity binding belongs to nobody but its uploader.
162
+ // 2. Field exists but carries no PII annotation → anonymize (the
163
+ // conservative path); the boot-validator warns on a PII-typical field
164
+ // name so this stays visible and quiet-fixable via `personal`.
165
+ // 3. Entity or field not resolvable (upload hardening in file-routes.ts
166
+ // should prevent this from ever being written; theoretical remainder
167
+ // only) → anonymize, never a silent hard-delete.
168
+ function isPersonalFileRow(registry: Registry, row: Record<string, unknown>): boolean {
169
+ const entityType = row["entityType"]; // @cast-boundary db-row
170
+ const fieldName = row["fieldName"]; // @cast-boundary db-row
171
+ if (entityType === null && fieldName === null) return true;
172
+ if (typeof entityType !== "string" || typeof fieldName !== "string") return false;
173
+ const fieldDef = registry.getEntity(entityType)?.fields[fieldName];
174
+ return isPersonalPiiField(fieldDef);
175
+ }
176
+
127
177
  // Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
128
178
  // never tracked anywhere but the storage layer: they're rendered under a
129
179
  // deterministic key (deriveKey + variantSuffix) computed from the original's
@@ -221,14 +271,34 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
221
271
  if (strategy !== "delete") {
222
272
  // anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
223
273
  // chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
274
+ // Applies to ALL rows — the entity strategy comes from a retention
275
+ // policy (e.g. blockDelete) and overrides the per-field decision below,
276
+ // which only applies for strategy="delete".
224
277
  await severPersonLink(ctx.db, systemUser, rows);
225
278
  // skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
226
279
  return;
227
280
  }
228
281
 
282
+ // strategy="delete" decides PER ROW based on the field, not uniformly
283
+ // (#3005): only rows whose field is marked as PII of the person (or
284
+ // unattached, see isPersonalFileRow) take the hard path. Everything else
285
+ // only loses the uploader link.
286
+ const personalRows: Record<string, unknown>[] = [];
287
+ const businessRows: Record<string, unknown>[] = [];
288
+ for (const row of rows) {
289
+ (isPersonalFileRow(ctx.registry, row) ? personalRows : businessRows).push(row);
290
+ }
291
+
292
+ if (businessRows.length > 0) {
293
+ await severPersonLink(ctx.db, systemUser, businessRows);
294
+ }
295
+
296
+ // skip: nothing left to hard-delete — businessRows above already had their person link severed via severPersonLink.
297
+ if (personalRows.length === 0) return;
298
+
229
299
  const storageProvider = await resolveProvider(ctx);
230
300
  if (storageProvider) {
231
- const failedKeys = await deleteBinaries(rows, storageProvider);
301
+ const failedKeys = await deleteBinaries(personalRows, storageProvider);
232
302
  if (failedKeys.length > 0) {
233
303
  throw new Error(
234
304
  `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
@@ -250,7 +320,7 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
250
320
  // auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
251
321
  // The old hard deleteMany was resurrected on rebuild; this closes that Art.17
252
322
  // hole without a direct write.
253
- for (const row of rows) {
323
+ for (const row of personalRows) {
254
324
  const id = row["id"]; // @cast-boundary db-row
255
325
  if (typeof id !== "string") continue;
256
326
  assertErased(await crud.forget({ id }, systemUser, ctx.db), "fileRef", id);