@cosmicdrift/kumiko-framework 0.256.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.
- package/package.json +3 -3
- package/src/db/__tests__/parent-ref-clause.test.ts +213 -0
- package/src/db/event-store-executor-read.ts +97 -19
- package/src/db/parent-ref-clause.ts +182 -0
- package/src/db/table-builder.ts +7 -0
- package/src/engine/__tests__/boot-validator.test.ts +228 -0
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +149 -0
- package/src/engine/boot-validator/action-wiring.ts +7 -7
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/parent-ref.ts +96 -0
- package/src/engine/boot-validator/projection-list-screens.ts +36 -23
- package/src/engine/boot-validator/query-output-columns.ts +2 -1
- package/src/engine/boot-validator/query-refs.ts +2 -1
- package/src/engine/boot-validator/screens.ts +322 -131
- package/src/engine/define-handler.ts +6 -0
- package/src/engine/entity-handlers.ts +6 -1
- package/src/engine/factories.ts +2 -0
- package/src/engine/feature-entity-handlers.ts +5 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/ownership.ts +15 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/screen-helpers.ts +25 -0
- package/src/engine/types/index.ts +5 -0
- package/src/i18n/required-surface-keys.ts +48 -0
- package/src/testing/e2e-generator.ts +2 -0
- package/src/ui-types/index.ts +4 -0
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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") {
|