@cosmicdrift/kumiko-framework 0.334.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 (157) 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/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. 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);
@@ -815,7 +824,16 @@ function validateRelatedListToolbarActions(args, toolbarActions) {
815
824
  }
816
825
  }
817
826
  }
818
- 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) {
819
837
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
820
838
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
821
839
  // Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
@@ -824,6 +842,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
824
842
  // zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
825
843
  // #1946) — kurze IDs bleiben same-feature wie zuvor.
826
844
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
845
+ const navigateLookups = {
846
+ allScreenQns,
847
+ navTargetShortIds,
848
+ screensByShortId,
849
+ detailForScreens,
850
+ };
827
851
  for (const [screenId, screen] of Object.entries(feature.screens)) {
828
852
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
829
853
  if (screen.type === "custom") {
@@ -944,14 +968,18 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
944
968
  `there is no fallback to the raw column name.`);
945
969
  }
946
970
  const navigate = typeof metric === "string" ? undefined : metric.navigate;
947
- if (navigate?.tab !== undefined) {
971
+ if (navigate !== undefined) {
948
972
  if (navigate.screen === undefined && navigate.entity === undefined) {
949
- 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)) {
950
975
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
951
976
  `navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
952
977
  }
953
978
  }
954
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.
955
983
  resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "metric", {
956
984
  kind: "navigate",
957
985
  id: field,
@@ -959,7 +987,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
959
987
  ...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
960
988
  ...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
961
989
  ...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
962
- tab: navigate.tab,
990
+ ...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
963
991
  }, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
964
992
  }
965
993
  }
@@ -1001,17 +1029,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1001
1029
  if (section.actions !== undefined) {
1002
1030
  for (const action of section.actions) {
1003
1031
  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
- }
1032
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1033
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, navigateLookups);
1007
1034
  }
1008
1035
  }
1009
1036
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
1010
1037
  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
- }
1038
+ validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
1015
1039
  }
1016
1040
  if (isExtensionEditSection(section)) {
1017
1041
  // projectionDetail is read-only (no composed form submit) — an
@@ -1116,6 +1140,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1116
1140
  if (screen.actions !== undefined) {
1117
1141
  for (const action of screen.actions) {
1118
1142
  validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
1143
+ validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
1119
1144
  if (action.kind === "navigate" && action.rowClick === true) {
1120
1145
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1121
1146
  `(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
@@ -1160,7 +1185,6 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1160
1185
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1161
1186
  }
1162
1187
  validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
1163
- rejectWriteOnlyFormField(feature.name, screenId, "configEdit", fname, fdef);
1164
1188
  }
1165
1189
  if (screen.layout.sections.length === 0) {
1166
1190
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1202,10 +1226,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1202
1226
  `keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
1203
1227
  `einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
1204
1228
  }
1205
- if (!allConfigKeyQns.has(qualified)) {
1229
+ const configKeyDef = configKeyDefsByQn.get(qualified);
1230
+ if (configKeyDef === undefined) {
1206
1231
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
1207
1232
  `Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
1208
- `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").`);
1209
1239
  }
1210
1240
  }
1211
1241
  continue;
@@ -1297,6 +1327,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1297
1327
  throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
1298
1328
  }
1299
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
+ }
1300
1333
  }
1301
1334
  // Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
1302
1335
  // fangen, damit kein "warum kommt die Liste leer / falsch
@@ -1488,9 +1521,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1488
1521
  const sectionLabel = section.title ?? section.id ?? "(untitled)";
1489
1522
  for (const action of section.actions) {
1490
1523
  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
- }
1524
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1525
+ validateNavigateActionTab(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, navigateLookups);
1494
1526
  }
1495
1527
  }
1496
1528
  if (isExtensionEditSection(section)) {
@@ -1525,6 +1557,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1525
1557
  // row to click either.
1526
1558
  if (screen.actions !== undefined) {
1527
1559
  for (const action of screen.actions) {
1560
+ validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
1528
1561
  if (action.kind === "navigate" && action.rowClick === true) {
1529
1562
  throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
1530
1563
  `(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
@@ -1584,7 +1617,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1584
1617
  validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
1585
1618
  }
1586
1619
  else {
1587
- validateDashboardQueryPanel(featureName, screenId, panel);
1620
+ validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
1588
1621
  }
1589
1622
  }
1590
1623
  if (screen.filter !== undefined) {
@@ -1638,6 +1671,15 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
1638
1671
  throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
1639
1672
  `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
1640
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
+ }
1641
1683
  const visibleWhen = panel.visibleWhen;
1642
1684
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
1643
1685
  throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
@@ -1663,7 +1705,7 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
1663
1705
  `declare a react/native component marker.`);
1664
1706
  }
1665
1707
  }
1666
- function validateDashboardQueryPanel(featureName, screenId, panel) {
1708
+ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
1667
1709
  if (!panel.query || typeof panel.query !== "string") {
1668
1710
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1669
1711
  }
@@ -1681,7 +1723,11 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
1681
1723
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
1682
1724
  }
1683
1725
  for (const col of panel.columns) {
1684
- 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
+ }
1685
1731
  }
1686
1732
  }
1687
1733
  }
@@ -1746,10 +1792,14 @@ export function validateColumnRendererForm(featureName, screenId, column) {
1746
1792
  function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
1747
1793
  const target = parseRefTarget(refTarget, currentFeatureName);
1748
1794
  const targetFeature = featureMap.get(target.featureName);
1749
- 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) {
1750
1800
  throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
1751
1801
  `registered entity. Known entities in feature "${target.featureName}": ` +
1752
- `${Object.keys(targetFeature?.entities ?? {})
1802
+ `${Object.keys(targetFeature.entities ?? {})
1753
1803
  .sort()
1754
1804
  .join(", ") || "(none)"}.`);
1755
1805
  }
@@ -1790,9 +1840,29 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
1790
1840
  }
1791
1841
  if (facet.type === "reference") {
1792
1842
  assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1843
+ assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
1793
1844
  }
1794
1845
  }
1795
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;
1861
+ }
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.`);
1865
+ }
1796
1866
  export function findEntityFeature(entityName, featureMap) {
1797
1867
  for (const [name, feature] of featureMap) {
1798
1868
  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,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).
@@ -447,11 +475,11 @@ function projectField(fieldDef) {
447
475
  out["locatedBy"] = def["locatedBy"];
448
476
  // file/image/images: upload picker constraints; the first variant key picks
449
477
  // the preview variant.
450
- if (Array.isArray(def["accept"]) && isJsonSafeValue(def["accept"]))
478
+ if (isJsonSafeArray(def["accept"]))
451
479
  out["accept"] = def["accept"];
452
480
  if (typeof def["maxSize"] === "string")
453
481
  out["maxSize"] = def["maxSize"];
454
- if (isPlainObject(def["variants"]) && isJsonSafeValue(def["variants"]))
482
+ if (isJsonSafeObject(def["variants"]))
455
483
  out["variants"] = def["variants"];
456
484
  // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
457
485
  if (typeof def["scale"] === "number")
@@ -459,7 +487,7 @@ function projectField(fieldDef) {
459
487
  // money: declared currency source (MoneyCurrencySource, fw#2933) — without
460
488
  // it the renderer can't tell a "currency: { kind: 'tenant' }" field apart
461
489
  // from an undeclared one and always falls back to entity.defaultCurrency.
462
- if (isPlainObject(def["currency"]) && isJsonSafeValue(def["currency"]))
490
+ if (isJsonSafeObject(def["currency"]))
463
491
  out["currency"] = def["currency"];
464
492
  // embedded lists: row-count bounds, computed cells, totals row, and the
465
493
  // sibling-money-field totals check (fw#2497).
@@ -467,11 +495,11 @@ function projectField(fieldDef) {
467
495
  out["minItems"] = def["minItems"];
468
496
  if (typeof def["maxItems"] === "number")
469
497
  out["maxItems"] = def["maxItems"];
470
- if (isPlainObject(def["derived"]) && isJsonSafeValue(def["derived"]))
498
+ if (isJsonSafeObject(def["derived"]))
471
499
  out["derived"] = def["derived"];
472
- if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"]))
500
+ if (isJsonSafeArray(def["totals"]))
473
501
  out["totals"] = def["totals"];
474
- if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
502
+ if (isJsonSafeObject(def["totalsMatch"]))
475
503
  out["totalsMatch"] = def["totalsMatch"];
476
504
  // embedded lists: the sub-field map itself — without it the client-side
477
505
  // edit view-model iterates `undefined` and the screen crashes (fw#2507).
@@ -489,15 +517,30 @@ function isPlainObject(value) {
489
517
  const proto = Object.getPrototypeOf(value);
490
518
  return proto === Object.prototype || proto === null;
491
519
  }
492
- 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()) {
493
529
  if (value === null)
494
530
  return true;
495
531
  const t = typeof value;
496
- if (t === "string" || t === "number" || t === "boolean")
532
+ if (t === "string" || t === "boolean")
497
533
  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;
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;
503
546
  }
@@ -16,6 +16,7 @@
16
16
  // ids/parent/screen-Refs (buildNavRegistrySliceForApp qualifiziert selbst).
17
17
  import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
18
18
  import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
19
+ import { isEncryptedAtRest } from "./config-helpers.js";
19
20
  import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
20
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
21
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
@@ -619,7 +620,13 @@ function deriveField(key, selectorPluginIds, screenKeys, fieldIdOf) {
619
620
  ? createSelectField({ options: def.options })
620
621
  : createTextField({ personal: false, reason: "system_metadata" });
621
622
  default:
622
- return createTextField({ personal: false, reason: "system_metadata" });
623
+ // A stored secret never goes back to the client: the form shows set/empty
624
+ // and sends only a replacement or a clear.
625
+ return createTextField({
626
+ personal: false,
627
+ reason: "system_metadata",
628
+ ...(def.type === "text" && isEncryptedAtRest(def) && { writeOnly: true }),
629
+ });
623
630
  }
624
631
  }
625
632
  function collectMaskedKeys(registry) {