@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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 (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -7,20 +7,14 @@ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
7
  import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } 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
- import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
10
+ import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
11
+ import { isEncryptedAtRest } from "../config-helpers.js";
11
12
  import { parseRefTarget } from "../parse-ref-target.js";
12
13
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
13
14
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
14
15
  import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
15
16
  import { metricField } from "../types/index.js";
16
- // entityList columns accept exactly the row-meta columns the renderer
17
- // (computeListViewModel) knows how to type — LIST_ROW_META_COLUMNS, NOT the
18
- // wider rowMetaFieldNames(softDelete) used below for rowAction payload/visible
19
- // field refs. Those two checks guard different things: a screen column must
20
- // be renderable, a payload/visible field reference only needs to exist on the
21
- // row. Using the wider set here would let a softDelete column like
22
- // "deletedAt" pass the boot gate and then throw at render-time instead.
23
- const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
17
+ import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
24
18
  // entityList and projectionList both allow a rowAction to double as the
25
19
  // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
26
20
  // screen, or the renderer can't tell which one should fire.
@@ -486,11 +480,13 @@ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNa
486
480
  }
487
481
  }
488
482
  }
483
+ function isWriteOnlyField(fdef) {
484
+ return fdef?.writeOnly === true; // @cast-boundary schema-walk
485
+ }
489
486
  // Inline forms have no stored value to hide or keep: writeOnly there would
490
487
  // render a "set / keep" affordance with nothing behind it.
491
488
  function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
492
- if (fdef.writeOnly === true) {
493
- // @cast-boundary schema-walk
489
+ if (isWriteOnlyField(fdef)) {
494
490
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
495
491
  `writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
496
492
  `Use format: "password" for a masked input.`);
@@ -616,8 +612,21 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
616
612
  `name the success-payload field carrying the navigation id, or use the plain string ` +
617
613
  `redirect form to navigate with the handler's own "id".`);
618
614
  }
615
+ // The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
616
+ // never matches and the navigation silently lands without an entityId.
617
+ if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
618
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom "${redirect.idFrom}" ` +
619
+ `has leading/trailing whitespace — it would never match a payload field.`);
620
+ }
619
621
  validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
620
622
  }
623
+ // A writeHandler record action's redirect (projectionDetail/entityEdit
624
+ // header and section actions) follows the entityEdit.redirect rule.
625
+ function validateRecordActionRedirect(feature, screenId, screenKind, action, allScreenQns) {
626
+ if (action.kind !== "navigate" && action.kind !== "drawer" && action.redirect !== undefined) {
627
+ validateRedirectTarget(feature, screenId, screenKind, action.redirect, allScreenQns);
628
+ }
629
+ }
621
630
  function validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns) {
622
631
  if (screen.redirect !== undefined) {
623
632
  validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
@@ -681,20 +690,30 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
681
690
  }
682
691
  }
683
692
  }
684
- // nav.screen is a full QN (cross-feature), so a standalone r.nav() pointing
685
- // at `targetQn` must be found by scanning every feature's nav entries and actions.
686
- function hasStandaloneNavEntry(targetQn, featureMap) {
693
+ // validateScreenHasNavArea runs once per screen; the index is built once per
694
+ // featureMap (immutable during boot) instead of re-scanning every feature each call.
695
+ const navAreaIndexByFeatureMap = new WeakMap();
696
+ function navTargetScreenQns(nav) {
697
+ return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
698
+ }
699
+ function getNavAreaIndex(featureMap) {
700
+ const cached = navAreaIndexByFeatureMap.get(featureMap);
701
+ if (cached !== undefined)
702
+ return cached;
703
+ const navTargetQns = new Set();
704
+ const allScreens = [];
705
+ let anyNavRegistered = false;
687
706
  for (const f of featureMap.values()) {
707
+ allScreens.push(...Object.values(f.screens));
688
708
  for (const nav of Object.values(f.navs)) {
689
- if (nav.screen === targetQn)
690
- return true;
691
- if (nav.createAction?.screen === targetQn)
692
- return true;
693
- if (nav.actions?.some((a) => a.screen === targetQn))
694
- return true;
709
+ anyNavRegistered = true;
710
+ for (const qn of navTargetScreenQns(nav))
711
+ navTargetQns.add(qn);
695
712
  }
696
713
  }
697
- return false;
714
+ const index = { anyNavRegistered, navTargetQns, allScreens };
715
+ navAreaIndexByFeatureMap.set(featureMap, index);
716
+ return index;
698
717
  }
699
718
  // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
700
719
  // `dormant: true`. Skipped when the composed set has no nav entries at all.
@@ -705,16 +724,15 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
705
724
  // skip: explicitly opted out via `dormant: true`.
706
725
  if (screen.dormant === true)
707
726
  return;
708
- const anyNavRegistered = [...featureMap.values()].some((f) => Object.keys(f.navs).length > 0);
727
+ const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
709
728
  // skip: no nav entries exist anywhere in the composed set — a feature/
710
729
  // recipe/test fixture booted without an app shell, not an omission.
711
730
  if (!anyNavRegistered)
712
731
  return;
713
732
  const targetQn = qualifyEntityName(feature.name, "screen", screenId);
714
733
  // skip: a standalone r.nav() elsewhere already points at this screen.
715
- if (hasStandaloneNavEntry(targetQn, featureMap))
734
+ if (navTargetQns.has(targetQn))
716
735
  return;
717
- const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
718
736
  // skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
719
737
  if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
720
738
  return;
@@ -815,7 +833,16 @@ function validateRelatedListToolbarActions(args, toolbarActions) {
815
833
  }
816
834
  }
817
835
  }
818
- export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns, screensByShortId, detailForScreens) {
836
+ // Only a navigate action that sets `tab` needs the target resolved (to check
837
+ // the tab id); plain navigates are validated elsewhere. One place for every
838
+ // section-level action site so a new site can't forget the tab check.
839
+ function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
840
+ // skip: only navigate actions with an explicit tab need target validation
841
+ if (action.kind !== "navigate" || action.tab === undefined)
842
+ return;
843
+ resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
844
+ }
845
+ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
819
846
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
820
847
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
821
848
  // Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
@@ -824,6 +851,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
824
851
  // zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
825
852
  // #1946) — kurze IDs bleiben same-feature wie zuvor.
826
853
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
854
+ const navigateLookups = {
855
+ allScreenQns,
856
+ navTargetShortIds,
857
+ screensByShortId,
858
+ detailForScreens,
859
+ };
827
860
  for (const [screenId, screen] of Object.entries(feature.screens)) {
828
861
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
829
862
  if (screen.type === "custom") {
@@ -944,14 +977,18 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
944
977
  `there is no fallback to the raw column name.`);
945
978
  }
946
979
  const navigate = typeof metric === "string" ? undefined : metric.navigate;
947
- if (navigate?.tab !== undefined) {
980
+ if (navigate !== undefined) {
948
981
  if (navigate.screen === undefined && navigate.entity === undefined) {
949
- if (!screen.layout.sections.some((section) => section.id === navigate.tab)) {
982
+ if (navigate.tab !== undefined &&
983
+ !screen.layout.sections.some((section) => section.id === navigate.tab)) {
950
984
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
951
985
  `navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
952
986
  }
953
987
  }
954
988
  else {
989
+ // Also runs without `tab`: a typo'd screen/entity or an entity
990
+ // target without entityId would otherwise render a clickable
991
+ // metric that silently does nothing.
955
992
  resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "metric", {
956
993
  kind: "navigate",
957
994
  id: field,
@@ -959,7 +996,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
959
996
  ...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
960
997
  ...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
961
998
  ...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
962
- tab: navigate.tab,
999
+ ...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
963
1000
  }, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
964
1001
  }
965
1002
  }
@@ -1001,17 +1038,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1001
1038
  if (section.actions !== undefined) {
1002
1039
  for (const action of section.actions) {
1003
1040
  validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action);
1004
- if (action.kind === "navigate" && action.tab !== undefined) {
1005
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1006
- }
1041
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1042
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, navigateLookups);
1007
1043
  }
1008
1044
  }
1009
1045
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
1010
1046
  validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action);
1011
- if (section.emptyState.action.kind === "navigate" &&
1012
- section.emptyState.action.tab !== undefined) {
1013
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1014
- }
1047
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
1015
1048
  }
1016
1049
  if (isExtensionEditSection(section)) {
1017
1050
  // projectionDetail is read-only (no composed form submit) — an
@@ -1116,6 +1149,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1116
1149
  if (screen.actions !== undefined) {
1117
1150
  for (const action of screen.actions) {
1118
1151
  validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
1152
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1119
1153
  if (action.kind === "navigate" && action.rowClick === true) {
1120
1154
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1121
1155
  `(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
@@ -1160,7 +1194,6 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1160
1194
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1161
1195
  }
1162
1196
  validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
1163
- rejectWriteOnlyFormField(feature.name, screenId, "configEdit", fname, fdef);
1164
1197
  }
1165
1198
  if (screen.layout.sections.length === 0) {
1166
1199
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1202,10 +1235,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1202
1235
  `keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
1203
1236
  `einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
1204
1237
  }
1205
- if (!allConfigKeyQns.has(qualified)) {
1238
+ const configKeyDef = configKeyDefsByQn.get(qualified);
1239
+ if (configKeyDef === undefined) {
1206
1240
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
1207
1241
  `Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
1208
- `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...allConfigKeyQns].sort().join(", ") || "(keine)"}`);
1242
+ `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(keine)"}`);
1243
+ }
1244
+ if (isWriteOnlyField(screen.fields[fname]) && !isEncryptedAtRest(configKeyDef)) {
1245
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" declares writeOnly ` +
1246
+ `but config key "${qualified}" is not encrypted at rest — writeOnly on a configEdit field ` +
1247
+ `only hides a stored secret (declare the key with encrypted: true or backing: "secrets").`);
1209
1248
  }
1210
1249
  }
1211
1250
  continue;
@@ -1297,6 +1336,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1297
1336
  throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
1298
1337
  }
1299
1338
  validateColumnRendererForm(feature.name, screenId, normalized);
1339
+ if (normalized.refEntity !== undefined) {
1340
+ assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
1341
+ }
1300
1342
  }
1301
1343
  // Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
1302
1344
  // fangen, damit kein "warum kommt die Liste leer / falsch
@@ -1488,9 +1530,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1488
1530
  const sectionLabel = section.title ?? section.id ?? "(untitled)";
1489
1531
  for (const action of section.actions) {
1490
1532
  validateActionHasIcon(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action);
1491
- if (action.kind === "navigate" && action.tab !== undefined) {
1492
- resolveRowActionNavigateTarget(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1493
- }
1533
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1534
+ validateNavigateActionTab(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, navigateLookups);
1494
1535
  }
1495
1536
  }
1496
1537
  if (isExtensionEditSection(section)) {
@@ -1525,6 +1566,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1525
1566
  // row to click either.
1526
1567
  if (screen.actions !== undefined) {
1527
1568
  for (const action of screen.actions) {
1569
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1528
1570
  if (action.kind === "navigate" && action.rowClick === true) {
1529
1571
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1530
1572
  `(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
@@ -1584,7 +1626,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1584
1626
  validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
1585
1627
  }
1586
1628
  else {
1587
- validateDashboardQueryPanel(featureName, screenId, panel);
1629
+ validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
1588
1630
  }
1589
1631
  }
1590
1632
  if (screen.filter !== undefined) {
@@ -1638,6 +1680,15 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
1638
1680
  throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
1639
1681
  `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
1640
1682
  }
1683
+ // ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
1684
+ // cancel even when embedded, which would pull the user off the dashboard.
1685
+ if ((target.type === "actionForm" || target.type === "secretMint") &&
1686
+ (target.redirect !== undefined ||
1687
+ (target.cancelTarget !== undefined && target.cancelTarget !== false))) {
1688
+ throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
1689
+ `navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
1690
+ `with cancelTarget: false (or omitted).`);
1691
+ }
1641
1692
  const visibleWhen = panel.visibleWhen;
1642
1693
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
1643
1694
  throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
@@ -1663,7 +1714,7 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
1663
1714
  `declare a react/native component marker.`);
1664
1715
  }
1665
1716
  }
1666
- function validateDashboardQueryPanel(featureName, screenId, panel) {
1717
+ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
1667
1718
  if (!panel.query || typeof panel.query !== "string") {
1668
1719
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1669
1720
  }
@@ -1681,7 +1732,11 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
1681
1732
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
1682
1733
  }
1683
1734
  for (const col of panel.columns) {
1684
- validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
1735
+ const normalized = normalizeListColumn(col);
1736
+ validateColumnRendererForm(featureName, screenId, normalized);
1737
+ if (normalized.refEntity !== undefined) {
1738
+ assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
1739
+ }
1685
1740
  }
1686
1741
  }
1687
1742
  }
@@ -1746,10 +1801,14 @@ export function validateColumnRendererForm(featureName, screenId, column) {
1746
1801
  function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
1747
1802
  const target = parseRefTarget(refTarget, currentFeatureName);
1748
1803
  const targetFeature = featureMap.get(target.featureName);
1749
- if (targetFeature?.entities?.[target.entityName] === undefined) {
1804
+ if (targetFeature === undefined) {
1805
+ throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
1806
+ `mounted — mount it and declare r.requires("${target.featureName}").`);
1807
+ }
1808
+ if (targetFeature.entities?.[target.entityName] === undefined) {
1750
1809
  throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
1751
1810
  `registered entity. Known entities in feature "${target.featureName}": ` +
1752
- `${Object.keys(targetFeature?.entities ?? {})
1811
+ `${Object.keys(targetFeature.entities ?? {})
1753
1812
  .sort()
1754
1813
  .join(", ") || "(none)"}.`);
1755
1814
  }
@@ -1790,9 +1849,29 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
1790
1849
  }
1791
1850
  if (facet.type === "reference") {
1792
1851
  assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1852
+ assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1793
1853
  }
1794
1854
  }
1795
1855
  }
1856
+ // The facet's options are loaded at render time by dispatching the target's
1857
+ // list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
1858
+ // override); a missing or unregistered handler is swallowed there and leaves
1859
+ // a permanently empty dropdown, so it has to fail at boot instead.
1860
+ function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
1861
+ const target = parseRefTarget(refTarget, currentFeatureName);
1862
+ const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
1863
+ const expectedQn = override?.queryQn ??
1864
+ qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
1865
+ for (const feature of featureMap.values()) {
1866
+ for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
1867
+ // skip: expected list handler found
1868
+ if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
1869
+ return;
1870
+ }
1871
+ }
1872
+ throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
1873
+ `query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
1874
+ }
1796
1875
  export function findEntityFeature(entityName, featureMap) {
1797
1876
  for (const [name, feature] of featureMap) {
1798
1877
  if (feature.entities?.[entityName])
@@ -40,9 +40,10 @@ function referenceTargets(entity) {
40
40
  return targets;
41
41
  }
42
42
  // A `multiple` reference stores a jsonb array of ids, which the handover's
43
- // `= ANY($ids)` column match cannot address. Declaring one on a transferable
44
- // entity would mean its rows never move with their host.
45
- function validateNoMultipleReferenceEdge(entry, featureName) {
43
+ // `= ANY($ids)` column match cannot address. Declaring one towards a transferable
44
+ // entity would mean its rows never move with their host. A target outside the
45
+ // graph (e.g. a plain lookup) is never a host, so it cannot strand anything.
46
+ function validateNoMultipleReferenceEdge(entry, featureName, transferable) {
46
47
  // skip: the entity never travels, so the shape that would strand its rows
47
48
  // during a handover cannot arise — rejecting it would break consumers that
48
49
  // legitimately declare a multiple reference on a non-transferable entity.
@@ -51,6 +52,8 @@ function validateNoMultipleReferenceEdge(entry, featureName) {
51
52
  for (const [fieldName, field] of Object.entries(entry.entity.fields)) {
52
53
  if (field.type !== "reference" || field.multiple !== true)
53
54
  continue;
55
+ if (!transferable.has(parseRefTargetEntityName(field.entity)))
56
+ continue;
54
57
  throw new Error(`[Kumiko TransferGraph] entity "${entry.name}" declares transferable: true and a ` +
55
58
  `multiple reference field "${fieldName}" -> "${field.entity}" (feature: "${featureName}"). ` +
56
59
  `A multiple reference stores a jsonb array, which the tenant-handover transfer graph ` +
@@ -85,12 +88,12 @@ function longestTransferableChain(startName, bySource, onPath) {
85
88
  return undefined;
86
89
  }
87
90
  export function validateTransferGraph(feature, featureMap) {
88
- for (const [name, entity] of Object.entries(feature.entities ?? {})) {
89
- validateNoMultipleReferenceEdge({ name, entity }, feature.name);
90
- }
91
91
  const transferable = new Set(allEntities(featureMap)
92
92
  .filter((e) => e.entity.transferable === true)
93
93
  .map((e) => e.name));
94
+ for (const [name, entity] of Object.entries(feature.entities ?? {})) {
95
+ validateNoMultipleReferenceEdge({ name, entity }, feature.name, transferable);
96
+ }
94
97
  // Edges point child -> parent in the schema; the graph walks parent -> child,
95
98
  // so the lookup is inverted here.
96
99
  const childrenByParent = new Map();
@@ -20,12 +20,14 @@ function subtitlePartNavigates(screen) {
20
20
  function projectionDetailNavigateActions(screen) {
21
21
  return [
22
22
  ...scopeActions(screen.actions, screen.detailFor),
23
- ...screen.layout.sections.flatMap((section) => section.kind === "relatedList" ? scopeActions(section.rowActions, undefined) : []),
24
- ...screen.layout.sections.flatMap((section) => section.kind === "relatedList"
25
- ? scopeActions(section.toolbarActions, undefined, {
26
- pick: [relatedListParentParamField(section)],
27
- })
28
- : []),
23
+ ...screen.layout.sections.flatMap((section) => section.kind !== "relatedList"
24
+ ? []
25
+ : [
26
+ ...scopeActions(section.rowActions, undefined),
27
+ ...scopeActions(section.toolbarActions, undefined, {
28
+ pick: [relatedListParentParamField(section)],
29
+ }),
30
+ ]),
29
31
  ...(screen.metrics ?? []).flatMap((metric) => typeof metric === "string" || metric.navigate === undefined
30
32
  ? []
31
33
  : scopeActions([metric.navigate], undefined)),
@@ -1,4 +1,4 @@
1
- import { ZodArray, ZodCatch, ZodDefault, ZodIntersection, ZodLazy, ZodNonOptional, ZodNullable, ZodObject, ZodOptional, ZodPipe, ZodPrefault, ZodReadonly, ZodRecord, ZodTuple, ZodUnion, ZodXor, } from "zod";
1
+ import { ZodArray, ZodCatch, ZodDefault, ZodIntersection, ZodLazy, ZodMap, ZodNonOptional, ZodNullable, ZodObject, ZodOptional, ZodPipe, ZodPrefault, ZodPromise, ZodReadonly, ZodRecord, ZodSet, ZodTuple, ZodUnion, ZodXor, } from "zod";
2
2
  // Drills through wrapper types (.nullable(), .optional(), .default()) a
3
3
  // handler's schema may use around its actual object/array shape — e.g. a
4
4
  // detail query returning `Row | null` when the record doesn't exist.
@@ -27,7 +27,10 @@ export function getZodObjectShape(schema) {
27
27
  if (schema === undefined)
28
28
  return undefined;
29
29
  const unwrapped = unwrapZodType(schema);
30
- return unwrapped instanceof ZodObject ? unwrapped.shape : undefined;
30
+ // loose()/catchall() objects accept keys beyond the declared shape, so the
31
+ // declared keys are not the full set callers may legitimately reference.
32
+ const hasOpenKeys = unwrapped instanceof ZodObject && unwrapped.def.catchall !== undefined;
33
+ return unwrapped instanceof ZodObject && !hasOpenKeys ? unwrapped.shape : undefined;
31
34
  }
32
35
  // The shape of one row for a query whose result follows the paged-list
33
36
  // contract `{ rows: T[], nextCursor, total? }` (projectionList/relatedList/
@@ -68,12 +71,22 @@ function combinatorChildSchemas(schema) {
68
71
  return [];
69
72
  }
70
73
  function childSchemas(schema) {
71
- if (schema instanceof ZodObject)
72
- return Object.values(schema.shape);
74
+ if (schema instanceof ZodObject) {
75
+ // .catchall()/z.looseObject() values: keys are arbitrary, but a nested
76
+ // object inside the catchall schema can still carry personal-data fields.
77
+ const { catchall } = schema.def;
78
+ return catchall ? [...Object.values(schema.shape), catchall] : Object.values(schema.shape);
79
+ }
73
80
  if (schema instanceof ZodArray)
74
81
  return [schema.element];
75
82
  if (schema instanceof ZodRecord)
76
83
  return [schema.valueType];
84
+ if (schema instanceof ZodMap)
85
+ return [schema.keyType, schema.valueType];
86
+ if (schema instanceof ZodSet)
87
+ return [schema.def.valueType];
88
+ if (schema instanceof ZodPromise)
89
+ return [schema.unwrap()];
77
90
  if (schema instanceof ZodTuple) {
78
91
  const { items, rest } = schema.def;
79
92
  return rest ? [...items, rest] : items;
@@ -29,14 +29,18 @@
29
29
  import { ZodObject } from "zod";
30
30
  import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
31
31
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";
32
- import { qualifyEntityName } from "./qualified-name.js";
32
+ import { isEncryptedAtRest } from "./config-helpers.js";
33
+ import { QnTypes, qualifyEntityName } from "./qualified-name.js";
33
34
  export function buildAppSchema(registry, options = {}) {
34
35
  const features = [];
35
36
  const urlPrefillFieldsByScreenQn = collectUrlPrefillFieldsByScreenQn([
36
37
  ...registry.features.values(),
37
38
  ]);
39
+ const encryptedConfigKeyQns = collectEncryptedConfigKeyQns(registry);
38
40
  for (const [featureName, feature] of registry.features) {
39
- const navs = Object.values(feature.navs);
41
+ // r.nav() may carry explicit `undefined` optionals; drop them so the
42
+ // schema stays free of non-JSON-safe values.
43
+ const navs = Object.values(feature.navs).map((nav) => Object.fromEntries(Object.entries(nav).filter(([, value]) => value !== undefined)));
40
44
  // The nav entry alone doesn't say which kind a collection lists, so the
41
45
  // client can't derive its tree provider from `navs` — project the
42
46
  // collections separately, with the nav QN already qualified.
@@ -47,7 +51,7 @@ export function buildAppSchema(registry, options = {}) {
47
51
  const featureSchema = {
48
52
  featureName,
49
53
  entities: projectEntities(feature.entities ?? {}),
50
- screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn),
54
+ screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns),
51
55
  ...(navs.length > 0 && { navs }),
52
56
  ...(contentCollections.length > 0 && { contentCollections }),
53
57
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -262,8 +266,10 @@ export function findNonJsonSafePath(value, path) {
262
266
  // (fw#2165). A hand-written `searchable: true` still wins over the derived
263
267
  // default (screen.searchable ?? derived); the boot-validator (3a) rejects
264
268
  // one that contradicts the schema.
265
- function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn) {
269
+ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
266
270
  return Object.entries(screens).map(([shortId, screen]) => {
271
+ if (screen.type === "configEdit")
272
+ return projectConfigEditScreen(screen, encryptedConfigKeyQns);
267
273
  if (screen.type === "projectionList")
268
274
  return projectProjectionListScreen(screen, registry);
269
275
  if (screen.type === "actionForm" ||
@@ -275,6 +281,31 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
275
281
  return screen;
276
282
  });
277
283
  }
284
+ function collectEncryptedConfigKeyQns(registry) {
285
+ const qns = new Set();
286
+ for (const [featureName, feature] of registry.features) {
287
+ for (const [key, def] of Object.entries(feature.configKeys)) {
288
+ if (isEncryptedAtRest(def))
289
+ qns.add(qualifyEntityName(featureName, QnTypes.config, key));
290
+ }
291
+ }
292
+ return qns;
293
+ }
294
+ // A hand-written configEdit field over an encrypted-at-rest key would load the
295
+ // read mask as editable text and a partial edit would overwrite the secret with
296
+ // it. Forcing writeOnly here matches what deriveField does for generated screens.
297
+ function projectConfigEditScreen(screen, encryptedConfigKeyQns) {
298
+ let fields;
299
+ for (const [fieldName, field] of Object.entries(screen.fields)) {
300
+ const qualified = screen.configKeys[fieldName];
301
+ if (field.type !== "text" || qualified === undefined || !encryptedConfigKeyQns.has(qualified)) {
302
+ continue;
303
+ }
304
+ fields ??= { ...screen.fields };
305
+ fields[fieldName] = { ...field, writeOnly: true };
306
+ }
307
+ return fields === undefined ? screen : { ...screen, fields };
308
+ }
278
309
  function projectProjectionListScreen(screen, registry) {
279
310
  const schema = registry.getQueryHandler(screen.query)?.schema;
280
311
  const capabilities = deriveProjectionListCapabilities(schema);
@@ -384,7 +415,7 @@ function projectField(fieldDef) {
384
415
  // Select: options-Liste ist plain JSON, durchschicken.
385
416
  if (Array.isArray(def["options"]))
386
417
  out["options"] = def["options"];
387
- if (isPlainObject(def["optionTones"]) && isJsonSafeValue(def["optionTones"]))
418
+ if (isJsonSafeObject(def["optionTones"]))
388
419
  out["optionTones"] = def["optionTones"];
389
420
  // Reference: entity-Target + labelField + multiple müssen zum Renderer.
390
421
  // Der ReferenceInput baut die Options-Query aus refEntity/refFeature und
@@ -410,8 +441,7 @@ function projectField(fieldDef) {
410
441
  out["maxRows"] = def["maxRows"];
411
442
  // text/longText: textarea row count — DefaultInput renders a single-line
412
443
  // input otherwise (fw#2497).
413
- if (typeof def["multiline"] === "boolean" ||
414
- (isPlainObject(def["multiline"]) && isJsonSafeValue(def["multiline"])))
444
+ if (typeof def["multiline"] === "boolean" || isJsonSafeObject(def["multiline"]))
415
445
  out["multiline"] = def["multiline"];
416
446
  // number: Zod write-boundary bounds. date/timestamp/locatedTimestamp:
417
447
  // ISO-string picker bounds — same keys, different literal type (fw#2497).
@@ -447,11 +477,11 @@ function projectField(fieldDef) {
447
477
  out["locatedBy"] = def["locatedBy"];
448
478
  // file/image/images: upload picker constraints; the first variant key picks
449
479
  // the preview variant.
450
- if (Array.isArray(def["accept"]) && isJsonSafeValue(def["accept"]))
480
+ if (isJsonSafeArray(def["accept"]))
451
481
  out["accept"] = def["accept"];
452
482
  if (typeof def["maxSize"] === "string")
453
483
  out["maxSize"] = def["maxSize"];
454
- if (isPlainObject(def["variants"]) && isJsonSafeValue(def["variants"]))
484
+ if (isJsonSafeObject(def["variants"]))
455
485
  out["variants"] = def["variants"];
456
486
  // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
457
487
  if (typeof def["scale"] === "number")
@@ -459,7 +489,7 @@ function projectField(fieldDef) {
459
489
  // money: declared currency source (MoneyCurrencySource, fw#2933) — without
460
490
  // it the renderer can't tell a "currency: { kind: 'tenant' }" field apart
461
491
  // from an undeclared one and always falls back to entity.defaultCurrency.
462
- if (isPlainObject(def["currency"]) && isJsonSafeValue(def["currency"]))
492
+ if (isJsonSafeObject(def["currency"]))
463
493
  out["currency"] = def["currency"];
464
494
  // embedded lists: row-count bounds, computed cells, totals row, and the
465
495
  // sibling-money-field totals check (fw#2497).
@@ -467,11 +497,11 @@ function projectField(fieldDef) {
467
497
  out["minItems"] = def["minItems"];
468
498
  if (typeof def["maxItems"] === "number")
469
499
  out["maxItems"] = def["maxItems"];
470
- if (isPlainObject(def["derived"]) && isJsonSafeValue(def["derived"]))
500
+ if (isJsonSafeObject(def["derived"]))
471
501
  out["derived"] = def["derived"];
472
- if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"]))
502
+ if (isJsonSafeArray(def["totals"]))
473
503
  out["totals"] = def["totals"];
474
- if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
504
+ if (isJsonSafeObject(def["totalsMatch"]))
475
505
  out["totalsMatch"] = def["totalsMatch"];
476
506
  // embedded lists: the sub-field map itself — without it the client-side
477
507
  // edit view-model iterates `undefined` and the screen crashes (fw#2507).
@@ -489,15 +519,30 @@ function isPlainObject(value) {
489
519
  const proto = Object.getPrototypeOf(value);
490
520
  return proto === Object.prototype || proto === null;
491
521
  }
492
- function isJsonSafeValue(value) {
522
+ function isJsonSafeObject(value) {
523
+ return isPlainObject(value) && isJsonSafeValue(value);
524
+ }
525
+ function isJsonSafeArray(value) {
526
+ return Array.isArray(value) && isJsonSafeValue(value);
527
+ }
528
+ // `ancestors` tracks the current path only, so a shared (non-cyclic) sub-object
529
+ // stays valid while a self-reference returns false instead of overflowing the stack.
530
+ function isJsonSafeValue(value, ancestors = new Set()) {
493
531
  if (value === null)
494
532
  return true;
495
533
  const t = typeof value;
496
- if (t === "string" || t === "number" || t === "boolean")
534
+ if (t === "string" || t === "boolean")
497
535
  return true;
498
- if (Array.isArray(value))
499
- return value.every(isJsonSafeValue);
500
- if (isPlainObject(value))
501
- return Object.values(value).every(isJsonSafeValue);
502
- return false;
536
+ // NaN/Infinity serialize to null via JSON.stringify.
537
+ if (t === "number")
538
+ return Number.isFinite(value);
539
+ if (typeof value !== "object" || !(Array.isArray(value) || isPlainObject(value)))
540
+ return false;
541
+ if (ancestors.has(value))
542
+ return false;
543
+ ancestors.add(value);
544
+ const children = Array.isArray(value) ? value : Object.values(value);
545
+ const safe = children.every((child) => isJsonSafeValue(child, ancestors));
546
+ ancestors.delete(value);
547
+ return safe;
503
548
  }