@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
@@ -0,0 +1,195 @@
1
+ // PII field encryption with per-subject DEKs (crypto-shredding, #724 phase C).
2
+ // Same executor hook points as `encrypted: true`, but the key belongs to the
3
+ // erase subject — kms.eraseKey(subject) makes every value unreadable at once.
4
+ // Storage format is a sniffable string that fits existing text columns and
5
+ // names its subject inline, so decrypt needs no schema change and no resolver:
6
+ // kumiko-pii:v1:<subjectKey>:<base64(iv|tag|ciphertext)>
7
+
8
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9
+ import type { EntityDefinition } from "../engine/types/fields";
10
+ import type { TenantId } from "../engine/types/identifiers";
11
+ import {
12
+ isLocalKeyKmsAdapter,
13
+ KeyAlreadyExistsError,
14
+ KeyErasedError,
15
+ KeyNotFoundError,
16
+ type KmsAdapter,
17
+ type KmsContext,
18
+ type LocalKeyKmsAdapter,
19
+ type SubjectDek,
20
+ type SubjectId,
21
+ subjectIdFromKey,
22
+ subjectIdToKey,
23
+ } from "./kms-adapter";
24
+ import { resolveSubjectForField } from "./subject-resolver";
25
+
26
+ // Spec value (crypto-shredding.md) — renderers show it verbatim.
27
+ export const PII_ERASED_SENTINEL = "[[erased]]";
28
+
29
+ export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v1:";
30
+ const IV_LENGTH = 12;
31
+ const AUTH_TAG_LENGTH = 16;
32
+
33
+ export function isPiiCiphertext(value: unknown): value is string {
34
+ return typeof value === "string" && value.startsWith(PII_CIPHERTEXT_PREFIX);
35
+ }
36
+
37
+ function encryptValue(subject: SubjectId, dek: SubjectDek, plaintext: string): string {
38
+ const iv = randomBytes(IV_LENGTH);
39
+ const cipher = createCipheriv("aes-256-gcm", dek, iv);
40
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
41
+ const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
42
+ return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
43
+ }
44
+
45
+ function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer } {
46
+ const rest = value.slice(PII_CIPHERTEXT_PREFIX.length);
47
+ // subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
48
+ // last ":" is always the key/blob separator.
49
+ const sep = rest.lastIndexOf(":");
50
+ if (sep === -1) throw new Error(`Malformed PII ciphertext (no subject/blob separator)`);
51
+ return {
52
+ subject: subjectIdFromKey(rest.slice(0, sep)),
53
+ blob: Buffer.from(rest.slice(sep + 1), "base64"),
54
+ };
55
+ }
56
+
57
+ function decryptValue(dek: SubjectDek, blob: Buffer): string {
58
+ const iv = blob.subarray(0, IV_LENGTH);
59
+ const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
60
+ const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
61
+ const decipher = createDecipheriv("aes-256-gcm", dek, iv);
62
+ decipher.setAuthTag(tag);
63
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
64
+ }
65
+
66
+ // First write to a PII field of a new subject creates the key implicitly
67
+ // (user-signup / tenant-create need no extra hook). The AlreadyExists catch
68
+ // covers the concurrent-first-write race; a tombstoned subject re-throws
69
+ // KeyErasedError from the final getKey — writes to a forgotten subject fail.
70
+ async function getOrCreateDek(
71
+ kms: LocalKeyKmsAdapter,
72
+ subject: SubjectId,
73
+ ctx: KmsContext,
74
+ ): Promise<SubjectDek> {
75
+ try {
76
+ return await kms.getKey(subject, ctx);
77
+ } catch (e) {
78
+ if (!(e instanceof KeyNotFoundError)) throw e;
79
+ }
80
+ try {
81
+ await kms.createKey(subject, ctx);
82
+ } catch (e) {
83
+ if (!(e instanceof KeyAlreadyExistsError)) throw e;
84
+ }
85
+ return kms.getKey(subject, ctx);
86
+ }
87
+
88
+ // Single-value encrypt for callers that resolve the subject themselves
89
+ // (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
90
+ // idempotent like the field-map variant.
91
+ export async function encryptPiiValueForSubject(
92
+ kms: LocalKeyKmsAdapter,
93
+ subject: SubjectId,
94
+ value: string,
95
+ kmsCtx: KmsContext,
96
+ ): Promise<string> {
97
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
98
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
99
+ return encryptValue(subject, dek, value);
100
+ }
101
+
102
+ export interface EncryptPiiOptions {
103
+ readonly onlyKeys?: Iterable<string>;
104
+ // Write-time tenant for tenantOwned fields on rows without a tenantId column.
105
+ readonly tenantId?: TenantId;
106
+ // Row to resolve subjects from when `row` is a partial (update changes may
107
+ // carry a pii field without its ownerField — the merged row still has it).
108
+ readonly subjectSource?: Record<string, unknown>;
109
+ }
110
+
111
+ export async function encryptPiiFieldValues(
112
+ row: Record<string, unknown>,
113
+ entity: EntityDefinition,
114
+ piiFields: readonly string[],
115
+ kms: LocalKeyKmsAdapter,
116
+ kmsCtx: KmsContext,
117
+ opts: EncryptPiiOptions = {},
118
+ ): Promise<Record<string, unknown>> {
119
+ if (piiFields.length === 0) return row;
120
+ const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
121
+ const subjectSource = opts.subjectSource ?? row;
122
+ const out = { ...row };
123
+ for (const name of piiFields) {
124
+ if (only && !only.has(name)) continue;
125
+ if (!(name in out)) continue;
126
+ const value = out[name];
127
+ if (value === null || value === undefined) continue;
128
+ // Re-encrypt paths (update previous, detail cache) may see values that
129
+ // are already ciphertext or the erased sentinel — both stay as-is.
130
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
131
+ if (typeof value !== "string") {
132
+ throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
133
+ }
134
+ const subject = resolveSubjectForField(entity, name, subjectSource, {
135
+ ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
136
+ });
137
+ // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
138
+ if (subject === null) continue;
139
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
140
+ out[name] = encryptValue(subject, dek, value);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ export async function decryptPiiFieldValues(
146
+ row: Record<string, unknown>,
147
+ piiFields: readonly string[],
148
+ kms: LocalKeyKmsAdapter,
149
+ kmsCtx: KmsContext,
150
+ ): Promise<Record<string, unknown>> {
151
+ if (piiFields.length === 0) return row;
152
+ const out = { ...row };
153
+ for (const name of piiFields) {
154
+ const value = out[name];
155
+ // Pre-engine plaintext rows pass through unchanged (mixed-state reads
156
+ // work during rollout; backfill is tracked in kumiko-framework#799).
157
+ if (!isPiiCiphertext(value)) continue;
158
+ const { subject, blob } = parseCiphertext(value);
159
+ try {
160
+ out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob);
161
+ } catch (e) {
162
+ // KeyNotFound deliberately propagates: ciphertext without a key row
163
+ // means the key store is wrong (not shredded) — fail loud.
164
+ if (!(e instanceof KeyErasedError)) throw e;
165
+ out[name] = PII_ERASED_SENTINEL;
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Boot-injected app-wide subject KMS, mirroring configureEntityFieldEncryption:
172
+ // run{Prod,Dev}App call configurePiiSubjectKms once; executors resolve lazily.
173
+ // Absent adapter = engine off (pii fields stay plaintext, pre-phase-C
174
+ // behavior) — the hard boot gate arrives with the prod-grade PgKmsAdapter
175
+ // (phase E); until then no production deployment can satisfy it.
176
+ let injectedKms: LocalKeyKmsAdapter | undefined;
177
+
178
+ export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
179
+ if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
180
+ throw new Error(
181
+ "PII field encryption requires a local-key KMS adapter — remote-crypto " +
182
+ "(Vault transit) support lands with the BYOK adapter.",
183
+ );
184
+ }
185
+ injectedKms = adapter;
186
+ }
187
+
188
+ export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
189
+ return injectedKms;
190
+ }
191
+
192
+ /** @internal test-only */
193
+ export function resetPiiSubjectKmsForTests(): void {
194
+ injectedKms = undefined;
195
+ }
@@ -0,0 +1,232 @@
1
+ // Blind-Index end-to-end (#818): create/update schreiben die bidx-Spalte
2
+ // (nie ins Event-Log), Equality-Lookups matchen über den OR-Rewrite sowohl
3
+ // verschlüsselte als auch Klartext-Alt-Rows, Rebuild recomputet bidx aus
4
+ // dem Ciphertext (erased → NULL), und der Forget-Sweep nullt sofort.
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import {
8
+ computeBlindIndex,
9
+ configureBlindIndexKey,
10
+ configurePiiSubjectKms,
11
+ decodeBlindIndexKey,
12
+ InMemoryKmsAdapter,
13
+ isPiiCiphertext,
14
+ resetBlindIndexKeyForTests,
15
+ resetPiiSubjectKmsForTests,
16
+ subjectIdToKey,
17
+ } from "../../crypto";
18
+ import { defineFeature } from "../../engine/define-feature";
19
+ import { createEntity, createTextField } from "../../engine/factories";
20
+ import { createRegistry } from "../../engine/registry";
21
+ import { createEventsTable } from "../../event-store";
22
+ import { rebuildProjection } from "../../pipeline";
23
+ import { createProjectionStateTable } from "../../pipeline/projection-state";
24
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
25
+ import { nullBlindIndexesForSubject } from "../blind-index-cleanup";
26
+ import { createEventStoreExecutor } from "../event-store-executor";
27
+ import { asRawClient, fetchOne } from "../query";
28
+ import { buildEntityTable } from "../table-builder";
29
+ import { createTenantDb, type TenantDb } from "../tenant-db";
30
+
31
+ const TEST_KEY_B64 = Buffer.alloc(32, 7).toString("base64");
32
+ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
33
+
34
+ const personEntity = createEntity({
35
+ table: "read_bidx_persons",
36
+ fields: {
37
+ email: createTextField({ required: true, pii: true, lookupable: true }),
38
+ firstName: createTextField(),
39
+ },
40
+ });
41
+ const personFeature = defineFeature("bidxtest", (r) => {
42
+ r.entity("person", personEntity);
43
+ });
44
+ const personTable = buildEntityTable("person", personEntity);
45
+
46
+ let testDb: TestDb;
47
+ let tdb: TenantDb;
48
+ let kms: InMemoryKmsAdapter;
49
+ const adminUser = TestUsers.admin;
50
+
51
+ beforeAll(async () => {
52
+ testDb = await createTestDb();
53
+ await unsafeCreateEntityTable(testDb.db, personEntity, "person");
54
+ await createEventsTable(testDb.db);
55
+ await createProjectionStateTable(testDb.db);
56
+ tdb = createTenantDb(testDb.db, adminUser.tenantId);
57
+ });
58
+
59
+ afterAll(async () => {
60
+ await testDb.cleanup();
61
+ });
62
+
63
+ beforeEach(async () => {
64
+ await asRawClient(testDb.db).unsafe(
65
+ `TRUNCATE kumiko_events, read_bidx_persons, kumiko_projections RESTART IDENTITY CASCADE`,
66
+ );
67
+ kms = new InMemoryKmsAdapter();
68
+ configurePiiSubjectKms(kms);
69
+ configureBlindIndexKey(TEST_KEY_B64);
70
+ });
71
+
72
+ afterEach(() => {
73
+ resetPiiSubjectKmsForTests();
74
+ resetBlindIndexKeyForTests();
75
+ });
76
+
77
+ const crud = createEventStoreExecutor(personTable, personEntity, { entityName: "person" });
78
+
79
+ async function rawRow(id: string): Promise<Record<string, unknown>> {
80
+ const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
81
+ `SELECT * FROM read_bidx_persons WHERE id = $1`,
82
+ [id],
83
+ );
84
+ const row = rows[0];
85
+ if (!row) throw new Error(`no row for ${id}`);
86
+ return row;
87
+ }
88
+
89
+ describe("blind-index write path", () => {
90
+ test("create: bidx in the row, ciphertext in the email column, NOTHING in the event", async () => {
91
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
92
+ if (!created.isSuccess) throw new Error("create failed");
93
+
94
+ const row = await rawRow(String(created.data.id));
95
+ expect(row["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
96
+ expect(isPiiCiphertext(row["email"])).toBe(true);
97
+
98
+ // Executor-Response: plaintext email, kein bidx-Leak.
99
+ expect(created.data.data["email"]).toBe("marc@example.com");
100
+ expect("emailBidx" in created.data.data).toBe(false);
101
+
102
+ const events = await asRawClient(testDb.db).unsafe<{ payload: unknown }>(
103
+ `SELECT payload FROM kumiko_events WHERE aggregate_id = $1`,
104
+ [String(created.data.id)],
105
+ );
106
+ const payloadText = JSON.stringify(events.map((e) => e.payload));
107
+ expect(payloadText).not.toContain("bidx");
108
+ });
109
+
110
+ test("update recomputes bidx for the changed field", async () => {
111
+ const created = await crud.create({ email: "old@example.com" }, adminUser, tdb);
112
+ if (!created.isSuccess) throw new Error("create failed");
113
+ await crud.update(
114
+ { id: created.data.id, version: 1, changes: { email: "new@example.com" } },
115
+ adminUser,
116
+ tdb,
117
+ );
118
+ const row = await rawRow(String(created.data.id));
119
+ expect(row["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "new@example.com"));
120
+ });
121
+ });
122
+
123
+ describe("blind-index lookup (OR-rewrite)", () => {
124
+ test("fetchOne by plaintext email finds the encrypted row", async () => {
125
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
126
+ if (!created.isSuccess) throw new Error("create failed");
127
+
128
+ const hit = await fetchOne<Record<string, unknown>>(tdb, personTable, {
129
+ email: "marc@example.com",
130
+ });
131
+ expect(hit).toBeDefined();
132
+ expect(hit?.["id"]).toBe(created.data.id);
133
+ // Read-Strip: bidx nie am Caller.
134
+ expect(hit && "emailBidx" in hit).toBe(false);
135
+ expect(hit && "email_bidx" in hit).toBe(false);
136
+
137
+ expect(await fetchOne(tdb, personTable, { email: "other@example.com" })).toBeUndefined();
138
+ });
139
+
140
+ test("plaintext legacy row (written pre-rollout) still matches the same query", async () => {
141
+ // Rollout-Zustand VOR KMS+Key: Klartext-Row, bidx NULL.
142
+ resetPiiSubjectKmsForTests();
143
+ resetBlindIndexKeyForTests();
144
+ const legacy = await crud.create({ email: "legacy@example.com" }, adminUser, tdb);
145
+ if (!legacy.isSuccess) throw new Error("create failed");
146
+ const legacyRow = await rawRow(String(legacy.data.id));
147
+ expect(legacyRow["email"]).toBe("legacy@example.com");
148
+ expect(legacyRow["email_bidx"]).toBeNull();
149
+
150
+ // Rollout an: dieselbe Query matcht über den Plaintext-Arm.
151
+ configurePiiSubjectKms(kms);
152
+ configureBlindIndexKey(TEST_KEY_B64);
153
+ const hit = await fetchOne<Record<string, unknown>>(tdb, personTable, {
154
+ email: "legacy@example.com",
155
+ });
156
+ expect(hit?.["id"]).toBe(legacy.data.id);
157
+ });
158
+
159
+ test("list filter eq matches the encrypted row and strips bidx", async () => {
160
+ const created = await crud.create(
161
+ { email: "marc@example.com", firstName: "Marc" },
162
+ adminUser,
163
+ tdb,
164
+ );
165
+ if (!created.isSuccess) throw new Error("create failed");
166
+
167
+ const page = await crud.list(
168
+ { filter: { field: "email", op: "eq", value: "marc@example.com" } },
169
+ adminUser,
170
+ tdb,
171
+ );
172
+ expect(page.rows).toHaveLength(1);
173
+ expect(page.rows[0]?.["email"]).toBe("marc@example.com");
174
+ expect(page.rows[0] && "emailBidx" in page.rows[0]).toBe(false);
175
+
176
+ const miss = await crud.list(
177
+ { filter: { field: "email", op: "eq", value: "nobody@example.com" } },
178
+ adminUser,
179
+ tdb,
180
+ );
181
+ expect(miss.rows).toHaveLength(0);
182
+ });
183
+ });
184
+
185
+ describe("blind-index rebuild + forget", () => {
186
+ const implicitName = "bidxtest:projection:person-entity";
187
+
188
+ test("rebuild recomputes bidx from ciphertext (identical to live)", async () => {
189
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
190
+ if (!created.isSuccess) throw new Error("create failed");
191
+ const liveBidx = (await rawRow(String(created.data.id)))["email_bidx"];
192
+
193
+ const registry = createRegistry([personFeature]);
194
+ expect(registry.getAllProjections().has(implicitName)).toBe(true);
195
+ await rebuildProjection(implicitName, { db: testDb.db, registry });
196
+
197
+ const rebuilt = await rawRow(String(created.data.id));
198
+ expect(rebuilt["email_bidx"]).toBe(liveBidx);
199
+ expect(rebuilt["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
200
+ });
201
+
202
+ test("erased subject → rebuild sets bidx NULL, lookup stops matching", async () => {
203
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
204
+ if (!created.isSuccess) throw new Error("create failed");
205
+
206
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
207
+
208
+ const registry = createRegistry([personFeature]);
209
+ await rebuildProjection(implicitName, { db: testDb.db, registry });
210
+
211
+ const rebuilt = await rawRow(String(created.data.id));
212
+ expect(rebuilt["email_bidx"]).toBeNull();
213
+ expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
214
+ });
215
+
216
+ test("nullBlindIndexesForSubject nulls bidx immediately (no rebuild needed)", async () => {
217
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
218
+ if (!created.isSuccess) throw new Error("create failed");
219
+ expect((await rawRow(String(created.data.id)))["email_bidx"]).not.toBeNull();
220
+
221
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
222
+ const registry = createRegistry([personFeature]);
223
+ await nullBlindIndexesForSubject(
224
+ testDb.db,
225
+ registry.features,
226
+ subjectIdToKey({ kind: "user", userId: String(created.data.id) }),
227
+ );
228
+
229
+ expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
230
+ expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
231
+ });
232
+ });
@@ -1,7 +1,8 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { InMemoryKmsAdapter, PII_ERASED_SENTINEL } from "../../crypto";
2
3
  import { asRawClient } from "../../db/query";
3
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
- import { append, createEventsTable } from "../../event-store";
5
+ import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
5
6
  import type { EntityCache } from "../../pipeline/entity-cache";
6
7
  import {
7
8
  createTestDb,
@@ -11,6 +12,7 @@ import {
11
12
  unsafeCreateEntityTable,
12
13
  } from "../../stack";
13
14
  import { createTestEnvelopeCipher } from "../../testing";
15
+ import { applyEntityEvent } from "../apply-entity-event";
14
16
  import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
15
17
  import { createEventStoreExecutor } from "../event-store-executor";
16
18
  import { buildEntityTable } from "../table-builder";
@@ -553,4 +555,170 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
553
555
  const second = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
554
556
  expect(second?.["secretNote"]).toBe("cache-plaintext-note");
555
557
  });
558
+
559
+ // Regression twin pack for the list() path:
560
+ // 1. list decrypted BEFORE the snake→camel coercion, so any multi-word
561
+ // encrypted/pii field (secret_note vs secretNote) came back as raw
562
+ // ciphertext to the caller.
563
+ // 2. list's mset cached the decrypted rows — plaintext in Redis, the
564
+ // exact leak the detail() path re-encrypts to avoid.
565
+ test("list() returns plaintext for camelCase encrypted fields and caches ciphertext", async () => {
566
+ const created = await cachedEncryptedCrud.create(
567
+ { email: "cache-list@test.de", secretNote: "list-plaintext-note" },
568
+ adminUser,
569
+ tdb,
570
+ );
571
+ if (!created.isSuccess) throw new Error("create failed");
572
+ const storeKey = `${adminUser.tenantId}:esExecEncrypted:${created.data.id}`;
573
+ store.clear();
574
+
575
+ const list = await cachedEncryptedCrud.list({}, adminUser, tdb);
576
+ const listed = list.rows.find((r) => r["id"] === created.data.id);
577
+ expect(listed?.["secretNote"]).toBe("list-plaintext-note");
578
+
579
+ const cachedRaw = store.get(storeKey);
580
+ expect(cachedRaw?.["secretNote"]).toBeDefined();
581
+ expect(cachedRaw?.["secretNote"]).not.toBe("list-plaintext-note");
582
+ });
583
+ });
584
+
585
+ // PII subject encryption (crypto-shredding, #724 phase C): pii/userOwned/
586
+ // tenantOwned fields are encrypted with the erase subject's DEK. Event
587
+ // payload AND projection row carry ciphertext (live == rebuild); erasing the
588
+ // subject key renders every value as the [[erased]] sentinel without
589
+ // touching the immutable log.
590
+ const piiEntity = createEntity({
591
+ table: "read_es_exec_pii",
592
+ fields: {
593
+ email: createTextField({ required: true, pii: true }),
594
+ note: createTextField({ userOwned: { ownerField: "authorId" } }),
595
+ authorId: createTextField(),
596
+ plain: createTextField(),
597
+ },
598
+ });
599
+ const piiTable = buildEntityTable("esExecPii", piiEntity);
600
+
601
+ describe("event-store-executor — pii subject encryption", () => {
602
+ const kms = new InMemoryKmsAdapter();
603
+ const crud = createEventStoreExecutor(piiTable, piiEntity, {
604
+ entityName: "esExecPii",
605
+ kms,
606
+ });
607
+
608
+ beforeAll(async () => {
609
+ await unsafeCreateEntityTable(testDb.db, piiEntity, "esExecPii");
610
+ });
611
+
612
+ beforeEach(async () => {
613
+ await asRawClient(testDb.db).unsafe(
614
+ `TRUNCATE kumiko_events, read_es_exec_pii RESTART IDENTITY CASCADE`,
615
+ );
616
+ });
617
+
618
+ test("create: projection row + event payload are ciphertext, API reads plaintext", async () => {
619
+ const result = await crud.create({ email: "pii@test.de", plain: "visible" }, adminUser, tdb);
620
+ if (!result.isSuccess) throw new Error("create failed");
621
+ expect(result.data.data["email"]).toBe("pii@test.de");
622
+
623
+ const rawRows = (await asRawClient(testDb.db).unsafe(
624
+ `SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
625
+ )) as Array<{ email: string; plain: string }>;
626
+ expect(rawRows[0]!.email).toStartWith(`kumiko-pii:v1:user:${result.data.id}:`);
627
+ expect(rawRows[0]!.plain).toBe("visible");
628
+
629
+ const events = (await asRawClient(testDb.db).unsafe(
630
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
631
+ )) as Array<{ payload: { email?: string } }>;
632
+ expect(events[0]?.payload.email).toStartWith("kumiko-pii:v1:");
633
+
634
+ const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
635
+ expect(detail?.["email"]).toBe("pii@test.de");
636
+ const list = await crud.list({}, adminUser, tdb);
637
+ expect(list.rows[0]?.["email"]).toBe("pii@test.de");
638
+ });
639
+
640
+ test("userOwned update without ownerField in changes resolves via the merged row", async () => {
641
+ const created = await crud.create(
642
+ { email: "owner@test.de", note: "v1", authorId: TestUsers.admin.id },
643
+ adminUser,
644
+ tdb,
645
+ );
646
+ if (!created.isSuccess) throw new Error("create failed");
647
+
648
+ const updated = await crud.update(
649
+ { id: created.data.id, version: 1, changes: { note: "v2" } },
650
+ adminUser,
651
+ tdb,
652
+ );
653
+ if (!updated.isSuccess) throw new Error("update failed");
654
+ expect(updated.data.data["note"]).toBe("v2");
655
+
656
+ const rawRows = (await asRawClient(testDb.db).unsafe(
657
+ `SELECT note FROM read_es_exec_pii LIMIT 1`,
658
+ )) as Array<{ note: string }>;
659
+ expect(rawRows[0]!.note).toStartWith(`kumiko-pii:v1:user:${TestUsers.admin.id}:`);
660
+ });
661
+
662
+ test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
663
+ const created = await crud.create({ email: "forget@test.de" }, adminUser, tdb);
664
+ if (!created.isSuccess) throw new Error("create failed");
665
+
666
+ const eventsBefore = (await asRawClient(testDb.db).unsafe(
667
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
668
+ )) as Array<{ payload: { email?: string } }>;
669
+
670
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
671
+
672
+ const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
673
+ expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
674
+
675
+ const eventsAfter = (await asRawClient(testDb.db).unsafe(
676
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
677
+ )) as Array<{ payload: { email?: string } }>;
678
+ expect(eventsAfter[0]?.payload.email).toBe(eventsBefore[0]!.payload.email!);
679
+ });
680
+
681
+ test("rebuild after erase resurrects no plaintext (replay writes ciphertext, reads render sentinel)", async () => {
682
+ const created = await crud.create({ email: "rebuild@test.de" }, adminUser, tdb);
683
+ if (!created.isSuccess) throw new Error("create failed");
684
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
685
+
686
+ // Same code path rebuildProjection replays: applyEntityEvent on the
687
+ // PERSISTED event (ciphertext payload). The response echo carries the
688
+ // plaintext payload since #820 and must not be used as a replay source.
689
+ const responseEvent = created.data.event as { payload: Record<string, unknown> } | undefined;
690
+ expect(responseEvent?.payload["email"]).toBe("rebuild@test.de");
691
+ await asRawClient(testDb.db).unsafe(`TRUNCATE read_es_exec_pii RESTART IDENTITY CASCADE`);
692
+ const [storedEvent] = await loadEventsAfterVersion(
693
+ testDb.db,
694
+ String(created.data.id),
695
+ adminUser.tenantId,
696
+ 0,
697
+ );
698
+ if (!storedEvent) throw new Error("no persisted event for the aggregate");
699
+ const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
700
+ expect(applied.kind).toBe("applied");
701
+
702
+ const rawRows = (await asRawClient(testDb.db).unsafe(
703
+ `SELECT email FROM read_es_exec_pii LIMIT 1`,
704
+ )) as Array<{ email: string }>;
705
+ expect(rawRows[0]!.email).toStartWith("kumiko-pii:v1:");
706
+ expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
707
+
708
+ const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
709
+ expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
710
+ });
711
+
712
+ test("without a kms adapter the engine is off: plaintext row (pre-#724 behavior)", async () => {
713
+ const plainCrud = createEventStoreExecutor(piiTable, piiEntity, {
714
+ entityName: "esExecPii",
715
+ });
716
+ const created = await plainCrud.create({ email: "off@test.de" }, adminUser, tdb);
717
+ if (!created.isSuccess) throw new Error("create failed");
718
+
719
+ const rawRows = (await asRawClient(testDb.db).unsafe(
720
+ `SELECT email FROM read_es_exec_pii LIMIT 1`,
721
+ )) as Array<{ email: string }>;
722
+ expect(rawRows[0]!.email).toBe("off@test.de");
723
+ });
556
724
  });
@@ -109,3 +109,42 @@ describe("lock-step — softDelete + explizite Indexes", () => {
109
109
  expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
110
110
  });
111
111
  });
112
+
113
+ // Dritte Probe: lookupable-Feld (#818) — bidx-Spalte, bidx-Index und das
114
+ // partielle Unique-Pendant müssen auf beiden Pfaden identisch entstehen.
115
+ const entityWithLookupable = createEntity({
116
+ table: "read_lockstep_probe_bidx",
117
+ fields: {
118
+ email: { type: "text", required: true, pii: true, lookupable: true },
119
+ tenantSlug: { type: "text", required: true },
120
+ },
121
+ indexes: [{ columns: ["tenantSlug", "email"], unique: true }],
122
+ });
123
+
124
+ describe("lock-step — lookupable / blind-index (#818)", () => {
125
+ const fromBuilder = asEntityTableMeta(
126
+ buildEntityTable("lockstepProbeBidx", entityWithLookupable),
127
+ );
128
+ const fromMeta = buildEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
129
+
130
+ test("identical columns inkl. nullable bidx-Spalte", () => {
131
+ expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
132
+ byName<ColumnMeta>(fromMeta.columns),
133
+ );
134
+ const bidx = (fromMeta.columns ?? []).find((c) => c.name === "email_bidx");
135
+ expect(bidx).toEqual({ name: "email_bidx", pgType: "text", notNull: false });
136
+ });
137
+
138
+ test("identical indexes inkl. bidx-Index + partiellem Unique-Pendant", () => {
139
+ expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
140
+ byName<IndexMeta>(fromMeta.indexes),
141
+ );
142
+ const names = fromMeta.indexes.map((i) => i.name);
143
+ expect(names).toContain("read_lockstep_probe_bidx_email_bidx_idx");
144
+ const partial = fromMeta.indexes.find((i) => i.name.endsWith("_tenant_slug_email_unique_bidx"));
145
+ expect(partial).toBeDefined();
146
+ expect(partial?.unique).toBe(true);
147
+ expect(partial?.columns).toEqual(["tenant_slug", "email_bidx"]);
148
+ expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
149
+ });
150
+ });
@@ -49,6 +49,7 @@
49
49
  // - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
50
50
  // Aggregate). Caller no-op.
51
51
 
52
+ import { collectLookupableFields, computeBlindIndexValues } from "../crypto/blind-index";
52
53
  import { deleteMany, insertOne, updateMany } from "../db/query";
53
54
  import type { EntityDefinition } from "../engine/types";
54
55
  import { InternalError } from "../errors";
@@ -116,6 +117,11 @@ export async function applyEntityEvent(
116
117
  }
117
118
  const row = await insertOne<DbRow>(tx, table, {
118
119
  ...event.payload,
120
+ // Blind-Index-Spalten aus dem Payload-Wert (ciphertext → decrypt →
121
+ // HMAC, plaintext → HMAC, erased → NULL). Hier statt im Executor,
122
+ // damit Live-Write und Rebuild denselben Wert produzieren und der
123
+ // deterministische HMAC nie im Event-Log landet.
124
+ ...(await computeBlindIndexValues(event.payload, collectLookupableFields(entity))),
119
125
  tenantId,
120
126
  id: event.aggregateId,
121
127
  version: event.version,
@@ -142,6 +148,8 @@ export async function applyEntityEvent(
142
148
  table,
143
149
  {
144
150
  ...changes,
151
+ // bidx nur für die geänderten lookupable-Felder (siehe created).
152
+ ...(await computeBlindIndexValues(changes, collectLookupableFields(entity))),
145
153
  version: event.version,
146
154
  modifiedAt: event.createdAt,
147
155
  modifiedById: event.createdBy,