@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
@@ -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 () => {