@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.
@@ -15,9 +15,10 @@ import {
15
15
  normalizeEditField,
16
16
  normalizeListColumn,
17
17
  } from "../screen-helpers";
18
- import type { EntityDefinition, FeatureDefinition } from "../types";
18
+ import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
19
19
  import { metricField } from "../types";
20
20
  import type {
21
+ ActionFormScreenDefinition,
21
22
  DashboardCustomPanel,
22
23
  DashboardFilterDefinition,
23
24
  DashboardPanelDefinition,
@@ -32,6 +33,7 @@ import type {
32
33
  RowActionNavigateBase,
33
34
  RowFieldExtractor,
34
35
  ScreenDefinition,
36
+ SecretMintScreenDefinition,
35
37
  ToolbarAction,
36
38
  } from "../types/screen";
37
39
 
@@ -157,7 +159,9 @@ function validateRowActionNavigateParams(
157
159
  (action.entityId !== undefined ||
158
160
  (screenEntity !== undefined && target.screen.entity === screenEntity));
159
161
  if (
160
- (target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
162
+ (target.screen.type !== "actionForm" &&
163
+ target.screen.type !== "secretMint" &&
164
+ target.screen.type !== "entityEdit") ||
161
165
  isEntityEditUpdate
162
166
  ) {
163
167
  const reason = isEntityEditUpdate
@@ -171,9 +175,10 @@ function validateRowActionNavigateParams(
171
175
  action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
172
176
  throw new Error(
173
177
  `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
174
- `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm ` +
175
- `and entityEdit-create targets read URL search params as initial values. Remove the ` +
176
- `params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
178
+ `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
179
+ `secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
180
+ `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
181
+ `entityEdit-create screen.`,
177
182
  );
178
183
  }
179
184
  }
@@ -184,7 +189,7 @@ function validateRowActionNavigateParams(
184
189
  function validateWizardLayout(
185
190
  featureName: string,
186
191
  screenId: string,
187
- screenType: "entityEdit" | "actionForm" | "configEdit",
192
+ screenType: "entityEdit" | "actionForm" | "configEdit" | "secretMint",
188
193
  layout: EditLayout,
189
194
  featureMap: ReadonlyMap<string, FeatureDefinition>,
190
195
  ): void {
@@ -395,6 +400,8 @@ function validateDrawerTargetAction(
395
400
  `Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
396
401
  );
397
402
  }
403
+ // secretMint is intentionally excluded — a one-time secret reveal belongs on
404
+ // its own page, not layered in a Drawer above a list.
398
405
  if (target.type !== "actionForm") {
399
406
  throw new Error(
400
407
  `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
@@ -496,6 +503,277 @@ function resolveRowActionNavigateTarget(
496
503
  return screensByShortId.get(action.screen)?.[0];
497
504
  }
498
505
 
506
+ // Shared by the mint step's own handler (screen.handler) and its optional
507
+ // confirm step (confirm.handler) — both are a bare write-handler QN with the
508
+ // same registration requirement.
509
+ function validateWriteHandlerRegistered(
510
+ featureName: string,
511
+ screenId: string,
512
+ context: string,
513
+ handler: unknown,
514
+ allWriteHandlerQns: ReadonlySet<string>,
515
+ ): void {
516
+ if (!handler || typeof handler !== "string") {
517
+ throw new Error(
518
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler.`,
519
+ );
520
+ }
521
+ if (!allWriteHandlerQns.has(handler)) {
522
+ throw new Error(
523
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
524
+ `is not a registered write-handler. Check the QN spelling (expected ` +
525
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
526
+ );
527
+ }
528
+ }
529
+
530
+ // Every field entry must carry a `type` discriminator. An author typo
531
+ // (`title: { required: true }` without a type) would otherwise let
532
+ // RenderField silently fall through to the default renderer and submit an
533
+ // empty string — failing at boot is clearer. `type as unknown` because
534
+ // FieldDefinition, as a union, only allows known type strings; here we're
535
+ // checking author code that may have circumvented the type check.
536
+ //
537
+ // Parametrized over (fields, layout) instead of a whole screen so a
538
+ // secretMint's own `confirm` step — a second, independent inline form on the
539
+ // same screen — can reuse the same checks as its `fields`/`layout` pair.
540
+ // `allowEmpty` is only ever true for a secretMint's own mint step (an
541
+ // input-less mint, fw#2838): the secret is server-generated and the mint
542
+ // step is just its submit button — actionForm and the confirm step always
543
+ // require at least one field.
544
+ function validateFormFieldsMap(
545
+ featureName: string,
546
+ screenId: string,
547
+ context: string,
548
+ fields: Readonly<Record<string, FieldDefinition>>,
549
+ allowEmpty: boolean,
550
+ ): Set<string> {
551
+ const fieldNames = new Set(Object.keys(fields));
552
+ if (fieldNames.size === 0 && !allowEmpty) {
553
+ throw new Error(
554
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) has empty fields map — ` +
555
+ `declare at least one field.`,
556
+ );
557
+ }
558
+ for (const [fname, fdef] of Object.entries(fields)) {
559
+ // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
560
+ const ftype = (fdef as { type?: unknown }).type;
561
+ if (typeof ftype !== "string" || ftype.length === 0) {
562
+ throw new Error(
563
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fname}" has no ` +
564
+ `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
565
+ );
566
+ }
567
+ }
568
+ return fieldNames;
569
+ }
570
+
571
+ // `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
572
+ // caller only ever passes true together with an actually-empty fields map
573
+ // (an input-less secretMint declares BOTH `fields: {}` and
574
+ // `layout.sections: []`; fields declared with no layout to show them stays
575
+ // an error).
576
+ function validateFormLayoutSections(
577
+ featureName: string,
578
+ screenId: string,
579
+ context: string,
580
+ layout: EditLayout,
581
+ fieldNames: ReadonlySet<string>,
582
+ allowEmptySections: boolean,
583
+ ): void {
584
+ if (layout.sections.length === 0) {
585
+ // skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
586
+ if (allowEmptySections) return;
587
+ throw new Error(
588
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) has an empty sections list — ` +
589
+ `declare at least one section.`,
590
+ );
591
+ }
592
+ for (const section of layout.sections) {
593
+ if (isExtensionEditSection(section)) {
594
+ if (section.component?.react === undefined && section.component?.native === undefined) {
595
+ throw new Error(
596
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) extension section ` +
597
+ `"${section.title}" has no component — declare a react/native component marker.`,
598
+ );
599
+ }
600
+ continue;
601
+ }
602
+ if (section.kind === "relatedList") {
603
+ throw new Error(
604
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
605
+ `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
606
+ `primitive (fw#2166).`,
607
+ );
608
+ }
609
+ if (isWriteFormEditSection(section)) {
610
+ throw new Error(
611
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
612
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
613
+ );
614
+ }
615
+ if (section.fields.length === 0) {
616
+ throw new Error(
617
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) has a section "${section.title}" ` +
618
+ `with zero fields — drop the section or add fields to it.`,
619
+ );
620
+ }
621
+ for (const fieldSpec of section.fields) {
622
+ const normalized = normalizeEditField(fieldSpec);
623
+ if (!fieldNames.has(normalized.field)) {
624
+ throw new Error(
625
+ `[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
626
+ `"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
627
+ );
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ function validateInlineFormNavTargets(
634
+ feature: FeatureDefinition,
635
+ screenId: string,
636
+ screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
637
+ kind: "actionForm" | "secretMint",
638
+ allScreenQns: ReadonlySet<string>,
639
+ ): void {
640
+ if (screen.redirect !== undefined) {
641
+ // redirect is either a short screen id (same-feature, e.g. "item-list")
642
+ // or a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
643
+ // renderer strips the latter to the short id (lastSegment) when
644
+ // navigating, which the nav-router resolves app-wide (#1946). The object
645
+ // form (fw#2670, actionForm only) carries the same target under `screen`
646
+ // plus the payload field `idFrom`.
647
+ const redirectTarget =
648
+ typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
649
+ if (
650
+ typeof screen.redirect !== "string" &&
651
+ (typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
652
+ ) {
653
+ throw new Error(
654
+ `[Feature ${feature.name}] Screen "${screenId}" (${kind}) redirect.idFrom is empty or not a string — ` +
655
+ `name the success-payload field carrying the navigation id, or use the plain string ` +
656
+ `redirect form to navigate with the handler's own "id".`,
657
+ );
658
+ }
659
+ validateScreenNavTarget(
660
+ feature.name,
661
+ screenId,
662
+ kind,
663
+ "redirect",
664
+ redirectTarget,
665
+ allScreenQns,
666
+ feature.screens,
667
+ );
668
+ }
669
+ if (typeof screen.cancelTarget === "string") {
670
+ // Same rule as redirect — `false` (no Cancel button) needs no validation.
671
+ validateScreenNavTarget(
672
+ feature.name,
673
+ screenId,
674
+ kind,
675
+ "cancelTarget",
676
+ screen.cancelTarget,
677
+ allScreenQns,
678
+ feature.screens,
679
+ );
680
+ }
681
+ }
682
+
683
+ // actionForm/secretMint have no entity link, only a write-handler QN +
684
+ // inline fields: checks handler registration, field types, layout refs, and nav targets.
685
+ function validateInlineFormScreen(
686
+ feature: FeatureDefinition,
687
+ screenId: string,
688
+ screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
689
+ kind: "actionForm" | "secretMint",
690
+ allWriteHandlerQns: ReadonlySet<string>,
691
+ allScreenQns: ReadonlySet<string>,
692
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
693
+ ): void {
694
+ validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
695
+ // Only a secretMint's own mint step may skip fields/sections entirely
696
+ // (input-less mint, fw#2838) — actionForm always needs at least one field.
697
+ const allowEmptyMintForm = kind === "secretMint";
698
+ const fieldNames = validateFormFieldsMap(
699
+ feature.name,
700
+ screenId,
701
+ kind,
702
+ screen.fields,
703
+ allowEmptyMintForm,
704
+ );
705
+ validateFormLayoutSections(
706
+ feature.name,
707
+ screenId,
708
+ kind,
709
+ screen.layout,
710
+ fieldNames,
711
+ allowEmptyMintForm && fieldNames.size === 0,
712
+ );
713
+ validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
714
+ validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns);
715
+ }
716
+
717
+ // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
718
+ // on the reveal card in place of the bare acknowledge button (TOTP enrollment:
719
+ // scan the code, then enter one). Unlike the mint step, confirm always needs
720
+ // at least one field — a step whose only job is proving receipt with no input
721
+ // would be meaningless — and its layout is a single plain form, never a
722
+ // wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
723
+ // not a multi-step flow, and persisting a draft of it would re-open the leak
724
+ // fw#2548 closed (a lingering copy of the just-revealed secret in storage).
725
+ function validateSecretMintConfirm(
726
+ feature: FeatureDefinition,
727
+ screenId: string,
728
+ screen: SecretMintScreenDefinition,
729
+ allWriteHandlerQns: ReadonlySet<string>,
730
+ ): void {
731
+ const confirm = screen.confirm;
732
+ // skip: the screen declares no confirm step — nothing to validate.
733
+ if (confirm === undefined) return;
734
+ const context = "secretMint confirm";
735
+ validateWriteHandlerRegistered(
736
+ feature.name,
737
+ screenId,
738
+ context,
739
+ confirm.handler,
740
+ allWriteHandlerQns,
741
+ );
742
+ const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
743
+ validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, false);
744
+ if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
745
+ throw new Error(
746
+ `[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
747
+ `"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
748
+ `Remove mode or set it to "single".`,
749
+ );
750
+ }
751
+ if (confirm.layout.draft === true) {
752
+ throw new Error(
753
+ `[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.draft: true — ` +
754
+ `persisting a draft of the reveal-confirm step would leak the one-time secret into storage, ` +
755
+ `the exact leak fw#2548 closed. Remove draft: true.`,
756
+ );
757
+ }
758
+ // skip: no carry list declared — no carried fields to cross-check against confirm.fields.
759
+ if (confirm.carry === undefined) return;
760
+ for (const carryField of confirm.carry) {
761
+ if (typeof carryField !== "string" || carryField.trim() === "") {
762
+ throw new Error(
763
+ `[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
764
+ `entry in "carry".`,
765
+ );
766
+ }
767
+ if (fieldNames.has(carryField)) {
768
+ throw new Error(
769
+ `[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
770
+ `also names a confirm.fields entry — a carried mint-payload value would silently ` +
771
+ `overwrite the user-entered field of the same name. Rename one of them.`,
772
+ );
773
+ }
774
+ }
775
+ }
776
+
499
777
  export function validateScreens(
500
778
  feature: FeatureDefinition,
501
779
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -1052,144 +1330,57 @@ export function validateScreens(
1052
1330
  }
1053
1331
 
1054
1332
  if (screen.type === "actionForm") {
1055
- // Tier 2.7d: Action-Form-Screens haben keinen entity-Link, nur
1056
- // einen Write-Handler-QN + Inline-Fields. Sechs Author-Code-
1057
- // Checks am Boot:
1058
- // 1) handler ist non-empty String.
1059
- // 2) handler ist als Write-Handler registriert (cross-feature-
1060
- // Lookup gegen die collected QN-Map). Tippfehler/umbenannte
1061
- // Handler fallen sonst erst beim ersten Klick als 404 auf.
1062
- // 3) fields-Map ist non-empty.
1063
- // 4) Jeder Field-Eintrag hat einen `type`-Discriminator
1064
- // (Tippfehler in Schema → Renderer crasht stumm sonst).
1065
- // 5) layout.sections + jedes referenced field existiert in
1066
- // fields.
1067
- // 6) redirect (wenn gesetzt) verweist auf einen registrierten
1068
- // Screen-QN (Cross-Feature ok).
1069
- if (!screen.handler || typeof screen.handler !== "string") {
1070
- throw new Error(
1071
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty or non-string handler.`,
1072
- );
1073
- }
1074
- if (!allWriteHandlerQns.has(screen.handler)) {
1075
- throw new Error(
1076
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) handler "${screen.handler}" ` +
1077
- `is not a registered write-handler. Check the QN spelling (expected ` +
1078
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
1079
- );
1080
- }
1081
- const fieldNames = new Set(Object.keys(screen.fields));
1082
- if (fieldNames.size === 0) {
1333
+ validateInlineFormScreen(
1334
+ feature,
1335
+ screenId,
1336
+ screen,
1337
+ "actionForm",
1338
+ allWriteHandlerQns,
1339
+ allScreenQns,
1340
+ featureMap,
1341
+ );
1342
+ continue;
1343
+ }
1344
+
1345
+ if (screen.type === "secretMint") {
1346
+ validateInlineFormScreen(
1347
+ feature,
1348
+ screenId,
1349
+ screen,
1350
+ "secretMint",
1351
+ allWriteHandlerQns,
1352
+ allScreenQns,
1353
+ featureMap,
1354
+ );
1355
+ if (screen.reveal.fields.length === 0) {
1083
1356
  throw new Error(
1084
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty fields map — ` +
1085
- `declare at least one field.`,
1357
+ `[Feature ${feature.name}] Screen "${screenId}" (secretMint) has an empty reveal.fields ` +
1358
+ `list — declare at least one field to reveal.`,
1086
1359
  );
1087
1360
  }
1088
- // Jeder Field-Eintrag muss einen `type`-Discriminator haben.
1089
- // Author-Tippfehler (`title: { required: true }` ohne type) →
1090
- // RenderField fällt zur Laufzeit auf den Default-Renderer und
1091
- // schickt einen leeren String — silent broken. Boot-Fail ist
1092
- // klarer. `type as unknown` weil FieldDefinition als Union nur
1093
- // bekannte Strings erlaubt; wir prüfen Author-Code, der ggf.
1094
- // den Type-Check umgangen hat.
1095
- for (const [fname, fdef] of Object.entries(screen.fields)) {
1096
- // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
1097
- const ftype = (fdef as { type?: unknown }).type;
1098
- if (typeof ftype !== "string" || ftype.length === 0) {
1361
+ const revealFieldNames = new Set<string>();
1362
+ for (const revealField of screen.reveal.fields) {
1363
+ if (typeof revealField.field !== "string" || revealField.field.trim() === "") {
1099
1364
  throw new Error(
1100
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) field "${fname}" has no ` +
1101
- `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
1365
+ `[Feature ${feature.name}] Screen "${screenId}" (secretMint) has a reveal.fields entry ` +
1366
+ `with an empty or non-string "field".`,
1102
1367
  );
1103
1368
  }
1104
- }
1105
- if (screen.layout.sections.length === 0) {
1106
- throw new Error(
1107
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has an empty sections list — ` +
1108
- `declare at least one section.`,
1109
- );
1110
- }
1111
- for (const section of screen.layout.sections) {
1112
- if (isExtensionEditSection(section)) {
1113
- if (section.component?.react === undefined && section.component?.native === undefined) {
1114
- throw new Error(
1115
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
1116
- `"${section.title}" has no component — declare a react/native component marker.`,
1117
- );
1118
- }
1119
- continue;
1120
- }
1121
- if (section.kind === "relatedList") {
1369
+ if (revealFieldNames.has(revealField.field)) {
1122
1370
  throw new Error(
1123
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) relatedList section ` +
1124
- `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
1125
- `primitive (fw#2166).`,
1371
+ `[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields has a ` +
1372
+ `duplicate field "${revealField.field}" — each revealed field must be unique.`,
1126
1373
  );
1127
1374
  }
1128
- if (isWriteFormEditSection(section)) {
1375
+ revealFieldNames.add(revealField.field);
1376
+ if (typeof revealField.label !== "string" || revealField.label.trim() === "") {
1129
1377
  throw new Error(
1130
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) writeForm section ` +
1131
- `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1378
+ `[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields entry ` +
1379
+ `"${revealField.field}" has an empty or non-string "label".`,
1132
1380
  );
1133
1381
  }
1134
- if (section.fields.length === 0) {
1135
- throw new Error(
1136
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
1137
- `with zero fields — drop the section or add fields to it.`,
1138
- );
1139
- }
1140
- for (const fieldSpec of section.fields) {
1141
- const normalized = normalizeEditField(fieldSpec);
1142
- if (!fieldNames.has(normalized.field)) {
1143
- throw new Error(
1144
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) layout references unknown field ` +
1145
- `"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
1146
- );
1147
- }
1148
- }
1149
- }
1150
- validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
1151
- if (screen.redirect !== undefined) {
1152
- // redirect is either a short screen id (same-feature, e.g.
1153
- // "item-list") or a fully-qualified cross-feature QN
1154
- // (`<feature>:screen:<id>`) — the renderer strips the latter to the
1155
- // short id (lastSegment) when navigating, which the nav-router
1156
- // resolves app-wide (#1946). The object form (fw#2670) carries the
1157
- // same target under `screen` plus the payload field `idFrom`.
1158
- const redirectTarget =
1159
- typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
1160
- if (
1161
- typeof screen.redirect !== "string" &&
1162
- (typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
1163
- ) {
1164
- throw new Error(
1165
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect.idFrom is empty or not a string — ` +
1166
- `name the success-payload field carrying the navigation id, or use the plain string ` +
1167
- `redirect form to navigate with the handler's own "id".`,
1168
- );
1169
- }
1170
- validateScreenNavTarget(
1171
- feature.name,
1172
- screenId,
1173
- "actionForm",
1174
- "redirect",
1175
- redirectTarget,
1176
- allScreenQns,
1177
- feature.screens,
1178
- );
1179
- }
1180
- if (typeof screen.cancelTarget === "string") {
1181
- // Gleiche Regel wie redirect — `false` (kein Cancel-Button)
1182
- // braucht keine Validierung.
1183
- validateScreenNavTarget(
1184
- feature.name,
1185
- screenId,
1186
- "actionForm",
1187
- "cancelTarget",
1188
- screen.cancelTarget,
1189
- allScreenQns,
1190
- feature.screens,
1191
- );
1192
1382
  }
1383
+ validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns);
1193
1384
  continue;
1194
1385
  }
1195
1386
 
@@ -1,4 +1,5 @@
1
1
  import type { CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
2
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import type { ZodType, z } from "zod";
3
4
  import type { ContainsSecret } from "../secrets/types";
4
5
  import { runPipeline } from "./run-pipeline";
@@ -147,6 +148,11 @@ export function isPagedQueryHandler(def: object): boolean {
147
148
  return (def as Record<string, unknown>)[PAGED_QUERY_HANDLER_BRAND] === true;
148
149
  }
149
150
 
151
+ export function isEntityConventionQueryHandler(def: object): boolean {
152
+ // @cast-boundary brand-probe — same shape as isPagedQueryHandler above.
153
+ return (def as Record<string, unknown>)[ENTITY_CONVENTION_QUERY_BRAND] === true;
154
+ }
155
+
150
156
  export type PagedQueryHandlerDefinition<
151
157
  TName extends string = string,
152
158
  TSchema extends ZodType = ZodType,
@@ -1,3 +1,4 @@
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import { type ZodType, z } from "zod";
2
3
  import type { DbRow } from "../db/connection";
3
4
  import {
@@ -388,6 +389,7 @@ export function defineEntityQueryHandler(
388
389
  fields: ctx.registry.getSortableReferences(entityName),
389
390
  resolveEntity: (name) => ctx.registry.getEntity(name),
390
391
  },
392
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
391
393
  });
392
394
  const enrichedRows = hasRefFields
393
395
  ? await enrichWithReferences(
@@ -404,7 +406,9 @@ export function defineEntityQueryHandler(
404
406
  schema = idSchema;
405
407
  handler = async (query, ctx) => {
406
408
  const db = dbFor(ctx);
407
- const row = await executor.detail(query.payload as IdPayload, query.user, db); // @cast-boundary engine-payload
409
+ const row = await executor.detail(query.payload as IdPayload, query.user, db, {
410
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
411
+ }); // @cast-boundary engine-payload
408
412
  if (row === null || !hasRefFields) return row;
409
413
  return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db);
410
414
  };
@@ -426,6 +430,7 @@ export function defineEntityQueryHandler(
426
430
  // definePagedQueryHandler on top. The "detail" verb returns a single
427
431
  // row/null, never this shape.
428
432
  ...(verb === "list" && { [PAGED_QUERY_HANDLER_BRAND]: true }),
433
+ [ENTITY_CONVENTION_QUERY_BRAND]: true,
429
434
  };
430
435
  }
431
436
 
@@ -456,6 +456,8 @@ export function createEntity<F>(def: {
456
456
  readonly indexes?: readonly EntityIndexDef[];
457
457
  readonly idType?: "serial" | "uuid";
458
458
  readonly access?: EntityDefinition["access"];
459
+ /** Join-row carrier reference — see EntityDefinition.parentRef. */
460
+ readonly parentRef?: EntityDefinition["parentRef"];
459
461
  readonly retention?: RetentionDef;
460
462
  readonly derivedFields?: EntityDefinition["derivedFields"];
461
463
  }): F extends FieldsMap ? EntityDefinition<F> : never {
@@ -1,6 +1,8 @@
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import type { ZodType, z } from "zod";
2
3
  import { toTableName } from "../db/table-builder";
3
4
  import {
5
+ isEntityConventionQueryHandler,
4
6
  isPagedQueryHandler,
5
7
  PAGED_QUERY_HANDLER_BRAND,
6
8
  type QueryHandlerDefinition,
@@ -184,6 +186,9 @@ export function buildEntityHandlerMethods<TName extends string>(
184
186
  // Carry the definePagedQueryHandler brand through — this rebuild
185
187
  // drops any field not explicitly listed.
186
188
  ...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
189
+ ...(isEntityConventionQueryHandler(def) && {
190
+ [ENTITY_CONVENTION_QUERY_BRAND]: true as const,
191
+ }),
187
192
  };
188
193
  tryMapEntity(state, name, def.name);
189
194
  return { name: def.name };
@@ -221,6 +221,7 @@ export {
221
221
  export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
222
222
  export {
223
223
  buildOwnershipClause,
224
+ combineClauses,
224
225
  from,
225
226
  normalizeAccessEntry,
226
227
  userCanCreateFieldRow,
@@ -411,6 +412,7 @@ export type {
411
412
  NotifyPriority,
412
413
  NumberFieldDef,
413
414
  OnDeleteStrategy,
415
+ ParentRefDef,
414
416
  PersonalAnnotations,
415
417
  PersonalAnnotationsLongText,
416
418
  PersonalAnnotationsNoFind,
@@ -447,6 +449,10 @@ export type {
447
449
  ScreenNavSugar,
448
450
  ScreenSlots,
449
451
  SecretKeyHandle,
452
+ SecretMintConfirmStep,
453
+ SecretMintScreenDefinition,
454
+ SecretReveal,
455
+ SecretRevealField,
450
456
  SecretsEditScreenDefinition,
451
457
  SecretsEditSection,
452
458
  SelectFieldDef,
@@ -336,6 +336,21 @@ export function buildOwnershipClause(
336
336
  return { kind: "sql", sqlText: `(${sqlText})`, params };
337
337
  }
338
338
 
339
+ // AND-combine two independently built read clauses. Both are built at
340
+ // paramStart=1; `b`'s placeholders are shifted past `a`'s params so the
341
+ // concatenated fragment binds positionally.
342
+ export function combineClauses(a: OwnershipClause, b: OwnershipClause): OwnershipClause {
343
+ if (a.kind === "empty" || b.kind === "empty") return { kind: "empty" };
344
+ if (a.kind === "pass") return b;
345
+ if (b.kind === "pass") return a;
346
+ const shifted = shiftParams({ sqlText: b.sqlText, params: b.params }, a.params.length);
347
+ return {
348
+ kind: "sql",
349
+ sqlText: `(${a.sqlText} AND ${shifted.sqlText})`,
350
+ params: [...a.params, ...shifted.params],
351
+ };
352
+ }
353
+
339
354
  type RuleFragmentResult =
340
355
  | { readonly kind: "empty" }
341
356
  | { readonly kind: "sql"; readonly sqlText: string; readonly params: readonly unknown[] };
@@ -26,6 +26,7 @@ export const screenSchema: PatternFormSchema = {
26
26
  { value: "entityList", label: { en: "Entity list" } },
27
27
  { value: "entityEdit", label: { en: "Entity edit" } },
28
28
  { value: "actionForm", label: { en: "Action form" } },
29
+ { value: "secretMint", label: { en: "Secret mint" } },
29
30
  { value: "custom", label: { en: "Custom" } },
30
31
  ],
31
32
  required: true,