@cosmicdrift/kumiko-framework 0.333.0 → 0.335.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/eagerload.js +2 -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/config-deps.js +13 -0
- 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 +44 -4
- 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 +138 -36
- 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 +65 -19
- package/dist/engine/build-config-feature-schema.js +106 -22
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- 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/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +5 -4
- package/dist/engine/index.js +4 -3
- 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 +2 -0
- package/dist/engine/schema-builder.js +20 -3
- 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/types/index.d.ts +1 -1
- 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-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- 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 +28 -35
- package/dist/i18n/settings-hub-keys.js +21 -4
- 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-batch.js +8 -3
- 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 +27 -7
- package/dist/pipeline/dispatcher-utils.js +4 -1
- 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/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- 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/shared-entities.d.ts +10 -0
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- 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 +218 -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
|
@@ -4,23 +4,17 @@
|
|
|
4
4
|
// into validateColumnRendererForm, splitting them would create a
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
|
-
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
9
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
10
|
-
import {
|
|
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.
|
|
@@ -471,13 +465,37 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
|
|
|
471
465
|
if (ftype === "money") {
|
|
472
466
|
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
473
467
|
}
|
|
474
|
-
validateFormSelectOptions(featureName, screenId, context, fname, fdef);
|
|
468
|
+
validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
|
|
469
|
+
rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
|
|
475
470
|
}
|
|
476
471
|
return fieldNames;
|
|
477
472
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
473
|
+
function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
|
|
474
|
+
for (const ref of optionsQueryFieldRefs(payload)) {
|
|
475
|
+
if (ref === fieldName) {
|
|
476
|
+
throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" })`);
|
|
477
|
+
}
|
|
478
|
+
if (!siblingFieldNames.has(ref)) {
|
|
479
|
+
throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function isWriteOnlyField(fdef) {
|
|
484
|
+
return fdef?.writeOnly === true; // @cast-boundary schema-walk
|
|
485
|
+
}
|
|
486
|
+
// Inline forms have no stored value to hide or keep: writeOnly there would
|
|
487
|
+
// render a "set / keep" affordance with nothing behind it.
|
|
488
|
+
function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
|
|
489
|
+
if (isWriteOnlyField(fdef)) {
|
|
490
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
|
|
491
|
+
`writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
|
|
492
|
+
`Use format: "password" for a masked input.`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Static options XOR optionsQuery on inline-form select fields, and `{ field }`
|
|
496
|
+
// payload refs must name another field of the same form. The QN existence check
|
|
497
|
+
// lives in query-refs.ts with the other query refs.
|
|
498
|
+
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
481
499
|
if (fdef.type === "select") {
|
|
482
500
|
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
|
|
483
501
|
if (fdef.optionsQuery !== undefined) {
|
|
@@ -487,6 +505,7 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
|
|
|
487
505
|
if (fdef.options.length > 0) {
|
|
488
506
|
throw new Error(`${where} declares both options and optionsQuery — pick one`);
|
|
489
507
|
}
|
|
508
|
+
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
490
509
|
}
|
|
491
510
|
else if (fdef.optionsQueryPayload !== undefined) {
|
|
492
511
|
throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
|
|
@@ -593,8 +612,21 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
|
|
|
593
612
|
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
594
613
|
`redirect form to navigate with the handler's own "id".`);
|
|
595
614
|
}
|
|
615
|
+
// The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
|
|
616
|
+
// never matches and the navigation silently lands without an entityId.
|
|
617
|
+
if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
|
|
618
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom "${redirect.idFrom}" ` +
|
|
619
|
+
`has leading/trailing whitespace — it would never match a payload field.`);
|
|
620
|
+
}
|
|
596
621
|
validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
|
|
597
622
|
}
|
|
623
|
+
// A writeHandler record action's redirect (projectionDetail/entityEdit
|
|
624
|
+
// header and section actions) follows the entityEdit.redirect rule.
|
|
625
|
+
function validateRecordActionRedirect(feature, screenId, screenKind, action, allScreenQns) {
|
|
626
|
+
if (action.kind !== "navigate" && action.kind !== "drawer" && action.redirect !== undefined) {
|
|
627
|
+
validateRedirectTarget(feature, screenId, screenKind, action.redirect, allScreenQns);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
598
630
|
function validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns) {
|
|
599
631
|
if (screen.redirect !== undefined) {
|
|
600
632
|
validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
|
|
@@ -792,7 +824,16 @@ function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
|
792
824
|
}
|
|
793
825
|
}
|
|
794
826
|
}
|
|
795
|
-
|
|
827
|
+
// Only a navigate action that sets `tab` needs the target resolved (to check
|
|
828
|
+
// the tab id); plain navigates are validated elsewhere. One place for every
|
|
829
|
+
// section-level action site so a new site can't forget the tab check.
|
|
830
|
+
function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
|
|
831
|
+
// skip: only navigate actions with an explicit tab need target validation
|
|
832
|
+
if (action.kind !== "navigate" || action.tab === undefined)
|
|
833
|
+
return;
|
|
834
|
+
resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
|
|
835
|
+
}
|
|
836
|
+
export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
|
|
796
837
|
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
797
838
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
798
839
|
// Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
|
|
@@ -801,6 +842,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
801
842
|
// zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
|
|
802
843
|
// #1946) — kurze IDs bleiben same-feature wie zuvor.
|
|
803
844
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
845
|
+
const navigateLookups = {
|
|
846
|
+
allScreenQns,
|
|
847
|
+
navTargetShortIds,
|
|
848
|
+
screensByShortId,
|
|
849
|
+
detailForScreens,
|
|
850
|
+
};
|
|
804
851
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
805
852
|
validateScreenHasNavArea(feature, screenId, screen, featureMap);
|
|
806
853
|
if (screen.type === "custom") {
|
|
@@ -921,14 +968,18 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
921
968
|
`there is no fallback to the raw column name.`);
|
|
922
969
|
}
|
|
923
970
|
const navigate = typeof metric === "string" ? undefined : metric.navigate;
|
|
924
|
-
if (navigate
|
|
971
|
+
if (navigate !== undefined) {
|
|
925
972
|
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
926
|
-
if (
|
|
973
|
+
if (navigate.tab !== undefined &&
|
|
974
|
+
!screen.layout.sections.some((section) => section.id === navigate.tab)) {
|
|
927
975
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
|
|
928
976
|
`navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
|
|
929
977
|
}
|
|
930
978
|
}
|
|
931
979
|
else {
|
|
980
|
+
// Also runs without `tab`: a typo'd screen/entity or an entity
|
|
981
|
+
// target without entityId would otherwise render a clickable
|
|
982
|
+
// metric that silently does nothing.
|
|
932
983
|
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "metric", {
|
|
933
984
|
kind: "navigate",
|
|
934
985
|
id: field,
|
|
@@ -936,7 +987,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
936
987
|
...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
|
|
937
988
|
...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
|
|
938
989
|
...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
|
|
939
|
-
tab: navigate.tab,
|
|
990
|
+
...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
|
|
940
991
|
}, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
941
992
|
}
|
|
942
993
|
}
|
|
@@ -978,17 +1029,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
978
1029
|
if (section.actions !== undefined) {
|
|
979
1030
|
for (const action of section.actions) {
|
|
980
1031
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action);
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
1032
|
+
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
|
|
1033
|
+
validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, navigateLookups);
|
|
984
1034
|
}
|
|
985
1035
|
}
|
|
986
1036
|
if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
|
|
987
1037
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action);
|
|
988
|
-
|
|
989
|
-
section.emptyState.action.tab !== undefined) {
|
|
990
|
-
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
991
|
-
}
|
|
1038
|
+
validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
|
|
992
1039
|
}
|
|
993
1040
|
if (isExtensionEditSection(section)) {
|
|
994
1041
|
// projectionDetail is read-only (no composed form submit) — an
|
|
@@ -1048,6 +1095,14 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1048
1095
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
1049
1096
|
`(writeForm) has zero fields — drop the section or add fields to it.`);
|
|
1050
1097
|
}
|
|
1098
|
+
// A ref to a fieldDef the form never renders would never resolve.
|
|
1099
|
+
const writeFormFieldNames = new Set(
|
|
1100
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1101
|
+
section.fields.map((f) => normalizeEditField(f).field));
|
|
1102
|
+
for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
|
|
1103
|
+
validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
|
|
1104
|
+
rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
|
|
1105
|
+
}
|
|
1051
1106
|
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1052
1107
|
for (const f of section.fields) {
|
|
1053
1108
|
const fieldName = normalizeEditField(f).field;
|
|
@@ -1085,6 +1140,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1085
1140
|
if (screen.actions !== undefined) {
|
|
1086
1141
|
for (const action of screen.actions) {
|
|
1087
1142
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
|
|
1143
|
+
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
|
|
1088
1144
|
if (action.kind === "navigate" && action.rowClick === true) {
|
|
1089
1145
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1090
1146
|
`(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
|
|
@@ -1128,7 +1184,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1128
1184
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
|
|
1129
1185
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
|
|
1130
1186
|
}
|
|
1131
|
-
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
|
|
1187
|
+
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
|
|
1132
1188
|
}
|
|
1133
1189
|
if (screen.layout.sections.length === 0) {
|
|
1134
1190
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
|
|
@@ -1170,10 +1226,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1170
1226
|
`keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
|
|
1171
1227
|
`einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
|
|
1172
1228
|
}
|
|
1173
|
-
|
|
1229
|
+
const configKeyDef = configKeyDefsByQn.get(qualified);
|
|
1230
|
+
if (configKeyDef === undefined) {
|
|
1174
1231
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
|
|
1175
1232
|
`Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
|
|
1176
|
-
`Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...
|
|
1233
|
+
`Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(keine)"}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (isWriteOnlyField(screen.fields[fname]) && !isEncryptedAtRest(configKeyDef)) {
|
|
1236
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" declares writeOnly ` +
|
|
1237
|
+
`but config key "${qualified}" is not encrypted at rest — writeOnly on a configEdit field ` +
|
|
1238
|
+
`only hides a stored secret (declare the key with encrypted: true or backing: "secrets").`);
|
|
1177
1239
|
}
|
|
1178
1240
|
}
|
|
1179
1241
|
continue;
|
|
@@ -1265,6 +1327,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1265
1327
|
throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
|
|
1266
1328
|
}
|
|
1267
1329
|
validateColumnRendererForm(feature.name, screenId, normalized);
|
|
1330
|
+
if (normalized.refEntity !== undefined) {
|
|
1331
|
+
assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
|
|
1332
|
+
}
|
|
1268
1333
|
}
|
|
1269
1334
|
// Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
|
|
1270
1335
|
// fangen, damit kein "warum kommt die Liste leer / falsch
|
|
@@ -1456,9 +1521,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1456
1521
|
const sectionLabel = section.title ?? section.id ?? "(untitled)";
|
|
1457
1522
|
for (const action of section.actions) {
|
|
1458
1523
|
validateActionHasIcon(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action);
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
}
|
|
1524
|
+
validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
|
|
1525
|
+
validateNavigateActionTab(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, navigateLookups);
|
|
1462
1526
|
}
|
|
1463
1527
|
}
|
|
1464
1528
|
if (isExtensionEditSection(section)) {
|
|
@@ -1493,6 +1557,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1493
1557
|
// row to click either.
|
|
1494
1558
|
if (screen.actions !== undefined) {
|
|
1495
1559
|
for (const action of screen.actions) {
|
|
1560
|
+
validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
|
|
1496
1561
|
if (action.kind === "navigate" && action.rowClick === true) {
|
|
1497
1562
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1498
1563
|
`(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
|
|
@@ -1552,7 +1617,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1552
1617
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
1553
1618
|
}
|
|
1554
1619
|
else {
|
|
1555
|
-
validateDashboardQueryPanel(featureName, screenId, panel);
|
|
1620
|
+
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
1556
1621
|
}
|
|
1557
1622
|
}
|
|
1558
1623
|
if (screen.filter !== undefined) {
|
|
@@ -1606,6 +1671,15 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
1606
1671
|
throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
|
|
1607
1672
|
`${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
|
|
1608
1673
|
}
|
|
1674
|
+
// ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
|
|
1675
|
+
// cancel even when embedded, which would pull the user off the dashboard.
|
|
1676
|
+
if ((target.type === "actionForm" || target.type === "secretMint") &&
|
|
1677
|
+
(target.redirect !== undefined ||
|
|
1678
|
+
(target.cancelTarget !== undefined && target.cancelTarget !== false))) {
|
|
1679
|
+
throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
|
|
1680
|
+
`navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
|
|
1681
|
+
`with cancelTarget: false (or omitted).`);
|
|
1682
|
+
}
|
|
1609
1683
|
const visibleWhen = panel.visibleWhen;
|
|
1610
1684
|
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
1611
1685
|
throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
|
|
@@ -1631,7 +1705,7 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
|
1631
1705
|
`declare a react/native component marker.`);
|
|
1632
1706
|
}
|
|
1633
1707
|
}
|
|
1634
|
-
function validateDashboardQueryPanel(featureName, screenId, panel) {
|
|
1708
|
+
function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
1635
1709
|
if (!panel.query || typeof panel.query !== "string") {
|
|
1636
1710
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
|
|
1637
1711
|
}
|
|
@@ -1649,7 +1723,11 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
|
|
|
1649
1723
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
|
|
1650
1724
|
}
|
|
1651
1725
|
for (const col of panel.columns) {
|
|
1652
|
-
|
|
1726
|
+
const normalized = normalizeListColumn(col);
|
|
1727
|
+
validateColumnRendererForm(featureName, screenId, normalized);
|
|
1728
|
+
if (normalized.refEntity !== undefined) {
|
|
1729
|
+
assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
|
|
1730
|
+
}
|
|
1653
1731
|
}
|
|
1654
1732
|
}
|
|
1655
1733
|
}
|
|
@@ -1714,10 +1792,14 @@ export function validateColumnRendererForm(featureName, screenId, column) {
|
|
|
1714
1792
|
function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
1715
1793
|
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
1716
1794
|
const targetFeature = featureMap.get(target.featureName);
|
|
1717
|
-
if (targetFeature
|
|
1795
|
+
if (targetFeature === undefined) {
|
|
1796
|
+
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
1797
|
+
`mounted — mount it and declare r.requires("${target.featureName}").`);
|
|
1798
|
+
}
|
|
1799
|
+
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
1718
1800
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
1719
1801
|
`registered entity. Known entities in feature "${target.featureName}": ` +
|
|
1720
|
-
`${Object.keys(targetFeature
|
|
1802
|
+
`${Object.keys(targetFeature.entities ?? {})
|
|
1721
1803
|
.sort()
|
|
1722
1804
|
.join(", ") || "(none)"}.`);
|
|
1723
1805
|
}
|
|
@@ -1758,8 +1840,28 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
|
|
|
1758
1840
|
}
|
|
1759
1841
|
if (facet.type === "reference") {
|
|
1760
1842
|
assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
1843
|
+
assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// The facet's options are loaded at render time by dispatching the target's
|
|
1848
|
+
// list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
|
|
1849
|
+
// override); a missing or unregistered handler is swallowed there and leaves
|
|
1850
|
+
// a permanently empty dropdown, so it has to fail at boot instead.
|
|
1851
|
+
function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
1852
|
+
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
1853
|
+
const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
|
|
1854
|
+
const expectedQn = override?.queryQn ??
|
|
1855
|
+
qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
|
|
1856
|
+
for (const feature of featureMap.values()) {
|
|
1857
|
+
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
1858
|
+
// skip: expected list handler found
|
|
1859
|
+
if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
|
|
1860
|
+
return;
|
|
1761
1861
|
}
|
|
1762
1862
|
}
|
|
1863
|
+
throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
|
|
1864
|
+
`query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
|
|
1763
1865
|
}
|
|
1764
1866
|
export function findEntityFeature(entityName, featureMap) {
|
|
1765
1867
|
for (const [name, feature] of featureMap) {
|
|
@@ -40,9 +40,10 @@ function referenceTargets(entity) {
|
|
|
40
40
|
return targets;
|
|
41
41
|
}
|
|
42
42
|
// A `multiple` reference stores a jsonb array of ids, which the handover's
|
|
43
|
-
// `= ANY($ids)` column match cannot address. Declaring one
|
|
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).
|
|
@@ -439,16 +467,19 @@ function projectField(fieldDef) {
|
|
|
439
467
|
// refuse any prefill (URL or handoff) into the field.
|
|
440
468
|
if (def["sensitive"] === true)
|
|
441
469
|
out["sensitive"] = true;
|
|
470
|
+
// Without it the renderer shows a plain text input for a field whose value never leaves the server.
|
|
471
|
+
if (def["writeOnly"] === true)
|
|
472
|
+
out["writeOnly"] = true;
|
|
442
473
|
// timestamp: the edit view-model derives its wall-clock input mode from this.
|
|
443
474
|
if (typeof def["locatedBy"] === "string")
|
|
444
475
|
out["locatedBy"] = def["locatedBy"];
|
|
445
476
|
// file/image/images: upload picker constraints; the first variant key picks
|
|
446
477
|
// the preview variant.
|
|
447
|
-
if (
|
|
478
|
+
if (isJsonSafeArray(def["accept"]))
|
|
448
479
|
out["accept"] = def["accept"];
|
|
449
480
|
if (typeof def["maxSize"] === "string")
|
|
450
481
|
out["maxSize"] = def["maxSize"];
|
|
451
|
-
if (
|
|
482
|
+
if (isJsonSafeObject(def["variants"]))
|
|
452
483
|
out["variants"] = def["variants"];
|
|
453
484
|
// decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
|
|
454
485
|
if (typeof def["scale"] === "number")
|
|
@@ -456,7 +487,7 @@ function projectField(fieldDef) {
|
|
|
456
487
|
// money: declared currency source (MoneyCurrencySource, fw#2933) — without
|
|
457
488
|
// it the renderer can't tell a "currency: { kind: 'tenant' }" field apart
|
|
458
489
|
// from an undeclared one and always falls back to entity.defaultCurrency.
|
|
459
|
-
if (
|
|
490
|
+
if (isJsonSafeObject(def["currency"]))
|
|
460
491
|
out["currency"] = def["currency"];
|
|
461
492
|
// embedded lists: row-count bounds, computed cells, totals row, and the
|
|
462
493
|
// sibling-money-field totals check (fw#2497).
|
|
@@ -464,11 +495,11 @@ function projectField(fieldDef) {
|
|
|
464
495
|
out["minItems"] = def["minItems"];
|
|
465
496
|
if (typeof def["maxItems"] === "number")
|
|
466
497
|
out["maxItems"] = def["maxItems"];
|
|
467
|
-
if (
|
|
498
|
+
if (isJsonSafeObject(def["derived"]))
|
|
468
499
|
out["derived"] = def["derived"];
|
|
469
|
-
if (
|
|
500
|
+
if (isJsonSafeArray(def["totals"]))
|
|
470
501
|
out["totals"] = def["totals"];
|
|
471
|
-
if (
|
|
502
|
+
if (isJsonSafeObject(def["totalsMatch"]))
|
|
472
503
|
out["totalsMatch"] = def["totalsMatch"];
|
|
473
504
|
// embedded lists: the sub-field map itself — without it the client-side
|
|
474
505
|
// edit view-model iterates `undefined` and the screen crashes (fw#2507).
|
|
@@ -486,15 +517,30 @@ function isPlainObject(value) {
|
|
|
486
517
|
const proto = Object.getPrototypeOf(value);
|
|
487
518
|
return proto === Object.prototype || proto === null;
|
|
488
519
|
}
|
|
489
|
-
function
|
|
520
|
+
function isJsonSafeObject(value) {
|
|
521
|
+
return isPlainObject(value) && isJsonSafeValue(value);
|
|
522
|
+
}
|
|
523
|
+
function isJsonSafeArray(value) {
|
|
524
|
+
return Array.isArray(value) && isJsonSafeValue(value);
|
|
525
|
+
}
|
|
526
|
+
// `ancestors` tracks the current path only, so a shared (non-cyclic) sub-object
|
|
527
|
+
// stays valid while a self-reference returns false instead of overflowing the stack.
|
|
528
|
+
function isJsonSafeValue(value, ancestors = new Set()) {
|
|
490
529
|
if (value === null)
|
|
491
530
|
return true;
|
|
492
531
|
const t = typeof value;
|
|
493
|
-
if (t === "string" || t === "
|
|
532
|
+
if (t === "string" || t === "boolean")
|
|
494
533
|
return true;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
534
|
+
// NaN/Infinity serialize to null via JSON.stringify.
|
|
535
|
+
if (t === "number")
|
|
536
|
+
return Number.isFinite(value);
|
|
537
|
+
if (typeof value !== "object" || !(Array.isArray(value) || isPlainObject(value)))
|
|
538
|
+
return false;
|
|
539
|
+
if (ancestors.has(value))
|
|
540
|
+
return false;
|
|
541
|
+
ancestors.add(value);
|
|
542
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
543
|
+
const safe = children.every((child) => isJsonSafeValue(child, ancestors));
|
|
544
|
+
ancestors.delete(value);
|
|
545
|
+
return safe;
|
|
500
546
|
}
|