@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.
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +103 -33
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +62 -19
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +206 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
971
|
+
if (navigate !== undefined) {
|
|
948
972
|
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
949
|
-
if (
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${[...
|
|
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
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
44
|
-
// entity would mean its rows never move with their host.
|
|
45
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
498
|
+
if (isJsonSafeObject(def["derived"]))
|
|
471
499
|
out["derived"] = def["derived"];
|
|
472
|
-
if (
|
|
500
|
+
if (isJsonSafeArray(def["totals"]))
|
|
473
501
|
out["totals"] = def["totals"];
|
|
474
|
-
if (
|
|
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
|
|
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 === "
|
|
532
|
+
if (t === "string" || t === "boolean")
|
|
497
533
|
return true;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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) {
|