@cosmicdrift/kumiko-framework 0.254.0 → 0.255.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.254.0",
3
+ "version": "0.255.1",
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.254.0",
201
+ "@cosmicdrift/kumiko-types": "0.255.1",
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.254.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.255.1",
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
  }
@@ -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
  });
@@ -0,0 +1,60 @@
1
+ // subjectIdSchema is `satisfies z.ZodType<SubjectId>` at the definition site
2
+ // (kms-adapter.ts) — a future 4th SubjectId variant or a renamed field fails
3
+ // to compile there. This file pins the runtime side of that binding: exactly
4
+ // the three SubjectId shapes parse, everything else is refused.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { subjectIdSchema } from "../kms-adapter";
8
+
9
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
10
+
11
+ describe("subjectIdSchema", () => {
12
+ test("accepts a user subject", () => {
13
+ expect(subjectIdSchema.safeParse({ kind: "user", userId: UUID_A }).success).toBe(true);
14
+ });
15
+
16
+ test("accepts a tenant subject", () => {
17
+ expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: UUID_A }).success).toBe(true);
18
+ });
19
+
20
+ test("accepts a record subject", () => {
21
+ expect(
22
+ subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: UUID_A }).success,
23
+ ).toBe(true);
24
+ });
25
+
26
+ test("mints tenantId as a branded TenantId, not a plain string type", () => {
27
+ const result = subjectIdSchema.parse({ kind: "tenant", tenantId: UUID_A });
28
+ expect(result).toEqual({ kind: "tenant", tenantId: UUID_A });
29
+ });
30
+
31
+ test("rejects a non-UUID userId", () => {
32
+ expect(subjectIdSchema.safeParse({ kind: "user", userId: "not-a-uuid" }).success).toBe(false);
33
+ });
34
+
35
+ test("rejects a non-UUID tenantId", () => {
36
+ expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: "not-a-uuid" }).success).toBe(
37
+ false,
38
+ );
39
+ });
40
+
41
+ test("rejects a non-UUID record id", () => {
42
+ expect(
43
+ subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: "not-a-uuid" })
44
+ .success,
45
+ ).toBe(false);
46
+ });
47
+
48
+ test("rejects a record entity that violates RECORD_ENTITY_PATTERN", () => {
49
+ expect(
50
+ subjectIdSchema.safeParse({ kind: "record", entity: "_internal", id: UUID_A }).success,
51
+ ).toBe(false);
52
+ expect(
53
+ subjectIdSchema.safeParse({ kind: "record", entity: "v2.digest", id: UUID_A }).success,
54
+ ).toBe(false);
55
+ });
56
+
57
+ test("rejects an unknown subject kind", () => {
58
+ expect(subjectIdSchema.safeParse({ kind: "bogus", id: UUID_A }).success).toBe(false);
59
+ });
60
+ });
@@ -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);
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine/factories";
3
+ import type { TenantId } from "../../engine/types/identifiers";
3
4
  import {
4
5
  collectPiiSubjectFields,
6
+ resolveEventSubject,
5
7
  resolveSubjectForField,
6
8
  SubjectResolutionError,
7
9
  } from "../subject-resolver";
@@ -138,3 +140,90 @@ describe("collectPiiSubjectFields", () => {
138
140
  expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
139
141
  });
140
142
  });
143
+
144
+ const EVENT_TENANT = "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId;
145
+
146
+ describe("resolveEventSubject (fw#2801)", () => {
147
+ test("personal: { of } → user subject from the named payload field", () => {
148
+ const subject = resolveEventSubject(
149
+ "note",
150
+ { personal: { of: "authorId" } },
151
+ { authorId: UUID_A },
152
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
153
+ );
154
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
155
+ });
156
+
157
+ test("personal: { of } with a missing owner value → null, no throw (system-triggered event)", () => {
158
+ const subject = resolveEventSubject(
159
+ "note",
160
+ { personal: { of: "authorId" } },
161
+ { authorId: null },
162
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
163
+ );
164
+ expect(subject).toBeNull();
165
+ });
166
+
167
+ test('personal: "tenant" → subject from the envelope tenantId', () => {
168
+ const subject = resolveEventSubject(
169
+ "note",
170
+ { personal: "tenant" },
171
+ {},
172
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
173
+ );
174
+ expect(subject).toEqual({ kind: "tenant", tenantId: EVENT_TENANT });
175
+ });
176
+
177
+ test('personal: "self" → record subject from the envelope aggregateType/aggregateId', () => {
178
+ const subject = resolveEventSubject(
179
+ "note",
180
+ { personal: "self" },
181
+ {},
182
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
183
+ );
184
+ expect(subject).toEqual({ kind: "record", entity: "note", id: UUID_B });
185
+ });
186
+
187
+ test('personal: "tenant" with an empty envelope tenantId throws and names the pii field, not "tenant"', () => {
188
+ expect(() =>
189
+ resolveEventSubject(
190
+ "note",
191
+ { personal: "tenant" },
192
+ {},
193
+ { tenantId: "" as TenantId, aggregateType: "note", aggregateId: UUID_B },
194
+ ),
195
+ ).toThrow(/field "note"/);
196
+ });
197
+
198
+ test('personal: "self" with an empty envelope aggregateId throws and names the pii field, not "self"', () => {
199
+ expect(() =>
200
+ resolveEventSubject(
201
+ "note",
202
+ { personal: "self" },
203
+ {},
204
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: "" },
205
+ ),
206
+ ).toThrow(/field "note"/);
207
+ });
208
+
209
+ test('personal: "self" with an aggregateType that violates the record-entity pattern throws — mint must never outrun shred (fw#2801)', () => {
210
+ expect(() =>
211
+ resolveEventSubject(
212
+ "note",
213
+ { personal: "self" },
214
+ {},
215
+ { tenantId: EVENT_TENANT, aggregateType: "v2.digest", aggregateId: UUID_B },
216
+ ),
217
+ ).toThrow(/field "note".*v2\.digest/s);
218
+ });
219
+
220
+ test("legacy subjectField normalizes to the same user subject as personal: { of }", () => {
221
+ const subject = resolveEventSubject(
222
+ "note",
223
+ { subjectField: "authorId" },
224
+ { authorId: UUID_A },
225
+ { tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
226
+ );
227
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
228
+ });
229
+ });
@@ -12,9 +12,10 @@
12
12
  // silent no-op (forgetting the option) is now either an explicit "none" or a
13
13
  // missing subject KMS at encrypt-time, both visible states, not a gap.
14
14
 
15
+ import type { EventPiiFields } from "@cosmicdrift/kumiko-types/handlers";
15
16
  import { requestContext } from "../api/request-context";
16
- import type { EventPiiFields } from "../engine/types/handlers";
17
17
  import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
18
+ import { type EventSubjectEnvelope, resolveEventSubject } from "./subject-resolver";
18
19
 
19
20
  export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
20
21
 
@@ -35,14 +36,20 @@ export function resetEventPiiCatalogForTests(): void {
35
36
  catalog = new Map();
36
37
  }
37
38
 
38
- // Encrypts catalogued payload fields under the owning user's DEK. No-op when
39
- // the event type is uncatalogued or no subject KMS is configured (plaintext
40
- // rollout mode — the hard boot gate governs whether that is acceptable).
41
- // A null/absent subject field (system cron runs, recipient-less skip
42
- // attempts) leaves the value plaintext: there is no user key to shred.
39
+ // Encrypts catalogued payload fields under the declared subject's DEK
40
+ // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
41
+ // type is uncatalogued or no subject KMS is configured (plaintext rollout
42
+ // mode — the hard boot gate governs whether that is acceptable). A
43
+ // user-subject field with no resolvable owner (system cron runs,
44
+ // recipient-less skip attempts) leaves the value plaintext: there is no
45
+ // user key to shred. This is the ONLY live-write encrypt path — the backfill
46
+ // catalog branch (`backfill-pii.ts`) resolves through the same
47
+ // resolveEventSubject so a field never ends up encrypted under different
48
+ // subjects depending on which path wrote it.
43
49
  export async function encryptEventPayloadPii(
44
50
  eventType: string,
45
51
  payload: Record<string, unknown>,
52
+ envelope: EventSubjectEnvelope,
46
53
  ): Promise<Record<string, unknown>> {
47
54
  const piiFields = catalog.get(eventType);
48
55
  if (!piiFields) return payload;
@@ -58,11 +65,11 @@ export async function encryptEventPayloadPii(
58
65
  `Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
59
66
  );
60
67
  }
61
- const subjectId = payload[spec.subjectField];
62
- if (typeof subjectId !== "string" || subjectId.length === 0) continue;
68
+ const subject = resolveEventSubject(field, spec, payload, envelope);
69
+ if (subject === null) continue;
63
70
  const encrypted = await encryptPiiValueForSubject(
64
71
  kms,
65
- { kind: "user", userId: subjectId },
72
+ subject,
66
73
  value,
67
74
  { requestId: requestContext.get()?.requestId ?? "append-event" },
68
75
  field,
@@ -24,12 +24,15 @@ export {
24
24
  type KmsContext,
25
25
  type KmsHealth,
26
26
  type LocalKeyKmsAdapter,
27
+ RECORD_ENTITY_PATTERN,
27
28
  type RemoteCryptoKmsAdapter,
28
29
  type SubjectDek,
29
30
  type SubjectId,
30
31
  type SubjectKey,
31
32
  subjectIdFromKey,
33
+ subjectIdSchema,
32
34
  subjectIdToKey,
35
+ subjectKeyForRecord,
33
36
  subjectKeyForTenant,
34
37
  subjectKeyForUser,
35
38
  } from "./kms-adapter";
@@ -1,10 +1,34 @@
1
- import { type SubjectId, subjectIdToKey } from "@cosmicdrift/kumiko-types/kms-adapter-types";
1
+ import {
2
+ RECORD_ENTITY_PATTERN,
3
+ type SubjectId,
4
+ subjectIdToKey,
5
+ } from "@cosmicdrift/kumiko-types/kms-adapter-types";
6
+ import { z } from "zod";
7
+ import type { TenantId } from "../engine/types/identifiers";
2
8
 
3
9
  export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
10
 
5
11
  // The KMS error classes live here and not in kumiko-types (#1629): callers
6
12
  // branch on them with `instanceof`, which needs a single copy of the class.
7
13
 
14
+ // `satisfies z.ZodType<SubjectId>` binds this to the TS type at compile time
15
+ // (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
16
+ // longer drift apart on which subjects are valid, the way #2809 happened.
17
+ export const subjectIdSchema = z.discriminatedUnion("kind", [
18
+ z.object({ kind: z.literal("user"), userId: z.uuid() }),
19
+ z.object({
20
+ kind: z.literal("tenant"),
21
+ // z.uuid() only proves string shape; TenantId is a nominal brand on top
22
+ // of that, minted here right after validation (@cast-boundary).
23
+ tenantId: z.uuid().transform((value) => value as TenantId),
24
+ }),
25
+ z.object({
26
+ kind: z.literal("record"),
27
+ entity: z.string().regex(RECORD_ENTITY_PATTERN),
28
+ id: z.uuid(),
29
+ }),
30
+ ]) satisfies z.ZodType<SubjectId>;
31
+
8
32
  export class KeyErasedError extends Error {
9
33
  constructor(public readonly subject: SubjectId) {
10
34
  super(`Subject key erased: ${subjectIdToKey(subject)}`);