@cosmicdrift/kumiko-framework 0.351.0 → 0.353.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 (66) hide show
  1. package/dist/api/pii-leak-guard.js +3 -0
  2. package/dist/api/route-registrars.d.ts +2 -1
  3. package/dist/api/route-registrars.js +8 -0
  4. package/dist/api/server.js +11 -3
  5. package/dist/api/sse-route.js +11 -1
  6. package/dist/bun-db/query.js +7 -2
  7. package/dist/compliance/sub-processors.js +5 -0
  8. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  9. package/dist/crypto/pii-field-encryption.js +2 -3
  10. package/dist/db/entity-table-meta.js +11 -0
  11. package/dist/db/event-store-executor-context.js +20 -25
  12. package/dist/db/migrate-generator.js +9 -1
  13. package/dist/db/queries/backfill-pii.d.ts +1 -0
  14. package/dist/db/queries/backfill-pii.js +13 -0
  15. package/dist/db/queries/seed-context.d.ts +17 -0
  16. package/dist/db/queries/seed-context.js +26 -0
  17. package/dist/db/queries/shadow-swap.js +5 -2
  18. package/dist/engine/boot-validator/access-declarations.js +16 -1
  19. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  20. package/dist/engine/boot-validator/entity-handler.js +30 -0
  21. package/dist/engine/boot-validator/index.js +2 -1
  22. package/dist/engine/boot-validator/query-refs.js +7 -2
  23. package/dist/engine/boot-validator/screens.js +60 -22
  24. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  25. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  26. package/dist/engine/build-app-schema.js +33 -2
  27. package/dist/engine/build-config-feature-schema.js +6 -0
  28. package/dist/engine/dedupe-features.js +17 -1
  29. package/dist/engine/entity-handlers.js +15 -6
  30. package/dist/engine/feature-builder-state.d.ts +2 -7
  31. package/dist/engine/feature-config-events-jobs.js +3 -0
  32. package/dist/engine/feature-entity-handlers.js +1 -1
  33. package/dist/engine/i18n-key.js +14 -3
  34. package/dist/engine/index.d.ts +1 -1
  35. package/dist/engine/index.js +1 -1
  36. package/dist/engine/pipeline.js +18 -1
  37. package/dist/engine/project-app-schema-for-roles.js +8 -2
  38. package/dist/engine/role-assignment.d.ts +2 -0
  39. package/dist/engine/role-assignment.js +2 -0
  40. package/dist/engine/schema-builder.js +8 -0
  41. package/dist/engine/where-rule-lint.js +6 -3
  42. package/dist/env/index.d.ts +4 -0
  43. package/dist/env/index.js +4 -0
  44. package/dist/es-ops/context.js +34 -2
  45. package/dist/es-ops/index.d.ts +1 -1
  46. package/dist/es-ops/types.d.ts +20 -0
  47. package/dist/event-store/index.d.ts +1 -0
  48. package/dist/event-store/index.js +1 -0
  49. package/dist/event-store/tenant-db-append.d.ts +8 -0
  50. package/dist/event-store/tenant-db-append.js +16 -0
  51. package/dist/files/file-ref-entity.d.ts +6 -0
  52. package/dist/files/file-ref-table.d.ts +6 -0
  53. package/dist/files/file-routes.js +3 -3
  54. package/dist/files/types.js +21 -1
  55. package/dist/observability/metrics-wiring.d.ts +1 -1
  56. package/dist/observability/metrics-wiring.js +2 -2
  57. package/dist/pipeline/dispatch-batch.js +5 -1
  58. package/dist/pipeline/dispatch-write.js +36 -82
  59. package/dist/pipeline/member-reader.js +1 -0
  60. package/dist/pipeline/pre-handler-gates.d.ts +9 -0
  61. package/dist/pipeline/pre-handler-gates.js +104 -0
  62. package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
  63. package/dist/pipeline/pre-transaction-reservations.js +34 -21
  64. package/dist/testing/shared-entities.d.ts +10 -0
  65. package/package.json +4 -4
  66. package/src/changes.json +57 -0
@@ -4,7 +4,7 @@
4
4
  // into validateColumnRendererForm, splitting them would create a
5
5
  // same-folder require cycle.
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
- import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
7
+ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
9
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
10
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
12
12
  import { parseRefTarget } from "../parse-ref-target.js";
13
13
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
14
14
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
15
- import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
15
+ import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
16
16
  import { metricField } from "../types/index.js";
17
17
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
18
+ import { isSelectOptionTone } from "./select-option-tone.js";
18
19
  // entityList and projectionList both allow a rowAction to double as the
19
20
  // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
20
21
  // screen, or the renderer can't tell which one should fire.
@@ -345,19 +346,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
345
346
  `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
346
347
  `actionForm screen, or use kind:"navigate" for a full-page target.`);
347
348
  }
348
- // A prefill key the target form doesn't declare is dropped silently by the
349
- // renderer (mergeSearchParamsIntoInitial iterates the target's fields), so
350
- // the typo would only show up as an empty field at click time.
349
+ // The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
350
+ // when the target doesn't declare the field, marks it sensitive/password,
351
+ // or doesn't render it in its layout — each would only show up as an empty
352
+ // field at click time.
351
353
  // skip: no params extractor — nothing to check against the target's fields.
352
354
  if (action.params === undefined)
353
355
  return;
354
- for (const fieldName of rowFieldExtractorKeys(action.params)) {
355
- if (Object.hasOwn(target.fields, fieldName))
356
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, action.screen, target, action.params);
357
+ }
358
+ function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
359
+ const renderedFieldNames = new Set();
360
+ for (const section of target.layout.sections) {
361
+ if (!isFieldsEditSection(section))
356
362
  continue;
357
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
358
- `params prefills "${fieldName}", which drawer-target "${action.screen}" does not declare as a ` +
359
- `field — the renderer would drop it and leave the form empty. Target fields: ` +
360
- `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
363
+ for (const spec of sectionFieldSpecs(section))
364
+ renderedFieldNames.add(normalizeEditField(spec).field);
365
+ }
366
+ for (const fieldName of rowFieldExtractorKeys(params)) {
367
+ if (!Object.hasOwn(target.fields, fieldName)) {
368
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not declare as a ` +
369
+ `field — the renderer would drop it and leave the form empty. Target fields: ` +
370
+ `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
371
+ }
372
+ const targetField = target.fields[fieldName];
373
+ const isSensitiveOrPassword = targetField !== undefined &&
374
+ (("sensitive" in targetField && targetField.sensitive === true) ||
375
+ ("format" in targetField && targetField.format === "password"));
376
+ if (isSensitiveOrPassword) {
377
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" marks as ` +
378
+ `sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
379
+ }
380
+ if (!renderedFieldNames.has(fieldName)) {
381
+ throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not render in ` +
382
+ `its layout — the renderer only prefills fields the layout shows and would drop it.`);
383
+ }
361
384
  }
362
385
  }
363
386
  // The target can be any screen, so this checks the resolved target's
@@ -493,7 +516,8 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
493
516
  }
494
517
  }
495
518
  // Static options XOR optionsQuery on inline-form select fields, and `{ field }`
496
- // payload refs must name another field of the same form. The QN existence check
519
+ // payload refs must name another field of the same form (the payload also feeds
520
+ // optionsAvailabilityQuery). The QN existence check
497
521
  // lives in query-refs.ts with the other query refs.
498
522
  function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
499
523
  if (fdef.type === "select") {
@@ -507,6 +531,12 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
507
531
  }
508
532
  validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
509
533
  }
534
+ else if (fdef.optionsAvailabilityQuery !== undefined) {
535
+ if (fdef.optionsAvailabilityQuery.length === 0) {
536
+ throw new Error(`${where} has an empty optionsAvailabilityQuery`);
537
+ }
538
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
539
+ }
510
540
  else if (fdef.optionsQueryPayload !== undefined) {
511
541
  throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
512
542
  }
@@ -557,7 +587,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
557
587
  throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
558
588
  `when using groups.`);
559
589
  }
560
- if (section.fields.length === 0 && section.groups === undefined) {
590
+ if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
561
591
  throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
562
592
  `fields (or groups) to it.`);
563
593
  }
@@ -1041,6 +1071,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1041
1071
  }
1042
1072
  }
1043
1073
  }
1074
+ for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
1075
+ if (!isSelectOptionTone(tone)) {
1076
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
1077
+ `is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
1078
+ }
1079
+ }
1044
1080
  if (Array.isArray(screen.header?.subtitle)) {
1045
1081
  if (screen.header.subtitleHref !== undefined) {
1046
1082
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
@@ -1736,6 +1772,11 @@ function validateDashboardPanelVisibleWhen(context, visibleWhen) {
1736
1772
  throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
1737
1773
  }
1738
1774
  }
1775
+ function validateDashboardPanelSpan(featureName, screenId, panel) {
1776
+ if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
1777
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
1778
+ }
1779
+ }
1739
1780
  function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
1740
1781
  const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
1741
1782
  if (panel.stats.length === 0) {
@@ -1744,6 +1785,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
1744
1785
  if (panel.subtitle !== undefined && panel.label === undefined) {
1745
1786
  throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header.`);
1746
1787
  }
1788
+ validateDashboardPanelSpan(featureName, screenId, panel);
1747
1789
  validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
1748
1790
  for (const stat of panel.stats) {
1749
1791
  addPanelId(stat.id, "stat-group child");
@@ -1769,8 +1811,9 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1769
1811
  throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1770
1812
  }
1771
1813
  validateStackedAreaOnlyProps(where, panel);
1772
- if (panel.legendTotals !== undefined && typeof panel.legendTotals !== "boolean") {
1773
- throw new Error(`${where} has a non-boolean legendTotals.`);
1814
+ const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1815
+ if (nonBooleanProp !== undefined) {
1816
+ throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
1774
1817
  }
1775
1818
  for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
1776
1819
  if (typeof color !== "string" || color.trim() === "") {
@@ -1787,7 +1830,7 @@ function validateStackedAreaOnlyProps(where, panel) {
1787
1830
  // skip: stacked-area is the one chart kind that supports these props
1788
1831
  if (panel.chart === "stacked-area")
1789
1832
  return;
1790
- const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
1833
+ const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges", "brush"].filter((prop) => panel[prop] !== undefined);
1791
1834
  if (stackedAreaOnly.length > 0) {
1792
1835
  throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
1793
1836
  }
@@ -1856,12 +1899,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
1856
1899
  if (!panel.query || typeof panel.query !== "string") {
1857
1900
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1858
1901
  }
1859
- if ("span" in panel &&
1860
- panel.span !== undefined &&
1861
- panel.span !== "half" &&
1862
- panel.span !== "full") {
1863
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
1864
- }
1902
+ validateDashboardPanelSpan(featureName, screenId, panel);
1865
1903
  if (panel.kind === "stat") {
1866
1904
  if (panel.valueField.length === 0) {
1867
1905
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
@@ -0,0 +1,2 @@
1
+ import { type SelectOptionTone } from "@cosmicdrift/kumiko-types/fields";
2
+ export declare function isSelectOptionTone(value: unknown): value is SelectOptionTone;
@@ -0,0 +1,4 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
+ export function isSelectOptionTone(value) {
3
+ return SELECT_OPTION_TONES.some((tone) => tone === value);
4
+ }
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
268
268
  // one that contradicts the schema.
269
269
  function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
270
270
  return Object.entries(screens).map(([shortId, screen]) => {
271
+ assertValidScreenMultilineRows(featureName, shortId, screen);
271
272
  if (screen.type === "configEdit")
272
273
  return projectConfigEditScreen(screen, encryptedConfigKeyQns);
273
274
  if (screen.type === "projectionList")
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
330
331
  function projectEntities(entities) {
331
332
  const out = {};
332
333
  for (const [name, entity] of Object.entries(entities)) {
333
- out[name] = projectEntity(entity);
334
+ out[name] = projectEntity(name, entity);
334
335
  }
335
336
  return out;
336
337
  }
@@ -339,9 +340,10 @@ function projectEntities(entities) {
339
340
  // einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
340
341
  // Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
341
342
  // Functions, Computed-Functions, Default-Functions.
342
- function projectEntity(entity) {
343
+ function projectEntity(entityName, entity) {
343
344
  const fieldsOut = {};
344
345
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
346
+ assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
345
347
  fieldsOut[fieldName] = projectField(fieldDef);
346
348
  }
347
349
  // derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
@@ -380,6 +382,32 @@ function projectEntity(entity) {
380
382
  function projectDerivedField(derivedDef) {
381
383
  return { valueType: derivedDef.valueType };
382
384
  }
385
+ // A textarea height that is not a positive integer cannot render anywhere; fail
386
+ // at boot with the field name instead of letting each renderer guess a fallback.
387
+ function assertValidMultilineRows(owner, fieldName, fieldDef) {
388
+ const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
389
+ const rows = typeof multiline === "object" ? multiline.rows : undefined;
390
+ // Non-number values are not JSON-safe and get dropped by the projection below.
391
+ if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
392
+ throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
393
+ }
394
+ }
395
+ // Screen forms carry their own inline field maps into the client schema.
396
+ function assertValidScreenMultilineRows(featureName, shortId, screen) {
397
+ const owner = `Screen "${featureName}:${shortId}"`;
398
+ const check = (fields) => {
399
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
400
+ assertValidMultilineRows(owner, fieldName, fieldDef);
401
+ }
402
+ };
403
+ if (screen.type === "configEdit" || screen.type === "actionForm")
404
+ check(screen.fields);
405
+ if (screen.type === "secretMint") {
406
+ check(screen.fields);
407
+ if (screen.confirm !== undefined)
408
+ check(screen.confirm.fields);
409
+ }
410
+ }
383
411
  // Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
384
412
  // an array/plain-object built only from JSON-safe values) — even though the
385
413
  // FieldDefinition types only type `default` as a literal, the sample-authoring
@@ -428,6 +456,9 @@ function projectField(fieldDef) {
428
456
  // fw#2780: author-supplied picker source — without it the client falls back to the list handler.
429
457
  if (typeof def["optionsQuery"] === "string")
430
458
  out["optionsQuery"] = def["optionsQuery"];
459
+ // Select: tier/tenant availability of the static options, loaded by the renderer.
460
+ if (typeof def["optionsAvailabilityQuery"] === "string")
461
+ out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
431
462
  if (typeof def["multiple"] === "boolean")
432
463
  out["multiple"] = def["multiple"];
433
464
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
415
415
  // need a writable local type; each entry is still a fresh, never-mutated object.
416
416
  const translations = {};
417
417
  const sections = [];
418
+ const seenFieldIds = new Map();
418
419
  for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
419
420
  const fieldIds = [];
420
421
  for (const s of secrets.filter((v) => v.feature === feature)) {
421
422
  const fieldId = `${s.feature}-${s.shortKey}`;
423
+ const prevQn = seenFieldIds.get(fieldId);
424
+ if (prevQn !== undefined) {
425
+ throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
426
+ }
427
+ seenFieldIds.set(fieldId, s.qn);
422
428
  fieldIds.push(fieldId);
423
429
  secretKeys[fieldId] = s.qn;
424
430
  const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
6
6
  }
7
7
  return true;
8
8
  }
9
+ function sameKeys(a, b) {
10
+ const aKeys = Object.keys(a ?? {});
11
+ const bKeys = new Set(Object.keys(b ?? {}));
12
+ return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
13
+ }
14
+ // dedupeOptions is a declaration, not proof: two same-named features that both
15
+ // carry `{}` but register different surface must still clash, not silently drop one.
16
+ function sameRegisteredSurface(a, b) {
17
+ return (sameKeys(a.entities, b.entities) &&
18
+ sameKeys(a.writeHandlers, b.writeHandlers) &&
19
+ sameKeys(a.queryHandlers, b.queryHandlers) &&
20
+ sameKeys(a.streamHandlers, b.streamHandlers));
21
+ }
9
22
  function isInterchangeable(existing, next) {
10
23
  if (existing === next)
11
24
  return true;
12
25
  if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
13
- return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions);
26
+ return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
27
+ sameRegisteredSurface(existing, next));
14
28
  }
15
29
  return false;
16
30
  }
17
31
  // First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
32
+ // An app holding its own handle to a later, dropped instance would bind state the registry never
33
+ // runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
18
34
  export function dedupeFeatures(features) {
19
35
  const byName = new Map();
20
36
  const result = [];
@@ -198,11 +198,14 @@ export function defineEntityWriteHandler(name, entity, options) {
198
198
  // query-handler branch below: this ONE write handler reads/writes across
199
199
  // every tenant without making the whole feature r.systemScope().
200
200
  const crossTenantReason = resolveCrossTenantReason(name, options);
201
+ // create always writes into the acting user's own tenant, so its FK,
202
+ // unique and preSave lookups must stay tenant-filtered even when the
203
+ // handler declares cross-tenant access for the row-addressing verbs.
201
204
  const dbFor = (ctx) => {
202
205
  if (ctx.systemDb) {
203
206
  return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
204
207
  }
205
- return crossTenantReason !== undefined
208
+ return crossTenantReason !== undefined && verb !== "create"
206
209
  ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
207
210
  : ctx.db;
208
211
  };
@@ -539,18 +542,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
539
542
  };
540
543
  const resolveWriteOpts = (verb) => {
541
544
  const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
545
+ const verbAgent = options?.agents?.[verb];
542
546
  return {
543
547
  ...writeOpts,
544
548
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
545
549
  description: options?.descriptions?.[verb] ?? writeOpts?.description,
550
+ ...(verbAgent !== undefined && { agent: verbAgent }),
546
551
  ...(excludeFields !== undefined && { excludeFields }),
547
552
  };
548
553
  };
549
- const resolveReadOpts = (verb) => ({
550
- ...readOpts,
551
- access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
552
- description: options?.descriptions?.[verb] ?? readOpts?.description,
553
- });
554
+ const resolveReadOpts = (verb) => {
555
+ const verbAgent = options?.agents?.[verb];
556
+ return {
557
+ ...readOpts,
558
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
559
+ description: options?.descriptions?.[verb] ?? readOpts?.description,
560
+ ...(verbAgent !== undefined && { agent: verbAgent }),
561
+ };
562
+ };
554
563
  if (verbs.create) {
555
564
  r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
556
565
  }
@@ -1,8 +1,7 @@
1
1
  import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
2
2
  import type * as z from "zod";
3
- import type { ZodType } from "zod";
4
3
  import type { HttpRouteDefinition } from "./types/http-route.js";
5
- import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
4
+ import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
6
5
  import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
7
6
  import type { ScreenDefinition } from "./types/screen.js";
8
7
  import type { WorkspaceDefinition } from "./types/workspace.js";
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
23
22
  configKeys: Record<string, ConfigKeyDefinition>;
24
23
  configSeeds: ConfigSeedDef[];
25
24
  jobs: Record<string, JobDefinition>;
26
- events: Record<string, {
27
- name: string;
28
- schema: ZodType;
29
- version: number;
30
- }>;
25
+ events: Record<string, EventDef>;
31
26
  eventMigrations: Record<string, EventMigrationDef[]>;
32
27
  configReads: string[];
33
28
  entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
@@ -73,6 +73,9 @@ export function buildConfigEventsJobsMethods(state, name) {
73
73
  if (field === normalized.ownerField) {
74
74
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`);
75
75
  }
76
+ if (normalized.ownerField === "id") {
77
+ throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" uses owner field "id" — on entities \`of: "id"\` means record-owned, but on events it would encrypt under a phantom user key that a forget never reaches. Use \`personal: "self"\` for a record-owned event field.`);
78
+ }
76
79
  for (const required of [field, normalized.ownerField]) {
77
80
  if (shape && !(required in shape)) {
78
81
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`);
@@ -55,7 +55,7 @@ function agentSlots(source) {
55
55
  };
56
56
  }
57
57
  function requireInlineHandlerArgs(kind, schema, handler, options) {
58
- if (!schema || !handler || !options) {
58
+ if (!schema || !handler || !options?.access) {
59
59
  throw new Error(`${kind} inline form requires schema + handler + options.access`);
60
60
  }
61
61
  return { schema, handler, options };
@@ -1,10 +1,21 @@
1
1
  // Dot-form labels are indistinguishable from literal display text ("actions.open"),
2
2
  // so authors mark the i18n ones explicitly; the registry is what boot validation reads.
3
- const explicitDotFormKeys = new Set();
3
+ // Marking is by string value and therefore global across features. The registry sits on
4
+ // globalThis so a second installed copy of the framework still reads the marks the
5
+ // other copy wrote; otherwise they are silently ignored and #2313 reopens.
6
+ const REGISTRY_KEY = Symbol.for("kumiko.i18n.explicitDotFormKeys");
7
+ function explicitDotFormKeys() {
8
+ const existing = Reflect.get(globalThis, REGISTRY_KEY);
9
+ if (existing instanceof Set)
10
+ return existing;
11
+ const created = new Set();
12
+ Reflect.set(globalThis, REGISTRY_KEY, created);
13
+ return created;
14
+ }
4
15
  export function i18nKey(value) {
5
- explicitDotFormKeys.add(value);
16
+ explicitDotFormKeys().add(value);
6
17
  return value;
7
18
  }
8
19
  export function isExplicitDotFormKey(value) {
9
- return explicitDotFormKeys.has(value);
20
+ return explicitDotFormKeys().has(value);
10
21
  }
@@ -59,7 +59,7 @@ export { createRegistry } from "./registry.js";
59
59
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
60
60
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
61
61
  export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
62
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
62
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
63
63
  export { runsInLane } from "./run-in.js";
64
64
  export type { StepListOutcome } from "./run-pipeline.js";
65
65
  export { runPipeline, runStepList } from "./run-pipeline.js";
@@ -55,7 +55,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
55
55
  export { readClaim } from "./read-claim.js";
56
56
  export { createRegistry } from "./registry.js";
57
57
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
58
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
58
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
59
59
  export { runsInLane } from "./run-in.js";
60
60
  export { runPipeline, runStepList } from "./run-pipeline.js";
61
61
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
@@ -79,9 +79,26 @@ export function stepsPipeline(closure) {
79
79
  // carried in (D4, workflow-resume-loop.md), never an unchecked external
80
80
  // value.
81
81
  export function buildPipelineSteps(pipelineDef, event) {
82
- return pipelineDef.build({
82
+ const steps = pipelineDef.build({
83
83
  event,
84
84
  r: stepBuilder,
85
85
  awaits: (pipelineDef.awaits ?? {}),
86
86
  });
87
+ assertUniqueWaitForEventTypes(steps);
88
+ return steps;
89
+ }
90
+ // waitForEvent's result key is the awaited event type, so a second wait on the
91
+ // same type would overwrite the first one's resumed payload.
92
+ function assertUniqueWaitForEventTypes(steps) {
93
+ const seen = new Set();
94
+ for (const step of steps) {
95
+ if (step.kind !== "workflow.waitForEvent")
96
+ continue;
97
+ const { event } = step.args; // @cast-boundary engine-payload
98
+ if (seen.has(event)) {
99
+ throw new Error(`r.step.waitForEvent: event "${String(event)}" is awaited more than once in one workflow — ` +
100
+ "each awaits entry can back only one waitForEvent step.");
101
+ }
102
+ seen.add(event);
103
+ }
87
104
  }
@@ -530,20 +530,26 @@ function projectActionFormScreen(screen, feature, indices, keptScreenQns) {
530
530
  };
531
531
  }
532
532
  function projectSecretMintScreen(screen, feature, indices, keptScreenQns) {
533
- const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
533
+ const { redirect, cancelTarget, layout, listScreenId, confirm, ...rest } = screen;
534
534
  const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
535
535
  const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
536
536
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
537
537
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
538
+ const nextConfirmLayout = confirm === undefined
539
+ ? undefined
540
+ : projectEditLayout(confirm.layout, feature, indices, keptScreenQns);
538
541
  const unchanged = nextRedirect === redirect &&
539
542
  nextCancelTarget === cancelTarget &&
540
543
  nextLayout === layout &&
541
- nextListScreenId === listScreenId;
544
+ nextListScreenId === listScreenId &&
545
+ nextConfirmLayout === confirm?.layout;
542
546
  if (unchanged)
543
547
  return screen;
544
548
  return {
545
549
  ...rest,
546
550
  layout: nextLayout,
551
+ ...(confirm !== undefined &&
552
+ nextConfirmLayout !== undefined && { confirm: { ...confirm, layout: nextConfirmLayout } }),
547
553
  ...(nextRedirect !== undefined && { redirect: nextRedirect }),
548
554
  ...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
549
555
  ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
@@ -1,4 +1,6 @@
1
1
  import type { Registry } from "./types/feature.js";
2
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
3
+ export declare const BUILT_IN_MEMBERSHIP_ROLES: readonly string[];
2
4
  declare const ASSIGNABLE_FROM_ROLE_LIST: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin", "SystemAdmin"];
3
5
  export type AssignableFromRole = (typeof ASSIGNABLE_FROM_ROLE_LIST)[number];
4
6
  /** App role → lowest built-in role allowed to assign it. */
@@ -16,6 +16,8 @@ const ROLE_RANKS = new Map([
16
16
  ["SystemAdmin", 4],
17
17
  ["system", 5],
18
18
  ]);
19
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
20
+ export const BUILT_IN_MEMBERSHIP_ROLES = [...ROLE_RANKS.keys()].filter((role) => role !== "system");
19
21
  const ASSIGNABLE_FROM_ROLE_LIST = [
20
22
  "User",
21
23
  "Member",
@@ -87,6 +87,14 @@ export function fieldToZod(field, currencies, opts = {}) {
87
87
  let schema = z.string();
88
88
  if (field.maxLength)
89
89
  schema = schema.max(field.maxLength);
90
+ if (field.minLength) {
91
+ // "" must stay valid for optional fields and writeOnly updates (""
92
+ // = unchanged); `required` rejects it separately via min(1) below.
93
+ const minLength = field.minLength;
94
+ schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
95
+ message: `at least ${minLength} characters`,
96
+ });
97
+ }
90
98
  if (field.writeOnly === true) {
91
99
  // Update: "" = unchanged (dropped before the executor), null = clear,
92
100
  // unless required. Create: "" would leave a required field unset.
@@ -17,9 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
17
17
  const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
18
18
  // Resolve the SQL column names a table exposes, so the lint can tell a
19
19
  // real column reference apart from a table alias / function name / SQL
20
- // keyword. Returns an empty set when nothing is derivable; the column-aware
21
- // check then finds no bare column and is effectively skipped (only the
22
- // self-comparison check still applies).
20
+ // keyword. Returns an empty set when nothing is derivable; callers that need
21
+ // the column-aware check must treat that as "unknown" (see
22
+ // assertQualifiedWhereFragment, which fails closed on it).
23
23
  export function tableColumnSqlNames(table) {
24
24
  if (table === null || typeof table !== "object")
25
25
  return new Set();
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
182
182
  // the fail-open shape this lint targets is impossible here.
183
183
  if (!/\bSELECT\b/i.test(cleaned))
184
184
  return;
185
+ if (columnSqlNames.size === 0) {
186
+ throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
187
+ }
185
188
  const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
186
189
  if (unqualified !== null) {
187
190
  throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
88
88
  /** When set, error suggestions include `pulumi config set <prefix>...`. */
89
89
  readonly pulumiPrefix?: string;
90
90
  };
91
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
92
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
93
+ * is `undefined` in the result despite its `string` type — it is only set once
94
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
91
95
  export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
92
96
  /** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
93
97
  * Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
package/dist/env/index.js CHANGED
@@ -259,6 +259,10 @@ function safeParseReportingThrows(schema, input) {
259
259
  ]);
260
260
  }
261
261
  }
262
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
263
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
264
+ * is `undefined` in the result despite its `string` type — it is only set once
265
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
262
266
  export function parseEnv(schema, env, options = {}) {
263
267
  // Filter undefined values to keep Zod's required-vs-invalid signal clean.
264
268
  // (process.env returns string|undefined; passing undefined would parse