@cosmicdrift/kumiko-framework 0.173.1 → 0.174.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +11 -2
  3. package/src/api/__tests__/request-id-middleware.test.ts +73 -0
  4. package/src/api/request-id-middleware.ts +13 -2
  5. package/src/api/sse-broker.ts +7 -3
  6. package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
  7. package/src/crypto/ciphertext-pattern.ts +19 -0
  8. package/src/crypto/kms-wiring.ts +5 -0
  9. package/src/db/__tests__/eagerload.integration.test.ts +119 -1
  10. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +45 -0
  11. package/src/db/__tests__/migrate-runner.test.ts +16 -0
  12. package/src/db/blind-index-cleanup.ts +15 -24
  13. package/src/db/eagerload.ts +75 -9
  14. package/src/db/entity-table-meta.ts +6 -1
  15. package/src/db/event-store-executor-write.ts +38 -3
  16. package/src/db/migrate-runner.ts +5 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
  18. package/src/engine/__tests__/boot-validator.test.ts +51 -1
  19. package/src/engine/__tests__/ownership.test.ts +23 -0
  20. package/src/engine/__tests__/schema-builder.test.ts +76 -6
  21. package/src/engine/boot-validator/access-roles.ts +63 -21
  22. package/src/engine/boot-validator/entity-handler.ts +4 -0
  23. package/src/engine/boot-validator/index.ts +17 -2
  24. package/src/engine/boot-validator/pii-retention.ts +17 -10
  25. package/src/engine/boot-validator/screens.ts +10 -2
  26. package/src/engine/boot-validator.ts +1 -0
  27. package/src/engine/create-app.ts +4 -2
  28. package/src/engine/field-access.ts +17 -3
  29. package/src/engine/index.ts +2 -0
  30. package/src/engine/ownership.ts +19 -0
  31. package/src/engine/schema-builder.ts +46 -22
  32. package/src/entrypoint/index.ts +2 -5
  33. package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
  34. package/src/jobs/job-runner.ts +7 -1
  35. package/src/pipeline/dispatch-shared.ts +11 -3
  36. package/src/pipeline/dispatch-stream.ts +3 -6
  37. package/src/pipeline/system-hooks.ts +20 -7
  38. package/src/schema-cli.ts +7 -5
  39. package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
  40. package/src/search/purge-subject.ts +2 -9
  41. package/src/search/reindex-entity.ts +15 -2
  42. package/src/secrets/derive-purpose-secret.ts +6 -16
  43. package/src/testing/__tests__/e2e-generator.test.ts +7 -0
  44. package/src/testing/__tests__/wait-for.test.ts +2 -2
  45. package/src/testing/e2e-generator.ts +5 -0
  46. package/src/testing/shared-entities.ts +3 -3
@@ -247,7 +247,7 @@ export function deriveEntityTableMeta(
247
247
  const tableName = resolveTableName(entityName, entity, options?.featureName);
248
248
  const source = options?.source ?? "managed";
249
249
  if (source === "unmanaged") {
250
- assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
250
+ assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
251
251
  }
252
252
  const idType = entity.idType ?? "uuid";
253
253
 
@@ -424,6 +424,11 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
424
424
  /**
425
425
  * Hand-built EntityTableMeta for direct-write stores (no entity base columns).
426
426
  * Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
427
+ *
428
+ * Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
429
+ * no softDelete — the app author owns tenant-scoping and retention for this
430
+ * table. Justify WHY in the call site; reviewers should scrutinize every new
431
+ * unmanaged table.
427
432
  */
428
433
  export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
429
434
  assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
@@ -69,7 +69,12 @@ async function runPreSave(
69
69
  ): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
70
70
  if (!preSave) return { data: changes as DbRow };
71
71
  try {
72
- return { data: (await preSave(changes, previous, isNew)) as DbRow };
72
+ const hookResult = await preSave(changes, previous, isNew);
73
+ // A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
74
+ // must not leak them into the persisted row — aggregateId already comes
75
+ // from generateId()/the loaded row, not from hook output (fw#1685).
76
+ const { id: _hookId, version: _hookVersion, ...safe } = hookResult as Record<string, unknown>;
77
+ return { data: safe as DbRow };
73
78
  } catch (e) {
74
79
  return {
75
80
  failure: writeFailure(
@@ -133,7 +138,20 @@ export function createWriteVerbs(
133
138
  // Field-level write-ownership on create — mirror of entity-level but
134
139
  // per declared field. Role-level was already checked by the
135
140
  // dispatcher; here we enforce ownership-rules against the new row.
136
- const fieldDeniedCreate = checkWriteFieldOwnership(entity, data, user);
141
+ //
142
+ // Which fields get checked is scoped to the pre-hook payload (fw#1685)
143
+ // — a hook-derived field the user never submitted must not be
144
+ // field-ownership-checked against the user. The rule for a checked
145
+ // field is still evaluated against the full post-hook row (`data`) —
146
+ // an ownership rule can reference a column only a hook populates
147
+ // (kumiko-framework#1672).
148
+ const fieldDeniedCreate = checkWriteFieldOwnership(
149
+ entity,
150
+ applyDefaults(payloadWithoutId),
151
+ user,
152
+ undefined,
153
+ data,
154
+ );
137
155
  if (fieldDeniedCreate) {
138
156
  return writeFailure(
139
157
  new UnprocessableError("ownership_denied", {
@@ -301,7 +319,24 @@ export function createWriteVerbs(
301
319
  // `previous`, we can run the ownership rules per field against both
302
320
  // sides and reject individual fields the user isn't entitled to
303
321
  // touch on this specific row.
304
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, changes, user, previous);
322
+ //
323
+ // Which fields get checked is scoped to `payload.changes` (the user's
324
+ // actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
325
+ // — a hook-derived field the user never submitted (e.g. a system hook
326
+ // setting `assignedTo`) has no business being field-ownership-checked
327
+ // against the *user*, and doing so rejects writes the user is fully
328
+ // entitled to make. The dispatcher's role-gate (checkWriteFieldAccess)
329
+ // already runs on this same pre-hook payload for consistency. The rule
330
+ // for a checked field is still evaluated against the full post-hook
331
+ // row (`changes` merged onto `previous`) — an ownership rule can
332
+ // reference a column only a hook populates (kumiko-framework#1672).
333
+ const fieldDeniedUpdate = checkWriteFieldOwnership(
334
+ entity,
335
+ payload.changes,
336
+ user,
337
+ previous,
338
+ changes,
339
+ );
305
340
  if (fieldDeniedUpdate) {
306
341
  return writeFailure(
307
342
  new UnprocessableError("ownership_denied", {
@@ -160,6 +160,11 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
160
160
  current += ch;
161
161
  continue;
162
162
  }
163
+ if (ch === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
164
+ throw new Error(
165
+ "splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split",
166
+ );
167
+ }
163
168
  if (ch === ";") {
164
169
  statements.push(current);
165
170
  current = "";
@@ -626,6 +626,25 @@ describe("validateBoot — retention", () => {
626
626
  expect(matchingWarn).toBeUndefined();
627
627
  });
628
628
 
629
+ test("blockDelete with only a subjectRef-only field and no anonymize warns (#1645)", () => {
630
+ const feature = defineFeature("test", (r) => {
631
+ r.entity(
632
+ "lease",
633
+ createEntity({
634
+ fields: {
635
+ authorId: createTextField({ subjectRef: true }),
636
+ },
637
+ retention: { keepFor: "10y", strategy: "blockDelete" },
638
+ }),
639
+ );
640
+ });
641
+ validateBoot([feature]);
642
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
643
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
644
+ );
645
+ expect(matchingWarn).toBeDefined();
646
+ });
647
+
629
648
  test('retention.keepFor with invalid format "30days" warns', () => {
630
649
  const feature = defineFeature("test", (r) => {
631
650
  r.entity(
@@ -600,7 +600,7 @@ describe("boot-validator", () => {
600
600
  });
601
601
  }),
602
602
  ];
603
- validateBoot(features);
603
+ validateBootRaw(withBootValidatorFixture(features), { warnOnUniqueAccessRoles: true });
604
604
  // Not toHaveBeenCalledTimes(1): this file's tests share the process-global
605
605
  // console.warn and run with the default concurrency (bunfig.toml) — other
606
606
  // concurrently-running tests' own "role used by one handler" warnings can
@@ -615,6 +615,27 @@ describe("boot-validator", () => {
615
615
  }
616
616
  });
617
617
 
618
+ test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
619
+ const warnSpy = spyOn(console, "warn");
620
+ try {
621
+ const features = [
622
+ defineFeature("b", (r) => {
623
+ r.queryHandler("list", z.object({}), async () => [], {
624
+ access: { roles: ["OnlyThereRole"] },
625
+ });
626
+ }),
627
+ ];
628
+ validateBoot(features);
629
+ expect(
630
+ warnSpy.mock.calls.some((call) =>
631
+ (call[0] as string | undefined)?.includes("OnlyThereRole"),
632
+ ),
633
+ ).toBe(false);
634
+ } finally {
635
+ warnSpy.mockRestore();
636
+ }
637
+ });
638
+
618
639
  test("throws when a stream handler has no access rule", () => {
619
640
  const features = [
620
641
  defineFeature("a", (r) => {
@@ -2886,6 +2907,35 @@ describe("boot-validator — config key backing × scope", () => {
2886
2907
  );
2887
2908
  });
2888
2909
 
2910
+ test("navigate with params targeting a cross-entity entityList screen → no throw (list screens read URL search params for filter-prefill, fw#1708)", () => {
2911
+ const feature = defineFeature("housing", (r) => {
2912
+ r.entity("unit", createEntity({ fields: { id: createTextField() } }));
2913
+ r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
2914
+ r.screen({
2915
+ id: "unit-list",
2916
+ type: "entityList",
2917
+ entity: "unit",
2918
+ columns: ["id"],
2919
+ rowActions: [
2920
+ {
2921
+ kind: "navigate",
2922
+ id: "view-contracts",
2923
+ label: "actions.viewContracts",
2924
+ screen: "contract-list",
2925
+ params: { map: { "housing:contract-list.f.unitId": "id" } },
2926
+ },
2927
+ ],
2928
+ });
2929
+ r.screen({
2930
+ id: "contract-list",
2931
+ type: "entityList",
2932
+ entity: "contract",
2933
+ columns: ["unitId"],
2934
+ });
2935
+ });
2936
+ expect(() => validateBoot([feature])).not.toThrow();
2937
+ });
2938
+
2889
2939
  test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
2890
2940
  const feature = defineFeature("shop", (r) => {
2891
2941
  r.entity("product", createEntity({ fields: { name: createTextField() } }));
@@ -195,6 +195,29 @@ describe("userCanReadFieldRow() — multi-role OR", () => {
195
195
  // row with mismatched teamId — TeamMember would fail, Admin passes
196
196
  expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
197
197
  });
198
+
199
+ // fw#1700: matchesRule() throws on a where-rule (SQL-layer only, can't
200
+ // evaluate in-memory). Field-level access is boot-validator-rejected for
201
+ // where-rules, but this function is also reachable from hand-rolled
202
+ // entity-level reads — a role backed by a where-rule must fail closed
203
+ // (deny, no throw) instead of crashing the caller with an uncaught 500.
204
+ test("role backed by a where-rule → fails closed (deny), does not throw", () => {
205
+ const whereMap: OwnershipMap = {
206
+ Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
207
+ };
208
+ const user = mkUser({ roles: ["Support"] });
209
+ expect(() => userCanReadFieldRow(user, whereMap, { teamId: "ops" })).not.toThrow();
210
+ expect(userCanReadFieldRow(user, whereMap, { teamId: "ops" })).toBe(false);
211
+ });
212
+
213
+ test("where-rule role does not block a later 'all' role in the same access map", () => {
214
+ const mixedMap: OwnershipMap = {
215
+ Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
216
+ Admin: "all",
217
+ };
218
+ const user = mkUser({ roles: ["Support", "Admin"] });
219
+ expect(userCanReadFieldRow(user, mixedMap, { teamId: "ops" })).toBe(true);
220
+ });
198
221
  });
199
222
 
200
223
  // --- userCanWriteFieldRow() — STRADDLE PREVENTION ---
@@ -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() },
@@ -438,6 +450,26 @@ describe("buildInsertSchema", () => {
438
450
  }
439
451
  });
440
452
 
453
+ // Review-fix (kumiko-framework#1712): an optional select WITHOUT a default
454
+ // normalizes an untouched <select> to null (see the "unset (null)" test
455
+ // above). A client that reuses that null against a since-defaulted field
456
+ // must fall back to the default too, not get rejected as an invalid enum
457
+ // value the way a bare `null` previously was.
458
+ test("optional select with default accepts null and falls back to the default", () => {
459
+ const entity = createEntity({
460
+ table: "Test",
461
+ fields: {
462
+ locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
463
+ },
464
+ });
465
+ const schema = buildInsertSchema(entity);
466
+ const result = schema.safeParse({ locale: null });
467
+ expect(result.success).toBe(true);
468
+ if (result.success) {
469
+ expect(result.data["locale"]).toBe("de");
470
+ }
471
+ });
472
+
441
473
  test("optional select with default still validates a real value", () => {
442
474
  const entity = createEntity({
443
475
  table: "Test",
@@ -537,11 +569,13 @@ describe("buildUpdateSchema", () => {
537
569
  }
538
570
  });
539
571
 
540
- // Update schemas strip defaults deliberately (buildUpdateSchema never
541
- // applies them — omitting a field must leave it untouched). So "" maps
542
- // to the same explicit clear-to-null as the no-default case; only the
543
- // insert path falls back to the default (#1702).
544
- test("optional select with default on update: empty string is a clear-to-null, not the default", () => {
572
+ // fw#1703: buildUpdateSchema never applies defaults for an OMITTED field —
573
+ // omitting a field must leave it untouched. But an explicit `""` from an
574
+ // untouched <select> is a submission, not an omission, and "a field with a
575
+ // default is never unset" (same invariant the insert path documents at
576
+ // #1702) so "" must map to the field's default on update too, not clobber
577
+ // an existing value to null.
578
+ test("optional select with default on update: empty string falls back to the default", () => {
545
579
  const entity = createEntity({
546
580
  table: "Test",
547
581
  fields: {
@@ -553,7 +587,43 @@ describe("buildUpdateSchema", () => {
553
587
  const result = schema.safeParse({ locale: "" });
554
588
  expect(result.success).toBe(true);
555
589
  if (result.success) {
556
- expect(result.data["locale"]).toBeNull();
590
+ expect(result.data["locale"]).toBe("de");
591
+ }
592
+ });
593
+
594
+ test("optional select with default on update: omitting the field leaves it untouched", () => {
595
+ const entity = createEntity({
596
+ table: "Test",
597
+ fields: {
598
+ locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
599
+ },
600
+ });
601
+
602
+ const schema = buildUpdateSchema(entity);
603
+ const result = schema.safeParse({});
604
+ expect(result.success).toBe(true);
605
+ if (result.success) {
606
+ expect(Object.hasOwn(result.data, "locale")).toBe(false);
607
+ }
608
+ });
609
+
610
+ test("required select with default on update: empty string falls back to the default instead of a required-error", () => {
611
+ const entity = createEntity({
612
+ table: "Test",
613
+ fields: {
614
+ locale: createSelectField({
615
+ options: ["de", "en"] as const,
616
+ default: "de",
617
+ required: true,
618
+ }),
619
+ },
620
+ });
621
+
622
+ const schema = buildUpdateSchema(entity);
623
+ const result = schema.safeParse({ locale: "" });
624
+ expect(result.success).toBe(true);
625
+ if (result.success) {
626
+ expect(result.data["locale"]).toBe("de");
557
627
  }
558
628
  });
559
629
  });
@@ -1,32 +1,74 @@
1
+ import { normalizeAccessEntry } from "../ownership";
1
2
  import type { FeatureDefinition } from "../types";
2
3
 
3
4
  const BUILTIN_ROLES = new Set(["all", "system"]);
4
5
 
6
+ function addRole(roleHandlers: Map<string, Set<string>>, role: string, identifier: string): void {
7
+ let handlers = roleHandlers.get(role);
8
+ if (!handlers) {
9
+ handlers = new Set();
10
+ roleHandlers.set(role, handlers);
11
+ }
12
+ handlers.add(identifier);
13
+ }
14
+
15
+ function addHandlerRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
16
+ const handlerGroups = [
17
+ { type: "write", defs: f.writeHandlers },
18
+ { type: "query", defs: f.queryHandlers },
19
+ { type: "stream", defs: f.streamHandlers },
20
+ ] as const;
21
+
22
+ for (const { type, defs } of handlerGroups) {
23
+ for (const [handlerName, def] of Object.entries(defs)) {
24
+ if (!def.access || !("roles" in def.access)) continue;
25
+ const identifier = `${f.name}:${type}:${handlerName}`;
26
+ for (const role of def.access.roles) {
27
+ addRole(roleHandlers, role, identifier);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ function addConfigKeyRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
34
+ for (const [key, keyDef] of Object.entries(f.configKeys ?? {})) {
35
+ const identifier = `${f.name}:config:${key}`;
36
+ for (const role of [...keyDef.access.read, ...keyDef.access.write]) {
37
+ addRole(roleHandlers, role, identifier);
38
+ }
39
+ }
40
+ }
41
+
42
+ function addEntityFieldRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
43
+ for (const [entityName, entity] of Object.entries(f.entities ?? {})) {
44
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
45
+ const identifier = `${f.name}:entity:${entityName}.${fieldName}`;
46
+ const readRoles = Object.keys(normalizeAccessEntry(field.access?.read) ?? {});
47
+ const writeRoles = Object.keys(normalizeAccessEntry(field.access?.write) ?? {});
48
+ for (const role of [...readRoles, ...writeRoles]) {
49
+ addRole(roleHandlers, role, identifier);
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // A single "exactly one handler" heuristic over-counts by construction:
56
+ // legitimate fine-grained roles (a role scoped to one admin endpoint on
57
+ // purpose) are the normal case, not a typo — and until every access
58
+ // surface is scanned, a role can look unique here while it's really used
59
+ // elsewhere (configKeys / entity+field access), a false positive in the
60
+ // other direction. Both is why this stays opt-in (#1711) rather than a
61
+ // default-on prod warning.
5
62
  export function warnOnUniqueAccessRoles(features: readonly FeatureDefinition[]): void {
6
- // role → set of distinct handler identifiers using it
63
+ // role → set of distinct identifiers using it, across every access
64
+ // surface — write/query/stream handlers, config-key access, and
65
+ // entity/field-level access rules.
7
66
  const roleHandlers = new Map<string, Set<string>>();
8
67
 
9
68
  for (const f of features) {
10
- const handlerGroups = [
11
- { type: "write", defs: f.writeHandlers },
12
- { type: "query", defs: f.queryHandlers },
13
- { type: "stream", defs: f.streamHandlers },
14
- ] as const;
15
-
16
- for (const { type, defs } of handlerGroups) {
17
- for (const [handlerName, def] of Object.entries(defs)) {
18
- if (!def.access || !("roles" in def.access)) continue;
19
- const identifier = `${f.name}:${type}:${handlerName}`;
20
- for (const role of def.access.roles) {
21
- let handlers = roleHandlers.get(role);
22
- if (!handlers) {
23
- handlers = new Set();
24
- roleHandlers.set(role, handlers);
25
- }
26
- handlers.add(identifier);
27
- }
28
- }
29
- }
69
+ addHandlerRoles(roleHandlers, f);
70
+ addConfigKeyRoles(roleHandlers, f);
71
+ addEntityFieldRoles(roleHandlers, f);
30
72
  }
31
73
 
32
74
  for (const [role, handlers] of roleHandlers) {
@@ -61,9 +61,12 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
61
61
  export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
62
62
  "authorid",
63
63
  "assigneeid",
64
+ "assigneeuserid",
64
65
  "ownerid",
66
+ "createdby",
65
67
  "createdbyid",
66
68
  "createdbyuserid",
69
+ "updatedby",
67
70
  "updatedbyid",
68
71
  "updatedbyuserid",
69
72
  "invitedby",
@@ -72,6 +75,7 @@ export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
72
75
  "uploadedby",
73
76
  "assignedto",
74
77
  "reportedby",
78
+ "memberid",
75
79
  ]);
76
80
 
77
81
  // --- Extension preSave wiring validation ---
@@ -61,11 +61,24 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
61
61
  // dieselbe Extraktionslogik.
62
62
  export { collectWriteHandlerQns } from "./nav";
63
63
 
64
+ export type ValidateBootOptions = {
65
+ /** Warn when an access role is used by exactly one handler/config-key/
66
+ * field across the whole boot scan — often a typo, but also the normal
67
+ * shape of a legitimate fine-grained role (one role scoped to one admin
68
+ * endpoint on purpose). Opt-in (default false, #1711): a default-on
69
+ * prod warning that nobody can silence per-role isn't worth the noise
70
+ * it generates on every boot. */
71
+ readonly warnOnUniqueAccessRoles?: boolean;
72
+ };
73
+
64
74
  /**
65
75
  * Validates all feature configurations at boot time.
66
76
  * Throws on the first error found — fail fast.
67
77
  */
68
- export function validateBoot(features: readonly FeatureDefinition[]): void {
78
+ export function validateBoot(
79
+ features: readonly FeatureDefinition[],
80
+ options?: ValidateBootOptions,
81
+ ): void {
69
82
  const featureMap = new Map<string, FeatureDefinition>();
70
83
  for (const f of features) {
71
84
  featureMap.set(f.name, f);
@@ -213,5 +226,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
213
226
 
214
227
  validateConfigReads(features, allConfigKeys);
215
228
  warnOnToggleableDependencies(features, featureMap);
216
- warnOnUniqueAccessRoles(features);
229
+ if (options?.warnOnUniqueAccessRoles === true) {
230
+ warnOnUniqueAccessRoles(features);
231
+ }
217
232
  }
@@ -21,6 +21,13 @@ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
21
21
  // werden statt erst beim ersten Cleanup-Run.
22
22
  const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
23
23
 
24
+ // A field carries a subject binding — pii/userOwned/tenantOwned mark
25
+ // annotated content, subjectRef marks a bare FK into `user` with no
26
+ // annotated content of its own but the same Art.17 obligations (#1645).
27
+ function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
28
+ return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
29
+ }
30
+
24
31
  // --- PII / Subject-Key Annotations + Retention validation ---
25
32
  //
26
33
  // Drei Klassen von Checks:
@@ -133,11 +140,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
133
140
  );
134
141
  }
135
142
 
136
- // Sortierung liest die Projection-Spaltedie bleibt Ciphertext, also
137
- // sortable + Subject-Annotation bleibt Boot-Fail. searchable ist seit
138
- // #1610 erlaubt: der Search-Consumer decryptet in den abgeleiteten
139
- // Index und forget purgt die Docs (siehe createSearchEventConsumer).
140
- // sensitive + searchable bleibt verboten (nobody-may-read-back).
143
+ // Sorting reads the projection column that stays ciphertext, so
144
+ // sortable + subject annotation stays a boot-fail. searchable has
145
+ // been allowed since #1610: the search consumer decrypts into the
146
+ // derived index and forget purges those docs (see
147
+ // createSearchEventConsumer). sensitive + searchable stays forbidden
148
+ // (nobody-may-read-back).
141
149
  {
142
150
  const flags = field as {
143
151
  readonly searchable?: boolean;
@@ -215,7 +223,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
215
223
  } else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
216
224
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
217
225
  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.`,
226
+ `[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 } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
219
227
  );
220
228
  }
221
229
  }
@@ -245,10 +253,9 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
245
253
  if (retention.strategy === "blockDelete") {
246
254
  // blockDelete on an entity with no subject field is the correct
247
255
  // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
248
- const hasSubjectField = Object.values(fieldsByName).some((f) => {
249
- const a = f as PiiAnnotations; // @cast-boundary schema-walk
250
- return Boolean(a.pii || a.userOwned || a.tenantOwned);
251
- });
256
+ const hasSubjectField = Object.values(fieldsByName).some(
257
+ (f) => hasSubjectAnnotation(f as PiiAnnotations), // @cast-boundary schema-walk
258
+ );
252
259
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
253
260
  const a = f as PiiAnnotations; // @cast-boundary schema-walk
254
261
  return Boolean(a.anonymize);
@@ -36,8 +36,16 @@ function validateRowActionNavigateParams(
36
36
  ): void {
37
37
  // skip: not a navigate-with-params action — nothing to validate here.
38
38
  if (action.kind !== "navigate" || action.params === undefined) return;
39
- // skip: unresolvable/custom target already reported (or exempt) elsewhere.
40
- if (target === undefined || target.screen.type === "custom") return;
39
+ // entityList/projectionList targets also read URL search params (Tier
40
+ // 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
41
+ // .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
42
+ const exemptTargetType =
43
+ target === undefined ||
44
+ target.screen.type === "custom" ||
45
+ target.screen.type === "entityList" ||
46
+ target.screen.type === "projectionList";
47
+ // skip: unresolvable/custom/list target already reported (or exempt) elsewhere.
48
+ if (exemptTargetType) return;
41
49
 
42
50
  const isEntityEditUpdate =
43
51
  target.screen.type === "entityEdit" &&
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  collectWriteHandlerQns,
3
+ type ValidateBootOptions,
3
4
  validateAppCustomScreenWriteQns,
4
5
  validateBoot,
5
6
  } from "./boot-validator/index";
@@ -1,4 +1,4 @@
1
- import { validateBoot } from "./boot-validator";
1
+ import { type ValidateBootOptions, validateBoot } from "./boot-validator";
2
2
  import { createRegistry } from "./registry";
3
3
  import type { FeatureDefinition, Registry } from "./types";
4
4
  import { DEFAULT_CURRENCIES } from "./types";
@@ -8,6 +8,8 @@ export type AppConfig = {
8
8
  features: readonly FeatureDefinition[];
9
9
  softDelete?: boolean; // Global default for all entities (default: true)
10
10
  currencies?: readonly string[]; // Extends DEFAULT_CURRENCIES
11
+ /** Opt-in boot-validator warnings — see ValidateBootOptions. */
12
+ validateBootOptions?: ValidateBootOptions;
11
13
  };
12
14
 
13
15
  export type App = {
@@ -98,7 +100,7 @@ export function createApp(config: AppConfig): App {
98
100
  }
99
101
 
100
102
  // Run boot-time validation before creating registry
101
- validateBoot(config.features);
103
+ validateBoot(config.features, config.validateBootOptions);
102
104
 
103
105
  return {
104
106
  registry: createRegistry(config.features),
@@ -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)) {
@@ -205,6 +205,8 @@ export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from
205
205
  export {
206
206
  buildOwnershipClause,
207
207
  from,
208
+ normalizeAccessEntry,
209
+ userCanCreateFieldRow,
208
210
  userCanReadFieldRow,
209
211
  userCanWriteFieldRow,
210
212
  } from "./ownership";