@cosmicdrift/kumiko-framework 0.173.0 → 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.0",
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.0",
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.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -200,6 +200,24 @@ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
200
200
  expect(cap.err.join("\n")).toContain("read_widgets");
201
201
  });
202
202
 
203
+ test("migration file missing a column its snapshot entry claims → column-drift hint, not unexpected-table", async () => {
204
+ writeSchemaFile(appCwd, "read_widgets", "note");
205
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
206
+ writeFileSync(
207
+ join(appCwd, "kumiko/migrations/0001_init.sql"),
208
+ `-- oops: hand-edited to drop the "note" column the snapshot still claims
209
+ CREATE TABLE IF NOT EXISTS "read_widgets" ("id" uuid PRIMARY KEY);
210
+ `,
211
+ );
212
+ const cap = captureOut();
213
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
214
+ expect(code).toBe(1);
215
+ const err = cap.err.join("\n");
216
+ expect(err).toContain("missing columns: note");
217
+ expect(err).toContain("Fix (missing-table/column-drift)");
218
+ expect(err).not.toContain("Fix (unexpected-table)");
219
+ });
220
+
203
221
  test("migration creates a table with no snapshot entry → unexpected-table hint points at r.storeTable, not hand-fix", async () => {
204
222
  writeSchemaFile(appCwd, "read_widgets");
205
223
  await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
@@ -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,15 +19,18 @@ 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 {
28
- // ponytail: in-process only publishAccessInvalidation does not fan out via
29
- // Redis. Multi-replica deployments will not revoke SSE streams on other pods
30
- // (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
32
+ // Cross-replica fanout lives one level up: the SSE + access-invalidation
33
+ // consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
31
34
  const channels = new Map<string, Map<string, SseClient>>();
32
35
  const accessInvalidationListeners = new Map<string, Set<() => void>>();
33
36
 
@@ -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
  // =============================================================================
@@ -38,6 +38,10 @@ describe("splitSqlStatements", () => {
38
38
  expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
39
39
  });
40
40
 
41
+ test("nested block comment also leaves a space at outer depth (#1599)", () => {
42
+ expect(splitSqlStatements("SELECT a/*x/*y*/z*/AS b;")).toEqual(["SELECT a AS b;"]);
43
+ });
44
+
41
45
  test("nested block comments close only at matching depth (Postgres)", () => {
42
46
  expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
43
47
  });
@@ -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
  );
@@ -48,6 +48,40 @@ function isUnchangedValue(a: unknown, b: unknown): boolean {
48
48
  return JSON.stringify(a) === JSON.stringify(b);
49
49
  }
50
50
 
51
+ type PreSaveFn = (
52
+ changes: Record<string, unknown>,
53
+ previous: Record<string, unknown>,
54
+ isNew: boolean,
55
+ ) => Promise<Record<string, unknown>>;
56
+
57
+ // preSave runs before ownership checks: authorization must evaluate the row
58
+ // as it will actually be persisted, including hook-derived fields
59
+ // (kumiko-framework#1672). A throwing hook is an app-author bug (bad
60
+ // business rule, not a framework fault) — map it to a clean writeFailure
61
+ // instead of letting it propagate as an internal_error 500.
62
+ async function runPreSave(
63
+ preSave: PreSaveFn | undefined,
64
+ changes: Record<string, unknown>,
65
+ previous: Record<string, unknown>,
66
+ isNew: boolean,
67
+ entityName: string,
68
+ action: "create" | "update",
69
+ ): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
70
+ if (!preSave) return { data: changes as DbRow };
71
+ try {
72
+ return { data: (await preSave(changes, previous, isNew)) as DbRow };
73
+ } catch (e) {
74
+ return {
75
+ failure: writeFailure(
76
+ new UnprocessableError("presave_hook_failed", {
77
+ i18nKey: "errors.presaveHookFailed",
78
+ details: { entityName, action, message: e instanceof Error ? e.message : String(e) },
79
+ }),
80
+ ),
81
+ };
82
+ }
83
+ }
84
+
51
85
  export function createWriteVerbs(
52
86
  ctx: ExecutorContext,
53
87
  ): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
@@ -74,12 +108,16 @@ export function createWriteVerbs(
74
108
  const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
75
109
  const aggregateId = explicitId ?? generateId();
76
110
  const { id: _id, ...payloadWithoutId } = payload;
77
- // preSave runs before ownership checks: authorization must evaluate the
78
- // row as it will actually be persisted, including hook-derived fields
79
- // (kumiko-framework#1672).
80
- const data = options?.preSave
81
- ? await options.preSave(applyDefaults(payloadWithoutId), {}, true)
82
- : applyDefaults(payloadWithoutId);
111
+ const preSaveResult = await runPreSave(
112
+ options?.preSave,
113
+ applyDefaults(payloadWithoutId),
114
+ {},
115
+ true,
116
+ entityName,
117
+ "create",
118
+ );
119
+ if ("failure" in preSaveResult) return preSaveResult.failure;
120
+ const data = preSaveResult.data;
83
121
 
84
122
  // H.2 — entity-level write-ownership on create. No oldRow exists, so
85
123
  // only the new row is checked. No Straddle concern for creates.
@@ -95,7 +133,20 @@ export function createWriteVerbs(
95
133
  // Field-level write-ownership on create — mirror of entity-level but
96
134
  // per declared field. Role-level was already checked by the
97
135
  // dispatcher; here we enforce ownership-rules against the new row.
98
- 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
+ );
99
150
  if (fieldDeniedCreate) {
100
151
  return writeFailure(
101
152
  new UnprocessableError("ownership_denied", {
@@ -226,12 +277,16 @@ export function createWriteVerbs(
226
277
  const previous = await loadById(payload.id, db);
227
278
  if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
228
279
 
229
- // preSave runs before ownership checks: authorization must evaluate the
230
- // row as it will actually be persisted, including hook-derived fields
231
- // (kumiko-framework#1672).
232
- const changes = updateOptions?.preSave
233
- ? await updateOptions.preSave(payload.changes, previous, false)
234
- : payload.changes;
280
+ const preSaveResult = await runPreSave(
281
+ updateOptions?.preSave,
282
+ payload.changes,
283
+ previous,
284
+ false,
285
+ entityName,
286
+ "update",
287
+ );
288
+ if ("failure" in preSaveResult) return preSaveResult.failure;
289
+ const changes = preSaveResult.data;
235
290
 
236
291
  // H.2 — entity-level write-ownership on update. Load old row (already
237
292
  // done above), build post-change row via shallow merge. Straddle-safe
@@ -259,7 +314,24 @@ export function createWriteVerbs(
259
314
  // `previous`, we can run the ownership rules per field against both
260
315
  // sides and reject individual fields the user isn't entitled to
261
316
  // touch on this specific row.
262
- 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
+ );
263
335
  if (fieldDeniedUpdate) {
264
336
  return writeFailure(
265
337
  new UnprocessableError("ownership_denied", {