@cosmicdrift/kumiko-framework 0.174.0 → 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 (35) 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__/event-store-executor-write-verbs.integration.test.ts +18 -0
  5. package/src/db/__tests__/migrate-runner.test.ts +16 -0
  6. package/src/db/blind-index-cleanup.ts +15 -24
  7. package/src/db/eagerload.ts +21 -5
  8. package/src/db/entity-table-meta.ts +6 -1
  9. package/src/db/event-store-executor-write.ts +6 -1
  10. package/src/db/migrate-runner.ts +5 -0
  11. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
  12. package/src/engine/__tests__/boot-validator.test.ts +51 -1
  13. package/src/engine/__tests__/ownership.test.ts +23 -0
  14. package/src/engine/__tests__/schema-builder.test.ts +64 -6
  15. package/src/engine/boot-validator/access-roles.ts +63 -21
  16. package/src/engine/boot-validator/entity-handler.ts +4 -0
  17. package/src/engine/boot-validator/index.ts +17 -2
  18. package/src/engine/boot-validator/pii-retention.ts +17 -10
  19. package/src/engine/boot-validator/screens.ts +10 -2
  20. package/src/engine/boot-validator.ts +1 -0
  21. package/src/engine/create-app.ts +4 -2
  22. package/src/engine/index.ts +2 -0
  23. package/src/engine/ownership.ts +19 -0
  24. package/src/engine/schema-builder.ts +42 -21
  25. package/src/entrypoint/index.ts +2 -5
  26. package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
  27. package/src/jobs/job-runner.ts +7 -1
  28. package/src/pipeline/system-hooks.ts +16 -3
  29. package/src/schema-cli.ts +7 -5
  30. package/src/search/purge-subject.ts +2 -9
  31. package/src/secrets/derive-purpose-secret.ts +6 -16
  32. package/src/testing/__tests__/e2e-generator.test.ts +7 -0
  33. package/src/testing/__tests__/wait-for.test.ts +2 -2
  34. package/src/testing/e2e-generator.ts +5 -0
  35. 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.174.1",
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.174.1",
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.174.1",
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
+ }
@@ -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
  );
@@ -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(
@@ -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
  });
@@ -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),
@@ -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";
@@ -158,6 +158,12 @@ export function userCanReadFieldRow(
158
158
  for (const role of user.roles) {
159
159
  const rule = accessMap[role];
160
160
  if (!rule) continue;
161
+ // where-rules are entity-level SQL predicates (buildOwnershipClause);
162
+ // matchesRule can't evaluate them in-memory and throws. Field-level
163
+ // access is boot-validator-rejected for where-rules, but this function
164
+ // is also reachable from hand-rolled entity-level reads.
165
+ // skip: where-rules are SQL-layer only — fail closed instead of throwing.
166
+ if (rule !== "all" && rule.kind === "where") continue;
161
167
  if (matchesRule(rule, user, row)) return true;
162
168
  }
163
169
  return false;
@@ -180,6 +186,8 @@ export function userCanWriteFieldRow(
180
186
  const rule = accessMap[role];
181
187
  if (!rule) continue;
182
188
  if (rule === "all") return true;
189
+ // skip: where-rules are SQL-layer only — fail closed instead of throwing.
190
+ if (rule.kind === "where") continue;
183
191
  if (matchesRule(rule, user, oldRow) && matchesRule(rule, user, newRow)) return true;
184
192
  }
185
193
  return false;
@@ -272,6 +280,17 @@ export function shiftParams(fragment: SqlFragment, shift: number): SqlFragment {
272
280
  // SQL names via the kumiko:schema:Columns symbol. Unknown column on a from-rule
273
281
  // is a boot-time misconfiguration; at request time we treat it as empty
274
282
  // (safe default) rather than passing silently.
283
+ //
284
+ // Caller obligations (fw#1700) — this function returns ONLY the ownership
285
+ // fragment, not the full row-access contract. A raw-SQL caller (not going
286
+ // through `ctx.db`, which already applies all three) must additionally:
287
+ // 1. Pass `paramStart` as `params.length + 1` for its own already-bound
288
+ // params, or `$N` placeholders in the returned fragment silently splice
289
+ // into the wrong query params.
290
+ // 2. Apply tenant + soft-delete scoping itself (event-store-executor-read.ts
291
+ // does this outside `buildOwnershipClause`) — this function does not.
292
+ // 3. Treat `kind: "empty"` (see `OwnershipClause`) as a hard DENY, and
293
+ // `kind: "pass"` as an explicit bypass — not as "no additional filter".
275
294
  export function buildOwnershipClause(
276
295
  user: SessionUser,
277
296
  accessMap: OwnershipMap | undefined,
@@ -55,7 +55,17 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
55
55
  }
56
56
  }
57
57
 
58
- export function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
58
+ export function fieldToZod(
59
+ field: FieldDefinition,
60
+ currencies: readonly string[],
61
+ opts: { readonly applyDefaults?: boolean } = {},
62
+ ): z.ZodTypeAny {
63
+ // Insert callers want `.default(...)` applied so an omitted field falls
64
+ // back to it; buildUpdateSchema passes applyDefaults: false so an omitted
65
+ // field on update stays omitted (a `{ title }` patch must not clobber
66
+ // other columns with their defaults) while a field's own default value is
67
+ // still known here for "" → default mapping (select case below).
68
+ const applyDefaults = opts.applyDefaults ?? true;
59
69
  switch (field.type) {
60
70
  case "text": {
61
71
  let schema = z.string();
@@ -63,7 +73,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
63
73
  if (field.format === "email") schema = schema.email();
64
74
  if (field.format === "url") schema = schema.url();
65
75
  if (field.required) schema = schema.min(1);
66
- return field.default !== undefined ? schema.default(field.default) : schema;
76
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
67
77
  }
68
78
  case "longText": {
69
79
  // longText hat keine `format`-Variante (per type-design). Nur
@@ -71,24 +81,32 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
71
81
  let schema = z.string();
72
82
  if (field.maxLength) schema = schema.max(field.maxLength);
73
83
  if (field.required) schema = schema.min(1);
74
- return field.default !== undefined ? schema.default(field.default) : schema;
84
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
75
85
  }
76
86
  case "boolean": {
77
87
  const schema = z.boolean();
78
- return field.default !== undefined ? schema.default(field.default) : schema;
88
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
79
89
  }
80
90
  case "select": {
81
91
  const [first, ...rest] = field.options;
82
92
  if (!first) return z.string();
83
93
  const enumSchema = z.enum([first, ...rest]);
84
- if (field.default !== undefined)
94
+ if (field.default !== undefined) {
85
95
  // Untouched <select> sends "" too; with a default that maps to the
86
96
  // default (same semantics as undefined) instead of the invalid-value
87
- // rejection from #1702. A field with a default is never "unset".
88
- return z.preprocess(
89
- (value) => (value === "" ? field.default : value),
90
- enumSchema.default(field.default),
97
+ // rejection from #1702. A field with a default is never "unset"
98
+ // true on both insert AND update, so this branch (and its "" → default
99
+ // mapping) fires regardless of applyDefaults; only the `.default(...)`
100
+ // schema-level fallback for OMITTED input is update-gated below.
101
+ // `null` maps the same way: the no-default branch below normalizes
102
+ // an untouched select to null, and a client that reuses that value
103
+ // against a since-defaulted field must not get rejected either.
104
+ const mapped = z.preprocess(
105
+ (value) => (value === "" || value === null ? field.default : value),
106
+ enumSchema,
91
107
  );
108
+ return applyDefaults ? mapped.default(field.default) : mapped;
109
+ }
92
110
  if (field.required) return enumSchema;
93
111
  // Optional select without a default: an untouched HTML <select> submits
94
112
  // "" for its placeholder option. Treat that as "unset" (null) instead of
@@ -105,7 +123,9 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
105
123
  // in buildInsertSchema kümmert sich um „darf fehlen".
106
124
  let schema = z.array(z.enum([first, ...rest]));
107
125
  if (field.required) schema = schema.min(1);
108
- return field.default !== undefined ? schema.default([...field.default]) : schema;
126
+ return field.default !== undefined && applyDefaults
127
+ ? schema.default([...field.default])
128
+ : schema;
109
129
  }
110
130
  case "number": {
111
131
  let schema = z.number();
@@ -115,7 +135,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
115
135
  if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
116
136
  if (field.min !== undefined) schema = schema.min(field.min);
117
137
  if (field.max !== undefined) schema = schema.max(field.max);
118
- return field.default !== undefined ? schema.default(field.default) : schema;
138
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
119
139
  }
120
140
  case "decimal": {
121
141
  // Stored as numeric(precision, scale), surfaced as JS number. Bound the
@@ -129,7 +149,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
129
149
  .refine((n) => isRepresentableAtScale(n, field.scale), {
130
150
  message: `at most ${field.scale} decimal places`,
131
151
  });
132
- return field.default !== undefined ? schema.default(field.default) : schema;
152
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
133
153
  }
134
154
  case "bigInt": {
135
155
  // JS-`number`-Round-trip via mode:"number"; sicher bis 2^53.
@@ -137,7 +157,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
137
157
  // Float reinwirft (z.B. parseFloat-Bug), beim Insert sofort
138
158
  // failed statt silent-Truncation zu kassieren.
139
159
  const schema = z.number().int().safe();
140
- return field.default !== undefined ? schema.default(field.default) : schema;
160
+ return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
141
161
  }
142
162
  case "money": {
143
163
  const [first, ...rest] = currencies;
@@ -245,14 +265,15 @@ export function buildUpdateSchema(
245
265
  const shape: Record<string, z.ZodTypeAny> = {};
246
266
 
247
267
  for (const [name, field] of Object.entries(entity.fields)) {
248
- // Update schemas never apply defaults — a user that sends only
249
- // `{ title }` means "only change title"; zod defaults would silently
250
- // inject default values for every omitted field and clobber existing
251
- // data via the event-store-executor's `changes` payload.
252
- // Cast widens the discriminated union so destructure works for variants
253
- // without a `default` field; remainder is structurally a FieldDefinition.
254
- const { default: _default, ...stripped } = field as FieldDefinition & { default?: unknown }; // @cast-boundary schema-walk
255
- shape[name] = fieldToZod(stripped as FieldDefinition, currencies).optional(); // @cast-boundary schema-walk
268
+ // Update schemas never apply defaults for OMITTED fields — a user that
269
+ // sends only `{ title }` means "only change title"; zod defaults would
270
+ // silently inject default values for every omitted field and clobber
271
+ // existing data via the event-store-executor's `changes` payload.
272
+ // The field is passed through un-stripped (unlike before fw#1703) so
273
+ // fieldToZod still knows the default for its "" default mapping
274
+ // (e.g. select) applyDefaults: false only suppresses the schema-level
275
+ // `.default(...)` fallback for a genuinely omitted key.
276
+ shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
256
277
  }
257
278
 
258
279
  return z.object(shape);
@@ -142,11 +142,8 @@ export type WorkerEntrypoint = {
142
142
  readonly eventDispatcher: EventDispatcher;
143
143
  readonly jobRunner: JobRunner;
144
144
  readonly observability: ObservabilityProvider;
145
- // Same dispatcher the API process exposes a worker builds the identical
146
- // server, only without routes. App-wired components that run in the worker
147
- // and must persist their result need it: JobContext has no write/query
148
- // (handlers.ts JobContext), so writing goes through dispatchSystemWrite,
149
- // the pattern inbound-mail-foundation/watch-supervisor.ts established.
145
+ // Same dispatcher the API process exposes. Background components in the
146
+ // worker persist through the write-path JobContext has no write/query.
150
147
  readonly dispatcher: Dispatcher;
151
148
  readonly mode: "worker";
152
149
  // Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { schedulerIdForJobName } from "../job-runner";
2
+ import { bootJobIdForJobName, schedulerIdForJobName } from "../job-runner";
3
3
 
4
4
  describe("schedulerIdForJobName", () => {
5
5
  test("strips dots and colons so BullMQ job ids stay under the 5-segment legacy heuristic", () => {
@@ -16,3 +16,15 @@ describe("schedulerIdForJobName", () => {
16
16
  expect(schedulerIdForJobName("app.job.tick")).toBe("scheduler-app-job-tick");
17
17
  });
18
18
  });
19
+
20
+ describe("bootJobIdForJobName", () => {
21
+ test("strips colons, same hazard as schedulerIdForJobName (fw#1604)", () => {
22
+ const id = bootJobIdForJobName("publicstatus:job:uptime-probe");
23
+ expect(id).toBe("boot-publicstatus-job-uptime-probe");
24
+ expect(id.includes(":")).toBe(false);
25
+ });
26
+
27
+ test("still collapses dotted QNs", () => {
28
+ expect(bootJobIdForJobName("app.job.tick")).toBe("boot-app-job-tick");
29
+ });
30
+ });
@@ -45,6 +45,12 @@ export function schedulerIdForJobName(jobName: string): string {
45
45
  return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
46
46
  }
47
47
 
48
+ // Same colon-in-BullMQ-id hazard as schedulerIdForJobName (fw#1603/#1604) —
49
+ // a QN like "publicstatus:job:uptime-probe" must not leave ":" in the id.
50
+ export function bootJobIdForJobName(jobName: string): string {
51
+ return `boot-${jobName.replace(/[.:]/g, "-")}`;
52
+ }
53
+
48
54
  // ponytail: migration shim, remove after fw#1603 deploy is everywhere.
49
55
  function legacySchedulerIdForJobName(jobName: string): string {
50
56
  return `scheduler-${jobName.replace(/\./g, "-")}`;
@@ -518,7 +524,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
518
524
  if (laneForJob(jobDef) !== consumerLane) continue;
519
525
  if (jobDef.runOnBoot) {
520
526
  const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
521
- await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
527
+ await consumerQueue.add(bootName, {}, { jobId: bootJobIdForJobName(name) });
522
528
  }
523
529
  }
524
530
 
@@ -111,9 +111,22 @@ export async function decryptSearchableSubjectFields(
111
111
  }
112
112
  return out;
113
113
  }
114
- return decryptPiiFieldValues(state, fields, kms, {
115
- requestId: "system:consumer:search",
116
- });
114
+ try {
115
+ return await decryptPiiFieldValues(state, fields, kms, {
116
+ requestId: "system:consumer:search",
117
+ });
118
+ } catch (err) {
119
+ console.warn(
120
+ `[kumiko:search] decryptSearchableSubjectFields failed for "${entityName}" — ` +
121
+ `dropping ciphertext fields for this document instead of wedging the consumer.`,
122
+ err,
123
+ );
124
+ const out = { ...state };
125
+ for (const name of fields) {
126
+ if (isPiiCiphertext(out[name])) delete out[name];
127
+ }
128
+ return out;
129
+ }
117
130
  }
118
131
 
119
132
  export function hasErasedSearchableSubjectField(
package/src/schema-cli.ts CHANGED
@@ -289,11 +289,13 @@ export async function runSchemaCli(
289
289
  }
290
290
  if (mismatches.some((m) => m.kind === "unexpected-table")) {
291
291
  out.err(
292
- " Fix (unexpected-table): register the raw/hand-written table via `table()` " +
293
- "(or `defineUnmanagedTable()`) from `@cosmicdrift/kumiko-framework/db`, then " +
294
- "`r.storeTable(meta, { reason: ... })` inside a feature — this adds it to " +
295
- "ENTITY_METAS and the snapshot without going through r.entity(). See the " +
296
- "bundled `jobs` feature's job-run-log store table for the pattern.",
292
+ " Fix (unexpected-table): build a meta via `defineUnmanagedTable()` " +
293
+ "from `@cosmicdrift/kumiko-framework/db`, then `r.storeTable(meta, { reason: ... })` " +
294
+ "inside a feature — this adds it to ENTITY_METAS immediately; the .snapshot.json " +
295
+ "only picks it up on the NEXT `kumiko-schema generate` run, which you still need " +
296
+ "to run and commit. `table()` returns a query handle, not a storeTable()-compatible " +
297
+ "meta — don't pass its result to storeTable(). See the bundled `jobs` feature's " +
298
+ "job-run-log store table for the pattern.",
297
299
  );
298
300
  }
299
301
  if (mismatches.some((m) => m.kind !== "unexpected-table")) {
@@ -8,6 +8,7 @@
8
8
  // 2. Ciphertext LIKE prefix (same as nullBlindIndexesForSubject) for rows
9
9
  // that still carry the subject key in encrypted columns.
10
10
 
11
+ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
11
12
  import type { SubjectId } from "../crypto/kms-adapter";
12
13
  import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
13
14
  import type { DbRunner } from "../db/connection";
@@ -19,14 +20,6 @@ import type { EntityId, TenantId } from "../engine/types/identifiers";
19
20
  import { toSnakeCase } from "../utils/case";
20
21
  import type { SearchAdapter } from "./types";
21
22
 
22
- function quoteIdent(name: string): string {
23
- return `"${name.replace(/"/g, '""')}"`;
24
- }
25
-
26
- function escapeLikePattern(value: string): string {
27
- return value.replace(/[\\%_]/g, (m) => `\\${m}`);
28
- }
29
-
30
23
  /** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
31
24
  function ownershipPredicates(
32
25
  entity: EntityDefinition,
@@ -76,7 +69,7 @@ export async function purgeSearchDocumentsForSubject(
76
69
  /** When set, also match rows by ownership — needed after anonymize rewrites ciphertext. */
77
70
  subject?: SubjectId,
78
71
  ): Promise<void> {
79
- const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
72
+ const likePattern = subjectCiphertextLikePattern(subjectKey);
80
73
  const byTenant = new Map<string, { entityType: string; entityId: EntityId }[]>();
81
74
  const seen = new Set<string>();
82
75
 
@@ -1,21 +1,11 @@
1
1
  import { hkdfSync } from "node:crypto";
2
2
 
3
- // One master secret, many purposes: HKDF turns `JWT_SECRET` into an
4
- // independent secret per trust boundary, so a token-signing key for MFA setup
5
- // cannot be used to forge a deletion token and neither can be walked back to
6
- // the master. Rotating the master rotates every purpose with it.
7
- //
8
- // The alternative is an env var per purpose. That is not more secure (same
9
- // blast radius if the deploy is compromised), it is just more operations, and
10
- // in practice one of them ends up unset in some environment.
11
- //
12
- // The purpose string is a domain separator and part of the contract: change it
13
- // and every previously issued token for that purpose stops verifying. Version
14
- // them ("mfa-setup-token-v1") so a single purpose can be rotated deliberately
15
- // without touching the master or the other purposes.
16
- //
17
- // Lived copy-pasted in four apps before fw#1623 (money-horse, kumiko-studio,
18
- // publicstatus, plus a stale worktree) — identical bodies, drifting comments.
3
+ // HKDF turns one master secret into an independent secret per purpose, so
4
+ // a token-signing key for MFA setup can't forge a deletion token and
5
+ // neither can be walked back to the master. `purpose` is a domain
6
+ // separator, not a label changing it invalidates every previously
7
+ // issued token for that purpose; version it ("mfa-setup-token-v1") to
8
+ // rotate one purpose deliberately.
19
9
  export function derivePurposeSecret(masterSecret: string, purpose: string): string {
20
10
  if (!masterSecret) {
21
11
  throw new Error("derivePurposeSecret: masterSecret must not be empty.");
@@ -259,4 +259,11 @@ describe("generateZodFixture", () => {
259
259
  expect(() => generateZodFixture(z.object({}))).toThrow(/not supported yet/);
260
260
  expect(() => generateZodFixture(z.array(z.string()))).toThrow(/not supported yet/);
261
261
  });
262
+
263
+ test("pipe (select-with-default, same shape schema-builder's z.preprocess produces) unwraps to the underlying type's fixture (fw#1712)", () => {
264
+ const enumSchema = z.enum(["a", "b"]);
265
+ const pipe = z.preprocess((value) => (value === "" ? "a" : value), enumSchema);
266
+ expect(pipe._def.type).toBe("pipe");
267
+ expect(generateZodFixture(pipe)).toBe("a");
268
+ });
262
269
  });
@@ -9,11 +9,11 @@ describe("waitFor", () => {
9
9
  () => {
10
10
  calls++;
11
11
  },
12
- { delays: [200, 200, 200] },
12
+ { delays: [2000] },
13
13
  );
14
14
  expect(calls).toBe(1);
15
15
  // try-first: must not burn the first delay when the condition already holds
16
- expect(Date.now() - started).toBeLessThan(100);
16
+ expect(Date.now() - started).toBeLessThan(500);
17
17
  });
18
18
 
19
19
  test("retries on failure and succeeds once fn passes", async () => {
@@ -355,6 +355,7 @@ type ZodInternals = {
355
355
  readonly format?: string;
356
356
  readonly innerType?: z.ZodTypeAny;
357
357
  readonly entries?: Record<string, string>;
358
+ readonly out?: z.ZodTypeAny;
358
359
  };
359
360
 
360
361
  function readZodInternals(schema: z.ZodTypeAny): ZodInternals | undefined {
@@ -384,6 +385,10 @@ export function generateZodFixture(schema: z.ZodTypeAny): unknown {
384
385
  }
385
386
  case "date":
386
387
  return new Date("2026-01-01T00:00:00Z");
388
+ case "pipe": {
389
+ if (!def?.out) throw new Error("zod pipe without out");
390
+ return generateZodFixture(def.out);
391
+ }
387
392
  default:
388
393
  throw new Error(`generateZodFixture: not supported yet: ${typeName ?? "<unknown>"}`);
389
394
  }
@@ -31,15 +31,15 @@ export const sharedUserEntity = createEntity({
31
31
  required: true,
32
32
  format: "email",
33
33
  searchable: true,
34
- allowPlaintext: "shared test fixture, not real user data",
34
+ allowPlaintext: "test-fixture",
35
35
  }),
36
36
  firstName: createTextField({
37
37
  searchable: true,
38
- allowPlaintext: "shared test fixture, not real user data",
38
+ allowPlaintext: "test-fixture",
39
39
  }),
40
40
  lastName: createTextField({
41
41
  searchable: true,
42
- allowPlaintext: "shared test fixture, not real user data",
42
+ allowPlaintext: "test-fixture",
43
43
  }),
44
44
  isEnabled: createBooleanField({ default: true }),
45
45
  },