@cosmicdrift/kumiko-framework 0.173.1 → 0.174.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.173.1",
3
+ "version": "0.174.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.173.1",
185
+ "@cosmicdrift/kumiko-types": "0.174.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.173.1",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -121,3 +121,76 @@ describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
121
121
  expect(captured).toBeUndefined();
122
122
  });
123
123
  });
124
+
125
+ describe("requestIdMiddleware — client-supplied id sanitization (input-validation)", () => {
126
+ test("oversized X-Request-ID is rejected, a fresh id is generated instead", async () => {
127
+ let captured: string | undefined;
128
+
129
+ const app = new Hono();
130
+ app.use("/probe", requestIdMiddleware());
131
+ app.get("/probe", (c) => {
132
+ captured = requestContext.get()?.requestId;
133
+ return c.text("ok");
134
+ });
135
+
136
+ const junk = "x".repeat(5000);
137
+ const res = await app.request(
138
+ new Request("http://test.local/probe", {
139
+ method: "GET",
140
+ headers: { "X-Request-ID": junk },
141
+ }),
142
+ );
143
+
144
+ expect(res.status).toBe(200);
145
+ expect(captured).toBeDefined();
146
+ expect(captured).not.toBe(junk);
147
+ expect(captured?.length).toBeLessThan(128);
148
+ });
149
+
150
+ test("disallowed-character X-Correlation-ID falls back to the (sanitized) requestId", async () => {
151
+ let captured: { requestId: string | undefined; correlationId: string | undefined } = {
152
+ requestId: undefined,
153
+ correlationId: undefined,
154
+ };
155
+
156
+ const app = new Hono();
157
+ app.use("/probe", requestIdMiddleware());
158
+ app.get("/probe", (c) => {
159
+ const ctx = requestContext.get();
160
+ captured = { requestId: ctx?.requestId, correlationId: ctx?.correlationId };
161
+ return c.text("ok");
162
+ });
163
+
164
+ const res = await app.request(
165
+ new Request("http://test.local/probe", {
166
+ method: "GET",
167
+ headers: { "X-Request-ID": "req-1", "X-Correlation-ID": "corr injected; drop table" },
168
+ }),
169
+ );
170
+
171
+ expect(res.status).toBe(200);
172
+ expect(captured.requestId).toBe("req-1");
173
+ expect(captured.correlationId).toBe("req-1");
174
+ });
175
+
176
+ test("a well-formed client id is preserved", async () => {
177
+ let captured: string | undefined;
178
+
179
+ const app = new Hono();
180
+ app.use("/probe", requestIdMiddleware());
181
+ app.get("/probe", (c) => {
182
+ captured = requestContext.get()?.requestId;
183
+ return c.text("ok");
184
+ });
185
+
186
+ const res = await app.request(
187
+ new Request("http://test.local/probe", {
188
+ method: "GET",
189
+ headers: { "X-Request-ID": "trace-abc123.def" },
190
+ }),
191
+ );
192
+
193
+ expect(res.status).toBe(200);
194
+ expect(captured).toBe("trace-abc123.def");
195
+ });
196
+ });
@@ -4,6 +4,16 @@ import { requestContext } from "./request-context";
4
4
  const REQUEST_ID_HEADER = "X-Request-ID";
5
5
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
6
6
 
7
+ // requestId/correlationId flow unvalidated into append-only event-store
8
+ // metadata (e.g. sessions:revoke-all-for-user) — cap shape at the trust
9
+ // boundary so a client can't smuggle an oversized/control-char payload into
10
+ // permanent, replayed, DSGVO-exported storage via a client-set header.
11
+ const SAFE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
12
+
13
+ function sanitizeClientId(value: string | undefined): string | undefined {
14
+ return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
15
+ }
16
+
7
17
  /**
8
18
  * Assigns a requestId + correlationId to every request and wraps execution
9
19
  * in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
@@ -15,8 +25,9 @@ const CORRELATION_ID_HEADER = "X-Correlation-ID";
15
25
  */
16
26
  export function requestIdMiddleware() {
17
27
  return async (c: Context, next: Next) => {
18
- const requestId = c.req.header(REQUEST_ID_HEADER) ?? requestContext.generateId();
19
- const correlationId = c.req.header(CORRELATION_ID_HEADER) ?? requestId;
28
+ const requestId =
29
+ sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
30
+ const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
20
31
  c.header(REQUEST_ID_HEADER, requestId);
21
32
  c.header(CORRELATION_ID_HEADER, correlationId);
22
33
  c.set("requestId", requestId);
@@ -19,9 +19,13 @@ export type SseBroker = {
19
19
  getClientCount(channel: string): number;
20
20
  getTotalClientCount(): number;
21
21
  // Separate from addClient so it doesn't count towards getClientCount.
22
- // Optional: additive for external/test SseBroker impls (call sites use?.).
23
- subscribeAccessInvalidation?(userId: string, onInvalidate: () => void): () => void;
24
- publishAccessInvalidation?(userId: string): void;
22
+ // Required (fw#1601): an app-injected SseBroker (e.g. a Redis-backed
23
+ // multi-replica broker) that skips these silently turns the mid-stream
24
+ // access-teardown security control (#1561) into a no-op — a revoked
25
+ // session keeps receiving live SSE data with no error or log. A no-op
26
+ // stub is one line for a broker that genuinely doesn't need it.
27
+ subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
28
+ publishAccessInvalidation(userId: string): void;
25
29
  };
26
30
 
27
31
  export function createSseBroker(): SseBroker {
@@ -83,6 +83,12 @@ describe("buildPgKmsOptions", () => {
83
83
  /PLATFORM_KEK_PREVIOUS_VERSION must be set/,
84
84
  );
85
85
  });
86
+
87
+ test("rejects PLATFORM_KEK_PREVIOUS_VERSION without PLATFORM_KEK_PREVIOUS — half-set rotation must not silently drop the version", () => {
88
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_PREVIOUS_VERSION: "1" })).toThrow(
89
+ /PLATFORM_KEK_PREVIOUS must be set/,
90
+ );
91
+ });
86
92
  });
87
93
 
88
94
  describe("resolveKmsWiring", () => {
@@ -83,6 +83,11 @@ export function buildPgKmsOptions(env: PgKmsRotationEnv): PgKmsAdapterOptions {
83
83
  ? parseKekVersion(env.PLATFORM_KEK_VERSION, "PLATFORM_KEK_VERSION")
84
84
  : 1;
85
85
  if (!env.PLATFORM_KEK_PREVIOUS) {
86
+ if (env.PLATFORM_KEK_PREVIOUS_VERSION) {
87
+ throw new Error(
88
+ "PLATFORM_KEK_PREVIOUS must be set when PLATFORM_KEK_PREVIOUS_VERSION is set.",
89
+ );
90
+ }
86
91
  return {
87
92
  databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
88
93
  platformKek: env.PLATFORM_KEK,
@@ -9,7 +9,7 @@ import {
9
9
  InMemoryKmsAdapter,
10
10
  isPiiCiphertext,
11
11
  } from "../../crypto";
12
- import { createEntity, createTextField } from "../../engine";
12
+ import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
14
  import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
15
15
  import { createTestEnvelopeCipher, resetPiiSubjectKmsForTests, seedRows } from "../../testing";
@@ -58,13 +58,44 @@ const leadEntity = createEntity({
58
58
  fields: {
59
59
  title: createTextField({ required: true }),
60
60
  contact: { type: "reference", entity: "contact" },
61
+ ownedContact: { type: "reference", entity: "ownedContact" },
62
+ unrestrictedContact: { type: "reference", entity: "unrestrictedContact" },
61
63
  },
62
64
  });
63
65
  const contactTable = buildEntityTable("contact", contactEntity);
64
66
 
67
+ // fw#1671: ownership-scoped ref entity — eagerload has no SessionUser to
68
+ // evaluate access.read against, so PII/encrypted fields must be stripped
69
+ // entirely rather than decrypted (fail closed, not a same-tenant PII leak).
70
+ const ownedContactEntity = createEntity({
71
+ table: "el_owned_contacts",
72
+ fields: {
73
+ name: createTextField({ required: true }),
74
+ email: createTextField({ required: true, tenantOwned: true }),
75
+ iban: createTextField({ required: true, encrypted: true }),
76
+ },
77
+ access: { read: { admin: from("user:id", "ownerId") } },
78
+ });
79
+ const ownedContactTable = buildEntityTable("ownedContact", ownedContactEntity);
80
+
81
+ // access.read present but every role is "all" (unrestricted) — must NOT
82
+ // trigger the strip path, or #1667's decrypt would silently regress for
83
+ // every entity that merely declares an access.read map without narrowing it.
84
+ const unrestrictedContactEntity = createEntity({
85
+ table: "el_unrestricted_contacts",
86
+ fields: {
87
+ name: createTextField({ required: true }),
88
+ email: createTextField({ required: true, tenantOwned: true }),
89
+ },
90
+ access: { read: { admin: "all", member: "all" } },
91
+ });
92
+ const unrestrictedContactTable = buildEntityTable("unrestrictedContact", unrestrictedContactEntity);
93
+
65
94
  const resolve = (name: string): EntityDefinition | undefined => {
66
95
  if (name === "author") return authorEntity;
67
96
  if (name === "contact") return contactEntity;
97
+ if (name === "ownedContact") return ownedContactEntity;
98
+ if (name === "unrestrictedContact") return unrestrictedContactEntity;
68
99
  return undefined;
69
100
  };
70
101
 
@@ -83,6 +114,9 @@ const many = (r: EagerloadedRow | undefined, f: string) =>
83
114
  r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
84
115
 
85
116
  const CX = "44444444-4444-4444-8444-444444444444";
117
+ const OWNED = "66666666-6666-4666-8666-666666666666";
118
+ const UNRESTRICTED = "88888888-8888-4888-8888-888888888888";
119
+ const BROKEN = "77777777-7777-4777-8777-777777777777";
86
120
  const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
87
121
  const cipher = createTestEnvelopeCipher(TEST_KEY);
88
122
  const kms = new InMemoryKmsAdapter();
@@ -94,6 +128,8 @@ beforeAll(async () => {
94
128
  stack = await setupTestStack({ features: [] });
95
129
  await unsafeCreateEntityTable(stack.db, authorEntity);
96
130
  await unsafeCreateEntityTable(stack.db, contactEntity);
131
+ await unsafeCreateEntityTable(stack.db, ownedContactEntity);
132
+ await unsafeCreateEntityTable(stack.db, unrestrictedContactEntity);
97
133
  dbA = createTenantDb(stack.db, tenantA, "tenant");
98
134
 
99
135
  await seedRows(stack.db, authorTable, [
@@ -120,6 +156,44 @@ beforeAll(async () => {
120
156
  cipher,
121
157
  );
122
158
  await seedRows(stack.db, contactTable, [encrypted]);
159
+
160
+ const plainOwned = {
161
+ id: OWNED,
162
+ tenantId: tenantA,
163
+ name: "Owned Grace",
164
+ email: "owned-grace@acme.test",
165
+ iban: "DE99999",
166
+ };
167
+ const ownedPiiEncrypted = await encryptPiiFieldValues(
168
+ plainOwned,
169
+ ownedContactEntity,
170
+ ["email"],
171
+ kms,
172
+ { requestId: "test" },
173
+ );
174
+ const ownedEncrypted = await encryptEntityFieldValues(
175
+ ownedPiiEncrypted,
176
+ collectEncryptedFieldNames(ownedContactEntity),
177
+ cipher,
178
+ );
179
+ await seedRows(stack.db, ownedContactTable, [ownedEncrypted]);
180
+
181
+ // Legacy/backfilled row: iban never went through encryptEntityFieldValues,
182
+ // so decryptEntityFieldValues throws on it (fw#1671 error-propagation) —
183
+ // a single row like this must not 500 the whole eagerload lookup.
184
+ await seedRows(stack.db, contactTable, [
185
+ {
186
+ id: BROKEN,
187
+ tenantId: tenantA,
188
+ name: "Broken",
189
+ email: "broken@acme.test",
190
+ iban: "not-an-envelope",
191
+ },
192
+ ]);
193
+
194
+ await seedRows(stack.db, unrestrictedContactTable, [
195
+ { id: UNRESTRICTED, tenantId: tenantA, name: "Unrestricted", email: "unrestricted@acme.test" },
196
+ ]);
123
197
  });
124
198
 
125
199
  afterAll(async () => {
@@ -237,4 +311,48 @@ describe("enrichWithReferences", () => {
237
311
  expect(contact?.["iban"]).toBe("DE12345");
238
312
  expect(isPiiCiphertext(contact?.["email"])).toBe(false);
239
313
  });
314
+
315
+ test('fw#1671: access.read map where every rule is "all" (unrestricted) does NOT trigger the strip path', async () => {
316
+ const [row] = (await enrichWithReferences(
317
+ [{ id: "l1", unrestrictedContact: UNRESTRICTED }],
318
+ leadEntity,
319
+ resolve,
320
+ dbA,
321
+ )) as EagerloadedRow[];
322
+ const unrestricted = single(row, "unrestrictedContact");
323
+
324
+ expect(unrestricted).toBeDefined();
325
+ expect(unrestricted?.["name"]).toBe("Unrestricted");
326
+ expect(unrestricted?.["email"]).toBe("unrestricted@acme.test");
327
+ });
328
+
329
+ test("fw#1671: ownership-scoped ref entity — PII/encrypted fields are stripped, not decrypted or leaked as ciphertext", async () => {
330
+ const [row] = (await enrichWithReferences(
331
+ [{ id: "l1", ownedContact: OWNED }],
332
+ leadEntity,
333
+ resolve,
334
+ dbA,
335
+ )) as EagerloadedRow[];
336
+ const owned = single(row, "ownedContact");
337
+
338
+ expect(owned).toBeDefined();
339
+ expect(owned?.["name"]).toBe("Owned Grace");
340
+ expect(owned?.["email"]).toBeUndefined();
341
+ expect(owned?.["iban"]).toBeUndefined();
342
+ });
343
+
344
+ test("fw#1671: a row with a broken envelope is dropped, a sibling row's good ref still resolves in the same batch", async () => {
345
+ const [goodRow, brokenRow] = (await enrichWithReferences(
346
+ [
347
+ { id: "l1", contact: CX },
348
+ { id: "l2", contact: BROKEN },
349
+ ],
350
+ leadEntity,
351
+ resolve,
352
+ dbA,
353
+ )) as EagerloadedRow[];
354
+
355
+ expect(single(goodRow, "contact")?.["email"]).toBe("grace@acme.test");
356
+ expect(single(brokenRow, "contact")).toBeUndefined();
357
+ });
240
358
  });
@@ -245,6 +245,33 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
245
245
  if (result.isSuccess) return;
246
246
  expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
247
247
  });
248
+
249
+ // fw#1685: a preSave hook that derives a field the user never submitted
250
+ // must not have that field field-ownership-checked against the user —
251
+ // only what the user actually wrote in `payload.changes` is checked.
252
+ test("create: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
253
+ const result = await crud.create({ authorId: TestUsers.driver.id }, nonAdmin, tdb, {
254
+ preSave: async (changes) => ({ ...changes, note: "hook-derived" }),
255
+ });
256
+ expect(result.isSuccess).toBe(true);
257
+ });
258
+
259
+ test("update: preSave-derived `note` (not submitted by the user) does not trigger ownership_denied", async () => {
260
+ const created = await crud.create(
261
+ { authorId: TestUsers.driver.id, note: "original" },
262
+ admin,
263
+ tdb,
264
+ );
265
+ if (!created.isSuccess) throw new Error("setup failed");
266
+
267
+ const result = await crud.update(
268
+ { id: created.data.id, version: 1, changes: {} },
269
+ nonAdmin,
270
+ tdb,
271
+ { preSave: async (changes) => ({ ...changes, note: "hook-derived" }) },
272
+ );
273
+ expect(result.isSuccess).toBe(true);
274
+ });
248
275
  });
249
276
 
250
277
  // =============================================================================
@@ -95,25 +95,80 @@ export function collectReferenceFields(entity: EntityDefinition): readonly Refer
95
95
  // event-store-executor-context's decryptForRead ordering: PII is the outer
96
96
  // layer, peel it before the envelope-encrypted fields, or the envelope
97
97
  // cipher chokes on a still-PII-wrapped string.
98
+ //
99
+ // Ownership guard (fw#1671): the ref lookup below is tenant-scoped only, not
100
+ // ownership-scoped — this function has no SessionUser to evaluate
101
+ // refEntity.access.read against. If the ref entity declares row-level
102
+ // ownership at all, decrypting here would hand a same-tenant User A the
103
+ // plaintext PII of a User B row they merely reference (e.g. a freely-settable
104
+ // reference UUID), even though refEntity.access.read says "own" — the caller
105
+ // never gets to run that ownership check. Fail closed: strip PII/encrypted
106
+ // fields entirely instead of decrypting (or leaking ciphertext) when that
107
+ // guarantee can't be evaluated here.
108
+ function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
109
+ const readMap = refEntity.access?.read;
110
+ if (readMap === undefined) return false;
111
+ // "all" means that role sees every row unrestricted — a map where every
112
+ // rule is "all" carries no ownership restriction at all, so stripping
113
+ // here would just silently drop PII/encrypted fields #1667 wants
114
+ // decrypted, for no security benefit.
115
+ return Object.values(readMap).some((rule) => rule !== "all");
116
+ }
117
+
98
118
  async function decryptReferencedRow(
99
119
  row: Record<string, unknown>,
100
120
  refEntity: EntityDefinition,
101
121
  ): Promise<Record<string, unknown>> {
102
- let out = row;
103
122
  const piiFields = collectPiiSubjectFields(refEntity);
123
+ const encryptedFields = collectEncryptedFieldNames(refEntity);
124
+ if (hasOwnershipScopedRead(refEntity)) {
125
+ if (piiFields.length === 0 && encryptedFields.size === 0) return row;
126
+ const out = { ...row };
127
+ for (const field of piiFields) delete out[field];
128
+ for (const field of encryptedFields) delete out[field];
129
+ return out;
130
+ }
131
+
132
+ let out = row;
104
133
  const kms = configuredPiiSubjectKms();
105
134
  if (piiFields.length > 0 && kms) {
106
135
  out = await decryptPiiFieldValues(out, piiFields, kms, {
107
136
  requestId: requestContext.get()?.requestId ?? "eagerload",
108
137
  });
109
138
  }
110
- const encryptedFields = collectEncryptedFieldNames(refEntity);
111
139
  if (encryptedFields.size > 0) {
112
140
  out = await decryptEntityFieldValues(out, encryptedFields, resolveEntityFieldEncryption());
113
141
  }
114
142
  return out;
115
143
  }
116
144
 
145
+ // Per-row, not Promise.all: a single legacy/backfilled row without a valid
146
+ // envelope (decryptEntityFieldValues throws hard on malformed ciphertext)
147
+ // must not 500 the whole list request — the main rows the caller asked for
148
+ // are unrelated to this one broken reference. Drop just that row from the
149
+ // map; the renderer falls back to the raw UUID.
150
+ async function buildRefLookupMap(
151
+ rawRefRows: ReadonlyArray<Record<string, unknown>>,
152
+ refEntity: EntityDefinition,
153
+ refEntityName: string,
154
+ ): Promise<Map<string, Record<string, unknown>>> {
155
+ const map = new Map<string, Record<string, unknown>>();
156
+ for (const r of rawRefRows) {
157
+ let decrypted: Record<string, unknown>;
158
+ try {
159
+ decrypted = await decryptReferencedRow(r, refEntity);
160
+ } catch (e) {
161
+ console.warn(
162
+ `[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
163
+ );
164
+ continue;
165
+ }
166
+ const id = decrypted["id"];
167
+ if (typeof id === "string") map.set(id, decrypted);
168
+ }
169
+ return map;
170
+ }
171
+
117
172
  /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
118
173
  * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
119
174
  export async function enrichWithReferences(
@@ -159,12 +214,7 @@ export async function enrichWithReferences(
159
214
  const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
160
215
  Record<string, unknown>
161
216
  >;
162
- const refRows = await Promise.all(rawRefRows.map((r) => decryptReferencedRow(r, refEntity)));
163
- const map = new Map<string, Record<string, unknown>>();
164
- for (const r of refRows) {
165
- const id = r["id"];
166
- if (typeof id === "string") map.set(id, r);
167
- }
217
+ const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName);
168
218
  return { fieldName: rf.fieldName, multiple: rf.multiple, map };
169
219
  }),
170
220
  );
@@ -133,7 +133,20 @@ export function createWriteVerbs(
133
133
  // Field-level write-ownership on create — mirror of entity-level but
134
134
  // per declared field. Role-level was already checked by the
135
135
  // dispatcher; here we enforce ownership-rules against the new row.
136
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
136
+ //
137
+ // Which fields get checked is scoped to the pre-hook payload (fw#1685)
138
+ // — a hook-derived field the user never submitted must not be
139
+ // field-ownership-checked against the user. The rule for a checked
140
+ // field is still evaluated against the full post-hook row (`data`) —
141
+ // an ownership rule can reference a column only a hook populates
142
+ // (kumiko-framework#1672).
143
+ const fieldDeniedCreate = checkWriteFieldOwnership(
144
+ entity,
145
+ applyDefaults(payloadWithoutId),
146
+ user,
147
+ undefined,
148
+ data,
149
+ );
137
150
  if (fieldDeniedCreate) {
138
151
  return writeFailure(
139
152
  new UnprocessableError("ownership_denied", {
@@ -301,7 +314,24 @@ export function createWriteVerbs(
301
314
  // `previous`, we can run the ownership rules per field against both
302
315
  // sides and reject individual fields the user isn't entitled to
303
316
  // touch on this specific row.
304
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, changes, user, previous);
317
+ //
318
+ // Which fields get checked is scoped to `payload.changes` (the user's
319
+ // actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
320
+ // — a hook-derived field the user never submitted (e.g. a system hook
321
+ // setting `assignedTo`) has no business being field-ownership-checked
322
+ // against the *user*, and doing so rejects writes the user is fully
323
+ // entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
324
+ // already runs on this same pre-hook payload for consistency. The rule
325
+ // for a checked field is still evaluated against the full post-hook
326
+ // row (`changes` merged onto `previous`) — an ownership rule can
327
+ // reference a column only a hook populates (kumiko-framework#1672).
328
+ const fieldDeniedUpdate = checkWriteFieldOwnership(
329
+ entity,
330
+ payload.changes,
331
+ user,
332
+ previous,
333
+ changes,
334
+ );
305
335
  if (fieldDeniedUpdate) {
306
336
  return writeFailure(
307
337
  new UnprocessableError("ownership_denied", {
@@ -131,6 +131,18 @@ describe("buildInsertSchema", () => {
131
131
  valid: { age: 5 },
132
132
  invalid: { age: 11 },
133
133
  },
134
+ {
135
+ name: "integer field rejects a value outside Postgres int4 range (must 400, not crash the DB write)",
136
+ fields: { attempt: createNumberField({ integer: true }) },
137
+ valid: { attempt: 2147483647 },
138
+ invalid: { attempt: 2147483648 },
139
+ },
140
+ {
141
+ name: "integer field with explicit max narrower than int4 still enforces the explicit bound",
142
+ fields: { displayOrder: createNumberField({ integer: true, max: 100 }) },
143
+ valid: { displayOrder: 100 },
144
+ invalid: { displayOrder: 101 },
145
+ },
134
146
  {
135
147
  name: "date field",
136
148
  fields: { born: createDateField() },
@@ -98,15 +98,29 @@ export function checkWriteFieldRoles(
98
98
  // row. For creates, pass oldRow = undefined; the check degenerates to a
99
99
  // newRow-only evaluation.
100
100
  //
101
+ // `submittedChanges` and `rowContext` are deliberately separate (fw#1685):
102
+ // - `submittedChanges` drives WHICH fields get checked — only what the user
103
+ // actually wrote in the request. A preSave-hook-derived field the user
104
+ // never touched (e.g. a system hook setting `assignedTo`) must not be
105
+ // field-ownership-checked against the user at all.
106
+ // - `rowContext` drives what a checked field's rule is evaluated AGAINST —
107
+ // this needs the full post-hook row, because an ownership rule can
108
+ // reference a DIFFERENT column that only a hook populates (kumiko-
109
+ // framework#1672: a hook derives `authorId`, a user-submitted `secretNote`
110
+ // field's rule is `from("user:id", "authorId")` — the check needs the
111
+ // hook-derived `authorId` in scope even though the user only wrote
112
+ // `secretNote`). Defaults to `submittedChanges` when omitted.
113
+ //
101
114
  // Returns the denied field name for the caller to wrap into an
102
115
  // `ownership_denied` error with scope: "field", or null if all fields pass.
103
116
  export function checkWriteFieldOwnership(
104
117
  entity: EntityDefinition,
105
- changes: Readonly<Record<string, unknown>>,
118
+ submittedChanges: Readonly<Record<string, unknown>>,
106
119
  user: SessionUser,
107
120
  oldRow?: Readonly<Record<string, unknown>>,
121
+ rowContext: Readonly<Record<string, unknown>> = submittedChanges,
108
122
  ): string | null {
109
- for (const key of Object.keys(changes)) {
123
+ for (const key of Object.keys(submittedChanges)) {
110
124
  const field = entity.fields[key];
111
125
  if (!field) continue;
112
126
 
@@ -120,7 +134,7 @@ export function checkWriteFieldOwnership(
120
134
  const hasOwnershipRule = Object.values(accessMap).some((r) => r !== "all");
121
135
  if (!hasOwnershipRule) continue;
122
136
 
123
- const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...changes };
137
+ const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...rowContext };
124
138
  const effectiveOld = oldRow ?? newRow; // create: compare against newRow
125
139
 
126
140
  if (!userCanWriteFieldRow(user, accessMap, effectiveOld, newRow)) {
@@ -109,7 +109,10 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
109
109
  }
110
110
  case "number": {
111
111
  let schema = z.number();
112
- if (field.integer) schema = schema.int();
112
+ // `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
113
+ // — bound it here so an out-of-range write fails loud (400) at the
114
+ // schema boundary instead of dying in Postgres (22003 → 500).
115
+ if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
113
116
  if (field.min !== undefined) schema = schema.min(field.min);
114
117
  if (field.max !== undefined) schema = schema.max(field.max);
115
118
  return field.default !== undefined ? schema.default(field.default) : schema;
@@ -60,7 +60,7 @@ import {
60
60
  observabilityContext,
61
61
  } from "../observability";
62
62
  import { buildBucketKey } from "../rate-limit";
63
- import { createTzContext } from "../time";
63
+ import { createTzContext, isValidIanaTimeZone } from "../time";
64
64
  import { appendDomainEventCore } from "./append-event-core";
65
65
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
66
66
  import { executeQuery } from "./dispatch-query";
@@ -523,10 +523,18 @@ export async function buildHandlerContext(
523
523
  // tenant (createTzContext's own default). An app-injected GeoTzProvider
524
524
  // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
525
525
  const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
526
+ // Guarded against garbage: an unvalidated string here (free-form config
527
+ // key, legacy JWT claim predating validation) blows up every ctx.tz call
528
+ // for the whole tenant with a RangeError. Fall back to UTC/tenant instead
529
+ // of trusting the raw value.
530
+ const safeTenantTz =
531
+ typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC";
532
+ const safeUserTz =
533
+ user.timezone !== undefined && isValidIanaTimeZone(user.timezone) ? user.timezone : undefined;
526
534
  const tz = createTzContext({
527
535
  ...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
528
- tenant: typeof tenantTz === "string" ? tenantTz : "UTC",
529
- ...(user.timezone !== undefined && { user: user.timezone }),
536
+ tenant: safeTenantTz,
537
+ ...(safeUserTz !== undefined && { user: safeUserTz }),
530
538
  });
531
539
 
532
540
  return {
@@ -59,12 +59,9 @@ async function* executeStreamInner(
59
59
  const invalidated = new Promise<void>((resolve) => {
60
60
  resolveInvalidated = resolve;
61
61
  });
62
- const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation?.(
63
- user.id,
64
- () => {
65
- resolveInvalidated?.();
66
- },
67
- );
62
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
63
+ resolveInvalidated?.();
64
+ });
68
65
 
69
66
  let iterator: AsyncIterator<unknown> | undefined;
70
67
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
@@ -94,7 +94,7 @@ export function createSearchEventConsumer(
94
94
  // #1610 — subject-annotated searchable fields are ciphertext in the event
95
95
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
96
  // values rather than indexing blobs.
97
- async function decryptSearchableSubjectFields(
97
+ export async function decryptSearchableSubjectFields(
98
98
  entityName: string,
99
99
  state: Record<string, unknown>,
100
100
  registry: Registry,
@@ -116,7 +116,7 @@ async function decryptSearchableSubjectFields(
116
116
  });
117
117
  }
118
118
 
119
- function hasErasedSearchableSubjectField(
119
+ export function hasErasedSearchableSubjectField(
120
120
  entityName: string,
121
121
  state: Record<string, unknown>,
122
122
  registry: Registry,
@@ -416,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
416
416
  // poison would otherwise permanently stop access-invalidation for
417
417
  // every user behind one bad row).
418
418
  if (typeof userId !== "string" || userId.length === 0) return;
419
- sseBroker.publishAccessInvalidation?.(userId);
419
+ sseBroker.publishAccessInvalidation(userId);
420
420
  }
421
421
 
422
422
  if (
@@ -427,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
427
427
  // skip: previous snapshot missing/malformed userId — same fail-open
428
428
  // reasoning as above.
429
429
  if (userId === undefined) return;
430
- sseBroker.publishAccessInvalidation?.(userId);
430
+ sseBroker.publishAccessInvalidation(userId);
431
431
  }
432
432
  },
433
433
  };
@@ -5,6 +5,11 @@
5
5
  // nothing.
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import {
9
+ configurePiiSubjectKms,
10
+ InMemoryKmsAdapter,
11
+ isPiiCiphertext,
12
+ } from "@cosmicdrift/kumiko-framework/crypto";
8
13
  import {
9
14
  asRawClient,
10
15
  buildEntityTable,
@@ -20,6 +25,7 @@ import {
20
25
  TestUsers,
21
26
  unsafeCreateEntityTable,
22
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
23
29
 
24
30
  const widgetEntity = createEntity({
25
31
  table: "read_reindex_widgets",
@@ -35,16 +41,38 @@ const widgetFeature = defineFeature("reindex-test", (r) => {
35
41
  r.entity("widget", widgetEntity);
36
42
  });
37
43
 
44
+ // fw#1611: pii + searchable — the read-table column holds ciphertext, a
45
+ // naive backfill would index blobs (or resurrect a crypto-shredded row).
46
+ const contactEntity = createEntity({
47
+ table: "read_reindex_contacts",
48
+ fields: {
49
+ label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
50
+ },
51
+ });
52
+ const contactTable = buildEntityTable("contact", contactEntity);
53
+ const contactFeature = defineFeature("reindex-pii-test", (r) => {
54
+ r.entity("contact", contactEntity);
55
+ });
56
+
38
57
  let stack: TestStack;
58
+ let kms: InMemoryKmsAdapter;
39
59
  const admin = TestUsers.admin;
40
60
 
41
61
  beforeAll(async () => {
42
- stack = await setupTestStack({ features: [widgetFeature] });
62
+ stack = await setupTestStack({ features: [widgetFeature, contactFeature] });
43
63
  await unsafeCreateEntityTable(stack.db, widgetEntity);
64
+ await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
44
65
  await createEventsTable(stack.db);
66
+ // Shared across both pii tests below (not per-test) — reindexEntity scans
67
+ // the whole tenant table regardless of which test created which row, so a
68
+ // fresh KMS instance per test would make earlier rows' subject keys
69
+ // unresolvable (KeyNotFoundError) instead of exercising the erased path.
70
+ kms = new InMemoryKmsAdapter();
71
+ configurePiiSubjectKms(kms);
45
72
  });
46
73
 
47
74
  afterAll(async () => {
75
+ resetPiiSubjectKmsForTests();
48
76
  await stack.cleanup();
49
77
  });
50
78
 
@@ -141,4 +169,72 @@ describe("reindexEntity", () => {
141
169
  );
142
170
  }
143
171
  });
172
+
173
+ // fw#1611: reindexEntity read the read-table row (ciphertext for pii+
174
+ // searchable fields) straight into the search document, skipping the
175
+ // decrypt step createSearchEventConsumer applies on the live path.
176
+ test("decrypts pii+searchable fields before indexing — backfill is findable by plaintext, not ciphertext", async () => {
177
+ const plain = "UniqueReindexPiiLabel1611";
178
+ const executor = createEventStoreExecutor(contactTable, contactEntity, {
179
+ entityName: "contact",
180
+ });
181
+ const created = await executor.create(
182
+ { label: plain },
183
+ admin,
184
+ createTenantDb(stack.db, admin.tenantId, "system"),
185
+ );
186
+ if (!created.isSuccess) throw new Error("seed failed");
187
+
188
+ // No dispatcher run — row exists only on the read-table, ciphertext.
189
+ const row = (
190
+ await asRawClient(stack.db).unsafe(
191
+ `SELECT label FROM "read_reindex_contacts" WHERE id = $1`,
192
+ [created.data.id],
193
+ )
194
+ )[0] as { label: string };
195
+ expect(isPiiCiphertext(row.label)).toBe(true);
196
+
197
+ const result = await reindexEntity(
198
+ stack.db,
199
+ stack.registry,
200
+ stack.search,
201
+ "contact",
202
+ admin.tenantId,
203
+ );
204
+ expect(result.indexedRows).toBe(1);
205
+ expect(result.failures).toHaveLength(0);
206
+
207
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
208
+ expect(hits.some((h) => String(h.entityId) === String(created.data.id))).toBe(true);
209
+ });
210
+
211
+ test("skips a row whose subject key was already erased — reindex must not resurrect a crypto-shredded row", async () => {
212
+ const plain = "ErasedReindexPiiLabel1611";
213
+ const executor = createEventStoreExecutor(contactTable, contactEntity, {
214
+ entityName: "contact",
215
+ });
216
+ const created = await executor.create(
217
+ { label: plain },
218
+ admin,
219
+ createTenantDb(stack.db, admin.tenantId, "system"),
220
+ );
221
+ if (!created.isSuccess) throw new Error("seed failed");
222
+
223
+ // pii: true → subject key is the entity id itself.
224
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
225
+
226
+ const result = await reindexEntity(
227
+ stack.db,
228
+ stack.registry,
229
+ stack.search,
230
+ "contact",
231
+ admin.tenantId,
232
+ );
233
+ // Shares the tenant/table with the preceding test, so other rows may
234
+ // also index here — what matters is that THIS erased row didn't.
235
+ expect(result.failures).toHaveLength(0);
236
+
237
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
238
+ expect(hits.some((h) => String(h.entityId) === String(created.data.id))).toBe(false);
239
+ });
144
240
  });
@@ -9,7 +9,11 @@ import type { DbRunner } from "../db/connection";
9
9
  import { resolveTableName } from "../db/entity-table-meta";
10
10
  import { executeRawQuery } from "../db/queries/raw-sql";
11
11
  import type { Registry, TenantId } from "../engine/types";
12
- import { buildSearchDocument } from "../pipeline/system-hooks";
12
+ import {
13
+ buildSearchDocument,
14
+ decryptSearchableSubjectFields,
15
+ hasErasedSearchableSubjectField,
16
+ } from "../pipeline/system-hooks";
13
17
  import { toSnakeCase } from "../utils/case";
14
18
  import type { SearchAdapter, SearchDocument } from "./types";
15
19
 
@@ -135,7 +139,16 @@ export async function reindexEntity(
135
139
  result.scannedRows++;
136
140
  const entityId = String(row["id"]);
137
141
  try {
138
- const state = rowToState(row, fieldNames);
142
+ const rawState = rowToState(row, fieldNames);
143
+ // `pii + searchable` fields hold ciphertext on the read-table row —
144
+ // decrypt the same way the live write-path consumer does. An
145
+ // erased subject key doesn't throw, it swaps the value for
146
+ // PII_ERASED_SENTINEL (see decryptSearchableSubjectFields) — check
147
+ // for that AFTER decrypting, mirroring the softDelete filter above:
148
+ // resurrecting a crypto-shredded row here would undo
149
+ // purgeSearchDocumentsForSubject and make it findable again.
150
+ const state = await decryptSearchableSubjectFields(entityName, rawState, registry);
151
+ if (hasErasedSearchableSubjectField(entityName, state, registry)) continue;
139
152
  const doc = await buildSearchDocument(entityName, entityId, state, registry);
140
153
  if (doc) docs.push({ entityId, doc });
141
154
  } catch (e) {