@cosmicdrift/kumiko-framework 0.253.0 → 0.255.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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/bun-db/__tests__/run-in-savepoint-if-supported.integration.test.ts +47 -0
  3. package/src/bun-db/query.ts +16 -10
  4. package/src/crypto/__tests__/blind-index.test.ts +2 -0
  5. package/src/crypto/__tests__/event-pii.test.ts +144 -11
  6. package/src/crypto/__tests__/pii-field-encryption.test.ts +29 -8
  7. package/src/crypto/__tests__/subject-key-record.test.ts +13 -3
  8. package/src/crypto/__tests__/subject-resolver.test.ts +139 -24
  9. package/src/crypto/event-pii.ts +16 -9
  10. package/src/crypto/index.ts +2 -0
  11. package/src/crypto/pii-field-encryption.ts +3 -3
  12. package/src/crypto/subject-resolver.ts +64 -3
  13. package/src/db/__tests__/eagerload.integration.test.ts +9 -3
  14. package/src/db/queries/backfill-pii.ts +10 -6
  15. package/src/engine/__tests__/projection-detail-tabs.test.ts +33 -2
  16. package/src/engine/boot-validator/__tests__/record-owned.test.ts +56 -0
  17. package/src/engine/boot-validator/index.ts +2 -0
  18. package/src/engine/boot-validator/query-output-columns.ts +4 -2
  19. package/src/engine/boot-validator/record-owned.ts +22 -0
  20. package/src/engine/boot-validator/screens.ts +7 -4
  21. package/src/engine/feature-config-events-jobs.ts +18 -5
  22. package/src/engine/index.ts +2 -0
  23. package/src/engine/types/index.ts +3 -0
  24. package/src/engine/types/screen.ts +1 -0
  25. package/src/event-store/__tests__/backfill-pii.integration.test.ts +133 -7
  26. package/src/event-store/event-store.ts +5 -1
  27. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +79 -0
  28. package/src/pipeline/dispatch-write.ts +28 -10
  29. package/src/ui-types/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.253.0",
3
+ "version": "0.255.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.253.0",
201
+ "@cosmicdrift/kumiko-types": "0.255.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.253.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.255.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,47 @@
1
+ // dead-tx-in-afterCommit-hooks: runInSavepointIfSupported must fail
2
+ // loudly on a transaction handle that has already committed, instead of
3
+ // silently re-running `fn` outside the savepoint's isolation. Uses a real
4
+ // Postgres connection — a mock handle can't produce the driver's actual
5
+ // PG 25P01 ("no active sql transaction") on a post-commit savepoint call.
6
+
7
+ import { afterAll, describe, expect, test } from "bun:test";
8
+ import { InternalError } from "../../errors";
9
+ import { asRawClient, runInSavepointIfSupported } from "../query";
10
+ import { closeDb, getDb } from "./_helpers";
11
+
12
+ afterAll(async () => {
13
+ await closeDb();
14
+ });
15
+
16
+ describe("runInSavepointIfSupported — already-committed transaction", () => {
17
+ test("throws InternalError naming afterCommit + dbOutsideTransaction, never runs fn", async () => {
18
+ const db = await getDb();
19
+ const raw = asRawClient(db);
20
+
21
+ let committedTx: unknown;
22
+ await raw.begin(async (tx) => {
23
+ committedTx = tx;
24
+ });
25
+ // `committedTx` is now a driver handle whose transaction has committed —
26
+ // it still exposes `.savepoint()`, calling it fails with PG 25P01.
27
+
28
+ let fnCalls = 0;
29
+ const fn = async () => {
30
+ fnCalls++;
31
+ return "should-not-run";
32
+ };
33
+
34
+ let caught: unknown;
35
+ try {
36
+ await runInSavepointIfSupported(committedTx, fn);
37
+ } catch (e) {
38
+ caught = e;
39
+ }
40
+
41
+ expect(caught).toBeInstanceOf(InternalError);
42
+ const message = (caught as Error).message;
43
+ expect(message).toContain("afterCommit");
44
+ expect(message).toContain("dbOutsideTransaction");
45
+ expect(fnCalls).toBe(0);
46
+ });
47
+ });
@@ -34,6 +34,7 @@ import type { EntityTableMeta } from "../db/entity-table-meta";
34
34
  import { extractPgError } from "../db/pg-error";
35
35
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
36
36
  import { camelCase as envCamelCase } from "../env";
37
+ import { InternalError } from "../errors";
37
38
  import { parseJsonSafe } from "../utils/safe-json";
38
39
 
39
40
  // Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
@@ -145,15 +146,11 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
145
146
  // and doesn't need one, since each statement there is its own auto-committed
146
147
  // unit and a failed statement can't poison anything downstream.
147
148
  //
148
- // `.savepoint` being present isn't proof the transaction is still open: an
149
- // afterCommit hook closure captures the same handlerContext (and therefore
150
- // the same TransactionSql-shaped db) that was live during the write, but by
151
- // the time the hook fires the outer tx has already committed — the object
152
- // still exposes `.savepoint`, calling it now fails with PG 25P01 ("no
153
- // active sql transaction") because there's no BEGIN left to nest into. That
154
- // SAVEPOINT command is the first thing the driver sends, before `fn` runs,
155
- // so catching 25P01 and retrying directly is safe — nothing from `fn` has
156
- // executed yet.
149
+ // `.savepoint` being present isn't proof the transaction is still open: a
150
+ // committed TransactionSql-shaped db still exposes `.savepoint`, and calling
151
+ // it fails with PG 25P01 ("no active sql transaction") since there's no
152
+ // BEGIN left to nest into. That is now a programming error, not something
153
+ // to paper over by re-running `fn` without the savepoint's isolation.
157
154
  export async function runInSavepointIfSupported<T>(
158
155
  db: unknown,
159
156
  fn: (sp: DbRunner) => Promise<T>,
@@ -167,7 +164,16 @@ export async function runInSavepointIfSupported<T>(
167
164
  try {
168
165
  return await raw.savepoint((sp) => fn(asRunner(sp)));
169
166
  } catch (e) {
170
- if (extractPgError(e)?.code === "25P01") return fn(asRunner(db));
167
+ if (extractPgError(e)?.code === "25P01") {
168
+ throw new InternalError({
169
+ message:
170
+ "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
171
+ "this is almost always database work in an afterCommit hook. afterCommit is the default " +
172
+ "phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
173
+ "roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
174
+ cause: e instanceof Error ? e : undefined,
175
+ });
176
+ }
171
177
  throw e;
172
178
  }
173
179
  }
@@ -101,6 +101,7 @@ describe("computeBlindIndexValues", () => {
101
101
  ["email"],
102
102
  kms,
103
103
  { requestId: "test" },
104
+ { entityName: "bidx-user" },
104
105
  );
105
106
  const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
106
107
  expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
@@ -116,6 +117,7 @@ describe("computeBlindIndexValues", () => {
116
117
  ["email"],
117
118
  kms,
118
119
  { requestId: "test" },
120
+ { entityName: "bidx-user" },
119
121
  );
120
122
  await kms.eraseKey({ kind: "user", userId: UUID_A });
121
123
  const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
@@ -4,8 +4,10 @@
4
4
  // are testable without a database.
5
5
 
6
6
  import { afterEach, describe, expect, test } from "bun:test";
7
+ import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
7
8
  import { z } from "zod";
8
9
  import { createRegistry, defineFeature } from "../../engine";
10
+ import type { TenantId } from "../../engine/types/identifiers";
9
11
  import {
10
12
  configuredEventPiiCatalog,
11
13
  configureEventPiiCatalog,
@@ -20,6 +22,7 @@ import {
20
22
  PII_ERASED_SENTINEL,
21
23
  resetPiiSubjectKmsForTests,
22
24
  } from "../pii-field-encryption";
25
+ import type { EventSubjectEnvelope } from "../subject-resolver";
23
26
 
24
27
  const attemptSchema = z.object({
25
28
  recipientId: z.string().nullable(),
@@ -29,6 +32,12 @@ const attemptSchema = z.object({
29
32
 
30
33
  const EVENT_TYPE = "mailer:event:attempt";
31
34
 
35
+ const ENVELOPE: EventSubjectEnvelope = {
36
+ tenantId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId,
37
+ aggregateType: "mailer-attempt",
38
+ aggregateId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e2",
39
+ };
40
+
32
41
  function catalogWithAttempt(): void {
33
42
  configureEventPiiCatalog(
34
43
  new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
@@ -40,6 +49,19 @@ afterEach(() => {
40
49
  resetPiiSubjectKmsForTests();
41
50
  });
42
51
 
52
+ describe("normalizeEventPiiSubject", () => {
53
+ test("canonical personal.of and legacy subjectField normalize to the same form", () => {
54
+ expect(normalizeEventPiiSubject({ personal: { of: "recipientId" } })).toEqual({
55
+ kind: "user",
56
+ ownerField: "recipientId",
57
+ });
58
+ expect(normalizeEventPiiSubject({ subjectField: "recipientId" })).toEqual({
59
+ kind: "user",
60
+ ownerField: "recipientId",
61
+ });
62
+ });
63
+ });
64
+
43
65
  describe("defineEvent piiFields validation", () => {
44
66
  test("valid piiFields land on the EventDef and in the registry catalog", () => {
45
67
  const feature = defineFeature("mailer", (r) => {
@@ -73,14 +95,46 @@ describe("defineEvent piiFields validation", () => {
73
95
  ).toThrow(/piiFields references "ownerId"/);
74
96
  });
75
97
 
76
- test("field cannot be its own subjectField", () => {
98
+ test("field cannot be its own subjectField (legacy form)", () => {
77
99
  expect(() =>
78
100
  defineFeature("mailer", (r) => {
79
101
  r.defineEvent("attempt", attemptSchema, {
80
102
  piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
81
103
  });
82
104
  }),
83
- ).toThrow(/cannot use itself as subjectField/);
105
+ ).toThrow(/cannot use itself as the owner field/);
106
+ });
107
+
108
+ test("valid canonical personal.of piiFields land on the EventDef and in the registry catalog", () => {
109
+ const feature = defineFeature("mailer", (r) => {
110
+ r.defineEvent("attempt", attemptSchema, {
111
+ piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
112
+ });
113
+ });
114
+ createRegistry([feature]);
115
+ expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
116
+ recipientAddress: { personal: { of: "recipientId" } },
117
+ });
118
+ });
119
+
120
+ test("unknown owner field throws at definition time (canonical form)", () => {
121
+ expect(() =>
122
+ defineFeature("mailer", (r) => {
123
+ r.defineEvent("attempt", attemptSchema, {
124
+ piiFields: { recipientAddress: { personal: { of: "ownerId" } } },
125
+ });
126
+ }),
127
+ ).toThrow(/piiFields references "ownerId"/);
128
+ });
129
+
130
+ test("field cannot be its own owner field (canonical form)", () => {
131
+ expect(() =>
132
+ defineFeature("mailer", (r) => {
133
+ r.defineEvent("attempt", attemptSchema, {
134
+ piiFields: { recipientAddress: { personal: { of: "recipientAddress" } } },
135
+ });
136
+ }),
137
+ ).toThrow(/cannot use itself as the owner field/);
84
138
  });
85
139
 
86
140
  test("omitting the options argument throws an explicit PII stance error (fw#2558)", () => {
@@ -112,6 +166,46 @@ describe("defineEvent piiFields validation", () => {
112
166
  createRegistry([feature]);
113
167
  expect(configuredEventPiiCatalog().has(EVENT_TYPE)).toBe(false);
114
168
  });
169
+
170
+ test('personal: "tenant" needs only the pii field itself on the schema, no owner field', () => {
171
+ const feature = defineFeature("mailer", (r) => {
172
+ r.defineEvent("attempt", attemptSchema, {
173
+ piiFields: { recipientAddress: { personal: "tenant" } },
174
+ });
175
+ });
176
+ createRegistry([feature]);
177
+ expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
178
+ recipientAddress: { personal: "tenant" },
179
+ });
180
+ });
181
+
182
+ test('personal: "self" needs only the pii field itself on the schema, no owner field', () => {
183
+ const feature = defineFeature("mailer", (r) => {
184
+ r.defineEvent("attempt", attemptSchema, {
185
+ piiFields: { recipientAddress: { personal: "self" } },
186
+ });
187
+ });
188
+ createRegistry([feature]);
189
+ expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
190
+ recipientAddress: { personal: "self" },
191
+ });
192
+ });
193
+
194
+ test('personal: "tenant" on a field not in the schema throws', () => {
195
+ expect(() =>
196
+ defineFeature("mailer", (r) => {
197
+ r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "tenant" } } });
198
+ }),
199
+ ).toThrow(/piiFields references "nope"/);
200
+ });
201
+
202
+ test('personal: "self" on a field not in the schema throws', () => {
203
+ expect(() =>
204
+ defineFeature("mailer", (r) => {
205
+ r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "self" } } });
206
+ }),
207
+ ).toThrow(/piiFields references "nope"/);
208
+ });
115
209
  });
116
210
 
117
211
  describe("encryptEventPayloadPii", () => {
@@ -119,12 +213,12 @@ describe("encryptEventPayloadPii", () => {
119
213
 
120
214
  test("uncatalogued event type returns the payload untouched (same reference)", async () => {
121
215
  configurePiiSubjectKms(new InMemoryKmsAdapter());
122
- expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
216
+ expect(await encryptEventPayloadPii("other:event:x", payload, ENVELOPE)).toBe(payload);
123
217
  });
124
218
 
125
219
  test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
126
220
  catalogWithAttempt();
127
- expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
221
+ expect(await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE)).toBe(payload);
128
222
  });
129
223
 
130
224
  test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
@@ -132,7 +226,7 @@ describe("encryptEventPayloadPii", () => {
132
226
  const kms = new InMemoryKmsAdapter();
133
227
  configurePiiSubjectKms(kms);
134
228
 
135
- const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
229
+ const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
136
230
  expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
137
231
  expect(String(out["recipientAddress"])).toContain("user:u-1");
138
232
  expect(out["recipientId"]).toBe("u-1");
@@ -144,6 +238,23 @@ describe("encryptEventPayloadPii", () => {
144
238
  expect(back["recipientAddress"]).toBe("u1@example.com");
145
239
  });
146
240
 
241
+ test("legacy subjectField and canonical personal.of encrypt under the same subject key", async () => {
242
+ const LEGACY_TYPE = "mailer:event:legacy-attempt";
243
+ const CANONICAL_TYPE = "mailer:event:canonical-attempt";
244
+ configureEventPiiCatalog(
245
+ new Map([
246
+ [LEGACY_TYPE, { recipientAddress: { subjectField: "recipientId" } }],
247
+ [CANONICAL_TYPE, { recipientAddress: { personal: { of: "recipientId" } } }],
248
+ ]),
249
+ );
250
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
251
+
252
+ const legacy = await encryptEventPayloadPii(LEGACY_TYPE, payload, ENVELOPE);
253
+ const canonical = await encryptEventPayloadPii(CANONICAL_TYPE, payload, ENVELOPE);
254
+ expect(String(legacy["recipientAddress"])).toContain("user:u-1");
255
+ expect(String(canonical["recipientAddress"])).toContain("user:u-1");
256
+ });
257
+
147
258
  test("null subject field → value stays plaintext (no user key to shred)", async () => {
148
259
  catalogWithAttempt();
149
260
  configurePiiSubjectKms(new InMemoryKmsAdapter());
@@ -152,25 +263,25 @@ describe("encryptEventPayloadPii", () => {
152
263
  recipientAddress: "ops@example.com",
153
264
  status: "sent",
154
265
  };
155
- expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
266
+ expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE)).toBe(systemPayload);
156
267
  });
157
268
 
158
269
  test("null pii value passes through", async () => {
159
270
  catalogWithAttempt();
160
271
  configurePiiSubjectKms(new InMemoryKmsAdapter());
161
272
  const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
162
- expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
273
+ expect(await encryptEventPayloadPii(EVENT_TYPE, skipped, ENVELOPE)).toBe(skipped);
163
274
  });
164
275
 
165
276
  test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
166
277
  catalogWithAttempt();
167
278
  configurePiiSubjectKms(new InMemoryKmsAdapter());
168
- const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
169
- const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
279
+ const once = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
280
+ const twice = await encryptEventPayloadPii(EVENT_TYPE, once, ENVELOPE);
170
281
  expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
171
282
 
172
283
  const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
173
- const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
284
+ const out = await encryptEventPayloadPii(EVENT_TYPE, erased, ENVELOPE);
174
285
  expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
175
286
  });
176
287
 
@@ -178,6 +289,28 @@ describe("encryptEventPayloadPii", () => {
178
289
  catalogWithAttempt();
179
290
  configurePiiSubjectKms(new InMemoryKmsAdapter());
180
291
  const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
181
- expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(/must be a string/);
292
+ expect(encryptEventPayloadPii(EVENT_TYPE, broken, ENVELOPE)).rejects.toThrow(
293
+ /must be a string/,
294
+ );
295
+ });
296
+
297
+ test('personal: "tenant" encrypts under the envelope tenantId, not a payload field', async () => {
298
+ configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "tenant" } }]]));
299
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
300
+
301
+ const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
302
+ expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
303
+ expect(String(out["recipientAddress"])).toContain(`tenant:${ENVELOPE.tenantId}`);
304
+ });
305
+
306
+ test('personal: "self" encrypts under the envelope aggregateType:aggregateId', async () => {
307
+ configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "self" } }]]));
308
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
309
+
310
+ const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
311
+ expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
312
+ expect(String(out["recipientAddress"])).toContain(
313
+ `record:${ENVELOPE.aggregateType}:${ENVELOPE.aggregateId}`,
314
+ );
182
315
  });
183
316
  });
@@ -23,6 +23,7 @@ import { collectPiiSubjectFields } from "../subject-resolver";
23
23
  const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
24
24
  const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
25
25
  const KMS_CTX: KmsContext = { requestId: "test" };
26
+ const ENTITY_NAME = "pii-entity";
26
27
 
27
28
  const userLikeEntity = createEntity({
28
29
  fields: {
@@ -67,7 +68,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
67
68
  const fields = collectPiiSubjectFields(userLikeEntity);
68
69
  const row = { id: UUID_A, email: "marc@example.com", role: "admin" };
69
70
 
70
- const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX);
71
+ const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX, {
72
+ entityName: ENTITY_NAME,
73
+ });
71
74
  expect(isPiiCiphertext(stored["email"])).toBe(true);
72
75
  expect(String(stored["email"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_A}:`);
73
76
  expect(stored["role"]).toBe("admin");
@@ -88,6 +91,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
88
91
  ["email"],
89
92
  kms,
90
93
  KMS_CTX,
94
+ { entityName: ENTITY_NAME },
91
95
  );
92
96
  const dek = await kms.getKey({ kind: "user", userId: UUID_A });
93
97
  expect(dek.length).toBe(32);
@@ -101,6 +105,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
101
105
  ["body"],
102
106
  kms,
103
107
  KMS_CTX,
108
+ { entityName: ENTITY_NAME },
104
109
  );
105
110
  expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
106
111
  });
@@ -113,7 +118,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
113
118
  ["brandColor"],
114
119
  kms,
115
120
  KMS_CTX,
116
- { tenantId: UUID_B },
121
+ { tenantId: UUID_B, entityName: ENTITY_NAME },
117
122
  );
118
123
  expect(String(stored["brandColor"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
119
124
  });
@@ -124,6 +129,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
124
129
  const stored = await encryptPiiFieldValues(changes, commentEntity, ["body"], kms, KMS_CTX, {
125
130
  onlyKeys: ["body"],
126
131
  subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
132
+ entityName: ENTITY_NAME,
127
133
  });
128
134
  expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
129
135
  });
@@ -137,13 +143,16 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
137
143
  fields,
138
144
  kms,
139
145
  KMS_CTX,
146
+ { entityName: ENTITY_NAME },
140
147
  );
141
148
  await kms.eraseKey({ kind: "user", userId: UUID_A });
142
149
 
143
150
  const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
144
151
  expect(read["email"]).toBe(PII_ERASED_SENTINEL);
145
152
 
146
- const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX);
153
+ const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX, {
154
+ entityName: ENTITY_NAME,
155
+ });
147
156
  expect(reStored["email"]).toBe(PII_ERASED_SENTINEL);
148
157
  });
149
158
 
@@ -158,6 +167,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
158
167
  ["email"],
159
168
  kms,
160
169
  KMS_CTX,
170
+ { entityName: ENTITY_NAME },
161
171
  ),
162
172
  ).rejects.toThrow(/erased/);
163
173
  });
@@ -181,8 +191,11 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
181
191
  ["email"],
182
192
  kms,
183
193
  KMS_CTX,
194
+ { entityName: ENTITY_NAME },
184
195
  );
185
- const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX);
196
+ const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX, {
197
+ entityName: ENTITY_NAME,
198
+ });
186
199
  expect(twice["email"]).toBe(once["email"]);
187
200
  });
188
201
 
@@ -194,6 +207,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
194
207
  ["email"],
195
208
  kms,
196
209
  KMS_CTX,
210
+ { entityName: ENTITY_NAME },
197
211
  );
198
212
  expect(stored["email"]).toBeNull();
199
213
 
@@ -203,7 +217,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
203
217
  ["email"],
204
218
  kms,
205
219
  KMS_CTX,
206
- { onlyKeys: ["role"] },
220
+ { onlyKeys: ["role"], entityName: ENTITY_NAME },
207
221
  );
208
222
  expect(skipped["email"]).toBe("a@b.c");
209
223
  });
@@ -211,7 +225,9 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
211
225
  test("non-string pii value throws", async () => {
212
226
  const kms = new InMemoryKmsAdapter();
213
227
  await expect(
214
- encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX),
228
+ encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX, {
229
+ entityName: ENTITY_NAME,
230
+ }),
215
231
  ).rejects.toThrow(/must be a string/);
216
232
  });
217
233
 
@@ -223,6 +239,7 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
223
239
  ["email"],
224
240
  kmsA,
225
241
  KMS_CTX,
242
+ { entityName: ENTITY_NAME },
226
243
  );
227
244
  const kmsB = new InMemoryKmsAdapter();
228
245
  await expect(decryptPiiFieldValues(stored, ["email"], kmsB, KMS_CTX)).rejects.toBeInstanceOf(
@@ -248,7 +265,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
248
265
  expect(fields).toEqual(["iban"]);
249
266
 
250
267
  const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
251
- const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
268
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
269
+ entityName: ENTITY_NAME,
270
+ });
252
271
  expect(isPiiCiphertext(stored["iban"])).toBe(true);
253
272
  expect(String(stored["iban"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
254
273
 
@@ -266,7 +285,9 @@ describe("tenant-owned field encryption (kumiko-platform#457)", () => {
266
285
  });
267
286
  const fields = collectPiiSubjectFields(brandingWithAccess);
268
287
  const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
269
- const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
288
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX, {
289
+ entityName: ENTITY_NAME,
290
+ });
270
291
 
271
292
  // Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
272
293
  // know or care about the field's `personal` annotation, just the subject.
@@ -56,6 +56,16 @@ describe("record subject key round-trip", () => {
56
56
  expect(() => subjectKeyForRecord("bad:name", UUID_A)).toThrow();
57
57
  });
58
58
 
59
+ test("subjectKeyForRecord rejects entity names that subjectIdSchema (forget-subject) could never shred (fw#2801)", () => {
60
+ expect(() => subjectKeyForRecord("v2.digest", UUID_A)).toThrow();
61
+ expect(() => subjectKeyForRecord("_internal", UUID_A)).toThrow();
62
+ expect(() => subjectKeyForRecord("3rd", UUID_A)).toThrow();
63
+ });
64
+
65
+ test("subjectKeyForRecord accepts a kebab-case registry entity name", () => {
66
+ expect(subjectKeyForRecord("mail-account", UUID_A)).toBe(`record:mail-account:${UUID_A}`);
67
+ });
68
+
59
69
  test("ciphertext round-trip for a record-owned field", async () => {
60
70
  const kms = new InMemoryKmsAdapter();
61
71
  const fields = collectPiiSubjectFields(recordEntity);
@@ -76,9 +86,9 @@ describe("record subject key round-trip", () => {
76
86
 
77
87
  describe("resolveSubjectForField: recordOwned fail-closed", () => {
78
88
  test("recordOwned field without opts.entityName throws", () => {
79
- expect(() => resolveSubjectForField(recordEntity, "body", { id: UUID_A })).toThrow(
80
- SubjectResolutionError,
81
- );
89
+ expect(() =>
90
+ resolveSubjectForField(recordEntity, "body", { id: UUID_A }, { entityName: "" }),
91
+ ).toThrow(SubjectResolutionError);
82
92
  });
83
93
 
84
94
  test("recordOwned field with row missing id throws", () => {