@cosmicdrift/kumiko-framework 0.333.0 → 0.335.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 (172) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -4,23 +4,17 @@
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 } from "@cosmicdrift/kumiko-types/fields";
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.
@@ -471,13 +465,37 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
471
465
  if (ftype === "money") {
472
466
  validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
473
467
  }
474
- validateFormSelectOptions(featureName, screenId, context, fname, fdef);
468
+ validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
469
+ rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
475
470
  }
476
471
  return fieldNames;
477
472
  }
478
- // Static options XOR optionsQuery on inline-form select fields. The QN
479
- // existence check lives in query-refs.ts with the other query refs.
480
- function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef) {
473
+ function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
474
+ for (const ref of optionsQueryFieldRefs(payload)) {
475
+ if (ref === fieldName) {
476
+ throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" })`);
477
+ }
478
+ if (!siblingFieldNames.has(ref)) {
479
+ throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
480
+ }
481
+ }
482
+ }
483
+ function isWriteOnlyField(fdef) {
484
+ return fdef?.writeOnly === true; // @cast-boundary schema-walk
485
+ }
486
+ // Inline forms have no stored value to hide or keep: writeOnly there would
487
+ // render a "set / keep" affordance with nothing behind it.
488
+ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
489
+ if (isWriteOnlyField(fdef)) {
490
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
491
+ `writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
492
+ `Use format: "password" for a masked input.`);
493
+ }
494
+ }
495
+ // 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
497
+ // lives in query-refs.ts with the other query refs.
498
+ function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
481
499
  if (fdef.type === "select") {
482
500
  const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
483
501
  if (fdef.optionsQuery !== undefined) {
@@ -487,6 +505,7 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
487
505
  if (fdef.options.length > 0) {
488
506
  throw new Error(`${where} declares both options and optionsQuery — pick one`);
489
507
  }
508
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
490
509
  }
491
510
  else if (fdef.optionsQueryPayload !== undefined) {
492
511
  throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
@@ -593,8 +612,21 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
593
612
  `name the success-payload field carrying the navigation id, or use the plain string ` +
594
613
  `redirect form to navigate with the handler's own "id".`);
595
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
+ }
596
621
  validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
597
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
+ }
598
630
  function validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns) {
599
631
  if (screen.redirect !== undefined) {
600
632
  validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
@@ -792,7 +824,16 @@ function validateRelatedListToolbarActions(args, toolbarActions) {
792
824
  }
793
825
  }
794
826
  }
795
- export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns, screensByShortId, detailForScreens) {
827
+ // Only a navigate action that sets `tab` needs the target resolved (to check
828
+ // the tab id); plain navigates are validated elsewhere. One place for every
829
+ // section-level action site so a new site can't forget the tab check.
830
+ function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
831
+ // skip: only navigate actions with an explicit tab need target validation
832
+ if (action.kind !== "navigate" || action.tab === undefined)
833
+ return;
834
+ resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
835
+ }
836
+ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
796
837
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
797
838
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
798
839
  // Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
@@ -801,6 +842,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
801
842
  // zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
802
843
  // #1946) — kurze IDs bleiben same-feature wie zuvor.
803
844
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
845
+ const navigateLookups = {
846
+ allScreenQns,
847
+ navTargetShortIds,
848
+ screensByShortId,
849
+ detailForScreens,
850
+ };
804
851
  for (const [screenId, screen] of Object.entries(feature.screens)) {
805
852
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
806
853
  if (screen.type === "custom") {
@@ -921,14 +968,18 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
921
968
  `there is no fallback to the raw column name.`);
922
969
  }
923
970
  const navigate = typeof metric === "string" ? undefined : metric.navigate;
924
- if (navigate?.tab !== undefined) {
971
+ if (navigate !== undefined) {
925
972
  if (navigate.screen === undefined && navigate.entity === undefined) {
926
- if (!screen.layout.sections.some((section) => section.id === navigate.tab)) {
973
+ if (navigate.tab !== undefined &&
974
+ !screen.layout.sections.some((section) => section.id === navigate.tab)) {
927
975
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
928
976
  `navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
929
977
  }
930
978
  }
931
979
  else {
980
+ // Also runs without `tab`: a typo'd screen/entity or an entity
981
+ // target without entityId would otherwise render a clickable
982
+ // metric that silently does nothing.
932
983
  resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "metric", {
933
984
  kind: "navigate",
934
985
  id: field,
@@ -936,7 +987,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
936
987
  ...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
937
988
  ...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
938
989
  ...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
939
- tab: navigate.tab,
990
+ ...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
940
991
  }, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
941
992
  }
942
993
  }
@@ -978,17 +1029,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
978
1029
  if (section.actions !== undefined) {
979
1030
  for (const action of section.actions) {
980
1031
  validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action);
981
- if (action.kind === "navigate" && action.tab !== undefined) {
982
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
983
- }
1032
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1033
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, navigateLookups);
984
1034
  }
985
1035
  }
986
1036
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
987
1037
  validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action);
988
- if (section.emptyState.action.kind === "navigate" &&
989
- section.emptyState.action.tab !== undefined) {
990
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
991
- }
1038
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
992
1039
  }
993
1040
  if (isExtensionEditSection(section)) {
994
1041
  // projectionDetail is read-only (no composed form submit) — an
@@ -1048,6 +1095,14 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1095
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
1049
1096
  `(writeForm) has zero fields — drop the section or add fields to it.`);
1050
1097
  }
1098
+ // A ref to a fieldDef the form never renders would never resolve.
1099
+ const writeFormFieldNames = new Set(
1100
+ // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1101
+ section.fields.map((f) => normalizeEditField(f).field));
1102
+ for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
1103
+ validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
1104
+ rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
1105
+ }
1051
1106
  // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1052
1107
  for (const f of section.fields) {
1053
1108
  const fieldName = normalizeEditField(f).field;
@@ -1085,6 +1140,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1085
1140
  if (screen.actions !== undefined) {
1086
1141
  for (const action of screen.actions) {
1087
1142
  validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
1143
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1088
1144
  if (action.kind === "navigate" && action.rowClick === true) {
1089
1145
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1090
1146
  `(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
@@ -1128,7 +1184,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1128
1184
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
1129
1185
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1130
1186
  }
1131
- validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
1187
+ validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
1132
1188
  }
1133
1189
  if (screen.layout.sections.length === 0) {
1134
1190
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1170,10 +1226,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1170
1226
  `keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
1171
1227
  `einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
1172
1228
  }
1173
- if (!allConfigKeyQns.has(qualified)) {
1229
+ const configKeyDef = configKeyDefsByQn.get(qualified);
1230
+ if (configKeyDef === undefined) {
1174
1231
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
1175
1232
  `Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
1176
- `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...allConfigKeyQns].sort().join(", ") || "(keine)"}`);
1233
+ `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(keine)"}`);
1234
+ }
1235
+ if (isWriteOnlyField(screen.fields[fname]) && !isEncryptedAtRest(configKeyDef)) {
1236
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" declares writeOnly ` +
1237
+ `but config key "${qualified}" is not encrypted at rest — writeOnly on a configEdit field ` +
1238
+ `only hides a stored secret (declare the key with encrypted: true or backing: "secrets").`);
1177
1239
  }
1178
1240
  }
1179
1241
  continue;
@@ -1265,6 +1327,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1265
1327
  throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
1266
1328
  }
1267
1329
  validateColumnRendererForm(feature.name, screenId, normalized);
1330
+ if (normalized.refEntity !== undefined) {
1331
+ assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
1332
+ }
1268
1333
  }
1269
1334
  // Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
1270
1335
  // fangen, damit kein "warum kommt die Liste leer / falsch
@@ -1456,9 +1521,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1456
1521
  const sectionLabel = section.title ?? section.id ?? "(untitled)";
1457
1522
  for (const action of section.actions) {
1458
1523
  validateActionHasIcon(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action);
1459
- if (action.kind === "navigate" && action.tab !== undefined) {
1460
- resolveRowActionNavigateTarget(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1461
- }
1524
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1525
+ validateNavigateActionTab(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, navigateLookups);
1462
1526
  }
1463
1527
  }
1464
1528
  if (isExtensionEditSection(section)) {
@@ -1493,6 +1557,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1493
1557
  // row to click either.
1494
1558
  if (screen.actions !== undefined) {
1495
1559
  for (const action of screen.actions) {
1560
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1496
1561
  if (action.kind === "navigate" && action.rowClick === true) {
1497
1562
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1498
1563
  `(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
@@ -1552,7 +1617,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1552
1617
  validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
1553
1618
  }
1554
1619
  else {
1555
- validateDashboardQueryPanel(featureName, screenId, panel);
1620
+ validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
1556
1621
  }
1557
1622
  }
1558
1623
  if (screen.filter !== undefined) {
@@ -1606,6 +1671,15 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
1606
1671
  throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
1607
1672
  `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
1608
1673
  }
1674
+ // ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
1675
+ // cancel even when embedded, which would pull the user off the dashboard.
1676
+ if ((target.type === "actionForm" || target.type === "secretMint") &&
1677
+ (target.redirect !== undefined ||
1678
+ (target.cancelTarget !== undefined && target.cancelTarget !== false))) {
1679
+ throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
1680
+ `navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
1681
+ `with cancelTarget: false (or omitted).`);
1682
+ }
1609
1683
  const visibleWhen = panel.visibleWhen;
1610
1684
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
1611
1685
  throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
@@ -1631,7 +1705,7 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
1631
1705
  `declare a react/native component marker.`);
1632
1706
  }
1633
1707
  }
1634
- function validateDashboardQueryPanel(featureName, screenId, panel) {
1708
+ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
1635
1709
  if (!panel.query || typeof panel.query !== "string") {
1636
1710
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1637
1711
  }
@@ -1649,7 +1723,11 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
1649
1723
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
1650
1724
  }
1651
1725
  for (const col of panel.columns) {
1652
- validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
1726
+ const normalized = normalizeListColumn(col);
1727
+ validateColumnRendererForm(featureName, screenId, normalized);
1728
+ if (normalized.refEntity !== undefined) {
1729
+ assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
1730
+ }
1653
1731
  }
1654
1732
  }
1655
1733
  }
@@ -1714,10 +1792,14 @@ export function validateColumnRendererForm(featureName, screenId, column) {
1714
1792
  function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
1715
1793
  const target = parseRefTarget(refTarget, currentFeatureName);
1716
1794
  const targetFeature = featureMap.get(target.featureName);
1717
- if (targetFeature?.entities?.[target.entityName] === undefined) {
1795
+ if (targetFeature === undefined) {
1796
+ throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
1797
+ `mounted — mount it and declare r.requires("${target.featureName}").`);
1798
+ }
1799
+ if (targetFeature.entities?.[target.entityName] === undefined) {
1718
1800
  throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
1719
1801
  `registered entity. Known entities in feature "${target.featureName}": ` +
1720
- `${Object.keys(targetFeature?.entities ?? {})
1802
+ `${Object.keys(targetFeature.entities ?? {})
1721
1803
  .sort()
1722
1804
  .join(", ") || "(none)"}.`);
1723
1805
  }
@@ -1758,8 +1840,28 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
1758
1840
  }
1759
1841
  if (facet.type === "reference") {
1760
1842
  assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1843
+ assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1844
+ }
1845
+ }
1846
+ }
1847
+ // The facet's options are loaded at render time by dispatching the target's
1848
+ // list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
1849
+ // override); a missing or unregistered handler is swallowed there and leaves
1850
+ // a permanently empty dropdown, so it has to fail at boot instead.
1851
+ function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
1852
+ const target = parseRefTarget(refTarget, currentFeatureName);
1853
+ const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
1854
+ const expectedQn = override?.queryQn ??
1855
+ qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
1856
+ for (const feature of featureMap.values()) {
1857
+ for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
1858
+ // skip: expected list handler found
1859
+ if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
1860
+ return;
1761
1861
  }
1762
1862
  }
1863
+ throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
1864
+ `query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
1763
1865
  }
1764
1866
  export function findEntityFeature(entityName, featureMap) {
1765
1867
  for (const [name, feature] of featureMap) {
@@ -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,12 +29,14 @@
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
41
  const navs = Object.values(feature.navs);
40
42
  // The nav entry alone doesn't say which kind a collection lists, so the
@@ -47,7 +49,7 @@ export function buildAppSchema(registry, options = {}) {
47
49
  const featureSchema = {
48
50
  featureName,
49
51
  entities: projectEntities(feature.entities ?? {}),
50
- screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn),
52
+ screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns),
51
53
  ...(navs.length > 0 && { navs }),
52
54
  ...(contentCollections.length > 0 && { contentCollections }),
53
55
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -262,8 +264,10 @@ export function findNonJsonSafePath(value, path) {
262
264
  // (fw#2165). A hand-written `searchable: true` still wins over the derived
263
265
  // default (screen.searchable ?? derived); the boot-validator (3a) rejects
264
266
  // one that contradicts the schema.
265
- function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn) {
267
+ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
266
268
  return Object.entries(screens).map(([shortId, screen]) => {
269
+ if (screen.type === "configEdit")
270
+ return projectConfigEditScreen(screen, encryptedConfigKeyQns);
267
271
  if (screen.type === "projectionList")
268
272
  return projectProjectionListScreen(screen, registry);
269
273
  if (screen.type === "actionForm" ||
@@ -275,6 +279,31 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
275
279
  return screen;
276
280
  });
277
281
  }
282
+ function collectEncryptedConfigKeyQns(registry) {
283
+ const qns = new Set();
284
+ for (const [featureName, feature] of registry.features) {
285
+ for (const [key, def] of Object.entries(feature.configKeys)) {
286
+ if (isEncryptedAtRest(def))
287
+ qns.add(qualifyEntityName(featureName, QnTypes.config, key));
288
+ }
289
+ }
290
+ return qns;
291
+ }
292
+ // A hand-written configEdit field over an encrypted-at-rest key would load the
293
+ // read mask as editable text and a partial edit would overwrite the secret with
294
+ // it. Forcing writeOnly here matches what deriveField does for generated screens.
295
+ function projectConfigEditScreen(screen, encryptedConfigKeyQns) {
296
+ let fields;
297
+ for (const [fieldName, field] of Object.entries(screen.fields)) {
298
+ const qualified = screen.configKeys[fieldName];
299
+ if (field.type !== "text" || qualified === undefined || !encryptedConfigKeyQns.has(qualified)) {
300
+ continue;
301
+ }
302
+ fields ??= { ...screen.fields };
303
+ fields[fieldName] = { ...field, writeOnly: true };
304
+ }
305
+ return fields === undefined ? screen : { ...screen, fields };
306
+ }
278
307
  function projectProjectionListScreen(screen, registry) {
279
308
  const schema = registry.getQueryHandler(screen.query)?.schema;
280
309
  const capabilities = deriveProjectionListCapabilities(schema);
@@ -384,7 +413,7 @@ function projectField(fieldDef) {
384
413
  // Select: options-Liste ist plain JSON, durchschicken.
385
414
  if (Array.isArray(def["options"]))
386
415
  out["options"] = def["options"];
387
- if (isPlainObject(def["optionTones"]) && isJsonSafeValue(def["optionTones"]))
416
+ if (isJsonSafeObject(def["optionTones"]))
388
417
  out["optionTones"] = def["optionTones"];
389
418
  // Reference: entity-Target + labelField + multiple müssen zum Renderer.
390
419
  // Der ReferenceInput baut die Options-Query aus refEntity/refFeature und
@@ -410,8 +439,7 @@ function projectField(fieldDef) {
410
439
  out["maxRows"] = def["maxRows"];
411
440
  // text/longText: textarea row count — DefaultInput renders a single-line
412
441
  // input otherwise (fw#2497).
413
- if (typeof def["multiline"] === "boolean" ||
414
- (isPlainObject(def["multiline"]) && isJsonSafeValue(def["multiline"])))
442
+ if (typeof def["multiline"] === "boolean" || isJsonSafeObject(def["multiline"]))
415
443
  out["multiline"] = def["multiline"];
416
444
  // number: Zod write-boundary bounds. date/timestamp/locatedTimestamp:
417
445
  // ISO-string picker bounds — same keys, different literal type (fw#2497).
@@ -439,16 +467,19 @@ function projectField(fieldDef) {
439
467
  // refuse any prefill (URL or handoff) into the field.
440
468
  if (def["sensitive"] === true)
441
469
  out["sensitive"] = true;
470
+ // Without it the renderer shows a plain text input for a field whose value never leaves the server.
471
+ if (def["writeOnly"] === true)
472
+ out["writeOnly"] = true;
442
473
  // timestamp: the edit view-model derives its wall-clock input mode from this.
443
474
  if (typeof def["locatedBy"] === "string")
444
475
  out["locatedBy"] = def["locatedBy"];
445
476
  // file/image/images: upload picker constraints; the first variant key picks
446
477
  // the preview variant.
447
- if (Array.isArray(def["accept"]) && isJsonSafeValue(def["accept"]))
478
+ if (isJsonSafeArray(def["accept"]))
448
479
  out["accept"] = def["accept"];
449
480
  if (typeof def["maxSize"] === "string")
450
481
  out["maxSize"] = def["maxSize"];
451
- if (isPlainObject(def["variants"]) && isJsonSafeValue(def["variants"]))
482
+ if (isJsonSafeObject(def["variants"]))
452
483
  out["variants"] = def["variants"];
453
484
  // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
454
485
  if (typeof def["scale"] === "number")
@@ -456,7 +487,7 @@ function projectField(fieldDef) {
456
487
  // money: declared currency source (MoneyCurrencySource, fw#2933) — without
457
488
  // it the renderer can't tell a "currency: { kind: 'tenant' }" field apart
458
489
  // from an undeclared one and always falls back to entity.defaultCurrency.
459
- if (isPlainObject(def["currency"]) && isJsonSafeValue(def["currency"]))
490
+ if (isJsonSafeObject(def["currency"]))
460
491
  out["currency"] = def["currency"];
461
492
  // embedded lists: row-count bounds, computed cells, totals row, and the
462
493
  // sibling-money-field totals check (fw#2497).
@@ -464,11 +495,11 @@ function projectField(fieldDef) {
464
495
  out["minItems"] = def["minItems"];
465
496
  if (typeof def["maxItems"] === "number")
466
497
  out["maxItems"] = def["maxItems"];
467
- if (isPlainObject(def["derived"]) && isJsonSafeValue(def["derived"]))
498
+ if (isJsonSafeObject(def["derived"]))
468
499
  out["derived"] = def["derived"];
469
- if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"]))
500
+ if (isJsonSafeArray(def["totals"]))
470
501
  out["totals"] = def["totals"];
471
- if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
502
+ if (isJsonSafeObject(def["totalsMatch"]))
472
503
  out["totalsMatch"] = def["totalsMatch"];
473
504
  // embedded lists: the sub-field map itself — without it the client-side
474
505
  // edit view-model iterates `undefined` and the screen crashes (fw#2507).
@@ -486,15 +517,30 @@ function isPlainObject(value) {
486
517
  const proto = Object.getPrototypeOf(value);
487
518
  return proto === Object.prototype || proto === null;
488
519
  }
489
- function isJsonSafeValue(value) {
520
+ function isJsonSafeObject(value) {
521
+ return isPlainObject(value) && isJsonSafeValue(value);
522
+ }
523
+ function isJsonSafeArray(value) {
524
+ return Array.isArray(value) && isJsonSafeValue(value);
525
+ }
526
+ // `ancestors` tracks the current path only, so a shared (non-cyclic) sub-object
527
+ // stays valid while a self-reference returns false instead of overflowing the stack.
528
+ function isJsonSafeValue(value, ancestors = new Set()) {
490
529
  if (value === null)
491
530
  return true;
492
531
  const t = typeof value;
493
- if (t === "string" || t === "number" || t === "boolean")
532
+ if (t === "string" || t === "boolean")
494
533
  return true;
495
- if (Array.isArray(value))
496
- return value.every(isJsonSafeValue);
497
- if (isPlainObject(value))
498
- return Object.values(value).every(isJsonSafeValue);
499
- return false;
534
+ // NaN/Infinity serialize to null via JSON.stringify.
535
+ if (t === "number")
536
+ return Number.isFinite(value);
537
+ if (typeof value !== "object" || !(Array.isArray(value) || isPlainObject(value)))
538
+ return false;
539
+ if (ancestors.has(value))
540
+ return false;
541
+ ancestors.add(value);
542
+ const children = Array.isArray(value) ? value : Object.values(value);
543
+ const safe = children.every((child) => isJsonSafeValue(child, ancestors));
544
+ ancestors.delete(value);
545
+ return safe;
500
546
  }