@cosmicdrift/kumiko-framework 0.118.0 → 0.120.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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  3. package/src/api/pii-leak-guard.ts +45 -0
  4. package/src/api/server.ts +5 -0
  5. package/src/bun-db/query.ts +27 -2
  6. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  7. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  8. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  9. package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
  10. package/src/crypto/blind-index.ts +114 -0
  11. package/src/crypto/event-pii.ts +69 -0
  12. package/src/crypto/index.ts +35 -0
  13. package/src/crypto/kms-adapter.ts +8 -0
  14. package/src/crypto/pg-kms-adapter.ts +173 -0
  15. package/src/crypto/pii-field-encryption.ts +195 -0
  16. package/src/db/__tests__/blind-index.integration.test.ts +232 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
  18. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  19. package/src/db/apply-entity-event.ts +8 -0
  20. package/src/db/blind-index-cleanup.ts +53 -0
  21. package/src/db/entity-table-meta.ts +47 -2
  22. package/src/db/event-store-executor.ts +125 -30
  23. package/src/db/index.ts +1 -0
  24. package/src/db/queries/backfill-pii.ts +276 -0
  25. package/src/db/table-builder.ts +98 -51
  26. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
  27. package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
  28. package/src/engine/boot-validator/pii-retention.ts +45 -9
  29. package/src/engine/define-feature.ts +28 -3
  30. package/src/engine/registry.ts +25 -0
  31. package/src/engine/types/feature.ts +17 -2
  32. package/src/engine/types/fields.ts +6 -0
  33. package/src/engine/types/handlers.ts +7 -0
  34. package/src/engine/types/index.ts +2 -0
  35. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  36. package/src/event-store/event-store.ts +11 -6
  37. package/src/event-store/index.ts +6 -0
@@ -64,6 +64,35 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
64
64
  );
65
65
  }
66
66
 
67
+ // lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
68
+ // Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
69
+ // zweite (deterministische!) Kopie ohne Nutzen.
70
+ if (annot.lookupable === true) {
71
+ if (field.type !== "text") {
72
+ throw new Error(
73
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
74
+ );
75
+ }
76
+ if (annotCount === 0) {
77
+ throw new Error(
78
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { lookupable: true } without a subject annotation (pii / userOwned / tenantOwned). Plaintext fields don't need a blind index — add the subject annotation or drop lookupable.`,
79
+ );
80
+ }
81
+ }
82
+
83
+ // Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
84
+ // unmöglich — searchable würde Plaintext-Kopien in den Suchindex
85
+ // schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
86
+ if (annotCount > 0) {
87
+ const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
88
+ if (flags.searchable === true || flags.sortable === true) {
89
+ const offending = flags.searchable === true ? "searchable" : "sortable";
90
+ throw new Error(
91
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
92
+ );
93
+ }
94
+ }
95
+
67
96
  if (annot.userOwned) {
68
97
  const ownerName = annot.userOwned.ownerField;
69
98
  if (!ownerName || typeof ownerName !== "string") {
@@ -78,21 +107,28 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
78
107
  `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
79
108
  );
80
109
  }
81
- if (ownerField.type !== "reference") {
110
+ // Text is accepted alongside reference: the ES-framework carries
111
+ // user ids as plain text columns throughout the bundled features
112
+ // (user-session.userId, invitation.invitedBy) — there is no
113
+ // relational FK to point a reference at. Self-referencing
114
+ // ownerField (the field's own value IS the owner id) rides on this.
115
+ if (ownerField.type !== "reference" && ownerField.type !== "text") {
82
116
  throw new Error(
83
- `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference field, got type "${ownerField.type}"`,
117
+ `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
84
118
  );
85
119
  }
86
120
  // Soft-Warning wenn das reference-target nicht offensichtlich user
87
121
  // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
88
122
  // erlaubt, müssen aber bewusste Wahl sein.
89
- const refTarget = ownerField.entity;
90
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
91
- if (targetEntity !== "user") {
92
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
93
- console.warn(
94
- `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
95
- );
123
+ if (ownerField.type === "reference") {
124
+ const refTarget = ownerField.entity;
125
+ const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
126
+ if (targetEntity !== "user") {
127
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
128
+ console.warn(
129
+ `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
130
+ );
131
+ }
96
132
  }
97
133
  }
98
134
 
@@ -1,4 +1,4 @@
1
- import type { ZodType, z } from "zod";
1
+ import { ZodObject, type ZodType, type z } from "zod";
2
2
  import type { EntityTableMeta } from "../db/entity-table-meta";
3
3
  import { toTableName } from "../db/table-builder";
4
4
  import { LifecycleHookTypes } from "./constants";
@@ -19,6 +19,7 @@ import type {
19
19
  EntityRef,
20
20
  EventDef,
21
21
  EventMigrationDef,
22
+ EventPiiFields,
22
23
  EventUpcastFn,
23
24
  ExtensionSelectorDef,
24
25
  FeatureDefinition,
@@ -66,6 +67,7 @@ import type {
66
67
  TreeChildrenSubscribe,
67
68
  UiHints,
68
69
  UnmanagedTableEntry,
70
+ UnmanagedTableOptions,
69
71
  ValidationHookFn,
70
72
  WriteHandlerDef,
71
73
  WriteHandlerFn,
@@ -510,7 +512,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
510
512
  defineEvent: <const TInner extends string, TPayload>(
511
513
  eventName: TInner,
512
514
  schema: ZodType<TPayload>,
513
- options?: { readonly version?: number },
515
+ options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
514
516
  ): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
515
517
  // Return the fully-qualified event name so callers can pass it
516
518
  // straight to ctx.appendEvent without hand-building the
@@ -529,6 +531,27 @@ export function defineFeature<const TName extends string, TExports = undefined>(
529
531
  `[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
530
532
  );
531
533
  }
534
+ // piiFields misconfiguration is a boot-time error, not a silent
535
+ // plaintext leak: both the pii field and its subjectField must exist
536
+ // on the payload schema (checkable when the schema is a ZodObject).
537
+ const piiFields = options?.piiFields;
538
+ if (piiFields) {
539
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
540
+ for (const [field, spec] of Object.entries(piiFields)) {
541
+ if (field === spec.subjectField) {
542
+ throw new Error(
543
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
544
+ );
545
+ }
546
+ for (const required of [field, spec.subjectField]) {
547
+ if (shape && !(required in shape)) {
548
+ throw new Error(
549
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
550
+ );
551
+ }
552
+ }
553
+ }
554
+ }
532
555
  // @cast-boundary engine-bridge — runtime-string mirrors the
533
556
  // template-literal-type via QualifiedEventName + toKebab. Both
534
557
  // sides are tested (CamelToKebab type-tests + scan-events kebab
@@ -537,6 +560,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
537
560
  name: qualified as QualifiedEventName<TName, TInner>,
538
561
  schema,
539
562
  version,
563
+ ...(piiFields !== undefined && { piiFields }),
540
564
  };
541
565
  events[eventName] = def;
542
566
  return def;
@@ -867,7 +891,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
867
891
  };
868
892
  },
869
893
 
870
- unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void {
894
+ unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
871
895
  // Name comes from the meta itself — apps already give the table a
872
896
  // name when calling defineUnmanagedTable, no need to repeat it.
873
897
  const tableName = meta.tableName;
@@ -896,6 +920,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
896
920
  name: tableName,
897
921
  meta,
898
922
  reason: options.reason,
923
+ ...(options.piiEncryptedOnWrite && { piiEncryptedOnWrite: true }),
899
924
  };
900
925
  },
901
926
 
@@ -1,3 +1,4 @@
1
+ import { configureEventPiiCatalog } from "../crypto/event-pii";
1
2
  import { applyEntityEvent } from "../db/apply-entity-event";
2
3
  import {
3
4
  assertBackingTableSuperset,
@@ -26,6 +27,7 @@ import type {
26
27
  EntityProjectionExtension,
27
28
  EntityRelations,
28
29
  EventDef,
30
+ EventPiiFields,
29
31
  EventUpcastFn,
30
32
  FeatureDefinition,
31
33
  FeatureMetricDef,
@@ -678,6 +680,15 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
678
680
  `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
679
681
  );
680
682
  }
683
+ const piiFields = umDef.meta.piiSubjectFields ?? [];
684
+ if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
685
+ throw new Error(
686
+ `Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
687
+ `(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
688
+ `Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
689
+ `{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
690
+ );
691
+ }
681
692
  physicalTableOwners.set(umName, {
682
693
  kind: "unmanaged",
683
694
  owner: umName,
@@ -1354,6 +1365,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1354
1365
  return false;
1355
1366
  })();
1356
1367
 
1368
+ // Publish the event-PII catalog (#799): append() — the single write funnel
1369
+ // into kumiko_events — encrypts catalogued payload fields regardless of
1370
+ // which path produced the event (ctx.appendEvent, MSP-apply, low-level
1371
+ // append in delivery/jobs loggers).
1372
+ const eventPiiCatalog = new Map<string, EventPiiFields>();
1373
+ for (const [qualified, def] of eventMap) {
1374
+ if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
1375
+ }
1376
+ configureEventPiiCatalog(eventPiiCatalog);
1377
+
1357
1378
  // Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
1358
1379
  // entity opts into softDelete — the framework owns this machinery, no feature
1359
1380
  // declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
@@ -1386,6 +1407,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1386
1407
  return entityMap.get(name);
1387
1408
  },
1388
1409
 
1410
+ getAllEntities(): ReadonlyMap<string, EntityDefinition> {
1411
+ return entityMap;
1412
+ },
1413
+
1389
1414
  getWriteHandler(name: string): WriteHandlerDef | undefined {
1390
1415
  return writeHandlerMap.get(name);
1391
1416
  },
@@ -36,6 +36,7 @@ import type {
36
36
  EntityRef,
37
37
  EventDef,
38
38
  EventMigrationDef,
39
+ EventPiiFields,
39
40
  EventUpcastFn,
40
41
  HandlerRef,
41
42
  NameOrRef,
@@ -168,6 +169,15 @@ export type UnmanagedTableEntry = {
168
169
  readonly name: string;
169
170
  readonly meta: EntityTableMeta;
170
171
  readonly reason: string;
172
+ readonly piiEncryptedOnWrite?: true;
173
+ };
174
+
175
+ /** Options for r.unmanagedTable(). Direct-write stores skip the executor,
176
+ * so the executor's PII encryption never runs for them — a feature whose
177
+ * meta carries piiSubjectFields must encrypt those fields itself before
178
+ * every insert/update and declare that here, or boot fails (#820). */
179
+ export type UnmanagedTableOptions = RawTableOptions & {
180
+ readonly piiEncryptedOnWrite?: true;
171
181
  };
172
182
 
173
183
  /** Registry-aggregated unmanaged-table — adds the owning feature name. */
@@ -520,10 +530,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
520
530
  // on first registration. When you bump the payload shape, raise version
521
531
  // AND register r.eventMigration(shortName, N, N+1, transform) — the
522
532
  // framework refuses to boot if the chain from 1 → version has gaps.
533
+ //
534
+ // `options.piiFields` declares PII payload fields encrypted under the DEK
535
+ // of the user named by `subjectField` (crypto-shredding, #799). append()
536
+ // enforces the catalog on every write path.
523
537
  defineEvent<const TInner extends string, TPayload>(
524
538
  name: TInner,
525
539
  schema: ZodType<TPayload>,
526
- options?: { readonly version?: number },
540
+ options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
527
541
  ): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
528
542
 
529
543
  // Register a step-wise payload transform for event-schema evolution.
@@ -718,7 +732,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
718
732
  // `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
719
733
  // shape that `migrate-runner` consumes. After the full drizzle-cut they
720
734
  // will likely merge; for now they coexist.
721
- unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void;
735
+ unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
722
736
 
723
737
  // Register the tree-actions schema for this feature — a map of
724
738
  // action-name → action-definition (with optional typed args). At-most-
@@ -780,6 +794,7 @@ export type Registry = {
780
794
 
781
795
  getFeature(name: string): FeatureDefinition | undefined;
782
796
  getEntity(name: string): EntityDefinition | undefined;
797
+ getAllEntities(): ReadonlyMap<string, EntityDefinition>;
783
798
  getWriteHandler(name: string): WriteHandlerDef | undefined;
784
799
  getQueryHandler(name: string): QueryHandlerDef | undefined;
785
800
  getSearchableFields(entityName: string): readonly string[];
@@ -62,6 +62,12 @@ export type PiiAnnotations = {
62
62
  readonly tenantOwned?: boolean;
63
63
  readonly anonymize?: () => unknown | Promise<unknown>;
64
64
  readonly allowPlaintext?: string;
65
+ /** Equality-Lookups (fetchOne/filter eq) bleiben trotz Verschluesselung
66
+ * moeglich: generierte `<snake>_bidx`-Spalte traegt einen HMAC-Blind-
67
+ * Index, Query-Compiler matchen `(col = $1 OR col_bidx = $2)`. Nur auf
68
+ * text-Feldern MIT Subject-Annotation erlaubt (Boot-Validator).
69
+ * Siehe docs/plans/datenschutz/blind-index.md (kumiko-framework#818). */
70
+ readonly lookupable?: true;
65
71
  };
66
72
 
67
73
  // --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
@@ -584,6 +584,12 @@ export type QualifiedEventName<
584
584
  TInner extends string,
585
585
  > = `${CamelToKebab<TFeature>}:event:${CamelToKebab<TInner>}`;
586
586
 
587
+ // PII payload fields on a custom event (#799): `field` is encrypted under
588
+ // the DEK of the user named by the payload's `subjectField` (crypto-
589
+ // shredding). A null subject field leaves the value plaintext — there is
590
+ // no user key to shred for system-triggered events.
591
+ export type EventPiiFields = Readonly<Record<string, { readonly subjectField: string }>>;
592
+
587
593
  export type EventDef<TPayload = unknown, TName extends string = string> = {
588
594
  readonly name: TName;
589
595
  readonly schema: ZodType<TPayload>;
@@ -592,6 +598,7 @@ export type EventDef<TPayload = unknown, TName extends string = string> = {
592
598
  // upcasts older stored events. Reads consult this to decide if upcasters
593
599
  // need to run before the payload hits consumer code.
594
600
  readonly version: number;
601
+ readonly piiFields?: EventPiiFields;
595
602
  };
596
603
 
597
604
  // Args for ctx.appendEvent — explicit aggregate target, Marten-style.
@@ -78,6 +78,7 @@ export type {
78
78
  UiHints,
79
79
  UnmanagedTableDef,
80
80
  UnmanagedTableEntry,
81
+ UnmanagedTableOptions,
81
82
  } from "./feature";
82
83
  export type {
83
84
  AnyFileFieldDef,
@@ -135,6 +136,7 @@ export type {
135
136
  EntityRef,
136
137
  EventDef,
137
138
  EventMigrationDef,
139
+ EventPiiFields,
138
140
  EventUpcastCtx,
139
141
  EventUpcastFn,
140
142
  FetchForWritingArgs,
@@ -0,0 +1,279 @@
1
+ // backfillEventPiiEncryption (#799): pre-KMS plaintext events get re-
2
+ // encrypted in place — entity lifecycle payloads (created / updated
3
+ // changes+previous / forgotten previous) AND catalogued custom events.
4
+ // Pre-KMS-forgotten subjects (detectable only via their *.forgotten event)
5
+ // get [[erased]] instead of a freshly minted key. After the backfill,
6
+ // applyEntityEvent (the rebuild primitive) materializes ciphertext AND the
7
+ // blind-index column, so equality lookups keep working.
8
+
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { z } from "zod";
11
+ import {
12
+ configureBlindIndexKey,
13
+ configurePiiSubjectKms,
14
+ InMemoryKmsAdapter,
15
+ isPiiCiphertext,
16
+ PII_ERASED_SENTINEL,
17
+ resetBlindIndexKeyForTests,
18
+ resetPiiSubjectKmsForTests,
19
+ } from "../../crypto";
20
+ import { applyEntityEvent } from "../../db/apply-entity-event";
21
+ import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
22
+ import { asRawClient, fetchOne } from "../../db/query";
23
+ import { buildEntityTable } from "../../db/table-builder";
24
+ import { defineFeature } from "../../engine/define-feature";
25
+ import { createEntity, createTextField } from "../../engine/factories";
26
+ import { createRegistry } from "../../engine/registry";
27
+ import type { Registry, TenantId } from "../../engine/types";
28
+ import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
29
+ import { generateId } from "../../utils";
30
+ import { append, loadAggregate } from "../event-store";
31
+ import { createEventsTable } from "../events-schema";
32
+
33
+ const TENANT = "00000000-0000-4000-8000-000000000001" as TenantId;
34
+ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
35
+
36
+ const contactEntity = createEntity({
37
+ fields: {
38
+ email: createTextField({ required: true, pii: true, lookupable: true }),
39
+ displayName: createTextField(),
40
+ },
41
+ });
42
+ const contactTable = buildEntityTable("contact", contactEntity);
43
+
44
+ const crmFeature = defineFeature("crm", (r) => {
45
+ r.entity("contact", contactEntity);
46
+ });
47
+
48
+ const mailerFeature = defineFeature("mailer", (r) => {
49
+ r.defineEvent(
50
+ "ping",
51
+ z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
52
+ { piiFields: { address: { subjectField: "targetId" } } },
53
+ );
54
+ });
55
+
56
+ let testDb: TestDb;
57
+ let registry: Registry;
58
+ let kms: InMemoryKmsAdapter;
59
+
60
+ async function appendPlain(
61
+ aggregateId: string,
62
+ aggregateType: string,
63
+ type: string,
64
+ payload: Record<string, unknown>,
65
+ expectedVersion = 0,
66
+ ): Promise<void> {
67
+ await append(testDb.db, {
68
+ aggregateId,
69
+ aggregateType,
70
+ tenantId: TENANT,
71
+ expectedVersion,
72
+ type,
73
+ payload,
74
+ metadata: { userId: "system" },
75
+ });
76
+ }
77
+
78
+ beforeAll(async () => {
79
+ testDb = await createTestDb();
80
+ registry = createRegistry([crmFeature, mailerFeature]);
81
+ await createEventsTable(testDb.db);
82
+ await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await testDb.cleanup();
87
+ });
88
+
89
+ beforeEach(async () => {
90
+ const raw = asRawClient(testDb.db);
91
+ await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
92
+ await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
93
+ // Plaintext era: NO KMS while the legacy events are appended.
94
+ resetPiiSubjectKmsForTests();
95
+ resetBlindIndexKeyForTests();
96
+ });
97
+
98
+ afterEach(() => {
99
+ resetPiiSubjectKmsForTests();
100
+ resetBlindIndexKeyForTests();
101
+ });
102
+
103
+ function armKms(): void {
104
+ kms = new InMemoryKmsAdapter();
105
+ configurePiiSubjectKms(kms);
106
+ }
107
+
108
+ describe("backfillEventPiiEncryption", () => {
109
+ test("encrypts entity lifecycle payloads: created flat, updated changes+previous", async () => {
110
+ const c1 = generateId();
111
+ await appendPlain(c1, "contact", "contact.created", {
112
+ id: c1,
113
+ email: "old@x.com",
114
+ displayName: "Alice",
115
+ });
116
+ await appendPlain(
117
+ c1,
118
+ "contact",
119
+ "contact.updated",
120
+ { changes: { email: "new@x.com" }, previous: { id: c1, email: "old@x.com" } },
121
+ 1,
122
+ );
123
+
124
+ armKms();
125
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
126
+
127
+ expect(result.failures).toEqual([]);
128
+ expect(result.updatedEvents).toBe(2);
129
+ expect(result.encryptedFields).toBe(3);
130
+
131
+ const events = await loadAggregate(testDb.db, c1, TENANT);
132
+ const created = events[0]?.payload as Record<string, unknown>;
133
+ expect(isPiiCiphertext(created["email"])).toBe(true);
134
+ expect(String(created["email"])).toContain(`user:${c1}`);
135
+ expect(created["displayName"]).toBe("Alice");
136
+
137
+ const updated = events[1]?.payload as {
138
+ changes: Record<string, unknown>;
139
+ previous: Record<string, unknown>;
140
+ };
141
+ expect(isPiiCiphertext(updated.changes["email"])).toBe(true);
142
+ expect(isPiiCiphertext(updated.previous["email"])).toBe(true);
143
+ });
144
+
145
+ test("pre-KMS-forgotten aggregate gets [[erased]], not a fresh key", async () => {
146
+ const c2 = generateId();
147
+ await appendPlain(c2, "contact", "contact.created", { id: c2, email: "gone@x.com" });
148
+ await appendPlain(
149
+ c2,
150
+ "contact",
151
+ "contact.forgotten",
152
+ { previous: { id: c2, email: "gone@x.com" } },
153
+ 1,
154
+ );
155
+
156
+ armKms();
157
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
158
+
159
+ expect(result.failures).toEqual([]);
160
+ expect(result.erasedFields).toBe(2);
161
+ const events = await loadAggregate(testDb.db, c2, TENANT);
162
+ const created = events[0]?.payload as Record<string, unknown>;
163
+ expect(created["email"]).toBe(PII_ERASED_SENTINEL);
164
+ const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
165
+ expect(forgotten.previous["email"]).toBe(PII_ERASED_SENTINEL);
166
+ });
167
+
168
+ test("catalogued custom events: encrypt under target's DEK; forgotten target → [[erased]]", async () => {
169
+ const forgottenUser = generateId();
170
+ await appendPlain(forgottenUser, "contact", "contact.created", {
171
+ id: forgottenUser,
172
+ email: "f@x.com",
173
+ });
174
+ await appendPlain(
175
+ forgottenUser,
176
+ "contact",
177
+ "contact.forgotten",
178
+ { previous: { id: forgottenUser, email: "f@x.com" } },
179
+ 1,
180
+ );
181
+
182
+ const p1 = generateId();
183
+ const p2 = generateId();
184
+ const p3 = generateId();
185
+ await appendPlain(p1, "ping", "mailer:event:ping", {
186
+ targetId: "u-7",
187
+ address: "u7@x.com",
188
+ });
189
+ await appendPlain(p2, "ping", "mailer:event:ping", {
190
+ targetId: forgottenUser,
191
+ address: "f@x.com",
192
+ });
193
+ await appendPlain(p3, "ping", "mailer:event:ping", { targetId: null, address: "ops@x.com" });
194
+
195
+ armKms();
196
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
197
+
198
+ expect(result.failures).toEqual([]);
199
+ const alive = (await loadAggregate(testDb.db, p1, TENANT))[0]?.payload as Record<
200
+ string,
201
+ unknown
202
+ >;
203
+ expect(isPiiCiphertext(alive["address"])).toBe(true);
204
+ expect(String(alive["address"])).toContain("user:u-7");
205
+
206
+ const erased = (await loadAggregate(testDb.db, p2, TENANT))[0]?.payload as Record<
207
+ string,
208
+ unknown
209
+ >;
210
+ expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
211
+
212
+ // No subject → no key to shred; stays plaintext (documented rollout gap).
213
+ const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
214
+ string,
215
+ unknown
216
+ >;
217
+ expect(system["address"]).toBe("ops@x.com");
218
+ });
219
+
220
+ test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
221
+ const c1 = generateId();
222
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
223
+
224
+ armKms();
225
+ const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
226
+ expect(dry.updatedEvents).toBe(1);
227
+ const untouched = (await loadAggregate(testDb.db, c1, TENANT))[0]?.payload as Record<
228
+ string,
229
+ unknown
230
+ >;
231
+ expect(untouched["email"]).toBe("a@x.com");
232
+
233
+ const first = await backfillEventPiiEncryption(testDb.db, registry);
234
+ expect(first.updatedEvents).toBe(1);
235
+ const second = await backfillEventPiiEncryption(testDb.db, registry);
236
+ expect(second.updatedEvents).toBe(0);
237
+ expect(second.failures).toEqual([]);
238
+ });
239
+
240
+ test("small batchSize pages through the estate completely", async () => {
241
+ const ids = [generateId(), generateId(), generateId(), generateId(), generateId()];
242
+ for (const id of ids) {
243
+ await appendPlain(id, "contact", "contact.created", { id, email: `${id}@x.com` });
244
+ }
245
+
246
+ armKms();
247
+ const result = await backfillEventPiiEncryption(testDb.db, registry, { batchSize: 2 });
248
+ expect(result.scannedEvents).toBe(5);
249
+ expect(result.updatedEvents).toBe(5);
250
+ });
251
+
252
+ test("after backfill, applyEntityEvent (rebuild) materializes ciphertext + blind index", async () => {
253
+ const c1 = generateId();
254
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "login@x.com" });
255
+
256
+ armKms();
257
+ configureBlindIndexKey(BIDX_KEY);
258
+ await backfillEventPiiEncryption(testDb.db, registry);
259
+
260
+ const events = await loadAggregate(testDb.db, c1, TENANT);
261
+ const created = events[0];
262
+ if (!created) throw new Error("missing created event");
263
+ await applyEntityEvent(created, contactTable, contactEntity, testDb.db);
264
+
265
+ const row = await fetchOne(testDb.db, contactTable, { id: c1 });
266
+ expect(isPiiCiphertext(row?.["email"])).toBe(true);
267
+ const rawRows = (await asRawClient(testDb.db).unsafe(
268
+ `SELECT "email_bidx" FROM "${contactTable.tableName}" WHERE "id" = $1`,
269
+ [c1],
270
+ )) as ReadonlyArray<Record<string, unknown>>;
271
+ expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
272
+ });
273
+
274
+ test("throws without a configured KMS", async () => {
275
+ expect(backfillEventPiiEncryption(testDb.db, registry)).rejects.toThrow(
276
+ /requires a configured subject KMS/,
277
+ );
278
+ });
279
+ });
@@ -1,3 +1,4 @@
1
+ import { encryptEventPayloadPii } from "../crypto/event-pii";
1
2
  import type { DbRunner } from "../db";
2
3
  import { isUniqueViolation } from "../db/pg-error";
3
4
  import {
@@ -108,21 +109,25 @@ type SelectedEvent = {
108
109
  export const EVENTS_PUBSUB_CHANNEL = "kumiko_events_new";
109
110
 
110
111
  export async function append(db: DbRunner, event: EventToAppend): Promise<StoredEvent> {
111
- const newVersion = event.expectedVersion + 1;
112
- const eventVersion = event.eventVersion ?? 1;
112
+ // Event-PII (#799): stored payload AND returned echo carry ciphertext, so
113
+ // inline projections and rebuilds materialize identical rows.
114
+ const payload = await encryptEventPayloadPii(event.type, event.payload);
115
+ const toStore = payload === event.payload ? event : { ...event, payload };
116
+ const newVersion = toStore.expectedVersion + 1;
117
+ const eventVersion = toStore.eventVersion ?? 1;
113
118
 
114
119
  try {
115
120
  const row =
116
- event.expectedVersion === 0
117
- ? await insertFirstEvent(db, event, newVersion, eventVersion)
118
- : await insertSubsequentEvent(db, event, newVersion, eventVersion);
121
+ toStore.expectedVersion === 0
122
+ ? await insertFirstEvent(db, toStore, newVersion, eventVersion)
123
+ : await insertSubsequentEvent(db, toStore, newVersion, eventVersion);
119
124
 
120
125
  // NOTIFY fires on commit (PG buffers NOTIFY per TX), so subscribers never
121
126
  // see a wake-up for an event that later rolled back. Harmless no-op when
122
127
  // no LISTENer is attached.
123
128
  await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
124
129
 
125
- return buildStoredEvent(event, newVersion, eventVersion, row);
130
+ return buildStoredEvent(toStore, newVersion, eventVersion, row);
126
131
  } catch (e) {
127
132
  if (isUniqueViolation(e)) {
128
133
  // Only constraint left on the events table: events_aggregate_version_uq
@@ -1,3 +1,9 @@
1
+ export {
2
+ backfillEventPiiEncryption,
3
+ type PiiBackfillFailure,
4
+ type PiiBackfillOptions,
5
+ type PiiBackfillResult,
6
+ } from "../db/queries/backfill-pii";
1
7
  export {
2
8
  type ArchiveStreamArgs,
3
9
  archivedStreamsTable,