@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- 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.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -4,7 +4,7 @@
|
|
|
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, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } 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
10
|
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
|
|
|
12
12
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
13
13
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
14
14
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
15
|
-
import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
15
|
+
import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
16
16
|
import { metricField } from "../types/index.js";
|
|
17
17
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
18
|
+
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
18
19
|
// entityList and projectionList both allow a rowAction to double as the
|
|
19
20
|
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
20
21
|
// screen, or the renderer can't tell which one should fire.
|
|
@@ -126,6 +127,11 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
|
|
|
126
127
|
`the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
|
|
127
128
|
`entityEdit-create screen.`);
|
|
128
129
|
}
|
|
130
|
+
const formTarget = target.screen;
|
|
131
|
+
if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
|
|
132
|
+
formTarget.submitPrefilled === true) {
|
|
133
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
|
|
134
|
+
}
|
|
129
135
|
}
|
|
130
136
|
// Shared by projectionDetail and entityEdit: a tab strip needs something to
|
|
131
137
|
// label each tab with and a stable id per tab — the id anchors the ?tab=
|
|
@@ -345,19 +351,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
|
|
|
345
351
|
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
346
352
|
`actionForm screen, or use kind:"navigate" for a full-page target.`);
|
|
347
353
|
}
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
354
|
+
// The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
|
|
355
|
+
// when the target doesn't declare the field, marks it sensitive/password,
|
|
356
|
+
// or doesn't render it in its layout — each would only show up as an empty
|
|
357
|
+
// field at click time.
|
|
351
358
|
// skip: no params extractor — nothing to check against the target's fields.
|
|
352
359
|
if (action.params === undefined)
|
|
353
360
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
361
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
|
|
362
|
+
}
|
|
363
|
+
function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
|
|
364
|
+
const renderedFieldNames = new Set();
|
|
365
|
+
for (const section of target.layout.sections) {
|
|
366
|
+
if (!isFieldsEditSection(section))
|
|
356
367
|
continue;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
368
|
+
for (const spec of sectionFieldSpecs(section))
|
|
369
|
+
renderedFieldNames.add(normalizeEditField(spec).field);
|
|
370
|
+
}
|
|
371
|
+
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
372
|
+
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
373
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
|
|
374
|
+
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
375
|
+
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
376
|
+
}
|
|
377
|
+
const targetField = target.fields[fieldName];
|
|
378
|
+
const isSensitiveOrPassword = targetField !== undefined &&
|
|
379
|
+
(("sensitive" in targetField && targetField.sensitive === true) ||
|
|
380
|
+
("format" in targetField && targetField.format === "password"));
|
|
381
|
+
if (isSensitiveOrPassword) {
|
|
382
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
|
|
383
|
+
`sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
|
|
384
|
+
}
|
|
385
|
+
if (!renderedFieldNames.has(fieldName)) {
|
|
386
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
|
|
387
|
+
`its layout — the renderer only prefills fields the layout shows and would drop it.`);
|
|
388
|
+
}
|
|
361
389
|
}
|
|
362
390
|
}
|
|
363
391
|
// The target can be any screen, so this checks the resolved target's
|
|
@@ -496,9 +524,13 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
|
|
|
496
524
|
// payload refs must name another field of the same form (the payload also feeds
|
|
497
525
|
// optionsAvailabilityQuery). The QN existence check
|
|
498
526
|
// lives in query-refs.ts with the other query refs.
|
|
527
|
+
const CONDITIONAL_OPTIONS_ENTITY_ONLY = "conditionalOptions is only supported on entity select fields (enforced by the entity write path)";
|
|
499
528
|
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
500
529
|
if (fdef.type === "select") {
|
|
501
530
|
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
|
|
531
|
+
if (fdef.conditionalOptions !== undefined) {
|
|
532
|
+
throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}`);
|
|
533
|
+
}
|
|
502
534
|
if (fdef.optionsQuery !== undefined) {
|
|
503
535
|
if (fdef.optionsQuery.length === 0) {
|
|
504
536
|
throw new Error(`${where} has an empty optionsQuery`);
|
|
@@ -564,7 +596,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
|
|
|
564
596
|
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
|
|
565
597
|
`when using groups.`);
|
|
566
598
|
}
|
|
567
|
-
if (section.fields.length === 0 && section.groups ===
|
|
599
|
+
if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
|
|
568
600
|
throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
569
601
|
`fields (or groups) to it.`);
|
|
570
602
|
}
|
|
@@ -626,6 +658,17 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
|
|
|
626
658
|
`has leading/trailing whitespace — it would never match a payload field.`);
|
|
627
659
|
}
|
|
628
660
|
validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
|
|
661
|
+
// idFrom only means something to a target that carries a record id; the
|
|
662
|
+
// renderer drops it silently otherwise. Cross-feature QN targets can't be
|
|
663
|
+
// typed from here and stay unchecked.
|
|
664
|
+
const sameFeatureTarget = feature.screens[redirectTarget];
|
|
665
|
+
if (typeof redirect !== "string" &&
|
|
666
|
+
sameFeatureTarget !== undefined &&
|
|
667
|
+
sameFeatureTarget.type !== "entityEdit" &&
|
|
668
|
+
sameFeatureTarget.type !== "projectionDetail") {
|
|
669
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom is set but target screen ` +
|
|
670
|
+
`"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
|
|
671
|
+
}
|
|
629
672
|
}
|
|
630
673
|
// redirect is only honored on record actions; on list/related-list row
|
|
631
674
|
// actions it would be silently dropped, so the author would believe it works.
|
|
@@ -758,6 +801,20 @@ function getNavAreaIndex(featureMap) {
|
|
|
758
801
|
navAreaIndexByFeatureMap.set(featureMap, index);
|
|
759
802
|
return index;
|
|
760
803
|
}
|
|
804
|
+
// resolveNavParentScreen falls back to a heuristic when the explicit
|
|
805
|
+
// listScreenId matches no screen, so a typo would silently get the guess.
|
|
806
|
+
function validateExplicitListScreenExists(featureName, screenId, screen, screensByShortId) {
|
|
807
|
+
const listScreenId = explicitListScreenId(screen);
|
|
808
|
+
// skip: no explicit listScreenId, the nav heuristic applies and there is nothing to validate
|
|
809
|
+
if (listScreenId === undefined)
|
|
810
|
+
return;
|
|
811
|
+
const shortId = listScreenId.slice(listScreenId.lastIndexOf(":") + 1);
|
|
812
|
+
// skip: the explicit listScreenId resolves to a mounted screen
|
|
813
|
+
if (screensByShortId.has(shortId))
|
|
814
|
+
return;
|
|
815
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) listScreenId "${listScreenId}" ` +
|
|
816
|
+
`matches no mounted screen — fix the id or mount the list screen.`);
|
|
817
|
+
}
|
|
761
818
|
// Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
|
|
762
819
|
// `dormant: true`. Skipped when the composed set has no nav entries at all.
|
|
763
820
|
function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
|
|
@@ -902,6 +959,22 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
902
959
|
detailForScreens,
|
|
903
960
|
};
|
|
904
961
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
962
|
+
validateExplicitListScreenExists(feature.name, screenId, screen, screensByShortId);
|
|
963
|
+
// Output of buildAppSchema, which overwrites it — an authored value would be a silent no-op.
|
|
964
|
+
if ((screen.type === "entityEdit" ||
|
|
965
|
+
screen.type === "actionForm" ||
|
|
966
|
+
screen.type === "secretMint") &&
|
|
967
|
+
screen.urlPrefillFields !== undefined) {
|
|
968
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets urlPrefillFields — ` +
|
|
969
|
+
`it is derived from navigate params by buildAppSchema; declare params on the navigating action instead.`);
|
|
970
|
+
}
|
|
971
|
+
if (screen.type !== "actionForm" &&
|
|
972
|
+
screen.type !== "secretMint" &&
|
|
973
|
+
"submitPrefilled" in screen &&
|
|
974
|
+
screen.submitPrefilled !== undefined) {
|
|
975
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets submitPrefilled — ` +
|
|
976
|
+
`only actionForm and secretMint screens support it. Remove it.`);
|
|
977
|
+
}
|
|
905
978
|
validateScreenHasNavArea(feature, screenId, screen, featureMap);
|
|
906
979
|
validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap);
|
|
907
980
|
if (screen.type === "custom") {
|
|
@@ -925,6 +998,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
925
998
|
for (const col of screen.columns) {
|
|
926
999
|
const normalizedCol = normalizeListColumn(col);
|
|
927
1000
|
validateColumnRendererForm(feature.name, screenId, normalizedCol);
|
|
1001
|
+
rejectInertColumnSortable(feature.name, screenId, "projectionList", normalizedCol);
|
|
928
1002
|
if (normalizedCol.refEntity !== undefined) {
|
|
929
1003
|
assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
|
|
930
1004
|
}
|
|
@@ -1048,6 +1122,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1048
1122
|
}
|
|
1049
1123
|
}
|
|
1050
1124
|
}
|
|
1125
|
+
for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
|
|
1126
|
+
if (!isSelectOptionTone(tone)) {
|
|
1127
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
|
|
1128
|
+
`is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1051
1131
|
if (Array.isArray(screen.header?.subtitle)) {
|
|
1052
1132
|
if (screen.header.subtitleHref !== undefined) {
|
|
1053
1133
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
|
|
@@ -1382,6 +1462,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1382
1462
|
throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
|
|
1383
1463
|
}
|
|
1384
1464
|
validateColumnRendererForm(feature.name, screenId, normalized);
|
|
1465
|
+
rejectInertColumnSortable(feature.name, screenId, "entityList", normalized);
|
|
1385
1466
|
if (normalized.refEntity !== undefined) {
|
|
1386
1467
|
assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
|
|
1387
1468
|
}
|
|
@@ -1782,8 +1863,15 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
|
1782
1863
|
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1783
1864
|
}
|
|
1784
1865
|
validateStackedAreaOnlyProps(where, panel);
|
|
1785
|
-
|
|
1786
|
-
|
|
1866
|
+
validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
|
|
1867
|
+
validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
|
|
1868
|
+
"default-range",
|
|
1869
|
+
"from-today",
|
|
1870
|
+
]);
|
|
1871
|
+
validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
|
|
1872
|
+
const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
|
|
1873
|
+
if (nonBooleanProp !== undefined) {
|
|
1874
|
+
throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
|
|
1787
1875
|
}
|
|
1788
1876
|
for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
|
|
1789
1877
|
if (typeof color !== "string" || color.trim() === "") {
|
|
@@ -1796,11 +1884,27 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
|
1796
1884
|
if (panel.ranges !== undefined)
|
|
1797
1885
|
validateDashboardChartRanges(`${where} ranges`, panel.ranges);
|
|
1798
1886
|
}
|
|
1887
|
+
function validateStackedAreaEnumProp(where, prop, value, allowed) {
|
|
1888
|
+
// skip: optional prop, nothing to validate when unset
|
|
1889
|
+
if (value === undefined)
|
|
1890
|
+
return;
|
|
1891
|
+
if (!allowed.includes(value)) {
|
|
1892
|
+
throw new Error(`${where}.${prop} "${String(value)}" must be one of ${allowed.join(", ")}.`);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1799
1895
|
function validateStackedAreaOnlyProps(where, panel) {
|
|
1800
1896
|
// skip: stacked-area is the one chart kind that supports these props
|
|
1801
1897
|
if (panel.chart === "stacked-area")
|
|
1802
1898
|
return;
|
|
1803
|
-
const stackedAreaOnly = [
|
|
1899
|
+
const stackedAreaOnly = [
|
|
1900
|
+
"markerKinds",
|
|
1901
|
+
"legendTotals",
|
|
1902
|
+
"ranges",
|
|
1903
|
+
"brush",
|
|
1904
|
+
"dateFormat",
|
|
1905
|
+
"initialWindow",
|
|
1906
|
+
"markerLegend",
|
|
1907
|
+
].filter((prop) => panel[prop] !== undefined);
|
|
1804
1908
|
if (stackedAreaOnly.length > 0) {
|
|
1805
1909
|
throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1806
1910
|
}
|
|
@@ -1821,6 +1925,9 @@ function validateDashboardChartMarkerKind(where, look) {
|
|
|
1821
1925
|
if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
|
|
1822
1926
|
throw new Error(`${where}.color must be a non-empty CSS color.`);
|
|
1823
1927
|
}
|
|
1928
|
+
if (look.label !== undefined && (typeof look.label !== "string" || look.label.trim() === "")) {
|
|
1929
|
+
throw new Error(`${where}.label must be a non-empty i18n key.`);
|
|
1930
|
+
}
|
|
1824
1931
|
}
|
|
1825
1932
|
function validateDashboardChartRanges(where, ranges) {
|
|
1826
1933
|
if (ranges.options.length === 0) {
|
|
@@ -1919,6 +2026,20 @@ function validateDashboardFilterDefinition(featureName, screenId, filter) {
|
|
|
1919
2026
|
// stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
|
|
1920
2027
|
// nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
|
|
1921
2028
|
// nur eine Warnung aus, kein Boot-Fail.
|
|
2029
|
+
// Column-level sortable: true only enables relatedList headers. On entityList
|
|
2030
|
+
// the entity field's flag wins; on projectionList sortable comes from the
|
|
2031
|
+
// query schema and a column can only opt OUT with sortable: false.
|
|
2032
|
+
function rejectInertColumnSortable(featureName, screenId, screenType, column) {
|
|
2033
|
+
const isInert = screenType === "entityList" ? column.sortable !== undefined : column.sortable === true;
|
|
2034
|
+
// skip: the sortable flag is meaningful for this screen type
|
|
2035
|
+
if (!isInert)
|
|
2036
|
+
return;
|
|
2037
|
+
const source = screenType === "entityList"
|
|
2038
|
+
? "set sortable: true on the entity field instead"
|
|
2039
|
+
: "sorting is derived from the query's Zod schema; only sortable: false is honored";
|
|
2040
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
|
|
2041
|
+
`it is ignored here; ${source}.`);
|
|
2042
|
+
}
|
|
1922
2043
|
export function validateColumnRendererForm(featureName, screenId, column) {
|
|
1923
2044
|
const renderer = column.renderer;
|
|
1924
2045
|
// skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_TRANSFER_DEPTH } from "../transfer-adjacency.js";
|
|
1
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
2
|
-
export
|
|
3
|
+
export { MAX_TRANSFER_DEPTH };
|
|
3
4
|
export declare function validateTransferGraph(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseRefTargetEntityName } from "../parse-ref-target.js";
|
|
2
|
+
import { findOverDeepTransferChain, MAX_TRANSFER_DEPTH, resolveTransferAdjacency, } from "../transfer-adjacency.js";
|
|
2
3
|
// --- Transfer-graph boot validation (fw#3088) ---
|
|
3
4
|
//
|
|
4
5
|
// tenant-handover walks an entity graph across the tenant boundary along two
|
|
@@ -9,16 +10,7 @@ import { parseRefTargetEntityName } from "../parse-ref-target.js";
|
|
|
9
10
|
//
|
|
10
11
|
// Scoped to entities declaring `transferable: true`, so a consumer that has
|
|
11
12
|
// one of these shapes but never hands that entity over is unaffected.
|
|
12
|
-
|
|
13
|
-
// resolver in bundled-features because the dependency only runs that way:
|
|
14
|
-
// bundled-features imports from the framework, never the reverse.
|
|
15
|
-
//
|
|
16
|
-
// Guards against a schema whose reference edges span more levels than anyone
|
|
17
|
-
// intended — a runaway graph would move rows an operator never associated with
|
|
18
|
-
// the handover. Deliberately a constant and not per-feature config: the limit
|
|
19
|
-
// is a safety net, and a configurable one gets raised by whoever trips it
|
|
20
|
-
// instead of prompting them to reconsider their graph.
|
|
21
|
-
export const MAX_TRANSFER_DEPTH = 5;
|
|
13
|
+
export { MAX_TRANSFER_DEPTH };
|
|
22
14
|
function allEntities(featureMap) {
|
|
23
15
|
const out = [];
|
|
24
16
|
for (const feature of featureMap.values()) {
|
|
@@ -28,17 +20,6 @@ function allEntities(featureMap) {
|
|
|
28
20
|
}
|
|
29
21
|
return out;
|
|
30
22
|
}
|
|
31
|
-
function referenceTargets(entity) {
|
|
32
|
-
const targets = [];
|
|
33
|
-
for (const field of Object.values(entity.fields)) {
|
|
34
|
-
if (field.type !== "reference")
|
|
35
|
-
continue;
|
|
36
|
-
if (field.multiple === true)
|
|
37
|
-
continue;
|
|
38
|
-
targets.push(parseRefTargetEntityName(field.entity));
|
|
39
|
-
}
|
|
40
|
-
return targets;
|
|
41
|
-
}
|
|
42
23
|
// A `multiple` reference stores a jsonb array of ids, which the handover's
|
|
43
24
|
// `= ANY($ids)` column match cannot address. Declaring one towards a transferable
|
|
44
25
|
// entity would mean its rows never move with their host. A target outside the
|
|
@@ -52,6 +33,8 @@ function validateNoMultipleReferenceEdge(entry, featureName, transferable) {
|
|
|
52
33
|
for (const [fieldName, field] of Object.entries(entry.entity.fields)) {
|
|
53
34
|
if (field.type !== "reference" || field.multiple !== true)
|
|
54
35
|
continue;
|
|
36
|
+
if (field.handover === "stay")
|
|
37
|
+
continue;
|
|
55
38
|
if (!transferable.has(parseRefTargetEntityName(field.entity)))
|
|
56
39
|
continue;
|
|
57
40
|
throw new Error(`[Kumiko TransferGraph] entity "${entry.name}" declares transferable: true and a ` +
|
|
@@ -62,31 +45,6 @@ function validateNoMultipleReferenceEdge(entry, featureName, transferable) {
|
|
|
62
45
|
`from "${entry.name}".`);
|
|
63
46
|
}
|
|
64
47
|
}
|
|
65
|
-
// Depth is measured over reference edges between transferable entities, the
|
|
66
|
-
// only chain that nests (parentRef is one level by construction — see
|
|
67
|
-
// engine/boot-validator/parent-ref.ts).
|
|
68
|
-
//
|
|
69
|
-
// `onPath` counts NODES, the limit counts EDGES: a chain of exactly
|
|
70
|
-
// MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
|
|
71
|
-
// mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
|
|
72
|
-
// chain whole. Rejecting it here would make a schema the mover handles
|
|
73
|
-
// correctly refuse to boot.
|
|
74
|
-
function longestTransferableChain(startName, bySource, onPath) {
|
|
75
|
-
if (onPath.length > MAX_TRANSFER_DEPTH + 1)
|
|
76
|
-
return onPath;
|
|
77
|
-
for (const target of bySource.get(startName) ?? []) {
|
|
78
|
-
// skip: a cycle revisits a type already on this path, and schema depth
|
|
79
|
-
// cannot measure how far it actually runs — that depends on the rows, not
|
|
80
|
-
// the declaration. The mover carries this one instead, failing with
|
|
81
|
-
// `transfer_graph_too_deep` when its rounds run out (#3131).
|
|
82
|
-
if (onPath.includes(target))
|
|
83
|
-
continue;
|
|
84
|
-
const deeper = longestTransferableChain(target, bySource, [...onPath, target]);
|
|
85
|
-
if (deeper !== undefined)
|
|
86
|
-
return deeper;
|
|
87
|
-
}
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
48
|
export function validateTransferGraph(feature, featureMap) {
|
|
91
49
|
const transferable = new Set(allEntities(featureMap)
|
|
92
50
|
.filter((e) => e.entity.transferable === true)
|
|
@@ -94,30 +52,17 @@ export function validateTransferGraph(feature, featureMap) {
|
|
|
94
52
|
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
95
53
|
validateNoMultipleReferenceEdge({ name, entity }, feature.name, transferable);
|
|
96
54
|
}
|
|
97
|
-
|
|
98
|
-
// so the lookup is inverted here.
|
|
99
|
-
const childrenByParent = new Map();
|
|
100
|
-
for (const { name, entity } of allEntities(featureMap)) {
|
|
101
|
-
if (!transferable.has(name))
|
|
102
|
-
continue;
|
|
103
|
-
for (const target of referenceTargets(entity)) {
|
|
104
|
-
if (!transferable.has(target))
|
|
105
|
-
continue;
|
|
106
|
-
const children = childrenByParent.get(target) ?? [];
|
|
107
|
-
children.push(name);
|
|
108
|
-
childrenByParent.set(target, children);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
55
|
+
const adjacency = resolveTransferAdjacency(new Map(allEntities(featureMap).map(({ name, entity }) => [name, entity])));
|
|
111
56
|
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
112
57
|
if (entity.transferable !== true)
|
|
113
58
|
continue;
|
|
114
|
-
const tooDeep =
|
|
59
|
+
const tooDeep = findOverDeepTransferChain(adjacency, [name]);
|
|
115
60
|
if (tooDeep !== undefined) {
|
|
116
61
|
throw new Error(`[Kumiko TransferGraph] the transfer graph rooted at entity "${name}" is deeper than ` +
|
|
117
62
|
`the ${MAX_TRANSFER_DEPTH}-level limit (feature: "${feature.name}"): ` +
|
|
118
63
|
`${tooDeep.join(" -> ")}. Beyond that depth tenant-handover would move fewer rows ` +
|
|
119
|
-
`than the declaration implies. Fix: flatten the chain, or
|
|
120
|
-
`entity in it that should not move with its host
|
|
64
|
+
`than the declaration implies. Fix: flatten the chain, or mark a reference field on ` +
|
|
65
|
+
`an entity in it that should not move with its host \`handover: "stay"\`.`);
|
|
121
66
|
}
|
|
122
67
|
}
|
|
123
68
|
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types/index.js";
|
|
2
2
|
export declare function collectUrlPrefillFieldsByScreenQn(features: readonly FeatureDefinition[]): Map<string, Set<string>>;
|
|
3
|
+
export declare function validateSubmitPrefilledHiddenFields(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name.js";
|
|
2
|
+
import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
|
|
2
3
|
import { collectScreensByShortId, readsNavigateParamsAsFormPrefill, rowFieldExtractorKeys, } from "./screens.js";
|
|
3
4
|
function isNavigateKind(action) {
|
|
4
5
|
return action.kind === undefined || action.kind === "navigate";
|
|
@@ -123,3 +124,37 @@ function resolveTarget(source, screensByShortId, detailForByEntity) {
|
|
|
123
124
|
const match = screensByShortId.get(source.screen)?.[0];
|
|
124
125
|
return match === undefined ? undefined : { ...match, shortId: source.screen };
|
|
125
126
|
}
|
|
127
|
+
// submitPrefilled lets an unchanged form submit, so a required field the layout
|
|
128
|
+
// hides must be seeded by a navigate param — nothing else can ever fill it.
|
|
129
|
+
export function validateSubmitPrefilledHiddenFields(features) {
|
|
130
|
+
const prefillByScreenQn = collectUrlPrefillFieldsByScreenQn(features);
|
|
131
|
+
for (const feature of features) {
|
|
132
|
+
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
133
|
+
if (screen.type !== "actionForm" && screen.type !== "secretMint")
|
|
134
|
+
continue;
|
|
135
|
+
if (screen.submitPrefilled !== true)
|
|
136
|
+
continue;
|
|
137
|
+
const seeded = prefillByScreenQn.get(qualifyEntityName(feature.name, "screen", screenId));
|
|
138
|
+
for (const section of screen.layout.sections) {
|
|
139
|
+
if (!isFieldsEditSection(section))
|
|
140
|
+
continue;
|
|
141
|
+
for (const rawSpec of sectionFieldSpecs(section)) {
|
|
142
|
+
const spec = normalizeEditField(rawSpec);
|
|
143
|
+
if (spec.visible !== false)
|
|
144
|
+
continue;
|
|
145
|
+
const fieldDef = screen.fields[spec.field];
|
|
146
|
+
if (fieldDef === undefined || !("required" in fieldDef) || fieldDef.required !== true) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (seeded?.has(spec.field) === true)
|
|
150
|
+
continue;
|
|
151
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets submitPrefilled but ` +
|
|
152
|
+
`hides required field "${spec.field}" (visible: false) and no navigate action passes it in ` +
|
|
153
|
+
`params — the field could never get a value and the form could never submit. Add ` +
|
|
154
|
+
`"${spec.field}" to the params of a navigate action targeting this screen, make the field ` +
|
|
155
|
+
`visible, or drop required.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
|
|
|
268
268
|
// one that contradicts the schema.
|
|
269
269
|
function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
|
|
270
270
|
return Object.entries(screens).map(([shortId, screen]) => {
|
|
271
|
+
assertValidScreenMultilineRows(featureName, shortId, screen);
|
|
271
272
|
if (screen.type === "configEdit")
|
|
272
273
|
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
273
274
|
if (screen.type === "projectionList")
|
|
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
|
|
|
330
331
|
function projectEntities(entities) {
|
|
331
332
|
const out = {};
|
|
332
333
|
for (const [name, entity] of Object.entries(entities)) {
|
|
333
|
-
out[name] = projectEntity(entity);
|
|
334
|
+
out[name] = projectEntity(name, entity);
|
|
334
335
|
}
|
|
335
336
|
return out;
|
|
336
337
|
}
|
|
@@ -339,9 +340,10 @@ function projectEntities(entities) {
|
|
|
339
340
|
// einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
|
|
340
341
|
// Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
|
|
341
342
|
// Functions, Computed-Functions, Default-Functions.
|
|
342
|
-
function projectEntity(entity) {
|
|
343
|
+
function projectEntity(entityName, entity) {
|
|
343
344
|
const fieldsOut = {};
|
|
344
345
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
346
|
+
assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
|
|
345
347
|
fieldsOut[fieldName] = projectField(fieldDef);
|
|
346
348
|
}
|
|
347
349
|
// derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
|
|
@@ -380,6 +382,32 @@ function projectEntity(entity) {
|
|
|
380
382
|
function projectDerivedField(derivedDef) {
|
|
381
383
|
return { valueType: derivedDef.valueType };
|
|
382
384
|
}
|
|
385
|
+
// A textarea height that is not a positive integer cannot render anywhere; fail
|
|
386
|
+
// at boot with the field name instead of letting each renderer guess a fallback.
|
|
387
|
+
function assertValidMultilineRows(owner, fieldName, fieldDef) {
|
|
388
|
+
const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
|
|
389
|
+
const rows = typeof multiline === "object" ? multiline.rows : undefined;
|
|
390
|
+
// Non-number values are not JSON-safe and get dropped by the projection below.
|
|
391
|
+
if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
|
|
392
|
+
throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Screen forms carry their own inline field maps into the client schema.
|
|
396
|
+
function assertValidScreenMultilineRows(featureName, shortId, screen) {
|
|
397
|
+
const owner = `Screen "${featureName}:${shortId}"`;
|
|
398
|
+
const check = (fields) => {
|
|
399
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
400
|
+
assertValidMultilineRows(owner, fieldName, fieldDef);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (screen.type === "configEdit" || screen.type === "actionForm")
|
|
404
|
+
check(screen.fields);
|
|
405
|
+
if (screen.type === "secretMint") {
|
|
406
|
+
check(screen.fields);
|
|
407
|
+
if (screen.confirm !== undefined)
|
|
408
|
+
check(screen.confirm.fields);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
383
411
|
// Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
|
|
384
412
|
// an array/plain-object built only from JSON-safe values) — even though the
|
|
385
413
|
// FieldDefinition types only type `default` as a literal, the sample-authoring
|
|
@@ -431,6 +459,9 @@ function projectField(fieldDef) {
|
|
|
431
459
|
// Select: tier/tenant availability of the static options, loaded by the renderer.
|
|
432
460
|
if (typeof def["optionsAvailabilityQuery"] === "string")
|
|
433
461
|
out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
|
|
462
|
+
// Select: options only offered while a sibling-field condition holds; the renderer filters, the write path enforces.
|
|
463
|
+
if (Array.isArray(def["conditionalOptions"]))
|
|
464
|
+
out["conditionalOptions"] = def["conditionalOptions"];
|
|
434
465
|
if (typeof def["multiple"] === "boolean")
|
|
435
466
|
out["multiple"] = def["multiple"];
|
|
436
467
|
// MultiSelect: display picks checkboxes vs. combobox in the renderer,
|
|
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
|
|
|
415
415
|
// need a writable local type; each entry is still a fresh, never-mutated object.
|
|
416
416
|
const translations = {};
|
|
417
417
|
const sections = [];
|
|
418
|
+
const seenFieldIds = new Map();
|
|
418
419
|
for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
|
|
419
420
|
const fieldIds = [];
|
|
420
421
|
for (const s of secrets.filter((v) => v.feature === feature)) {
|
|
421
422
|
const fieldId = `${s.feature}-${s.shortKey}`;
|
|
423
|
+
const prevQn = seenFieldIds.get(fieldId);
|
|
424
|
+
if (prevQn !== undefined) {
|
|
425
|
+
throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
|
|
426
|
+
}
|
|
427
|
+
seenFieldIds.set(fieldId, s.qn);
|
|
422
428
|
fieldIds.push(fieldId);
|
|
423
429
|
secretKeys[fieldId] = s.qn;
|
|
424
430
|
const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
|