@cosmicdrift/kumiko-framework 0.257.0 → 0.258.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.
@@ -2558,6 +2558,234 @@ describe("boot-validator", () => {
2558
2558
  });
2559
2559
  });
2560
2560
 
2561
+ // --- secretMint-Screen (fw#2548) ---
2562
+ // Mint-form → one-time reveal → confirm. Shares its six inline-form checks
2563
+ // with actionForm (validateInlineFormScreen), plus its own reveal.fields
2564
+ // checks: non-empty, unique `field`, non-empty `label`.
2565
+ describe("secretMint screen (fw#2548)", () => {
2566
+ type SecretMintOverride = {
2567
+ readonly handler?: string | undefined;
2568
+ readonly fields?: Record<string, unknown>;
2569
+ readonly sections?: ReadonlyArray<{
2570
+ readonly title: string;
2571
+ readonly fields: readonly string[];
2572
+ }>;
2573
+ readonly reveal?: {
2574
+ readonly fields: ReadonlyArray<{
2575
+ readonly field: string;
2576
+ readonly label: string;
2577
+ readonly display?: "code" | "list";
2578
+ }>;
2579
+ };
2580
+ readonly redirect?: string;
2581
+ readonly extraScreens?: readonly string[];
2582
+ readonly confirm?: {
2583
+ readonly handler?: string;
2584
+ readonly fields?: Record<string, unknown>;
2585
+ readonly sections?: ReadonlyArray<{
2586
+ readonly title: string;
2587
+ readonly fields: readonly string[];
2588
+ }>;
2589
+ readonly carry?: readonly string[];
2590
+ readonly mode?: "single" | "wizard" | "tabs";
2591
+ readonly draft?: boolean;
2592
+ };
2593
+ };
2594
+
2595
+ function makeFeature(override: SecretMintOverride = {}) {
2596
+ const handler = override.handler ?? "shop:write:token:mint";
2597
+ const fields = override.fields ?? { label: { type: "text" } };
2598
+ const sections = override.sections ?? [{ title: "Mint", fields: ["label"] }];
2599
+ const reveal = override.reveal ?? {
2600
+ fields: [{ field: "token", label: "shop:screen:mint-token.field:token" }],
2601
+ };
2602
+ const confirmOverride = override.confirm;
2603
+ return defineFeature("shop", (r) => {
2604
+ r.writeHandler({
2605
+ name: "token:mint",
2606
+ schema: { _type: "stub" } as never,
2607
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
2608
+ access: { openToAll: true },
2609
+ });
2610
+ if (confirmOverride !== undefined) {
2611
+ r.writeHandler({
2612
+ name: "token:confirm",
2613
+ schema: { _type: "stub" } as never,
2614
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
2615
+ access: { openToAll: true },
2616
+ });
2617
+ }
2618
+ r.screen({
2619
+ id: "mint-token",
2620
+ type: "secretMint",
2621
+ handler,
2622
+ fields: fields as never,
2623
+ layout: { sections: sections as never },
2624
+ reveal: reveal as never,
2625
+ ...(override.redirect !== undefined && { redirect: override.redirect }),
2626
+ ...(confirmOverride !== undefined && {
2627
+ confirm: {
2628
+ handler: confirmOverride.handler ?? "shop:write:token:confirm",
2629
+ fields: (confirmOverride.fields ?? { code: { type: "text" } }) as never,
2630
+ layout: {
2631
+ sections: (confirmOverride.sections ?? [
2632
+ { title: "Confirm", fields: ["code"] },
2633
+ ]) as never,
2634
+ ...(confirmOverride.mode !== undefined && { mode: confirmOverride.mode }),
2635
+ ...(confirmOverride.draft !== undefined && { draft: confirmOverride.draft }),
2636
+ },
2637
+ ...(confirmOverride.carry !== undefined && { carry: confirmOverride.carry }),
2638
+ },
2639
+ }),
2640
+ });
2641
+ for (const extra of override.extraScreens ?? []) {
2642
+ r.screen({
2643
+ id: extra,
2644
+ type: "custom",
2645
+ renderer: { react: "stub" },
2646
+ });
2647
+ }
2648
+ });
2649
+ }
2650
+
2651
+ test("happy path: handler + fields + layout + reveal konsistent → kein Throw", () => {
2652
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
2653
+ });
2654
+
2655
+ test("handler nicht als write-handler registriert → Throw", () => {
2656
+ expect(() => validateBoot([makeFeature({ handler: "shop:query:token:list" })])).toThrow(
2657
+ /\(secretMint\) handler "shop:query:token:list" is not a registered write-handler/,
2658
+ );
2659
+ });
2660
+
2661
+ // secretMint allows an empty fields map (input-less mint, fw#2838) — but
2662
+ // the default `sections` still references "label", which no longer
2663
+ // exists once `fields` is empty, so this still throws (just via the
2664
+ // layout check instead of the fields-map check).
2665
+ test("fields empty-Map without also clearing sections → Throw", () => {
2666
+ expect(() => validateBoot([makeFeature({ fields: {} })])).toThrow(
2667
+ /\(secretMint\) layout references unknown field "label"/,
2668
+ );
2669
+ });
2670
+
2671
+ test("layout referenziert unknown field → Throw", () => {
2672
+ expect(() =>
2673
+ validateBoot([makeFeature({ sections: [{ title: "x", fields: ["ghost"] }] })]),
2674
+ ).toThrow(/\(secretMint\) layout references unknown field "ghost"/);
2675
+ });
2676
+
2677
+ test("redirect → existing screen-id → kein Throw", () => {
2678
+ expect(() =>
2679
+ validateBoot([makeFeature({ redirect: "after-mint", extraScreens: ["after-mint"] })]),
2680
+ ).not.toThrow();
2681
+ });
2682
+
2683
+ test("redirect → unknown screen-id → Throw", () => {
2684
+ expect(() => validateBoot([makeFeature({ redirect: "ghost-screen" })])).toThrow(
2685
+ /\(secretMint\) redirect "ghost-screen" does not resolve to a registered screen/,
2686
+ );
2687
+ });
2688
+
2689
+ test("reveal.fields leer → Throw", () => {
2690
+ expect(() => validateBoot([makeFeature({ reveal: { fields: [] } })])).toThrow(
2691
+ /has an empty reveal\.fields list/,
2692
+ );
2693
+ });
2694
+
2695
+ test("reveal.fields mit doppeltem field → Throw", () => {
2696
+ expect(() =>
2697
+ validateBoot([
2698
+ makeFeature({
2699
+ reveal: {
2700
+ fields: [
2701
+ { field: "token", label: "a" },
2702
+ { field: "token", label: "b" },
2703
+ ],
2704
+ },
2705
+ }),
2706
+ ]),
2707
+ ).toThrow(/reveal\.fields has a duplicate field "token"/);
2708
+ });
2709
+
2710
+ test("reveal.fields entry mit leerem label → Throw", () => {
2711
+ expect(() =>
2712
+ validateBoot([makeFeature({ reveal: { fields: [{ field: "token", label: "" }] } })]),
2713
+ ).toThrow(/reveal\.fields entry "token" has an empty or non-string "label"/);
2714
+ });
2715
+
2716
+ // --- input-less mint (fw#2838): fields: {} + layout.sections: [] is the
2717
+ // only valid empty shape — the secret is server-generated, the mint step
2718
+ // is just its submit button.
2719
+ test("empty fields + empty sections (input-less mint) → no throw", () => {
2720
+ expect(() => validateBoot([makeFeature({ fields: {}, sections: [] })])).not.toThrow();
2721
+ });
2722
+
2723
+ test("empty fields but non-empty sections list → Throw", () => {
2724
+ expect(() =>
2725
+ validateBoot([makeFeature({ fields: {}, sections: [{ title: "x", fields: ["ghost"] }] })]),
2726
+ ).toThrow(/\(secretMint\) layout references unknown field "ghost"/);
2727
+ });
2728
+
2729
+ test("non-empty fields but empty sections list → Throw", () => {
2730
+ expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
2731
+ /\(secretMint\) has an empty sections list/,
2732
+ );
2733
+ });
2734
+
2735
+ // --- confirm step (fw#2838) ---
2736
+ describe("confirm step", () => {
2737
+ test("happy path: confirm handler + fields + layout consistent → no throw", () => {
2738
+ expect(() => validateBoot([makeFeature({ confirm: {} })])).not.toThrow();
2739
+ });
2740
+
2741
+ test("confirm.handler not a registered write-handler → Throw", () => {
2742
+ expect(() =>
2743
+ validateBoot([makeFeature({ confirm: { handler: "shop:query:token:list" } })]),
2744
+ ).toThrow(
2745
+ /\(secretMint confirm\) handler "shop:query:token:list" is not a registered write-handler/,
2746
+ );
2747
+ });
2748
+
2749
+ test("confirm.fields empty map → Throw", () => {
2750
+ expect(() => validateBoot([makeFeature({ confirm: { fields: {} } })])).toThrow(
2751
+ /\(secretMint confirm\) has empty fields map/,
2752
+ );
2753
+ });
2754
+
2755
+ test("confirm layout references unknown field → Throw", () => {
2756
+ expect(() =>
2757
+ validateBoot([
2758
+ makeFeature({ confirm: { sections: [{ title: "x", fields: ["ghost"] }] } }),
2759
+ ]),
2760
+ ).toThrow(/\(secretMint confirm\) layout references unknown field "ghost"/);
2761
+ });
2762
+
2763
+ test("confirm layout mode 'wizard' → Throw", () => {
2764
+ expect(() => validateBoot([makeFeature({ confirm: { mode: "wizard" } })])).toThrow(
2765
+ /\(secretMint confirm\) sets layout\.mode: "wizard"/,
2766
+ );
2767
+ });
2768
+
2769
+ test("confirm layout draft true → Throw", () => {
2770
+ expect(() => validateBoot([makeFeature({ confirm: { draft: true } })])).toThrow(
2771
+ /\(secretMint confirm\) sets layout\.draft: true/,
2772
+ );
2773
+ });
2774
+
2775
+ test("carry entry colliding with a confirm.fields name → Throw", () => {
2776
+ expect(() => validateBoot([makeFeature({ confirm: { carry: ["code"] } })])).toThrow(
2777
+ /\(secretMint confirm\) carry field "code" also names a confirm\.fields entry/,
2778
+ );
2779
+ });
2780
+
2781
+ test("carry entry that is not a confirm.fields name → no throw", () => {
2782
+ expect(() =>
2783
+ validateBoot([makeFeature({ confirm: { carry: ["setupToken"] } })]),
2784
+ ).not.toThrow();
2785
+ });
2786
+ });
2787
+ });
2788
+
2561
2789
  // --- configEdit-Screen ---
2562
2790
  // Form gegen das bundled config-feature. Boot-Validator prüft:
2563
2791
  // 1) fields non-empty + jeder mit type-Discriminator
@@ -0,0 +1,149 @@
1
+ // fw#2766 — boot guard for join-row entities. The read gate needs the registry,
2
+ // which only exists at request time, so the raw executor stays ungated when a
3
+ // caller doesn't wire `parentVisibility`. That keeps framework-internal cascade
4
+ // and GDPR paths working, and moves enforcement here: a hand-written list/detail
5
+ // handler on a parentRef entity must fail boot rather than leak silently.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { z } from "zod";
9
+ import { defineFeature } from "../../define-feature";
10
+ import { defineEntityDetailHandler, defineEntityListHandler } from "../../entity-handlers";
11
+ import { createEntity, createTextField } from "../../factories";
12
+ import type { FeatureDefinition } from "../../types";
13
+ import { validateParentRefs } from "../parent-ref";
14
+
15
+ const textField = () =>
16
+ createTextField({
17
+ required: true,
18
+ maxLength: 64,
19
+ personal: false,
20
+ reason: "technical_reference",
21
+ });
22
+
23
+ const hostEntity = createEntity({
24
+ table: "read_hosts",
25
+ fields: { name: textField() },
26
+ });
27
+
28
+ function joinEntity(parentRef: {
29
+ entityTypeField: string;
30
+ entityIdField: string;
31
+ allowedTypes?: readonly string[];
32
+ }) {
33
+ return createEntity({
34
+ table: "read_joins",
35
+ fields: { entityType: textField(), entityId: textField() },
36
+ parentRef,
37
+ });
38
+ }
39
+
40
+ const validRef = { entityTypeField: "entityType", entityIdField: "entityId" } as const;
41
+
42
+ function featureMapOf(...features: readonly FeatureDefinition[]) {
43
+ return new Map(features.map((f) => [f.name, f]));
44
+ }
45
+
46
+ function validate(feature: FeatureDefinition, extra: readonly FeatureDefinition[] = []): void {
47
+ validateParentRefs(feature, featureMapOf(feature, ...extra));
48
+ }
49
+
50
+ describe("validateParentRefs", () => {
51
+ test("accepts a parentRef whose fields exist and whose list/detail are convention handlers", () => {
52
+ const entity = joinEntity(validRef);
53
+ const feature = defineFeature("joins", (r) => {
54
+ r.entity("join", entity);
55
+ r.queryHandler(defineEntityListHandler("join", entity, { access: { openToAll: true } }));
56
+ r.queryHandler(defineEntityDetailHandler("join", entity, { access: { openToAll: true } }));
57
+ });
58
+ expect(() => validate(feature)).not.toThrow();
59
+ });
60
+
61
+ test("an entity with no parentRef is not checked at all", () => {
62
+ const feature = defineFeature("plain", (r) => {
63
+ r.entity("host", hostEntity);
64
+ r.queryHandler({
65
+ name: "host:list",
66
+ schema: z.object({}),
67
+ handler: async () => ({ rows: [], nextCursor: null }),
68
+ access: { openToAll: true },
69
+ });
70
+ });
71
+ expect(() => validate(feature)).not.toThrow();
72
+ });
73
+
74
+ test("rejects an entityTypeField that is not a declared field", () => {
75
+ const feature = defineFeature("joins", (r) => {
76
+ r.entity("join", joinEntity({ ...validRef, entityTypeField: "nope" }));
77
+ });
78
+ expect(() => validate(feature)).toThrow(/entityTypeField references "nope"/);
79
+ });
80
+
81
+ test("rejects an entityIdField that is not a declared field", () => {
82
+ const feature = defineFeature("joins", (r) => {
83
+ r.entity("join", joinEntity({ ...validRef, entityIdField: "nope" }));
84
+ });
85
+ expect(() => validate(feature)).toThrow(/entityIdField references "nope"/);
86
+ });
87
+
88
+ test("rejects an allowedTypes entry that names no registered entity", () => {
89
+ const feature = defineFeature("joins", (r) => {
90
+ r.entity("join", joinEntity({ ...validRef, allowedTypes: ["ghost"] }));
91
+ });
92
+ expect(() => validate(feature)).toThrow(/names "ghost"/);
93
+ });
94
+
95
+ test("accepts an allowedTypes entry registered by another feature", () => {
96
+ const hosts = defineFeature("hosts", (r) => {
97
+ r.entity("host", hostEntity);
98
+ });
99
+ const entity = joinEntity({ ...validRef, allowedTypes: ["host"] });
100
+ const feature = defineFeature("joins", (r) => {
101
+ r.entity("join", entity);
102
+ r.queryHandler(defineEntityListHandler("join", entity, { access: { openToAll: true } }));
103
+ });
104
+ expect(() => validate(feature, [hosts])).not.toThrow();
105
+ });
106
+
107
+ test("rejects an allowedTypes host that is itself a join row — no recursive gating", () => {
108
+ const others = defineFeature("others", (r) => {
109
+ r.entity("other", joinEntity(validRef));
110
+ });
111
+ const feature = defineFeature("joins", (r) => {
112
+ r.entity("join", joinEntity({ ...validRef, allowedTypes: ["other"] }));
113
+ });
114
+ expect(() => validate(feature, [others])).toThrow(/itself declares a parentRef/);
115
+ });
116
+
117
+ test("rejects a hand-written list handler, which would bypass the read gate", () => {
118
+ const feature = defineFeature("joins", (r) => {
119
+ r.entity("join", joinEntity(validRef));
120
+ r.queryHandler({
121
+ name: "join:list",
122
+ schema: z.object({}),
123
+ handler: async () => ({ rows: [], nextCursor: null }),
124
+ access: { openToAll: true },
125
+ });
126
+ });
127
+ expect(() => validate(feature)).toThrow(/join:list/);
128
+ });
129
+
130
+ test("rejects a hand-written detail handler too", () => {
131
+ const feature = defineFeature("joins", (r) => {
132
+ r.entity("join", joinEntity(validRef));
133
+ r.queryHandler({
134
+ name: "join:detail",
135
+ schema: z.object({ id: z.uuid() }),
136
+ handler: async () => null,
137
+ access: { openToAll: true },
138
+ });
139
+ });
140
+ expect(() => validate(feature)).toThrow(/join:detail/);
141
+ });
142
+
143
+ test("registering no list/detail handler at all is allowed", () => {
144
+ const feature = defineFeature("joins", (r) => {
145
+ r.entity("join", joinEntity(validRef));
146
+ });
147
+ expect(() => validate(feature)).not.toThrow();
148
+ });
149
+ });
@@ -1,4 +1,9 @@
1
- import { isFieldsEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
1
+ import {
2
+ isEditLayoutScreen,
3
+ isFieldsEditSection,
4
+ normalizeEditField,
5
+ normalizeListColumn,
6
+ } from "../screen-helpers";
2
7
  import type {
3
8
  EditFieldSpec,
4
9
  EditLayout,
@@ -138,12 +143,7 @@ export function validateFieldWiring(feature: FeatureDefinition): void {
138
143
  validateColumnsNoFunctions(feature.name, screen.id, screen.type, screen.columns);
139
144
  continue;
140
145
  }
141
- if (
142
- screen.type === "entityEdit" ||
143
- screen.type === "actionForm" ||
144
- screen.type === "configEdit" ||
145
- screen.type === "projectionDetail"
146
- ) {
146
+ if (isEditLayoutScreen(screen)) {
147
147
  validateEditLayoutNoFunctions(feature.name, screen.id, screen.type, screen.layout);
148
148
  continue;
149
149
  }
@@ -45,6 +45,7 @@ import {
45
45
  warnOnNavAccessInversion,
46
46
  } from "./nav";
47
47
  import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
+ import { validateParentRefs } from "./parent-ref";
48
49
  import { validatePiiAndRetention } from "./pii-retention";
49
50
  import {
50
51
  validateProjectionListScreens,
@@ -195,6 +196,7 @@ export function validateBoot(
195
196
  validateConfigKeyBacking(feature);
196
197
  validateConfigKeyPiiEncrypted(feature);
197
198
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
199
+ validateParentRefs(feature, featureMap);
198
200
  validateMultiStreamProjections(feature);
199
201
  // Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
200
202
  // einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
@@ -0,0 +1,96 @@
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import type { EntityDefinition, FeatureDefinition, QueryHandlerDef } from "../types";
3
+
4
+ // --- Parent-ref boot validation (fw#2766) ---
5
+ //
6
+ // Join-row entities (EntityDefinition.parentRef) derive both the write-path
7
+ // deny and the read-path SQL gate from one declaration. This is the boot-time
8
+ // half of the fail-closed guarantee: the read gate itself (db/parent-ref-
9
+ // clause.ts) passes an ungated query through when no ParentVisibilityOption
10
+ // was supplied — throwing there would 500 every custom handler and
11
+ // framework-internal detail() call that doesn't wire it, so the enforcement
12
+ // has to happen loudly here instead, once, at boot.
13
+
14
+ function findEntity(
15
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
16
+ entityName: string,
17
+ ): EntityDefinition | undefined {
18
+ for (const f of featureMap.values()) {
19
+ const entity = f.entities?.[entityName];
20
+ if (entity) return entity;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function findQueryHandler(
26
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
27
+ handlerName: string,
28
+ ): QueryHandlerDef | undefined {
29
+ for (const f of featureMap.values()) {
30
+ const handler = f.queryHandlers[handlerName];
31
+ if (handler) return handler;
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export function validateParentRefs(
37
+ feature: FeatureDefinition,
38
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
39
+ ): void {
40
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
41
+ const parentRef = entity.parentRef;
42
+ if (parentRef === undefined) continue;
43
+
44
+ if (entity.fields[parentRef.entityTypeField] === undefined) {
45
+ throw new Error(
46
+ `[Kumiko ParentRef] entity "${entityName}".parentRef.entityTypeField references ` +
47
+ `"${parentRef.entityTypeField}" — which is not a declared field on "${entityName}" ` +
48
+ `(feature: "${feature.name}"). Fix: point entityTypeField at a real field on the entity.`,
49
+ );
50
+ }
51
+ if (entity.fields[parentRef.entityIdField] === undefined) {
52
+ throw new Error(
53
+ `[Kumiko ParentRef] entity "${entityName}".parentRef.entityIdField references ` +
54
+ `"${parentRef.entityIdField}" — which is not a declared field on "${entityName}" ` +
55
+ `(feature: "${feature.name}"). Fix: point entityIdField at a real field on the entity.`,
56
+ );
57
+ }
58
+
59
+ for (const hostName of parentRef.allowedTypes ?? []) {
60
+ const host = findEntity(featureMap, hostName);
61
+ if (host === undefined) {
62
+ throw new Error(
63
+ `[Kumiko ParentRef] entity "${entityName}".parentRef.allowedTypes names "${hostName}" ` +
64
+ `— no entity with that name is registered in this feature set (feature: ` +
65
+ `"${feature.name}"). Fix: remove "${hostName}" from allowedTypes, or register it.`,
66
+ );
67
+ }
68
+ if (host.parentRef !== undefined) {
69
+ throw new Error(
70
+ `[Kumiko ParentRef] entity "${entityName}".parentRef.allowedTypes names "${hostName}" ` +
71
+ `— which itself declares a parentRef (feature: "${feature.name}"). A join-row ` +
72
+ `entity can't act as a host for another join-row entity (no recursive gating). ` +
73
+ `Fix: remove "${hostName}" from allowedTypes, or drop its own parentRef.`,
74
+ );
75
+ }
76
+ }
77
+
78
+ for (const verb of ["list", "detail"] as const) {
79
+ const handlerName = `${entityName}:${verb}`;
80
+ const handler = findQueryHandler(featureMap, handlerName);
81
+ // skip: no handler registered for this verb — nothing to brand-check
82
+ if (handler === undefined) continue;
83
+ if (handler[ENTITY_CONVENTION_QUERY_BRAND] !== true) {
84
+ throw new Error(
85
+ `[Kumiko ParentRef] query handler "${handlerName}" does not carry ` +
86
+ `ENTITY_CONVENTION_QUERY_BRAND, but entity "${entityName}" declares a parentRef ` +
87
+ `(feature: "${feature.name}"). A hand-written handler bypasses the parent-ref ` +
88
+ `read-gate unless it explicitly wires parentVisibility. Fix: register "${handlerName}" ` +
89
+ `via defineEntityListHandler/defineEntityDetailHandler (or defineEntityQueryHandler), ` +
90
+ `or pass { parentVisibility: { entities: ctx.registry.getAllEntities() } } to ` +
91
+ `executor.${verb} yourself.`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+ }
@@ -1,5 +1,11 @@
1
1
  import { QnTypes, qualifyEntityName } from "../qualified-name";
2
- import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
2
+ import { isEditLayoutScreen } from "../screen-helpers";
3
+ import type {
4
+ EditRelatedListSection,
5
+ FeatureDefinition,
6
+ ProjectionListScreenDefinition,
7
+ QueryHandlerDef,
8
+ } from "../types";
3
9
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
4
10
  import { getZodObjectShape } from "./zod-shape";
5
11
 
@@ -122,6 +128,32 @@ export function validateProjectionListScreens(features: readonly FeatureDefiniti
122
128
  }
123
129
  }
124
130
 
131
+ function validateRelatedListSearchable(
132
+ prefix: string,
133
+ section: EditRelatedListSection,
134
+ schema: QueryHandlerDef["schema"] | undefined,
135
+ ): void {
136
+ // skip: not searchable, or the schema already accepts search — nothing to reject.
137
+ if (section.searchable !== true || schemaAccepts(schema, "search")) return;
138
+ throw new Error(
139
+ `${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
140
+ );
141
+ }
142
+
143
+ function validateRelatedListFacets(
144
+ prefix: string,
145
+ section: EditRelatedListSection,
146
+ schema: QueryHandlerDef["schema"] | undefined,
147
+ ): void {
148
+ // skip: no facets declared — nothing to reject.
149
+ if (section.facets === undefined || section.facets.length === 0) return;
150
+ // skip: the schema already accepts filters — nothing to reject.
151
+ if (schemaAccepts(schema, "filters")) return;
152
+ throw new Error(
153
+ `${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
154
+ );
155
+ }
156
+
125
157
  // relatedList sections declare search/facets the same way a projectionList
126
158
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
127
159
  // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
@@ -133,32 +165,13 @@ export function validateRelatedListSectionQueries(features: readonly FeatureDefi
133
165
  const queryHandlers = buildQueryHandlerMap(features);
134
166
  for (const feature of features) {
135
167
  for (const screen of Object.values(feature.screens)) {
136
- if (
137
- screen.type !== "projectionDetail" &&
138
- screen.type !== "entityEdit" &&
139
- screen.type !== "actionForm" &&
140
- screen.type !== "configEdit"
141
- ) {
142
- continue;
143
- }
168
+ if (!isEditLayoutScreen(screen)) continue;
144
169
  for (const section of screen.layout.sections) {
145
170
  if (section.kind !== "relatedList") continue;
146
171
  const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
147
172
  const schema = queryHandlers.get(section.query)?.schema;
148
- if (section.searchable === true && !schemaAccepts(schema, "search")) {
149
- throw new Error(
150
- `${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
151
- );
152
- }
153
- if (
154
- section.facets !== undefined &&
155
- section.facets.length > 0 &&
156
- !schemaAccepts(schema, "filters")
157
- ) {
158
- throw new Error(
159
- `${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
160
- );
161
- }
173
+ validateRelatedListSearchable(prefix, section, schema);
174
+ validateRelatedListFacets(prefix, section, schema);
162
175
  }
163
176
  }
164
177
  }
@@ -219,7 +219,8 @@ function checkScreenOutputColumns(
219
219
  } else if (
220
220
  screen.type === "entityEdit" ||
221
221
  screen.type === "actionForm" ||
222
- screen.type === "configEdit"
222
+ screen.type === "configEdit" ||
223
+ screen.type === "secretMint"
223
224
  ) {
224
225
  checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, screen.type, screen.layout);
225
226
  } else if (screen.type === "dashboard") {
@@ -111,7 +111,8 @@ function checkScreenQueryRefs(
111
111
  } else if (
112
112
  screen.type === "entityEdit" ||
113
113
  screen.type === "actionForm" ||
114
- screen.type === "configEdit"
114
+ screen.type === "configEdit" ||
115
+ screen.type === "secretMint"
115
116
  ) {
116
117
  checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
117
118
  } else if (screen.type === "dashboard") {