@cosmicdrift/kumiko-framework 0.270.0 → 0.274.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -1,12 +1,15 @@
1
- import { QnTypes, qualifyEntityName } from "../qualified-name";
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name";
2
3
  import { isEditLayoutScreen } from "../screen-helpers";
3
4
  import type {
4
5
  EditRelatedListSection,
6
+ EntityDefinition,
5
7
  FeatureDefinition,
6
8
  ProjectionListScreenDefinition,
7
9
  QueryHandlerDef,
8
10
  } from "../types";
9
11
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
12
+ import { findEntity } from "./parent-ref";
10
13
  import { getZodObjectShape } from "./zod-shape";
11
14
 
12
15
  // Sibling to entity-list-screens.ts rather than an extension of it:
@@ -93,7 +96,7 @@ function validateProjectionListFilterSchemaAcceptance(
93
96
  if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
94
97
  throw new Error(
95
98
  `${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
96
- `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
99
+ `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
97
100
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
98
101
  );
99
102
  }
@@ -154,6 +157,65 @@ function validateRelatedListFacets(
154
157
  );
155
158
  }
156
159
 
160
+ // parentFilter resolves an entity field map to
161
+ // check `field` against ONLY when the query is one of the entity-convention
162
+ // factories (defineEntityListHandler et al., branded by
163
+ // ENTITY_CONVENTION_QUERY_BRAND) — a hand-written handler carries no entity,
164
+ // same "capability absent, no throw" policy as schemaAccepts above. The
165
+ // entity name comes from the handler's own `<entityName>:list` naming
166
+ // convention (parseHandlerName in entity-handlers.ts), recovered here from
167
+ // the query's fully qualified name.
168
+ function resolveEntityBehindQuery(
169
+ queryQn: string,
170
+ handler: QueryHandlerDef | undefined,
171
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
172
+ ): EntityDefinition | undefined {
173
+ if (handler === undefined || handler[ENTITY_CONVENTION_QUERY_BRAND] !== true) return undefined;
174
+ let entityName: string;
175
+ try {
176
+ entityName = parseQn(queryQn).name.split(":")[0] ?? "";
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ return entityName === "" ? undefined : findEntity(featureMap, entityName);
181
+ }
182
+
183
+ // parentFilter lets a relatedList reuse the
184
+ // generic `<entity>:list` query instead of a bespoke child-rows handler —
185
+ // same 422 footgun as facets/filter above, plus its own mutual-exclusivity
186
+ // and field-existence checks.
187
+ function validateRelatedListParentFilter(
188
+ prefix: string,
189
+ section: EditRelatedListSection,
190
+ schema: QueryHandlerDef["schema"] | undefined,
191
+ handler: QueryHandlerDef | undefined,
192
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
193
+ ): void {
194
+ // skip: no parentFilter declared — nothing to reject.
195
+ if (section.parentFilter === undefined) return;
196
+ if (section.parentParam !== undefined) {
197
+ throw new Error(
198
+ `${prefix}: declares both parentFilter and parentParam — parentFilter replaces the implicit ` +
199
+ `{ [parentParam]: parentId } payload key with a server-side "filter" clause, so the two can't ` +
200
+ `be combined. Remove parentParam or drop parentFilter.`,
201
+ );
202
+ }
203
+ if (!schemaAccepts(schema, "filter")) {
204
+ throw new Error(
205
+ `${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
206
+ `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
207
+ `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
208
+ );
209
+ }
210
+ const entity = resolveEntityBehindQuery(section.query, handler, featureMap);
211
+ if (entity !== undefined && entity.fields[section.parentFilter.field] === undefined) {
212
+ throw new Error(
213
+ `${prefix}: parentFilter.field "${section.parentFilter.field}" is not a declared field on the ` +
214
+ `entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`,
215
+ );
216
+ }
217
+ }
218
+
157
219
  // relatedList sections declare search/facets the same way a projectionList
158
220
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
159
221
  // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
@@ -163,15 +225,18 @@ function validateRelatedListFacets(
163
225
  // doesn't quietly skip these checks.
164
226
  export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
165
227
  const queryHandlers = buildQueryHandlerMap(features);
228
+ const featureMap = new Map(features.map((f) => [f.name, f] as const));
166
229
  for (const feature of features) {
167
230
  for (const screen of Object.values(feature.screens)) {
168
231
  if (!isEditLayoutScreen(screen)) continue;
169
232
  for (const section of screen.layout.sections) {
170
233
  if (section.kind !== "relatedList") continue;
171
234
  const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
172
- const schema = queryHandlers.get(section.query)?.schema;
235
+ const handler = queryHandlers.get(section.query);
236
+ const schema = handler?.schema;
173
237
  validateRelatedListSearchable(prefix, section, schema);
174
238
  validateRelatedListFacets(prefix, section, schema);
239
+ validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
175
240
  }
176
241
  }
177
242
  }
@@ -14,6 +14,7 @@ import type {
14
14
  } from "../types";
15
15
  import { metricField } from "../types";
16
16
  import { buildQueryHandlerMap } from "./projection-list-screens";
17
+ import { validateActionFieldRefs } from "./screens";
17
18
  import { getZodObjectShape, getZodRowShape } from "./zod-shape";
18
19
 
19
20
  // fw#2493: query handlers can now declare `outputSchema` (the Zod shape of
@@ -139,6 +140,38 @@ function checkProjectionDetailOutputFields(
139
140
  `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
140
141
  );
141
142
  }
143
+ checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
144
+ }
145
+
146
+ // relatedList toolbarActions' visible/params evaluate against the ENCLOSING
147
+ // projectionDetail's record, not the relatedList's own row — reuses the
148
+ // same per-action field-ref check as rowActions/toolbarActions elsewhere
149
+ // (validateActionFieldRefs), pinned to this screen's own outputSchema shape
150
+ // instead of an entity's field map. Same additive policy as the rest of this
151
+ // module: no introspectable outputSchema → no-op, not a throw.
152
+ function checkRelatedListToolbarActionFields(
153
+ recordShape: ShapeLookup | undefined,
154
+ featureName: string,
155
+ screenId: string,
156
+ screen: ProjectionDetailScreenDefinition,
157
+ ): void {
158
+ // skip: no introspectable outputSchema — same additive policy as the rest of this module.
159
+ if (recordShape === undefined) return;
160
+ const fieldNames = new Set(Object.keys(recordShape));
161
+ for (const section of screen.layout.sections) {
162
+ if (section.kind !== "relatedList") continue;
163
+ for (const action of section.toolbarActions ?? []) {
164
+ validateActionFieldRefs(
165
+ featureName,
166
+ screenId,
167
+ "toolbarAction",
168
+ action.id,
169
+ action,
170
+ fieldNames,
171
+ new Set(),
172
+ );
173
+ }
174
+ }
142
175
  }
143
176
 
144
177
  // Stat panels' query contract is a flat record (not the paged `{ rows }`
@@ -7,6 +7,7 @@
7
7
  import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { rowMetaFieldNames } from "../../db/table-builder";
9
9
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
10
+ import { parseRefTarget } from "../parse-ref-target";
10
11
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
11
12
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
12
13
  import {
@@ -14,6 +15,7 @@ import {
14
15
  isWriteFormEditSection,
15
16
  normalizeEditField,
16
17
  normalizeListColumn,
18
+ resolveNavParentScreen,
17
19
  } from "../screen-helpers";
18
20
  import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
19
21
  import { metricField } from "../types";
@@ -31,6 +33,7 @@ import type {
31
33
  FieldCondition,
32
34
  ListColumnSpec,
33
35
  ListFacetSpec,
36
+ RelatedListToolbarAction,
34
37
  RowAction,
35
38
  RowActionNavigateBase,
36
39
  RowFieldExtractor,
@@ -270,13 +273,15 @@ function validateWizardLayout(
270
273
  // Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
271
274
  // visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
272
275
  // falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
273
- // navigate/handler).
274
- function validateActionFieldRefs(
276
+ // navigate/handler). Exported for query-output-columns.ts, which reuses it
277
+ // against a projectionDetail's outputSchema-derived record shape instead of
278
+ // an entity's field map (relatedList toolbarActions' visible/params).
279
+ export function validateActionFieldRefs(
275
280
  featureName: string,
276
281
  screenId: string,
277
282
  actionKind: "rowAction" | "toolbarAction",
278
283
  actionId: string,
279
- action: RowAction | ToolbarAction,
284
+ action: RowAction | ToolbarAction | RelatedListToolbarAction,
280
285
  fieldNames: ReadonlySet<string>,
281
286
  rowMeta: ReadonlySet<string>,
282
287
  ): void {
@@ -581,6 +586,37 @@ function validateFormFieldsMap(
581
586
  // (an input-less secretMint declares BOTH `fields: {}` and
582
587
  // `layout.sections: []`; fields declared with no layout to show them stays
583
588
  // an error).
589
+ // `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
590
+ // every EditLayout-walking screen type instead of four hand-rolled copies.
591
+ function validateFieldsXorGroups(
592
+ errorPrefix: string,
593
+ section: { readonly title?: string; readonly fields: readonly EditFieldSpec[] } & {
594
+ readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
595
+ },
596
+ ): void {
597
+ if (section.fields.length > 0 && section.groups !== undefined) {
598
+ throw new Error(
599
+ `${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
600
+ `when using groups.`,
601
+ );
602
+ }
603
+ if (section.fields.length === 0 && section.groups === undefined) {
604
+ throw new Error(
605
+ `${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
606
+ `fields (or groups) to it.`,
607
+ );
608
+ }
609
+ }
610
+
611
+ function flattenFieldsOrGroups(section: {
612
+ readonly fields: readonly EditFieldSpec[];
613
+ readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
614
+ }): readonly EditFieldSpec[] {
615
+ return section.groups !== undefined
616
+ ? section.groups.flatMap((group) => group.fields)
617
+ : section.fields;
618
+ }
619
+
584
620
  function validateFormLayoutSections(
585
621
  featureName: string,
586
622
  screenId: string,
@@ -620,13 +656,8 @@ function validateFormLayoutSections(
620
656
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
621
657
  );
622
658
  }
623
- if (section.fields.length === 0) {
624
- throw new Error(
625
- `[Feature ${featureName}] Screen "${screenId}" (${context}) has a section "${section.title}" ` +
626
- `with zero fields — drop the section or add fields to it.`,
627
- );
628
- }
629
- for (const fieldSpec of section.fields) {
659
+ validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
660
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
630
661
  const normalized = normalizeEditField(fieldSpec);
631
662
  if (!fieldNames.has(normalized.field)) {
632
663
  throw new Error(
@@ -792,6 +823,52 @@ function validateSecretMintConfirm(
792
823
  }
793
824
  }
794
825
 
826
+ // nav.screen is a full QN (cross-feature), so a standalone r.nav() pointing
827
+ // at `targetQn` must be found by scanning every feature's nav entries and actions.
828
+ function hasStandaloneNavEntry(
829
+ targetQn: string,
830
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
831
+ ): boolean {
832
+ for (const f of featureMap.values()) {
833
+ for (const nav of Object.values(f.navs)) {
834
+ if (nav.screen === targetQn) return true;
835
+ if (nav.createAction?.screen === targetQn) return true;
836
+ if (nav.actions?.some((a) => a.screen === targetQn)) return true;
837
+ }
838
+ }
839
+ return false;
840
+ }
841
+
842
+ // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
843
+ // `dormant: true`. Skipped when the composed set has no nav entries at all.
844
+ function validateScreenHasNavArea(
845
+ feature: FeatureDefinition,
846
+ screenId: string,
847
+ screen: ScreenDefinition,
848
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
849
+ ): void {
850
+ // skip: the screen already declares its own nav entry.
851
+ if (screen.nav !== undefined) return;
852
+ // skip: explicitly opted out via `dormant: true`.
853
+ if (screen.dormant === true) return;
854
+ const anyNavRegistered = [...featureMap.values()].some((f) => Object.keys(f.navs).length > 0);
855
+ // skip: no nav entries exist anywhere in the composed set — a feature/
856
+ // recipe/test fixture booted without an app shell, not an omission.
857
+ if (!anyNavRegistered) return;
858
+ const targetQn = qualifyEntityName(feature.name, "screen", screenId);
859
+ // skip: a standalone r.nav() elsewhere already points at this screen.
860
+ if (hasStandaloneNavEntry(targetQn, featureMap)) return;
861
+ const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
862
+ // skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
863
+ if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined) return;
864
+ throw new Error(
865
+ `[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
866
+ `declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
867
+ `a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
868
+ `only via a direct link/redirect or a consuming app's own r.nav().`,
869
+ );
870
+ }
871
+
795
872
  export function validateScreens(
796
873
  feature: FeatureDefinition,
797
874
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -816,6 +893,7 @@ export function validateScreens(
816
893
  // #1946) — kurze IDs bleiben same-feature wie zuvor.
817
894
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
818
895
  for (const [screenId, screen] of Object.entries(feature.screens)) {
896
+ validateScreenHasNavArea(feature, screenId, screen, featureMap);
819
897
  if (screen.type === "custom") {
820
898
  if (!screen.renderer.react && !screen.renderer.native) {
821
899
  throw new Error(
@@ -863,6 +941,8 @@ export function validateScreens(
863
941
  `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
864
942
  screen.facets,
865
943
  screen.columns,
944
+ feature.name,
945
+ featureMap,
866
946
  );
867
947
  }
868
948
  if (screen.rowActions !== undefined) {
@@ -1158,8 +1238,26 @@ export function validateScreens(
1158
1238
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
1159
1239
  section.facets,
1160
1240
  section.columns,
1241
+ feature.name,
1242
+ featureMap,
1161
1243
  );
1162
1244
  }
1245
+ // Only drawer-kind is validated here — navigate/writeHandler
1246
+ // toolbarActions have no boot check yet, same gap as above.
1247
+ if (section.toolbarActions !== undefined) {
1248
+ for (const action of section.toolbarActions) {
1249
+ if (action.kind === "drawer") {
1250
+ validateDrawerTargetAction(
1251
+ feature.name,
1252
+ screenId,
1253
+ "projectionDetail",
1254
+ "toolbarAction",
1255
+ action,
1256
+ feature.screens,
1257
+ );
1258
+ }
1259
+ }
1260
+ }
1163
1261
  continue;
1164
1262
  }
1165
1263
  if (isWriteFormEditSection(section)) {
@@ -1202,12 +1300,10 @@ export function validateScreens(
1202
1300
  }
1203
1301
  continue;
1204
1302
  }
1205
- if (section.fields.length === 0) {
1206
- throw new Error(
1207
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
1208
- `with zero fields — drop the section or add fields to it.`,
1209
- );
1210
- }
1303
+ validateFieldsXorGroups(
1304
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
1305
+ section,
1306
+ );
1211
1307
  }
1212
1308
  // Header actions reuse RowAction (the displayed record stands in for
1213
1309
  // the row), so the same navigate/writeHandler existence checks as
@@ -1320,13 +1416,11 @@ export function validateScreens(
1320
1416
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1321
1417
  );
1322
1418
  }
1323
- if (section.fields.length === 0) {
1324
- throw new Error(
1325
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
1326
- `with zero fields — drop the section or add fields to it.`,
1327
- );
1328
- }
1329
- for (const fieldSpec of section.fields) {
1419
+ validateFieldsXorGroups(
1420
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
1421
+ section,
1422
+ );
1423
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
1330
1424
  const normalized = normalizeEditField(fieldSpec);
1331
1425
  if (!fieldNames.has(normalized.field)) {
1332
1426
  throw new Error(
@@ -1739,13 +1833,11 @@ export function validateScreens(
1739
1833
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1740
1834
  );
1741
1835
  }
1742
- if (section.fields.length === 0) {
1743
- throw new Error(
1744
- `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
1745
- `with zero fields — drop the section or add fields to it.`,
1746
- );
1747
- }
1748
- for (const fieldSpec of section.fields) {
1836
+ validateFieldsXorGroups(
1837
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
1838
+ section,
1839
+ );
1840
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
1749
1841
  const normalized = normalizeEditField(fieldSpec);
1750
1842
  if (!fieldNames.has(normalized.field)) {
1751
1843
  throw new Error(
@@ -2052,10 +2144,25 @@ export function validateColumnRendererForm(
2052
2144
  // typo (the user never sees the field anywhere), so this is hard-checked
2053
2145
  // rather than just documented. Shared by projectionList and relatedList
2054
2146
  // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
2147
+ //
2148
+ // `type: "reference"` is exempt from the same-name-column rule (fw akte-
2149
+ // bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
2150
+ // displays a different, human-readable column (e.g. "propertyLabel") — a
2151
+ // column named after the filter field would almost never exist, and
2152
+ // requiring one anyway would force apps to add a column no design calls
2153
+ // for just to satisfy this check. Its own `entity` is mandatory on the type
2154
+ // (TS, not just this validator) and is resolved below — that explicit,
2155
+ // checked declaration is this facet type's field inventory, filling the
2156
+ // same "not just a typo" role the column-name check plays for select/
2157
+ // boolean facets. select/boolean facets keep the column requirement
2158
+ // unchanged: both filter and display the same field, so a facet with no
2159
+ // matching column is still almost always a typo.
2055
2160
  function validateListFacets(
2056
2161
  prefix: string,
2057
2162
  facets: readonly ListFacetSpec[],
2058
2163
  columns: readonly ListColumnSpec[],
2164
+ currentFeatureName: string,
2165
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
2059
2166
  ): void {
2060
2167
  const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
2061
2168
  const seenFacetFields = new Set<string>();
@@ -2064,7 +2171,7 @@ function validateListFacets(
2064
2171
  throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
2065
2172
  }
2066
2173
  seenFacetFields.add(facet.field);
2067
- if (!columnFieldNames.has(facet.field)) {
2174
+ if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
2068
2175
  throw new Error(
2069
2176
  `${prefix} facet references field "${facet.field}" which is not a declared column. ` +
2070
2177
  `Known columns: ${[...columnFieldNames].sort().join(", ")}`,
@@ -2076,6 +2183,22 @@ function validateListFacets(
2076
2183
  `declare at least one option.`,
2077
2184
  );
2078
2185
  }
2186
+ if (facet.type === "reference") {
2187
+ const target = parseRefTarget(facet.entity, currentFeatureName);
2188
+ const targetFeature = featureMap.get(target.featureName);
2189
+ if (targetFeature?.entities?.[target.entityName] === undefined) {
2190
+ throw new Error(
2191
+ `${prefix} facet "${facet.field}" (type "reference") targets entity "${facet.entity}", ` +
2192
+ `which does not resolve to a registered entity. Known entities in feature ` +
2193
+ `"${target.featureName}": ` +
2194
+ `${
2195
+ Object.keys(targetFeature?.entities ?? {})
2196
+ .sort()
2197
+ .join(", ") || "(none)"
2198
+ }.`,
2199
+ );
2200
+ }
2201
+ }
2079
2202
  }
2080
2203
  }
2081
2204
 
@@ -498,9 +498,6 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
498
498
  // opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
499
499
  // AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
500
500
  // per Key gegated.
501
- const ALL_ROLE_OPEN_TO_ALL_REASON =
502
- 'config key declares the "all" role, so every signed-in user of the tenant may read/write it';
503
-
504
501
  // Union der Navs-Access-Regeln: ein openToAll-Nav öffnet das ganze Gate, sonst
505
502
  // die Vereinigung der Rollen. undefined-access-Navs tragen nichts bei.
506
503
  function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRule {
@@ -519,6 +516,12 @@ function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRul
519
516
  // `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
520
517
  // server-seitig per Key gegated.
521
518
  function rolesToAccess(roles: readonly string[]): AccessRule {
522
- if (roles.includes("all")) return { openToAll: { reason: ALL_ROLE_OPEN_TO_ALL_REASON } };
519
+ if (roles.includes("all"))
520
+ return {
521
+ openToAll: {
522
+ reason:
523
+ 'config key declares the "all" role, so every signed-in user of the tenant may read/write it',
524
+ },
525
+ };
523
526
  return { roles: [...new Set(roles)] };
524
527
  }