@cosmicdrift/kumiko-framework 0.168.0 → 0.171.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.168.0",
3
+ "version": "0.171.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.168.0",
185
+ "@cosmicdrift/kumiko-types": "0.171.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.168.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.171.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -67,14 +67,19 @@ export function createWriteVerbs(
67
67
  } = ctx;
68
68
 
69
69
  return {
70
- async create(payload, user, db) {
70
+ async create(payload, user, db, options) {
71
71
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
72
72
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
73
73
  // event payload so defaults + downstream consumers don't see a redundant id field.
74
74
  const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
75
75
  const aggregateId = explicitId ?? generateId();
76
76
  const { id: _id, ...payloadWithoutId } = payload;
77
- const data = applyDefaults(payloadWithoutId);
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);
78
83
 
79
84
  // H.2 — entity-level write-ownership on create. No oldRow exists, so
80
85
  // only the new row is checked. No Straddle concern for creates.
@@ -221,12 +226,19 @@ export function createWriteVerbs(
221
226
  const previous = await loadById(payload.id, db);
222
227
  if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
223
228
 
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;
235
+
224
236
  // H.2 — entity-level write-ownership on update. Load old row (already
225
237
  // done above), build post-change row via shallow merge. Straddle-safe
226
238
  // multi-role check: at least one role must accept BOTH old and new —
227
239
  // prevents the attack where role A passes old, role B passes new and
228
240
  // aggregation would wrongly allow a row-grab.
229
- const mergedNew: Record<string, unknown> = { ...previous, ...payload.changes };
241
+ const mergedNew: Record<string, unknown> = { ...previous, ...changes };
230
242
  if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
231
243
  return writeFailure(
232
244
  new UnprocessableError("ownership_denied", {
@@ -247,7 +259,7 @@ export function createWriteVerbs(
247
259
  // `previous`, we can run the ownership rules per field against both
248
260
  // sides and reject individual fields the user isn't entitled to
249
261
  // touch on this specific row.
250
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous);
262
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, changes, user, previous);
251
263
  if (fieldDeniedUpdate) {
252
264
  return writeFailure(
253
265
  new UnprocessableError("ownership_denied", {
@@ -303,11 +315,11 @@ export function createWriteVerbs(
303
315
  // ownerField — the merged row still names the subject.
304
316
  const submittedChanges = updateOptions?.skipUnchanged
305
317
  ? Object.fromEntries(
306
- Object.entries(payload.changes).filter(
318
+ Object.entries(changes).filter(
307
319
  ([key, value]) => !isUnchangedValue(value, previous[key]),
308
320
  ),
309
321
  )
310
- : payload.changes;
322
+ : changes;
311
323
  const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
312
324
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
313
325
  onlyKeys: Object.keys(submittedChanges),
@@ -368,7 +380,7 @@ export function createWriteVerbs(
368
380
  kind: "save",
369
381
  id: data["id"] as EntityId, // @cast-boundary engine-payload
370
382
  data,
371
- changes: payload.changes,
383
+ changes,
372
384
  previous,
373
385
  isNew: false,
374
386
  entityName,
@@ -271,6 +271,42 @@ describe("validateBoot — PII annotations", () => {
271
271
  expect(matchingWarn).toBeDefined();
272
272
  });
273
273
 
274
+ test("user-reference-name heuristic warns when authorId field has no subjectRef annotation", () => {
275
+ const feature = defineFeature("test", (r) => {
276
+ r.entity(
277
+ "thing",
278
+ createEntity({
279
+ fields: {
280
+ authorId: createTextField(),
281
+ },
282
+ }),
283
+ );
284
+ });
285
+ validateBoot([feature]);
286
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
287
+ String(args[0]).includes("user-reference-typical name"),
288
+ );
289
+ expect(matchingWarn).toBeDefined();
290
+ });
291
+
292
+ test("subjectRef: true on authorId field silences user-reference-name heuristic warning", () => {
293
+ const feature = defineFeature("test", (r) => {
294
+ r.entity(
295
+ "thing",
296
+ createEntity({
297
+ fields: {
298
+ authorId: createTextField({ subjectRef: true }),
299
+ },
300
+ }),
301
+ );
302
+ });
303
+ validateBoot([feature]);
304
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
305
+ String(args[0]).includes("user-reference-typical name"),
306
+ );
307
+ expect(matchingWarn).toBeUndefined();
308
+ });
309
+
274
310
  test("allowPlaintext marker silences PII-name heuristic warning", () => {
275
311
  const feature = defineFeature("test", (r) => {
276
312
  r.entity(
@@ -2820,4 +2820,204 @@ describe("boot-validator — config key backing × scope", () => {
2820
2820
  });
2821
2821
  expect(() => validateBoot([feature])).not.toThrow();
2822
2822
  });
2823
+
2824
+ test("navigate with params targeting a dashboard screen → Throw", () => {
2825
+ const feature = defineFeature("shop", (r) => {
2826
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2827
+ r.screen({
2828
+ id: "product-list",
2829
+ type: "entityList",
2830
+ entity: "product",
2831
+ columns: ["name"],
2832
+ rowActions: [
2833
+ {
2834
+ kind: "navigate",
2835
+ id: "view",
2836
+ label: "actions.view",
2837
+ screen: "product-dashboard",
2838
+ params: { pick: ["name"] },
2839
+ },
2840
+ ],
2841
+ });
2842
+ r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
2843
+ access: { openToAll: true },
2844
+ });
2845
+ r.screen({
2846
+ id: "product-dashboard",
2847
+ type: "dashboard",
2848
+ panels: [
2849
+ {
2850
+ kind: "stat",
2851
+ id: "count",
2852
+ label: "Products",
2853
+ query: "shop:query:count",
2854
+ valueField: "total",
2855
+ },
2856
+ ],
2857
+ });
2858
+ });
2859
+ expect(() => validateBoot([feature])).toThrow(
2860
+ /sets params on navigate-target "product-dashboard".*screen type "dashboard".*only actionForm and entityEdit-create/,
2861
+ );
2862
+ });
2863
+
2864
+ test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
2865
+ const feature = defineFeature("shop", (r) => {
2866
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2867
+ r.screen({
2868
+ id: "product-list",
2869
+ type: "entityList",
2870
+ entity: "product",
2871
+ columns: ["name"],
2872
+ rowActions: [
2873
+ {
2874
+ kind: "navigate",
2875
+ id: "view",
2876
+ label: "actions.view",
2877
+ screen: "product-dashboard",
2878
+ params: { pick: ["name"] },
2879
+ },
2880
+ ],
2881
+ });
2882
+ r.screen({
2883
+ id: "product-dashboard",
2884
+ type: "custom",
2885
+ renderer: { react: "stub" },
2886
+ });
2887
+ });
2888
+ expect(() => validateBoot([feature])).not.toThrow();
2889
+ });
2890
+
2891
+ test("navigate with params targeting a same-entity entityEdit screen → Throw (resolves to update mode)", () => {
2892
+ // No explicit entityId + same entity as the source list → the renderer's
2893
+ // runNavigate() auto-fills row["id"], landing in EntityEditUpdateBody,
2894
+ // which never reads searchParams. Params would silently no-op.
2895
+ const feature = defineFeature("shop", (r) => {
2896
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2897
+ r.screen({
2898
+ id: "product-list",
2899
+ type: "entityList",
2900
+ entity: "product",
2901
+ columns: ["name"],
2902
+ rowActions: [
2903
+ {
2904
+ kind: "navigate",
2905
+ id: "open",
2906
+ label: "actions.open",
2907
+ screen: "product-edit",
2908
+ params: { pick: ["name"] },
2909
+ },
2910
+ ],
2911
+ });
2912
+ r.screen({
2913
+ id: "product-edit",
2914
+ type: "entityEdit",
2915
+ entity: "product",
2916
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
2917
+ });
2918
+ });
2919
+ expect(() => validateBoot([feature])).toThrow(
2920
+ /resolves to UPDATE mode.*same entity "product" auto-fills row\["id"\]/,
2921
+ );
2922
+ });
2923
+
2924
+ test("navigate with params + explicit entityId targeting an entityEdit screen → Throw (forced update mode)", () => {
2925
+ const feature = defineFeature("housing", (r) => {
2926
+ r.entity("unit", createEntity({ fields: { name: createTextField() } }));
2927
+ r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
2928
+ r.screen({
2929
+ id: "unit-list",
2930
+ type: "entityList",
2931
+ entity: "unit",
2932
+ columns: ["name"],
2933
+ rowActions: [
2934
+ {
2935
+ kind: "navigate",
2936
+ id: "edit-contract",
2937
+ label: "actions.edit-contract",
2938
+ screen: "contract-edit",
2939
+ entityId: "name",
2940
+ params: { pick: ["name"] },
2941
+ },
2942
+ ],
2943
+ });
2944
+ r.screen({
2945
+ id: "contract-edit",
2946
+ type: "entityEdit",
2947
+ entity: "contract",
2948
+ layout: { sections: [{ columns: 1, fields: ["unitId"] }] },
2949
+ });
2950
+ });
2951
+ expect(() => validateBoot([feature])).toThrow(
2952
+ /resolves to UPDATE mode \(explicit entityId "name"\)/,
2953
+ );
2954
+ });
2955
+
2956
+ test("navigate with params targeting a cross-entity entityEdit screen (no explicit entityId) → no throw", () => {
2957
+ // The issue's actual use case: a unit row navigates to "create contract"
2958
+ // pre-filled with unitId. Different entity + no explicit entityId → the
2959
+ // renderer's same-entity row["id"] fallback does NOT fire, so this stays
2960
+ // in create mode and EntityEditCreateBody reads the params.
2961
+ const feature = defineFeature("housing", (r) => {
2962
+ r.entity("unit", createEntity({ fields: { name: createTextField() } }));
2963
+ r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
2964
+ r.screen({
2965
+ id: "unit-list",
2966
+ type: "entityList",
2967
+ entity: "unit",
2968
+ columns: ["name"],
2969
+ rowActions: [
2970
+ {
2971
+ kind: "navigate",
2972
+ id: "create-contract",
2973
+ label: "actions.create-contract",
2974
+ screen: "contract-edit",
2975
+ params: { map: { unitId: "name" } },
2976
+ },
2977
+ ],
2978
+ });
2979
+ r.screen({
2980
+ id: "contract-edit",
2981
+ type: "entityEdit",
2982
+ entity: "contract",
2983
+ layout: { sections: [{ columns: 1, fields: ["unitId"] }] },
2984
+ });
2985
+ });
2986
+ expect(() => validateBoot([feature])).not.toThrow();
2987
+ });
2988
+
2989
+ test("navigate with params targeting an actionForm screen → no throw", () => {
2990
+ const feature = defineFeature("shop", (r) => {
2991
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2992
+ r.writeHandler(
2993
+ "create",
2994
+ z.object({ name: z.string() }),
2995
+ async () => ({ isSuccess: true as const, data: { id: "1" } }),
2996
+ { access: { openToAll: true } },
2997
+ );
2998
+ r.screen({
2999
+ id: "product-list",
3000
+ type: "entityList",
3001
+ entity: "product",
3002
+ columns: ["name"],
3003
+ rowActions: [
3004
+ {
3005
+ kind: "navigate",
3006
+ id: "open-form",
3007
+ label: "actions.form",
3008
+ screen: "product-form",
3009
+ params: { pick: ["name"] },
3010
+ },
3011
+ ],
3012
+ });
3013
+ r.screen({
3014
+ id: "product-form",
3015
+ type: "actionForm",
3016
+ handler: "shop:write:create",
3017
+ fields: { name: createTextField() },
3018
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
3019
+ });
3020
+ });
3021
+ expect(() => validateBoot([feature])).not.toThrow();
3022
+ });
2823
3023
  });
@@ -0,0 +1,97 @@
1
+ // Regression coverage for kumiko-framework#1672 — preSave hooks were
2
+ // registered and boot-validated but never invoked by the dispatch path,
3
+ // making `r.hook("preSave", ...)` a silent no-op. This exercises the real
4
+ // HTTP dispatcher (not a hand-fed handler context) so the fix is proven at
5
+ // the layer app authors actually depend on.
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "../../db/query";
8
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
9
+ import { defineFeature } from "../define-feature";
10
+ import { createEntity, createTextField } from "../factories";
11
+
12
+ const contactEntity = createEntity({
13
+ table: "presave_wiring_contacts",
14
+ fields: {
15
+ firstName: createTextField({ required: true }),
16
+ lastName: createTextField({ required: true }),
17
+ displayName: createTextField(),
18
+ },
19
+ });
20
+
21
+ const seenIsNew: boolean[] = [];
22
+
23
+ const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx) => {
24
+ seenIsNew.push(ctx.isNew);
25
+ const first =
26
+ (changes["firstName"] as string | undefined) ??
27
+ (ctx.previous["firstName"] as string | undefined);
28
+ const last =
29
+ (changes["lastName"] as string | undefined) ?? (ctx.previous["lastName"] as string | undefined);
30
+ return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
31
+ };
32
+
33
+ const contactFeature = defineFeature("presave-wiring", (r) => {
34
+ r.crud("contact", contactEntity, {
35
+ write: { access: { roles: ["User"] } },
36
+ read: { access: { openToAll: true } },
37
+ });
38
+
39
+ // preSave has no entity-wide `{ allOf }` shorthand (unlike postSave/
40
+ // preDelete/postDelete) — r.crud registers separate create/update
41
+ // handlers, so both need their own target.
42
+ r.hook("preSave", "contact:create", deriveDisplayName);
43
+ r.hook("preSave", "contact:update", deriveDisplayName);
44
+ });
45
+
46
+ const CREATE = "presave-wiring:write:contact:create";
47
+ const UPDATE = "presave-wiring:write:contact:update";
48
+
49
+ describe("preSave hooks — real dispatcher path (#1672)", () => {
50
+ let stack: TestStack;
51
+
52
+ beforeAll(async () => {
53
+ stack = await setupTestStack({ features: [contactFeature] });
54
+ await unsafeCreateEntityTable(stack.db, contactEntity);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await stack.cleanup();
59
+ });
60
+
61
+ beforeEach(async () => {
62
+ seenIsNew.length = 0;
63
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
64
+ await asRawClient(stack.db).unsafe('DELETE FROM "presave_wiring_contacts"');
65
+ });
66
+
67
+ test("create: preSave hook derives displayName before persistence", async () => {
68
+ const res = await stack.http.write(
69
+ CREATE,
70
+ { firstName: "Marc", lastName: "Ristone" },
71
+ TestUsers.user,
72
+ );
73
+ expect(res.status).toBe(200);
74
+ const { data } = (await res.json()) as { data: { data: { displayName: string } } };
75
+ expect(data.data.displayName).toBe("Marc Ristone");
76
+ expect(seenIsNew).toEqual([true]);
77
+ });
78
+
79
+ test("update: preSave hook sees previous row and re-derives displayName", async () => {
80
+ const created = await stack.http.write(
81
+ CREATE,
82
+ { firstName: "Marc", lastName: "Ristone" },
83
+ TestUsers.user,
84
+ );
85
+ const { data } = (await created.json()) as { data: { data: { id: string; version: number } } };
86
+
87
+ const res = await stack.http.write(
88
+ UPDATE,
89
+ { id: data.data.id, version: data.data.version, changes: { lastName: "Kumiko" } },
90
+ TestUsers.user,
91
+ );
92
+ expect(res.status).toBe(200);
93
+ const { data: updated } = (await res.json()) as { data: { data: { displayName: string } } };
94
+ expect(updated.data.displayName).toBe("Marc Kumiko");
95
+ expect(seenIsNew).toEqual([true, false]);
96
+ });
97
+ });
@@ -384,6 +384,41 @@ describe("buildInsertSchema", () => {
384
384
  schema.safeParse({ pickup: { at: "2026-04-03T10:00:00", tz: "Mars/Phobos" } }).success,
385
385
  ).toBe(false);
386
386
  });
387
+
388
+ // #1674: an untouched HTML <select> submits "" for its placeholder option.
389
+ // An optional select without a default must treat "" as "not set" (null),
390
+ // not reject it as an invalid enum value.
391
+ test("optional select without default accepts empty string as unset (null)", () => {
392
+ const entity = createEntity({
393
+ table: "Test",
394
+ fields: { locale: createSelectField({ options: ["de", "en", "fr"] as const }) },
395
+ });
396
+ const schema = buildInsertSchema(entity);
397
+ const result = schema.safeParse({ locale: "" });
398
+ expect(result.success).toBe(true);
399
+ if (result.success) {
400
+ expect(result.data["locale"]).toBeNull();
401
+ }
402
+ });
403
+
404
+ test("optional select without default still validates a real value", () => {
405
+ const entity = createEntity({
406
+ table: "Test",
407
+ fields: { locale: createSelectField({ options: ["de", "en", "fr"] as const }) },
408
+ });
409
+ const schema = buildInsertSchema(entity);
410
+ expect(schema.safeParse({ locale: "en" }).success).toBe(true);
411
+ expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
412
+ });
413
+
414
+ test("required select rejects empty string", () => {
415
+ const entity = createEntity({
416
+ table: "Test",
417
+ fields: { locale: createSelectField({ options: ["de", "en"] as const, required: true }) },
418
+ });
419
+ const schema = buildInsertSchema(entity);
420
+ expect(schema.safeParse({ locale: "" }).success).toBe(false);
421
+ });
387
422
  });
388
423
 
389
424
  // --- Update schema (all partial) ---
@@ -422,4 +457,36 @@ describe("buildUpdateSchema", () => {
422
457
  expect(schema.safeParse({ email: "valid@test.de" }).success).toBe(true);
423
458
  expect(schema.safeParse({ email: "not-email" }).success).toBe(false);
424
459
  });
460
+
461
+ // #1674: clearing a previously-set optional select back to "unset" must
462
+ // work through the update path too — "" from an untouched <select> maps to
463
+ // an explicit `null`, not `undefined` (which would be dropped from the
464
+ // changes payload and silently no-op instead of clearing).
465
+ test("optional select accepts empty string as an explicit clear-to-null", () => {
466
+ const entity = createEntity({
467
+ table: "Test",
468
+ fields: { locale: createSelectField({ options: ["de", "en"] as const }) },
469
+ });
470
+
471
+ const schema = buildUpdateSchema(entity);
472
+ const result = schema.safeParse({ locale: "" });
473
+ expect(result.success).toBe(true);
474
+ if (result.success) {
475
+ expect(result.data["locale"]).toBeNull();
476
+ }
477
+ });
478
+
479
+ test("omitting an optional select on update leaves it untouched", () => {
480
+ const entity = createEntity({
481
+ table: "Test",
482
+ fields: { locale: createSelectField({ options: ["de", "en"] as const }) },
483
+ });
484
+
485
+ const schema = buildUpdateSchema(entity);
486
+ const result = schema.safeParse({});
487
+ expect(result.success).toBe(true);
488
+ if (result.success) {
489
+ expect(Object.hasOwn(result.data, "locale")).toBe(false);
490
+ }
491
+ });
425
492
  });
@@ -53,6 +53,27 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
53
53
  "notes",
54
54
  ]);
55
55
 
56
+ // Field names that typically hold a FK into the `user` entity — carry no
57
+ // content of their own, but the subject-data obligation attaches to the
58
+ // link, not to annotated content. Without this hint, an entity with no
59
+ // userOwned-content field stays invisible to the GDPR-hook-coverage guard
60
+ // even though `authorId` is still personal data.
61
+ export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
62
+ "authorid",
63
+ "assigneeid",
64
+ "ownerid",
65
+ "createdbyid",
66
+ "createdbyuserid",
67
+ "updatedbyid",
68
+ "updatedbyuserid",
69
+ "invitedby",
70
+ "approvedby",
71
+ "reviewedby",
72
+ "uploadedby",
73
+ "assignedto",
74
+ "reportedby",
75
+ ]);
76
+
56
77
  // --- Extension preSave wiring validation ---
57
78
 
58
79
  /** Extensions with preSave must target an entity that has mapped write handlers. */
@@ -1,6 +1,10 @@
1
1
  import type { FeatureDefinition } from "../types";
2
2
  import type { FieldAccess, PiiAnnotations } from "../types/fields";
3
- import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS } from "./entity-handler";
3
+ import {
4
+ PII_DIRECT_NAME_HINTS,
5
+ PII_USER_OWNED_NAME_HINTS,
6
+ PII_USER_REFERENCE_NAME_HINTS,
7
+ } from "./entity-handler";
4
8
 
5
9
  // Framework-managed Timestamp-Spalten — dürfen als retention.reference
6
10
  // genutzt werden auch wenn nicht in entity.fields deklariert.
@@ -208,6 +212,11 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
208
212
  console.warn(
209
213
  `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no { userOwned } annotation. If this contains user-generated content, mark it { userOwned: { ownerField: "<authorIdField>" }}. If business data, set { allowPlaintext: "..." } to silence.`,
210
214
  );
215
+ } else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
216
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
217
+ console.warn(
218
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true }, or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
219
+ );
211
220
  }
212
221
  }
213
222
  }
@@ -575,6 +575,48 @@ export function validateScreens(
575
575
  `the row field that names the target entity's id.`,
576
576
  );
577
577
  }
578
+ // params only have a reader in actionForm and entityEdit-CREATE;
579
+ // on projectionDetail/dashboard/configEdit/entityEdit-update they
580
+ // are silently ignored at runtime. `custom` is deliberately
581
+ // exempt: it renders an app-registered component the framework
582
+ // has no visibility into — the author may read nav.searchParams
583
+ // directly (real example: publicstatus's MonitorDetailScreen
584
+ // does exactly that), so flagging it would be a false positive
585
+ // on working code, not a caught bug.
586
+ //
587
+ // Whether an entityEdit target lands in create or update mode is
588
+ // decided by the same rule the renderer's runNavigate() uses: an
589
+ // explicit entityId always forces update mode, and (absent an
590
+ // explicit entityId) a same-entity target gets row["id"] auto-
591
+ // injected — only a cross-entity target with no explicit
592
+ // entityId reaches create.
593
+ if (
594
+ action.params !== undefined &&
595
+ target !== undefined &&
596
+ target.screen.type !== "custom"
597
+ ) {
598
+ const isEntityEditUpdate =
599
+ target.screen.type === "entityEdit" &&
600
+ (action.entityId !== undefined || target.screen.entity === screen.entity);
601
+ if (
602
+ (target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
603
+ isEntityEditUpdate
604
+ ) {
605
+ const reason = isEntityEditUpdate
606
+ ? `resolves to UPDATE mode (${
607
+ action.entityId !== undefined
608
+ ? `explicit entityId "${action.entityId}"`
609
+ : `same entity "${screen.entity}" auto-fills row["id"]`
610
+ })`
611
+ : `screen type "${target.screen.type}"`;
612
+ throw new Error(
613
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
614
+ `sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
615
+ `and entityEdit-create targets read URL search params as initial values. Remove the ` +
616
+ `params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
617
+ );
618
+ }
619
+ }
578
620
  } else {
579
621
  if (!allWriteHandlerQns.has(action.handler)) {
580
622
  throw new Error(
@@ -182,7 +182,14 @@ export function defineEntityWriteHandler(
182
182
  switch (verb) {
183
183
  case "create":
184
184
  schema = buildInsertSchema(entity);
185
- handler = async (event, ctx) => executor.create(event.payload as DbRow, event.user, ctx.db);
185
+ handler = async (event, ctx) => {
186
+ const { runPreSave } = ctx;
187
+ return executor.create(event.payload as DbRow, event.user, ctx.db, {
188
+ preSave:
189
+ runPreSave &&
190
+ ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
191
+ });
192
+ };
186
193
  break;
187
194
  case "update":
188
195
  schema = z.object({
@@ -190,16 +197,21 @@ export function defineEntityWriteHandler(
190
197
  version: z.number(),
191
198
  changes: buildUpdateSchema(entity),
192
199
  });
193
- handler = async (event, ctx) =>
200
+ handler = async (event, ctx) => {
201
+ const { runPreSave } = ctx;
194
202
  // skipUnchanged (#464): API-driven updates diff against the stored
195
203
  // row so a resubmitted-but-identical field doesn't force a fresh
196
204
  // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
197
205
  // KEK-rotation, the user-data-rights #494 backfill) don't go through
198
206
  // this handler and keep today's always-re-encrypt behavior, which
199
207
  // they rely on to intentionally force a fresh event/ciphertext.
200
- executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
208
+ return executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
201
209
  skipUnchanged: true,
210
+ preSave:
211
+ runPreSave &&
212
+ ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
202
213
  }); // @cast-boundary engine-payload
214
+ };
203
215
  break;
204
216
  case "delete":
205
217
  schema = idSchema;
@@ -202,7 +202,12 @@ export {
202
202
  stripForbiddenMembershipRoles,
203
203
  } from "./membership-roles";
204
204
  export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
205
- export { from } from "./ownership";
205
+ export {
206
+ buildOwnershipClause,
207
+ from,
208
+ userCanReadFieldRow,
209
+ userCanWriteFieldRow,
210
+ } from "./ownership";
206
211
  export { buildPipelineSteps, stepsPipeline } from "./pipeline";
207
212
  export { defineApply, defineMspApply, setFields } from "./projection-helpers";
208
213
  export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
@@ -80,8 +80,15 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
80
80
  case "select": {
81
81
  const [first, ...rest] = field.options;
82
82
  if (!first) return z.string();
83
- const schema = z.enum([first, ...rest]);
84
- return field.default !== undefined ? schema.default(field.default) : schema;
83
+ const enumSchema = z.enum([first, ...rest]);
84
+ if (field.default !== undefined) return enumSchema.default(field.default);
85
+ if (field.required) return enumSchema;
86
+ // Optional select without a default: an untouched HTML <select> submits
87
+ // "" for its placeholder option. Treat that as "unset" (null) instead of
88
+ // an invalid enum value — null (not undefined) so the value survives the
89
+ // JSON-serialized event payload and an update can actually clear a
90
+ // previously-set select back to unset, not just skip validation.
91
+ return z.preprocess((value) => (value === "" ? null : value), enumSchema.nullable());
85
92
  }
86
93
  case "multiSelect": {
87
94
  const [first, ...rest] = field.options;
@@ -154,7 +154,7 @@ export async function buildHandlerContext(
154
154
  afterCommitHooks?: AfterCommitHook[],
155
155
  includeDeleted?: boolean,
156
156
  ): Promise<HandlerContext> {
157
- const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
157
+ const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
158
158
  const isSystem = registry.isHandlerSystemScoped(type);
159
159
  // The outer dispatcher receives a DbConnection from the server/stack;
160
160
  // AppContext's `db` union also allows TenantDb (for downstream hook calls),
@@ -537,6 +537,18 @@ export async function buildHandlerContext(
537
537
  notify,
538
538
  ...(config && { config }),
539
539
  ...(files && { files }),
540
+ // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
541
+ // a handler actually starts building its write — bound here so entity
542
+ // CRUD handlers (entity-handlers.ts) can forward it to the executor
543
+ // (kumiko-framework#1672).
544
+ ...(lifecycle && {
545
+ runPreSave: (
546
+ handlerName: string,
547
+ changes: Record<string, unknown>,
548
+ previous: Readonly<Record<string, unknown>>,
549
+ isNew: boolean,
550
+ ) => lifecycle.runPreSave(handlerName, changes, previous, isNew, context),
551
+ }),
540
552
  tracer,
541
553
  metrics,
542
554
  tz,
@@ -62,12 +62,15 @@ function ensureEntityListRowNavigation(
62
62
  ...screen,
63
63
  rowActions: [
64
64
  ...(screen.rowActions ?? []),
65
+ // No `params` here: this same-entity target always resolves to
66
+ // UPDATE mode (the renderer auto-fills entityId from row["id"]),
67
+ // and entityEdit-update never reads search-params as initial
68
+ // values — the boot-validator now rejects that combination (#1680).
65
69
  {
66
70
  kind: "navigate",
67
71
  id: "edit",
68
72
  label: "stub:edit",
69
73
  screen: editScreen.id,
70
- params: { pick: ["id"] },
71
74
  },
72
75
  ],
73
76
  };
@@ -72,6 +72,7 @@ export type {
72
72
  EntityListScreenDefinition,
73
73
  FieldCondition,
74
74
  FieldRenderer,
75
+ FormWidth,
75
76
  ListColumnSpec,
76
77
  PlatformComponent,
77
78
  ProjectionDetailScreenDefinition,