@cosmicdrift/kumiko-framework 0.209.1 → 0.210.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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  4. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  5. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  6. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  7. package/src/db/__tests__/cursor.test.ts +26 -1
  8. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  9. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  10. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  12. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  13. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  14. package/src/db/cursor.ts +32 -0
  15. package/src/db/event-store-executor-read.ts +80 -9
  16. package/src/db/index.ts +2 -2
  17. package/src/db/pg-error.ts +7 -0
  18. package/src/db/queries/backfill-pii.ts +188 -18
  19. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  20. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  21. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  22. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  23. package/src/engine/__tests__/field-access.test.ts +1 -23
  24. package/src/engine/__tests__/store-table.test.ts +4 -1
  25. package/src/engine/boot-validator/pii-retention.ts +22 -54
  26. package/src/engine/build-config-feature-schema.ts +9 -1
  27. package/src/engine/factories.ts +108 -30
  28. package/src/engine/field-access.ts +2 -13
  29. package/src/engine/index.ts +7 -1
  30. package/src/engine/types/index.ts +7 -1
  31. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  32. package/src/files/file-ref-entity.ts +2 -2
  33. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  34. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  35. package/src/testing/shared-entities.ts +6 -3
@@ -106,16 +106,22 @@ export type {
106
106
  FieldsMap,
107
107
  FileFieldDef,
108
108
  FilesFieldDef,
109
+ Findability,
109
110
  ImageFieldDef,
110
111
  ImagesFieldDef,
111
112
  JsonbFieldDef,
112
113
  LocatedTimestampFieldDef,
113
114
  LongTextFieldDef,
115
+ LongTextFindability,
114
116
  MoneyFieldDef,
115
117
  MultiSelectFieldDef,
116
118
  NumberFieldDef,
117
- PiiAnnotations,
119
+ PersonalAnnotations,
120
+ PersonalAnnotationsLongText,
121
+ PersonalAnnotationsNoFind,
122
+ PersonalSubject,
118
123
  ReferenceFieldDef,
124
+ ResolvedPiiFlags,
119
125
  RetentionDef,
120
126
  SelectFieldDef,
121
127
  TextFieldDef,
@@ -37,7 +37,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
37
37
 
38
38
  const contactEntity = createEntity({
39
39
  fields: {
40
- email: createTextField({ required: true, pii: true, lookupable: true }),
40
+ email: createTextField({ required: true, personal: "self", find: "exact" }),
41
41
  displayName: createTextField(),
42
42
  },
43
43
  });
@@ -55,6 +55,20 @@ const mailerFeature = defineFeature("mailer", (r) => {
55
55
  );
56
56
  });
57
57
 
58
+ // personal: { of } (not "self") — the named owner field can legitimately be
59
+ // absent from an old event's payload, unlike contact.email (self-subject via row id).
60
+ const noteEntity = createEntity({
61
+ fields: {
62
+ authorId: createTextField(),
63
+ body: createTextField({ personal: { of: "authorId" }, find: "none" }),
64
+ },
65
+ });
66
+ const noteTable = buildEntityTable("note", noteEntity);
67
+
68
+ const notesFeature = defineFeature("notes", (r) => {
69
+ r.entity("note", noteEntity);
70
+ });
71
+
58
72
  let testDb: TestDb;
59
73
  let registry: Registry;
60
74
  let kms: InMemoryKmsAdapter;
@@ -79,9 +93,10 @@ async function appendPlain(
79
93
 
80
94
  beforeAll(async () => {
81
95
  testDb = await createTestDb();
82
- registry = createRegistry([crmFeature, mailerFeature]);
96
+ registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
83
97
  await createEventsTable(testDb.db);
84
98
  await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
99
+ await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
85
100
  });
86
101
 
87
102
  afterAll(async () => {
@@ -92,6 +107,7 @@ beforeEach(async () => {
92
107
  const raw = asRawClient(testDb.db);
93
108
  await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
94
109
  await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
110
+ await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
95
111
  // Plaintext era: NO KMS while the legacy events are appended.
96
112
  resetPiiSubjectKmsForTests();
97
113
  resetBlindIndexKeyForTests();
@@ -280,6 +296,167 @@ describe("backfillEventPiiEncryption", () => {
280
296
  });
281
297
  });
282
298
 
299
+ describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
300
+ async function insertNoteProjectionRow(id: string, authorId: string): Promise<void> {
301
+ await asRawClient(testDb.db).unsafe(
302
+ `INSERT INTO "${noteTable.tableName}" (id, tenant_id, author_id, body) VALUES ($1, $2, $3, $4)`,
303
+ [id, TENANT, authorId, "projection-row-body"],
304
+ );
305
+ }
306
+
307
+ test("payload missing owner + projection row has it: resolveOwnerFromProjection encrypts, no failures", async () => {
308
+ const author = generateId();
309
+ const noteId = generateId();
310
+ await insertNoteProjectionRow(noteId, author);
311
+ // Legacy event predating the authorId column — payload never carried it.
312
+ await appendPlain(noteId, "note", "note.created", { id: noteId, body: "old plaintext note" });
313
+
314
+ armKms();
315
+ const result = await backfillEventPiiEncryption(testDb.db, registry, {
316
+ resolveOwnerFromProjection: true,
317
+ });
318
+
319
+ expect(result.failures).toEqual([]);
320
+ expect(result.encryptedFields).toBe(1);
321
+ expect(result.ownerFromProjection).toBe(1);
322
+
323
+ const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
324
+ string,
325
+ unknown
326
+ >;
327
+ expect(isPiiCiphertext(created["body"])).toBe(true);
328
+ expect(String(created["body"])).toContain(`user:${author}`);
329
+ });
330
+
331
+ test("same event without the flags: stays in failures (default behavior unchanged)", async () => {
332
+ const author = generateId();
333
+ const noteId = generateId();
334
+ await insertNoteProjectionRow(noteId, author);
335
+ await appendPlain(noteId, "note", "note.created", { id: noteId, body: "old plaintext note" });
336
+
337
+ armKms();
338
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
339
+
340
+ expect(result.encryptedFields).toBe(0);
341
+ expect(result.failures).toHaveLength(1);
342
+ expect(result.failures[0]?.reason).toContain("resolveOwnerFromProjection");
343
+ expect(result.failures[0]?.reason).toContain("eraseUnresolvableSubjects");
344
+
345
+ const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
346
+ string,
347
+ unknown
348
+ >;
349
+ expect(created["body"]).toBe("old plaintext note");
350
+ });
351
+
352
+ test("owner missing from payload AND projection: eraseUnresolvableSubjects sentinels the field", async () => {
353
+ const noteId = generateId();
354
+ // No projection row for this aggregate — hard-deleted, or never rebuilt.
355
+ await appendPlain(noteId, "note", "note.created", { id: noteId, body: "orphaned note" });
356
+
357
+ armKms();
358
+ const result = await backfillEventPiiEncryption(testDb.db, registry, {
359
+ resolveOwnerFromProjection: true,
360
+ eraseUnresolvableSubjects: true,
361
+ });
362
+
363
+ expect(result.failures).toEqual([]);
364
+ expect(result.erasedFields).toBe(1);
365
+ expect(result.erasedUnresolvable).toBe(1);
366
+
367
+ const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
368
+ string,
369
+ unknown
370
+ >;
371
+ expect(created["body"]).toBe(PII_ERASED_SENTINEL);
372
+ });
373
+
374
+ test("already-ciphertext field with unresolvable owner is left untouched (no data loss)", async () => {
375
+ // Produce genuine ciphertext under a real, resolvable subject first.
376
+ const author = generateId();
377
+ const seedId = generateId();
378
+ await appendPlain(seedId, "note", "note.created", {
379
+ id: seedId,
380
+ authorId: author,
381
+ body: "seed",
382
+ });
383
+
384
+ armKms();
385
+ await backfillEventPiiEncryption(testDb.db, registry);
386
+ const seeded = (await loadAggregate(testDb.db, seedId, TENANT))[0]?.payload as Record<
387
+ string,
388
+ unknown
389
+ >;
390
+ const ciphertext = seeded["body"];
391
+ expect(isPiiCiphertext(ciphertext)).toBe(true);
392
+
393
+ // New event: body already holds that ciphertext, owner unresolvable
394
+ // (no authorId in payload, no projection row for THIS aggregate).
395
+ const noteId = generateId();
396
+ await appendPlain(noteId, "note", "note.created", { id: noteId, body: ciphertext });
397
+
398
+ const result = await backfillEventPiiEncryption(testDb.db, registry, {
399
+ resolveOwnerFromProjection: true,
400
+ eraseUnresolvableSubjects: true,
401
+ });
402
+
403
+ expect(result.failures).toEqual([]);
404
+ expect(result.erasedUnresolvable).toBe(0);
405
+ expect(result.encryptedFields).toBe(0);
406
+ expect(result.erasedFields).toBe(0);
407
+
408
+ const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
409
+ string,
410
+ unknown
411
+ >;
412
+ expect(created["body"]).toBe(ciphertext);
413
+ });
414
+
415
+ test("payload owner differs from projection owner: payload wins, ownerFromProjection stays 0", async () => {
416
+ const payloadAuthor = generateId();
417
+ const projectionAuthor = generateId();
418
+ const noteId = generateId();
419
+ await insertNoteProjectionRow(noteId, projectionAuthor);
420
+ // Ownership changed since this event was written — payload is the
421
+ // historical truth, the projection only today's state. Stage 2 must
422
+ // fill gaps, never override a value stage 1 already resolved.
423
+ await appendPlain(noteId, "note", "note.created", {
424
+ id: noteId,
425
+ authorId: payloadAuthor,
426
+ body: "old plaintext note",
427
+ });
428
+
429
+ armKms();
430
+ const result = await backfillEventPiiEncryption(testDb.db, registry, {
431
+ resolveOwnerFromProjection: true,
432
+ });
433
+
434
+ expect(result.failures).toEqual([]);
435
+ expect(result.encryptedFields).toBe(1);
436
+ expect(result.ownerFromProjection).toBe(0);
437
+
438
+ const created = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
439
+ string,
440
+ unknown
441
+ >;
442
+ expect(isPiiCiphertext(created["body"])).toBe(true);
443
+ expect(String(created["body"])).toContain(`user:${payloadAuthor}`);
444
+ });
445
+
446
+ test("pii field absent from the section with an unresolvable owner: skipped, no failure (no flags)", async () => {
447
+ const noteId = generateId();
448
+ // No "body" key at all, no authorId, no projection row — the value
449
+ // guard skips owner resolution entirely before it could ever fail.
450
+ await appendPlain(noteId, "note", "note.created", { id: noteId });
451
+
452
+ armKms();
453
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
454
+
455
+ expect(result.failures).toEqual([]);
456
+ expect(result.updatedEvents).toBe(0);
457
+ });
458
+ });
459
+
283
460
  describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
284
461
  // The UPDATE used to wrap outcome.payload in JSON.stringify before handing
285
462
  // it to the ::jsonb cast — Bun.SQL already serializes objects, so the
@@ -14,7 +14,7 @@ import { createBigIntField, createEntity, createTextField } from "../engine";
14
14
  // ohne `.default(now()).notNull()` macht inserted_at still nullable.
15
15
  //
16
16
  // PII-Annotations:
17
- // - fileName → pii: true (Originalname enthält oft Personen-Bezug:
17
+ // - fileName → personal: "self" (Originalname enthält oft Personen-Bezug:
18
18
  // "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
19
19
  // (storageKey, mimeType, size, entityType, entityId, fieldName) treffen
20
20
  // die PII-Heuristik nicht.
@@ -23,7 +23,7 @@ export const fileRefEntity = createEntity({
23
23
  softDelete: true,
24
24
  fields: {
25
25
  storageKey: createTextField({ required: true }),
26
- fileName: createTextField({ required: true, pii: true }),
26
+ fileName: createTextField({ required: true, personal: "self", find: "none" }),
27
27
  mimeType: createTextField({ required: true }),
28
28
  size: createBigIntField({ required: true }),
29
29
  entityType: createTextField(),
@@ -46,7 +46,7 @@ const widgetFeature = defineFeature("reindex-test", (r) => {
46
46
  const contactEntity = createEntity({
47
47
  table: "read_reindex_contacts",
48
48
  fields: {
49
- label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
49
+ label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
50
50
  },
51
51
  });
52
52
  const contactTable = buildEntityTable("contact", contactEntity);
@@ -18,8 +18,8 @@ import { purgeSearchDocumentsForSubject } from "../purge-subject";
18
18
  const contactEntity = createEntity({
19
19
  table: "read_search_pii_contacts",
20
20
  fields: {
21
- // pii: true → subject = entity id (self). searchable via derived index.
22
- label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
21
+ // personal: "self" → subject = entity id. find: "fuzzy" → searchable via derived index.
22
+ label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
23
23
  note: createTextField({ required: true, maxLength: 100, searchable: true }),
24
24
  },
25
25
  });
@@ -31,15 +31,18 @@ export const sharedUserEntity = createEntity({
31
31
  required: true,
32
32
  format: "email",
33
33
  searchable: true,
34
- allowPlaintext: "test-fixture",
34
+ personal: false,
35
+ reason: "test_fixture",
35
36
  }),
36
37
  firstName: createTextField({
37
38
  searchable: true,
38
- allowPlaintext: "test-fixture",
39
+ personal: false,
40
+ reason: "test_fixture",
39
41
  }),
40
42
  lastName: createTextField({
41
43
  searchable: true,
42
- allowPlaintext: "test-fixture",
44
+ personal: false,
45
+ reason: "test_fixture",
43
46
  }),
44
47
  isEnabled: createBooleanField({ default: true }),
45
48
  },