@cosmicdrift/kumiko-framework 0.334.0 → 0.336.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- 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/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- 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 +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- 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 +32 -11
- 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 +45 -38
- 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 +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- 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 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -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.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- 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/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- 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 +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- 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 +10 -2
- 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 +172 -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 +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -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-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- 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 +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -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-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- 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 +264 -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 +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- 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);
|
|
@@ -681,20 +690,30 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
|
|
|
681
690
|
}
|
|
682
691
|
}
|
|
683
692
|
}
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
693
|
+
// validateScreenHasNavArea runs once per screen; the index is built once per
|
|
694
|
+
// featureMap (immutable during boot) instead of re-scanning every feature each call.
|
|
695
|
+
const navAreaIndexByFeatureMap = new WeakMap();
|
|
696
|
+
function navTargetScreenQns(nav) {
|
|
697
|
+
return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
|
|
698
|
+
}
|
|
699
|
+
function getNavAreaIndex(featureMap) {
|
|
700
|
+
const cached = navAreaIndexByFeatureMap.get(featureMap);
|
|
701
|
+
if (cached !== undefined)
|
|
702
|
+
return cached;
|
|
703
|
+
const navTargetQns = new Set();
|
|
704
|
+
const allScreens = [];
|
|
705
|
+
let anyNavRegistered = false;
|
|
687
706
|
for (const f of featureMap.values()) {
|
|
707
|
+
allScreens.push(...Object.values(f.screens));
|
|
688
708
|
for (const nav of Object.values(f.navs)) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return true;
|
|
693
|
-
if (nav.actions?.some((a) => a.screen === targetQn))
|
|
694
|
-
return true;
|
|
709
|
+
anyNavRegistered = true;
|
|
710
|
+
for (const qn of navTargetScreenQns(nav))
|
|
711
|
+
navTargetQns.add(qn);
|
|
695
712
|
}
|
|
696
713
|
}
|
|
697
|
-
|
|
714
|
+
const index = { anyNavRegistered, navTargetQns, allScreens };
|
|
715
|
+
navAreaIndexByFeatureMap.set(featureMap, index);
|
|
716
|
+
return index;
|
|
698
717
|
}
|
|
699
718
|
// Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
|
|
700
719
|
// `dormant: true`. Skipped when the composed set has no nav entries at all.
|
|
@@ -705,16 +724,15 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
|
|
|
705
724
|
// skip: explicitly opted out via `dormant: true`.
|
|
706
725
|
if (screen.dormant === true)
|
|
707
726
|
return;
|
|
708
|
-
const anyNavRegistered
|
|
727
|
+
const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
|
|
709
728
|
// skip: no nav entries exist anywhere in the composed set — a feature/
|
|
710
729
|
// recipe/test fixture booted without an app shell, not an omission.
|
|
711
730
|
if (!anyNavRegistered)
|
|
712
731
|
return;
|
|
713
732
|
const targetQn = qualifyEntityName(feature.name, "screen", screenId);
|
|
714
733
|
// skip: a standalone r.nav() elsewhere already points at this screen.
|
|
715
|
-
if (
|
|
734
|
+
if (navTargetQns.has(targetQn))
|
|
716
735
|
return;
|
|
717
|
-
const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
|
|
718
736
|
// skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
|
|
719
737
|
if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
|
|
720
738
|
return;
|
|
@@ -815,7 +833,16 @@ function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
|
815
833
|
}
|
|
816
834
|
}
|
|
817
835
|
}
|
|
818
|
-
|
|
836
|
+
// Only a navigate action that sets `tab` needs the target resolved (to check
|
|
837
|
+
// the tab id); plain navigates are validated elsewhere. One place for every
|
|
838
|
+
// section-level action site so a new site can't forget the tab check.
|
|
839
|
+
function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
|
|
840
|
+
// skip: only navigate actions with an explicit tab need target validation
|
|
841
|
+
if (action.kind !== "navigate" || action.tab === undefined)
|
|
842
|
+
return;
|
|
843
|
+
resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
|
|
844
|
+
}
|
|
845
|
+
export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
|
|
819
846
|
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
820
847
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
821
848
|
// Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
|
|
@@ -824,6 +851,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
824
851
|
// zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
|
|
825
852
|
// #1946) — kurze IDs bleiben same-feature wie zuvor.
|
|
826
853
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
854
|
+
const navigateLookups = {
|
|
855
|
+
allScreenQns,
|
|
856
|
+
navTargetShortIds,
|
|
857
|
+
screensByShortId,
|
|
858
|
+
detailForScreens,
|
|
859
|
+
};
|
|
827
860
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
828
861
|
validateScreenHasNavArea(feature, screenId, screen, featureMap);
|
|
829
862
|
if (screen.type === "custom") {
|
|
@@ -944,14 +977,18 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
944
977
|
`there is no fallback to the raw column name.`);
|
|
945
978
|
}
|
|
946
979
|
const navigate = typeof metric === "string" ? undefined : metric.navigate;
|
|
947
|
-
if (navigate
|
|
980
|
+
if (navigate !== undefined) {
|
|
948
981
|
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
949
|
-
if (
|
|
982
|
+
if (navigate.tab !== undefined &&
|
|
983
|
+
!screen.layout.sections.some((section) => section.id === navigate.tab)) {
|
|
950
984
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
|
|
951
985
|
`navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
|
|
952
986
|
}
|
|
953
987
|
}
|
|
954
988
|
else {
|
|
989
|
+
// Also runs without `tab`: a typo'd screen/entity or an entity
|
|
990
|
+
// target without entityId would otherwise render a clickable
|
|
991
|
+
// metric that silently does nothing.
|
|
955
992
|
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "metric", {
|
|
956
993
|
kind: "navigate",
|
|
957
994
|
id: field,
|
|
@@ -959,7 +996,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
959
996
|
...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
|
|
960
997
|
...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
|
|
961
998
|
...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
|
|
962
|
-
tab: navigate.tab,
|
|
999
|
+
...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
|
|
963
1000
|
}, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
964
1001
|
}
|
|
965
1002
|
}
|
|
@@ -1001,17 +1038,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1001
1038
|
if (section.actions !== undefined) {
|
|
1002
1039
|
for (const action of section.actions) {
|
|
1003
1040
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1041
|
+
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
|
|
1042
|
+
validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" action`, action, navigateLookups);
|
|
1007
1043
|
}
|
|
1008
1044
|
}
|
|
1009
1045
|
if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
|
|
1010
1046
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action);
|
|
1011
|
-
|
|
1012
|
-
section.emptyState.action.tab !== undefined) {
|
|
1013
|
-
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
1014
|
-
}
|
|
1047
|
+
validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
|
|
1015
1048
|
}
|
|
1016
1049
|
if (isExtensionEditSection(section)) {
|
|
1017
1050
|
// projectionDetail is read-only (no composed form submit) — an
|
|
@@ -1116,6 +1149,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1116
1149
|
if (screen.actions !== undefined) {
|
|
1117
1150
|
for (const action of screen.actions) {
|
|
1118
1151
|
validateActionHasIcon(feature.name, screenId, "projectionDetail", "action", action);
|
|
1152
|
+
validateRecordActionRedirect(feature, screenId, "projectionDetail", action, allScreenQns);
|
|
1119
1153
|
if (action.kind === "navigate" && action.rowClick === true) {
|
|
1120
1154
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1121
1155
|
`(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
|
|
@@ -1160,7 +1194,6 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1160
1194
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
|
|
1161
1195
|
}
|
|
1162
1196
|
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef, fieldNames);
|
|
1163
|
-
rejectWriteOnlyFormField(feature.name, screenId, "configEdit", fname, fdef);
|
|
1164
1197
|
}
|
|
1165
1198
|
if (screen.layout.sections.length === 0) {
|
|
1166
1199
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
|
|
@@ -1202,10 +1235,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1202
1235
|
`keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
|
|
1203
1236
|
`einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
|
|
1204
1237
|
}
|
|
1205
|
-
|
|
1238
|
+
const configKeyDef = configKeyDefsByQn.get(qualified);
|
|
1239
|
+
if (configKeyDef === undefined) {
|
|
1206
1240
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
|
|
1207
1241
|
`Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
|
|
1208
|
-
`Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...
|
|
1242
|
+
`Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(keine)"}`);
|
|
1243
|
+
}
|
|
1244
|
+
if (isWriteOnlyField(screen.fields[fname]) && !isEncryptedAtRest(configKeyDef)) {
|
|
1245
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" declares writeOnly ` +
|
|
1246
|
+
`but config key "${qualified}" is not encrypted at rest — writeOnly on a configEdit field ` +
|
|
1247
|
+
`only hides a stored secret (declare the key with encrypted: true or backing: "secrets").`);
|
|
1209
1248
|
}
|
|
1210
1249
|
}
|
|
1211
1250
|
continue;
|
|
@@ -1297,6 +1336,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1297
1336
|
throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
|
|
1298
1337
|
}
|
|
1299
1338
|
validateColumnRendererForm(feature.name, screenId, normalized);
|
|
1339
|
+
if (normalized.refEntity !== undefined) {
|
|
1340
|
+
assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
|
|
1341
|
+
}
|
|
1300
1342
|
}
|
|
1301
1343
|
// Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
|
|
1302
1344
|
// fangen, damit kein "warum kommt die Liste leer / falsch
|
|
@@ -1488,9 +1530,8 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1488
1530
|
const sectionLabel = section.title ?? section.id ?? "(untitled)";
|
|
1489
1531
|
for (const action of section.actions) {
|
|
1490
1532
|
validateActionHasIcon(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action);
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1533
|
+
validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
|
|
1534
|
+
validateNavigateActionTab(feature.name, screenId, "entityEdit", `section "${sectionLabel}" action`, action, navigateLookups);
|
|
1494
1535
|
}
|
|
1495
1536
|
}
|
|
1496
1537
|
if (isExtensionEditSection(section)) {
|
|
@@ -1525,6 +1566,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1525
1566
|
// row to click either.
|
|
1526
1567
|
if (screen.actions !== undefined) {
|
|
1527
1568
|
for (const action of screen.actions) {
|
|
1569
|
+
validateRecordActionRedirect(feature, screenId, "entityEdit", action, allScreenQns);
|
|
1528
1570
|
if (action.kind === "navigate" && action.rowClick === true) {
|
|
1529
1571
|
throw new Error(`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
1530
1572
|
`(entityEdit) action "${action.id}" sets rowClick: true — there is no row to click ` +
|
|
@@ -1584,7 +1626,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
1584
1626
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
1585
1627
|
}
|
|
1586
1628
|
else {
|
|
1587
|
-
validateDashboardQueryPanel(featureName, screenId, panel);
|
|
1629
|
+
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
1588
1630
|
}
|
|
1589
1631
|
}
|
|
1590
1632
|
if (screen.filter !== undefined) {
|
|
@@ -1638,6 +1680,15 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
1638
1680
|
throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
|
|
1639
1681
|
`${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
|
|
1640
1682
|
}
|
|
1683
|
+
// ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
|
|
1684
|
+
// cancel even when embedded, which would pull the user off the dashboard.
|
|
1685
|
+
if ((target.type === "actionForm" || target.type === "secretMint") &&
|
|
1686
|
+
(target.redirect !== undefined ||
|
|
1687
|
+
(target.cancelTarget !== undefined && target.cancelTarget !== false))) {
|
|
1688
|
+
throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
|
|
1689
|
+
`navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
|
|
1690
|
+
`with cancelTarget: false (or omitted).`);
|
|
1691
|
+
}
|
|
1641
1692
|
const visibleWhen = panel.visibleWhen;
|
|
1642
1693
|
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
1643
1694
|
throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
|
|
@@ -1663,7 +1714,7 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
|
1663
1714
|
`declare a react/native component marker.`);
|
|
1664
1715
|
}
|
|
1665
1716
|
}
|
|
1666
|
-
function validateDashboardQueryPanel(featureName, screenId, panel) {
|
|
1717
|
+
function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
1667
1718
|
if (!panel.query || typeof panel.query !== "string") {
|
|
1668
1719
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
|
|
1669
1720
|
}
|
|
@@ -1681,7 +1732,11 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
|
|
|
1681
1732
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
|
|
1682
1733
|
}
|
|
1683
1734
|
for (const col of panel.columns) {
|
|
1684
|
-
|
|
1735
|
+
const normalized = normalizeListColumn(col);
|
|
1736
|
+
validateColumnRendererForm(featureName, screenId, normalized);
|
|
1737
|
+
if (normalized.refEntity !== undefined) {
|
|
1738
|
+
assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
|
|
1739
|
+
}
|
|
1685
1740
|
}
|
|
1686
1741
|
}
|
|
1687
1742
|
}
|
|
@@ -1746,10 +1801,14 @@ export function validateColumnRendererForm(featureName, screenId, column) {
|
|
|
1746
1801
|
function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
1747
1802
|
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
1748
1803
|
const targetFeature = featureMap.get(target.featureName);
|
|
1749
|
-
if (targetFeature
|
|
1804
|
+
if (targetFeature === undefined) {
|
|
1805
|
+
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
1806
|
+
`mounted — mount it and declare r.requires("${target.featureName}").`);
|
|
1807
|
+
}
|
|
1808
|
+
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
1750
1809
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
1751
1810
|
`registered entity. Known entities in feature "${target.featureName}": ` +
|
|
1752
|
-
`${Object.keys(targetFeature
|
|
1811
|
+
`${Object.keys(targetFeature.entities ?? {})
|
|
1753
1812
|
.sort()
|
|
1754
1813
|
.join(", ") || "(none)"}.`);
|
|
1755
1814
|
}
|
|
@@ -1790,9 +1849,29 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
|
|
|
1790
1849
|
}
|
|
1791
1850
|
if (facet.type === "reference") {
|
|
1792
1851
|
assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
1852
|
+
assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
1793
1853
|
}
|
|
1794
1854
|
}
|
|
1795
1855
|
}
|
|
1856
|
+
// The facet's options are loaded at render time by dispatching the target's
|
|
1857
|
+
// list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
|
|
1858
|
+
// override); a missing or unregistered handler is swallowed there and leaves
|
|
1859
|
+
// a permanently empty dropdown, so it has to fail at boot instead.
|
|
1860
|
+
function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
1861
|
+
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
1862
|
+
const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
|
|
1863
|
+
const expectedQn = override?.queryQn ??
|
|
1864
|
+
qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
|
|
1865
|
+
for (const feature of featureMap.values()) {
|
|
1866
|
+
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
1867
|
+
// skip: expected list handler found
|
|
1868
|
+
if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
|
|
1873
|
+
`query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
|
|
1874
|
+
}
|
|
1796
1875
|
export function findEntityFeature(entityName, featureMap) {
|
|
1797
1876
|
for (const [name, feature] of featureMap) {
|
|
1798
1877
|
if (feature.entities?.[entityName])
|
|
@@ -40,9 +40,10 @@ function referenceTargets(entity) {
|
|
|
40
40
|
return targets;
|
|
41
41
|
}
|
|
42
42
|
// A `multiple` reference stores a jsonb array of ids, which the handover's
|
|
43
|
-
// `= ANY($ids)` column match cannot address. Declaring one
|
|
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,14 +29,18 @@
|
|
|
29
29
|
import { ZodObject } from "zod";
|
|
30
30
|
import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
|
|
31
31
|
import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";
|
|
32
|
-
import {
|
|
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
|
+
// r.nav() may carry explicit `undefined` optionals; drop them so the
|
|
42
|
+
// schema stays free of non-JSON-safe values.
|
|
43
|
+
const navs = Object.values(feature.navs).map((nav) => Object.fromEntries(Object.entries(nav).filter(([, value]) => value !== undefined)));
|
|
40
44
|
// The nav entry alone doesn't say which kind a collection lists, so the
|
|
41
45
|
// client can't derive its tree provider from `navs` — project the
|
|
42
46
|
// collections separately, with the nav QN already qualified.
|
|
@@ -47,7 +51,7 @@ export function buildAppSchema(registry, options = {}) {
|
|
|
47
51
|
const featureSchema = {
|
|
48
52
|
featureName,
|
|
49
53
|
entities: projectEntities(feature.entities ?? {}),
|
|
50
|
-
screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn),
|
|
54
|
+
screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns),
|
|
51
55
|
...(navs.length > 0 && { navs }),
|
|
52
56
|
...(contentCollections.length > 0 && { contentCollections }),
|
|
53
57
|
// #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
|
|
@@ -262,8 +266,10 @@ export function findNonJsonSafePath(value, path) {
|
|
|
262
266
|
// (fw#2165). A hand-written `searchable: true` still wins over the derived
|
|
263
267
|
// default (screen.searchable ?? derived); the boot-validator (3a) rejects
|
|
264
268
|
// one that contradicts the schema.
|
|
265
|
-
function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn) {
|
|
269
|
+
function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
|
|
266
270
|
return Object.entries(screens).map(([shortId, screen]) => {
|
|
271
|
+
if (screen.type === "configEdit")
|
|
272
|
+
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
267
273
|
if (screen.type === "projectionList")
|
|
268
274
|
return projectProjectionListScreen(screen, registry);
|
|
269
275
|
if (screen.type === "actionForm" ||
|
|
@@ -275,6 +281,31 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
|
|
|
275
281
|
return screen;
|
|
276
282
|
});
|
|
277
283
|
}
|
|
284
|
+
function collectEncryptedConfigKeyQns(registry) {
|
|
285
|
+
const qns = new Set();
|
|
286
|
+
for (const [featureName, feature] of registry.features) {
|
|
287
|
+
for (const [key, def] of Object.entries(feature.configKeys)) {
|
|
288
|
+
if (isEncryptedAtRest(def))
|
|
289
|
+
qns.add(qualifyEntityName(featureName, QnTypes.config, key));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return qns;
|
|
293
|
+
}
|
|
294
|
+
// A hand-written configEdit field over an encrypted-at-rest key would load the
|
|
295
|
+
// read mask as editable text and a partial edit would overwrite the secret with
|
|
296
|
+
// it. Forcing writeOnly here matches what deriveField does for generated screens.
|
|
297
|
+
function projectConfigEditScreen(screen, encryptedConfigKeyQns) {
|
|
298
|
+
let fields;
|
|
299
|
+
for (const [fieldName, field] of Object.entries(screen.fields)) {
|
|
300
|
+
const qualified = screen.configKeys[fieldName];
|
|
301
|
+
if (field.type !== "text" || qualified === undefined || !encryptedConfigKeyQns.has(qualified)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
fields ??= { ...screen.fields };
|
|
305
|
+
fields[fieldName] = { ...field, writeOnly: true };
|
|
306
|
+
}
|
|
307
|
+
return fields === undefined ? screen : { ...screen, fields };
|
|
308
|
+
}
|
|
278
309
|
function projectProjectionListScreen(screen, registry) {
|
|
279
310
|
const schema = registry.getQueryHandler(screen.query)?.schema;
|
|
280
311
|
const capabilities = deriveProjectionListCapabilities(schema);
|
|
@@ -384,7 +415,7 @@ function projectField(fieldDef) {
|
|
|
384
415
|
// Select: options-Liste ist plain JSON, durchschicken.
|
|
385
416
|
if (Array.isArray(def["options"]))
|
|
386
417
|
out["options"] = def["options"];
|
|
387
|
-
if (
|
|
418
|
+
if (isJsonSafeObject(def["optionTones"]))
|
|
388
419
|
out["optionTones"] = def["optionTones"];
|
|
389
420
|
// Reference: entity-Target + labelField + multiple müssen zum Renderer.
|
|
390
421
|
// Der ReferenceInput baut die Options-Query aus refEntity/refFeature und
|
|
@@ -410,8 +441,7 @@ function projectField(fieldDef) {
|
|
|
410
441
|
out["maxRows"] = def["maxRows"];
|
|
411
442
|
// text/longText: textarea row count — DefaultInput renders a single-line
|
|
412
443
|
// input otherwise (fw#2497).
|
|
413
|
-
if (typeof def["multiline"] === "boolean" ||
|
|
414
|
-
(isPlainObject(def["multiline"]) && isJsonSafeValue(def["multiline"])))
|
|
444
|
+
if (typeof def["multiline"] === "boolean" || isJsonSafeObject(def["multiline"]))
|
|
415
445
|
out["multiline"] = def["multiline"];
|
|
416
446
|
// number: Zod write-boundary bounds. date/timestamp/locatedTimestamp:
|
|
417
447
|
// ISO-string picker bounds — same keys, different literal type (fw#2497).
|
|
@@ -447,11 +477,11 @@ function projectField(fieldDef) {
|
|
|
447
477
|
out["locatedBy"] = def["locatedBy"];
|
|
448
478
|
// file/image/images: upload picker constraints; the first variant key picks
|
|
449
479
|
// the preview variant.
|
|
450
|
-
if (
|
|
480
|
+
if (isJsonSafeArray(def["accept"]))
|
|
451
481
|
out["accept"] = def["accept"];
|
|
452
482
|
if (typeof def["maxSize"] === "string")
|
|
453
483
|
out["maxSize"] = def["maxSize"];
|
|
454
|
-
if (
|
|
484
|
+
if (isJsonSafeObject(def["variants"]))
|
|
455
485
|
out["variants"] = def["variants"];
|
|
456
486
|
// decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
|
|
457
487
|
if (typeof def["scale"] === "number")
|
|
@@ -459,7 +489,7 @@ function projectField(fieldDef) {
|
|
|
459
489
|
// money: declared currency source (MoneyCurrencySource, fw#2933) — without
|
|
460
490
|
// it the renderer can't tell a "currency: { kind: 'tenant' }" field apart
|
|
461
491
|
// from an undeclared one and always falls back to entity.defaultCurrency.
|
|
462
|
-
if (
|
|
492
|
+
if (isJsonSafeObject(def["currency"]))
|
|
463
493
|
out["currency"] = def["currency"];
|
|
464
494
|
// embedded lists: row-count bounds, computed cells, totals row, and the
|
|
465
495
|
// sibling-money-field totals check (fw#2497).
|
|
@@ -467,11 +497,11 @@ function projectField(fieldDef) {
|
|
|
467
497
|
out["minItems"] = def["minItems"];
|
|
468
498
|
if (typeof def["maxItems"] === "number")
|
|
469
499
|
out["maxItems"] = def["maxItems"];
|
|
470
|
-
if (
|
|
500
|
+
if (isJsonSafeObject(def["derived"]))
|
|
471
501
|
out["derived"] = def["derived"];
|
|
472
|
-
if (
|
|
502
|
+
if (isJsonSafeArray(def["totals"]))
|
|
473
503
|
out["totals"] = def["totals"];
|
|
474
|
-
if (
|
|
504
|
+
if (isJsonSafeObject(def["totalsMatch"]))
|
|
475
505
|
out["totalsMatch"] = def["totalsMatch"];
|
|
476
506
|
// embedded lists: the sub-field map itself — without it the client-side
|
|
477
507
|
// edit view-model iterates `undefined` and the screen crashes (fw#2507).
|
|
@@ -489,15 +519,30 @@ function isPlainObject(value) {
|
|
|
489
519
|
const proto = Object.getPrototypeOf(value);
|
|
490
520
|
return proto === Object.prototype || proto === null;
|
|
491
521
|
}
|
|
492
|
-
function
|
|
522
|
+
function isJsonSafeObject(value) {
|
|
523
|
+
return isPlainObject(value) && isJsonSafeValue(value);
|
|
524
|
+
}
|
|
525
|
+
function isJsonSafeArray(value) {
|
|
526
|
+
return Array.isArray(value) && isJsonSafeValue(value);
|
|
527
|
+
}
|
|
528
|
+
// `ancestors` tracks the current path only, so a shared (non-cyclic) sub-object
|
|
529
|
+
// stays valid while a self-reference returns false instead of overflowing the stack.
|
|
530
|
+
function isJsonSafeValue(value, ancestors = new Set()) {
|
|
493
531
|
if (value === null)
|
|
494
532
|
return true;
|
|
495
533
|
const t = typeof value;
|
|
496
|
-
if (t === "string" || t === "
|
|
534
|
+
if (t === "string" || t === "boolean")
|
|
497
535
|
return true;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
536
|
+
// NaN/Infinity serialize to null via JSON.stringify.
|
|
537
|
+
if (t === "number")
|
|
538
|
+
return Number.isFinite(value);
|
|
539
|
+
if (typeof value !== "object" || !(Array.isArray(value) || isPlainObject(value)))
|
|
540
|
+
return false;
|
|
541
|
+
if (ancestors.has(value))
|
|
542
|
+
return false;
|
|
543
|
+
ancestors.add(value);
|
|
544
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
545
|
+
const safe = children.every((child) => isJsonSafeValue(child, ancestors));
|
|
546
|
+
ancestors.delete(value);
|
|
547
|
+
return safe;
|
|
503
548
|
}
|