@cosmicdrift/kumiko-framework 0.174.0 → 0.176.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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +11 -2
  3. package/src/crypto/ciphertext-pattern.ts +19 -0
  4. package/src/db/__tests__/entity-table-from-registry.test.ts +73 -0
  5. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +18 -0
  6. package/src/db/__tests__/migrate-runner.test.ts +16 -0
  7. package/src/db/blind-index-cleanup.ts +15 -24
  8. package/src/db/eagerload.ts +21 -5
  9. package/src/db/entity-table-from-registry.ts +31 -0
  10. package/src/db/entity-table-meta.ts +6 -1
  11. package/src/db/event-store-executor-write.ts +6 -1
  12. package/src/db/index.ts +1 -0
  13. package/src/db/migrate-runner.ts +5 -0
  14. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
  15. package/src/engine/__tests__/boot-validator.test.ts +51 -1
  16. package/src/engine/__tests__/ownership.test.ts +23 -0
  17. package/src/engine/__tests__/schema-builder.test.ts +64 -6
  18. package/src/engine/boot-validator/access-roles.ts +63 -21
  19. package/src/engine/boot-validator/entity-handler.ts +4 -0
  20. package/src/engine/boot-validator/index.ts +17 -2
  21. package/src/engine/boot-validator/pii-retention.ts +17 -10
  22. package/src/engine/boot-validator/screens.ts +10 -2
  23. package/src/engine/boot-validator.ts +1 -0
  24. package/src/engine/create-app.ts +4 -2
  25. package/src/engine/index.ts +2 -0
  26. package/src/engine/ownership.ts +19 -0
  27. package/src/engine/schema-builder.ts +42 -21
  28. package/src/entrypoint/index.ts +2 -5
  29. package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
  30. package/src/jobs/job-runner.ts +7 -1
  31. package/src/pipeline/system-hooks.ts +16 -3
  32. package/src/schema-cli.ts +7 -5
  33. package/src/search/purge-subject.ts +2 -9
  34. package/src/secrets/derive-purpose-secret.ts +6 -16
  35. package/src/testing/__tests__/e2e-generator.test.ts +7 -0
  36. package/src/testing/__tests__/wait-for.test.ts +2 -2
  37. package/src/testing/e2e-generator.ts +5 -0
  38. package/src/testing/shared-entities.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.174.0",
3
+ "version": "0.176.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.174.0",
185
+ "@cosmicdrift/kumiko-types": "0.176.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.174.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -564,9 +564,18 @@ describe("POST /api/stream pre-pull race", () => {
564
564
  // in-flight .next(), so cleanup only runs once the pending pull settles.
565
565
  // Abort before heartbeatMs so the route hits the 499 branch; sleep then
566
566
  // completes and the queued return drains the generator's finally.
567
+ //
568
+ // Abort is triggered off an entry signal, not a fixed sleep margin against
569
+ // the heartbeat timer — a stalled event loop could otherwise let the
570
+ // heartbeat fire first and flip the route onto the 200-SSE branch.
567
571
  let cleanedUp = false;
572
+ let entered: () => void;
573
+ const atEntry = new Promise<void>((resolve) => {
574
+ entered = resolve;
575
+ });
568
576
  const dispatcher = stubDispatcher(async function* () {
569
577
  try {
578
+ entered();
570
579
  await Bun.sleep(80);
571
580
  yield { i: 0 };
572
581
  } finally {
@@ -583,8 +592,8 @@ describe("POST /api/stream pre-pull race", () => {
583
592
  signal: ac.signal,
584
593
  }),
585
594
  );
586
- // Abort while firstPull is still racing the heartbeat timer.
587
- await Bun.sleep(5);
595
+ // Abort as soon as the generator has been entered — no timing window left.
596
+ await atEntry;
588
597
  ac.abort();
589
598
  const res = await pending;
590
599
  expect(res.status).toBe(499);
@@ -0,0 +1,19 @@
1
+ // Shared SQL helpers for locating a subject's PII ciphertext by its inline
2
+ // subject-key prefix (kumiko-pii:v<version>:<subjectKey>:...). Used by both
3
+ // the blind-index sweep (db/blind-index-cleanup.ts) and the search-index
4
+ // purge (search/purge-subject.ts) — the two sweeps must stay in lockstep
5
+ // across ciphertext format versions.
6
+
7
+ export function quoteIdent(name: string): string {
8
+ return `"${name.replace(/"/g, '""')}"`;
9
+ }
10
+
11
+ export function escapeLikePattern(value: string): string {
12
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
13
+ }
14
+
15
+ // "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
16
+ // subject key placement is stable across versions.
17
+ export function subjectCiphertextLikePattern(subjectKey: string): string {
18
+ return `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
19
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../../engine/define-feature";
3
+ import { createEntity, createTextField } from "../../engine/factories";
4
+ import { createRegistry } from "../../engine/registry";
5
+ import { extractTableName, type SchemaTable } from "../dialect";
6
+ import { entityTableFromRegistry } from "../entity-table-from-registry";
7
+ import { buildEntityTable } from "../table-builder";
8
+
9
+ function unitEntity() {
10
+ return createEntity({ table: "read_units", fields: { name: createTextField() } });
11
+ }
12
+
13
+ describe("entityTableFromRegistry", () => {
14
+ test("returns the table the registry booted, not one derived from the definition", () => {
15
+ const registry = createRegistry([
16
+ defineFeature("housing", (r) => {
17
+ r.entity("unit", unitEntity());
18
+ }),
19
+ ]);
20
+
21
+ const fromRegistry = entityTableFromRegistry(registry, "unit", unitEntity());
22
+ const projection = [...registry.getAllProjections().values()].find(
23
+ (p) => p.isImplicit && p.source === "unit",
24
+ );
25
+
26
+ expect(projection).toBeDefined();
27
+ expect(fromRegistry).toBe(projection?.table as SchemaTable);
28
+ });
29
+
30
+ // An entity nobody registered has no projection to read — the caller still
31
+ // gets a usable table instead of undefined, which is what made the helper
32
+ // worth having over a raw lookup.
33
+ test("falls back to deriving the table when no implicit projection exists", () => {
34
+ const registry = createRegistry([]);
35
+
36
+ const derived = entityTableFromRegistry(registry, "unit", unitEntity());
37
+
38
+ expect(extractTableName(derived)).toBe(
39
+ extractTableName(buildEntityTable("unit", unitEntity())),
40
+ );
41
+ });
42
+
43
+ // A projection for a different entity must not be handed back: both are
44
+ // implicit, and picking the first one would silently write into the wrong
45
+ // table.
46
+ test("ignores implicit projections of other entities", () => {
47
+ const registry = createRegistry([
48
+ defineFeature("housing", (r) => {
49
+ r.entity("unit", unitEntity());
50
+ }),
51
+ ]);
52
+
53
+ const other = createEntity({ table: "read_tenants", fields: { name: createTextField() } });
54
+ const derived = entityTableFromRegistry(registry, "tenant", other);
55
+
56
+ expect(extractTableName(derived)).toBe("read_tenants");
57
+ });
58
+ });
59
+
60
+ // Guards the cast in entity-table-from-registry.ts: the helper hands the
61
+ // projection's table straight to callers that pass it to selectMany/executors,
62
+ // so it has to carry the physical name those consumers read off it.
63
+ test("the registry table carries a physical name", () => {
64
+ const registry = createRegistry([
65
+ defineFeature("housing", (r) => {
66
+ r.entity("unit", unitEntity());
67
+ }),
68
+ ]);
69
+
70
+ expect(extractTableName(entityTableFromRegistry(registry, "unit", unitEntity()))).toBe(
71
+ "read_units",
72
+ );
73
+ });
@@ -272,6 +272,24 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
272
272
  );
273
273
  expect(result.isSuccess).toBe(true);
274
274
  });
275
+
276
+ // Review-fix (kumiko-framework#1685): a preSave hook that echoes `id`/
277
+ // `version` back in its return value must not have those leak into the
278
+ // persisted row — the framework-minted aggregateId stays authoritative.
279
+ test("create: preSave hook returning `id`/`version` does not override the minted aggregateId", async () => {
280
+ const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb, {
281
+ preSave: async (changes) => ({ ...changes, id: "hook-injected-id", version: 999 }),
282
+ });
283
+ expect(result.isSuccess).toBe(true);
284
+ if (!result.isSuccess) return;
285
+ expect(result.data.id).not.toBe("hook-injected-id");
286
+
287
+ const row = await asRawClient(testDb.db).unsafe(
288
+ `SELECT id FROM read_es_write_owned_field WHERE id = $1`,
289
+ [result.data.id],
290
+ );
291
+ expect(row.length).toBe(1);
292
+ });
275
293
  });
276
294
 
277
295
  // =============================================================================
@@ -99,4 +99,20 @@ describe("splitSqlStatements", () => {
99
99
  'CREATE TABLE "a" ("id" uuid);',
100
100
  ]);
101
101
  });
102
+
103
+ test("throws fail-loud on a dollar-quoted body instead of splitting it in half", () => {
104
+ expect(() => splitSqlStatements("DO $$ BEGIN PERFORM 1; END $$;")).toThrow(
105
+ /unsupported dollar-quoted body/,
106
+ );
107
+ });
108
+
109
+ test("throws fail-loud on a tagged dollar-quoted body ($tag$...$tag$)", () => {
110
+ expect(() => splitSqlStatements("DO $tag$ BEGIN PERFORM 1; END $tag$;")).toThrow(
111
+ /unsupported dollar-quoted body/,
112
+ );
113
+ });
114
+
115
+ test("a bare $ not opening a dollar-tag does not false-positive (digit after $ is not a tag)", () => {
116
+ expect(splitSqlStatements("SELECT $1;")).toEqual(["SELECT $1;"]);
117
+ });
102
118
  });
@@ -1,46 +1,37 @@
1
- // Sofortiges Blind-Index-Nulling nach einem Subject-Erase (#818).
1
+ // Immediate blind-index nulling after a subject erase (#818).
2
2
  //
3
- // Nach kms.eraseKey ist der Ciphertext unlesbar, aber die deterministische
4
- // bidx-Spalte bliebe bis zum nächsten Write/Rebuild matchbar ein
5
- // Linkage-Fenster ("hat irgendeine Row den Wert X"). Dieser Sweep schließt
6
- // es sofort: der Ciphertext nennt sein Subject inline
7
- // (kumiko-pii:v1:<subjectKey>:...), also findet ein LIKE-Prefix-Match exakt
8
- // die Rows des erased Subjects pro lookupable-Feld ein UPDATE.
3
+ // After kms.eraseKey the ciphertext is unreadable, but the deterministic
4
+ // bidx column would stay matchable until the next write/rebuilda
5
+ // linkage window ("does any row hold value X"). This sweep closes it right
6
+ // away: the ciphertext names its subject inline
7
+ // (kumiko-pii:v1:<subjectKey>:...), so a LIKE-prefix match finds exactly
8
+ // the erased subject's rowsone UPDATE per lookupable field.
9
9
  //
10
- // Rows, die der Forget-Lauf ohnehin via Executor löscht/anonymisiert,
11
- // bekommen ihren bidx dort automatisch neu berechnet; dieser Sweep deckt
12
- // die liegen bleibenden Rows ab (fremde Entities mit userOwned-Feldern).
10
+ // Rows the forget run deletes/anonymizes via the executor anyway get their
11
+ // bidx recomputed automatically there; this sweep covers the rows left
12
+ // behind (foreign entities with userOwned fields).
13
13
 
14
14
  import { collectLookupableFields } from "../crypto/blind-index";
15
+ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
15
16
  import type { FeatureDefinition } from "../engine/types";
16
17
  import { toSnakeCase } from "../utils/case";
17
18
  import type { DbRunner } from "./connection";
18
19
  import { resolveTableName } from "./entity-table-meta";
19
20
  import { executeRawQuery } from "./queries/raw-sql";
20
21
 
21
- function quoteIdent(name: string): string {
22
- return `"${name.replace(/"/g, '""')}"`;
23
- }
24
-
25
- function escapeLikePattern(value: string): string {
26
- return value.replace(/[\\%_]/g, (m) => `\\${m}`);
27
- }
28
-
29
22
  export async function nullBlindIndexesForSubject(
30
23
  db: DbRunner,
31
24
  features: ReadonlyMap<string, FeatureDefinition>,
32
25
  subjectKey: string,
33
26
  ): Promise<void> {
34
- // "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
35
- // subject key placement is stable across versions.
36
- const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
27
+ const likePattern = subjectCiphertextLikePattern(subjectKey);
37
28
  for (const feature of features.values()) {
38
29
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
39
30
  const lookupable = collectLookupableFields(entity);
40
31
  if (lookupable.length === 0) continue;
41
- // Kein featureName-Prefixder Dispatcher baut Entity-Tables ohne
42
- // (buildEntityTable ohne featureName-Option), der Sweep muss dieselben
43
- // Namen treffen.
32
+ // No featureName prefix the dispatcher builds entity tables without
33
+ // one (buildEntityTable with no featureName option), the sweep has to
34
+ // hit the same names.
44
35
  const tableName = resolveTableName(entityName, entity, undefined);
45
36
  for (const fieldName of lookupable) {
46
37
  const snake = toSnakeCase(fieldName);
@@ -118,9 +118,10 @@ function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
118
118
  async function decryptReferencedRow(
119
119
  row: Record<string, unknown>,
120
120
  refEntity: EntityDefinition,
121
+ piiFields: readonly string[],
122
+ encryptedFields: ReadonlySet<string>,
123
+ kms: ReturnType<typeof configuredPiiSubjectKms>,
121
124
  ): Promise<Record<string, unknown>> {
122
- const piiFields = collectPiiSubjectFields(refEntity);
123
- const encryptedFields = collectEncryptedFieldNames(refEntity);
124
125
  if (hasOwnershipScopedRead(refEntity)) {
125
126
  if (piiFields.length === 0 && encryptedFields.size === 0) return row;
126
127
  const out = { ...row };
@@ -130,7 +131,6 @@ async function decryptReferencedRow(
130
131
  }
131
132
 
132
133
  let out = row;
133
- const kms = configuredPiiSubjectKms();
134
134
  if (piiFields.length > 0 && kms) {
135
135
  out = await decryptPiiFieldValues(out, piiFields, kms, {
136
136
  requestId: requestContext.get()?.requestId ?? "eagerload",
@@ -147,16 +147,22 @@ async function decryptReferencedRow(
147
147
  // must not 500 the whole list request — the main rows the caller asked for
148
148
  // are unrelated to this one broken reference. Drop just that row from the
149
149
  // map; the renderer falls back to the raw UUID.
150
+ //
151
+ // piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
152
+ // caller computes them once and passes them in instead of recomputing per row.
150
153
  async function buildRefLookupMap(
151
154
  rawRefRows: ReadonlyArray<Record<string, unknown>>,
152
155
  refEntity: EntityDefinition,
153
156
  refEntityName: string,
157
+ piiFields: readonly string[],
158
+ encryptedFields: ReadonlySet<string>,
159
+ kms: ReturnType<typeof configuredPiiSubjectKms>,
154
160
  ): Promise<Map<string, Record<string, unknown>>> {
155
161
  const map = new Map<string, Record<string, unknown>>();
156
162
  for (const r of rawRefRows) {
157
163
  let decrypted: Record<string, unknown>;
158
164
  try {
159
- decrypted = await decryptReferencedRow(r, refEntity);
165
+ decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
160
166
  } catch (e) {
161
167
  console.warn(
162
168
  `[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
@@ -214,7 +220,17 @@ export async function enrichWithReferences(
214
220
  const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
215
221
  Record<string, unknown>
216
222
  >;
217
- const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName);
223
+ const piiFields = collectPiiSubjectFields(refEntity);
224
+ const encryptedFields = collectEncryptedFieldNames(refEntity);
225
+ const kms = configuredPiiSubjectKms();
226
+ const map = await buildRefLookupMap(
227
+ rawRefRows,
228
+ refEntity,
229
+ rf.refEntityName,
230
+ piiFields,
231
+ encryptedFields,
232
+ kms,
233
+ );
218
234
  return { fieldName: rf.fieldName, multiple: rf.multiple, map };
219
235
  }),
220
236
  );
@@ -0,0 +1,31 @@
1
+ // The table an entity actually lives in, as the booted registry sees it.
2
+ //
3
+ // `buildEntityTable` derives a table from the entity definition alone, which is
4
+ // right until something changed the projection after registration — an
5
+ // extendEntityProjection, a featureName prefix, a table override. The registry
6
+ // carries the result of all of that, so a caller that needs to read or write
7
+ // the same rows the pipeline writes has to ask the registry first and may only
8
+ // fall back to deriving.
9
+ //
10
+ // Callers are the ones holding a registry and an entity definition but no
11
+ // executor: seeds, jobs and consumers that talk to a feature's read model
12
+ // directly.
13
+
14
+ import type { Registry } from "../engine";
15
+ import type { SchemaTable } from "./dialect";
16
+ import { buildEntityTable } from "./table-builder";
17
+
18
+ export function entityTableFromRegistry(
19
+ registry: Registry,
20
+ entityName: string,
21
+ entity: Parameters<typeof buildEntityTable>[1],
22
+ ): SchemaTable {
23
+ for (const projection of registry.getAllProjections().values()) {
24
+ if (!projection.isImplicit) continue;
25
+ if (projection.source !== entityName) continue;
26
+ // @cast-boundary registry-projection — ProjectionDefinition.table is the
27
+ // untyped table object every projection registrar accepts.
28
+ return projection.table as SchemaTable;
29
+ }
30
+ return buildEntityTable(entityName, entity);
31
+ }
@@ -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(
package/src/db/index.ts CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  decryptEntityFieldValues,
50
50
  encryptEntityFieldValues,
51
51
  } from "./entity-field-encryption";
52
+ export { entityTableFromRegistry } from "./entity-table-from-registry";
52
53
  export type {
53
54
  BuildEntityTableMetaOptions,
54
55
  ColumnMeta,
@@ -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 ---
@@ -450,6 +450,26 @@ describe("buildInsertSchema", () => {
450
450
  }
451
451
  });
452
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
+
453
473
  test("optional select with default still validates a real value", () => {
454
474
  const entity = createEntity({
455
475
  table: "Test",
@@ -549,11 +569,13 @@ describe("buildUpdateSchema", () => {
549
569
  }
550
570
  });
551
571
 
552
- // Update schemas strip defaults deliberately (buildUpdateSchema never
553
- // applies them — omitting a field must leave it untouched). So "" maps
554
- // to the same explicit clear-to-null as the no-default case; only the
555
- // insert path falls back to the default (#1702).
556
- 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", () => {
557
579
  const entity = createEntity({
558
580
  table: "Test",
559
581
  fields: {
@@ -565,7 +587,43 @@ describe("buildUpdateSchema", () => {
565
587
  const result = schema.safeParse({ locale: "" });
566
588
  expect(result.success).toBe(true);
567
589
  if (result.success) {
568
- 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");
569
627
  }
570
628
  });
571
629
  });