@cosmicdrift/kumiko-framework 0.262.0 → 0.263.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.262.0",
3
+ "version": "0.263.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.262.0",
201
+ "@cosmicdrift/kumiko-types": "0.263.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.262.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.263.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.263.0",
4
+ "type": "breaking",
5
+ "title": "Forms are prefilled from URL query parameters only for fields a declarative `navigate` action declares as `params` (fw#2852).",
6
+ "detail": "`buildAppSchema` now derives `urlPrefillFields` per actionForm/secretMint/entityEdit-create screen from every navigate `params` that targets it — entityList/projectionList rowActions, projectionDetail/entityEdit actions, relatedList rowActions, and projectionDetail metrics; a form nothing targets takes nothing from the URL. Previously any query parameter matching a field name was taken, so a crafted link could seed e.g. an IBAN or e-mail field. Declared params keep working. `sensitive` and `format: \"password\"` fields are still never prefilled — not from the URL, an allowlist, or a handoff.",
7
+ "migration": "Code that prefills a form via `nav.navigate` + `nav.setSearchParams` without a declared `params` (known: publicstatus `custom-field-list`, phronexsis `driver-map-node-panel`, kumiko-enterprise ai-agent `openForm` client tool / `agentPrefill=1`) must switch to a declared `navigate` action with `params`, or — for programmatic prefill — to `useNavigateWithInitialValues()` from `@cosmicdrift/kumiko-renderer`. No codemod."
8
+ },
9
+ {
10
+ "version": "0.263.0",
11
+ "type": "improvement",
12
+ "title": "New `useNavigateWithInitialValues()` hands a form screen initial values in memory instead of the query string (fw#2852).",
13
+ "detail": "`useNavigateWithInitialValues()`, exported from `@cosmicdrift/kumiko-renderer`, returns `(target: ScreenTarget, initialValues: Readonly<Record<string, unknown>>) => void`, which navigates to a form screen and carries the initial values in memory rather than as URL search params — for programmatic prefill outside a declared `navigate` `params` action. `sensitive` and `format: \"password\"` fields are still never prefilled, same as URL and allowlist prefill."
14
+ },
15
+ {
16
+ "version": "0.263.0",
17
+ "type": "fix",
18
+ "title": "`projectField`/`projectEntity` now forward field metadata the client schema previously dropped (fw#2852).",
19
+ "detail": "`projectField`/`projectEntity` (`packages/framework/src/engine/build-app-schema.ts`) now forward number `unit`, entity `defaultCurrency`, text `format`, timestamp `locatedBy`, file/image `accept`/`maxSize`/`variants`, decimal `scale` (incl. embedded sub-fields), and `sensitive` into the client schema; previously they were lost on the way, so the unit suffix did not render, money fields fell back to \"EUR\", password masking and the never-prefill-sensitive rule were inactive client-side."
20
+ },
2
21
  {
3
22
  "version": "0.262.0",
4
23
  "type": "improvement",
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAppSchema } from "../build-app-schema";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createRegistry } from "../registry";
5
+ import type { EntityDefinition } from "../types/fields";
6
+ import type { ScreenDefinition } from "../types/screen";
7
+
8
+ const leaseEntity = {
9
+ fields: { name: { type: "text" }, iban: { type: "text" } },
10
+ } as unknown as EntityDefinition;
11
+ const itemEntity = {
12
+ fields: { leaseId: { type: "text" }, note: { type: "text" } },
13
+ } as unknown as EntityDefinition;
14
+
15
+ const leasesFeature = defineFeature("leases", (r) => {
16
+ r.entity("lease", leaseEntity);
17
+ r.entity("item", itemEntity);
18
+ r.screen({
19
+ id: "lease-list",
20
+ type: "entityList",
21
+ entity: "lease",
22
+ columns: ["name"],
23
+ rowActions: [
24
+ {
25
+ kind: "navigate",
26
+ id: "add-item",
27
+ label: "add",
28
+ screen: "item-create",
29
+ params: { map: { leaseId: "id" } },
30
+ },
31
+ {
32
+ kind: "navigate",
33
+ id: "pay",
34
+ label: "pay",
35
+ screen: "record-payment",
36
+ params: { pick: ["name"] },
37
+ },
38
+ {
39
+ kind: "navigate",
40
+ id: "edit",
41
+ label: "edit",
42
+ screen: "lease-edit",
43
+ params: { pick: ["iban"] },
44
+ },
45
+ {
46
+ kind: "drawer",
47
+ id: "note",
48
+ label: "note",
49
+ screen: "drawer-only-form",
50
+ params: { pick: ["note"] },
51
+ },
52
+ ],
53
+ });
54
+ r.screen({
55
+ id: "lease-edit",
56
+ type: "entityEdit",
57
+ entity: "lease",
58
+ layout: { sections: [{ title: "x", fields: ["name", "iban"] }] },
59
+ actions: [
60
+ {
61
+ kind: "navigate",
62
+ id: "pay-party",
63
+ label: "pay",
64
+ screen: "record-payment",
65
+ params: { map: { partyId: "id" } },
66
+ },
67
+ ],
68
+ });
69
+ r.screen({
70
+ id: "lease-detail",
71
+ type: "projectionDetail",
72
+ query: "leases:query:lease:detail",
73
+ layout: { sections: [] },
74
+ metrics: [
75
+ {
76
+ field: "openItems",
77
+ label: "items",
78
+ navigate: { screen: "item-create", params: { map: { note: "summary" } } },
79
+ },
80
+ ],
81
+ });
82
+ r.screen({
83
+ id: "item-create",
84
+ type: "entityEdit",
85
+ entity: "item",
86
+ layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
87
+ });
88
+ r.screen({
89
+ id: "record-payment",
90
+ type: "actionForm",
91
+ handler: "leases:write:payment:record",
92
+ fields: { name: { type: "text" }, partyId: { type: "text" }, iban: { type: "text" } },
93
+ layout: { sections: [{ title: "x", fields: ["name", "partyId", "iban"] }] },
94
+ urlPrefillFields: ["iban"],
95
+ } as ScreenDefinition);
96
+ r.screen({
97
+ id: "drawer-only-form",
98
+ type: "actionForm",
99
+ handler: "leases:write:item:note",
100
+ fields: { note: { type: "text" } },
101
+ layout: { sections: [{ title: "x", fields: ["note"] }] },
102
+ });
103
+ });
104
+
105
+ const billingFeature = defineFeature("billing", (r) => {
106
+ r.entity("invoice", { fields: { payee: { type: "text" } } } as unknown as EntityDefinition);
107
+ r.screen({
108
+ id: "invoice-list",
109
+ type: "entityList",
110
+ entity: "invoice",
111
+ columns: ["payee"],
112
+ rowActions: [
113
+ {
114
+ kind: "navigate",
115
+ id: "pay",
116
+ label: "pay",
117
+ screen: "record-payment",
118
+ params: { map: { name: "payee" } },
119
+ },
120
+ ],
121
+ });
122
+ });
123
+
124
+ function urlPrefillFieldsOf(featureName: string, screenId: string): unknown {
125
+ const app = buildAppSchema(createRegistry([leasesFeature, billingFeature]));
126
+ const screen = app.features
127
+ .find((f) => f.featureName === featureName)
128
+ ?.screens.find((s) => s.id.endsWith(`:${screenId}`) || s.id === screenId);
129
+ if (screen === undefined) throw new Error(`screen ${featureName}/${screenId} not projected`);
130
+ return "urlPrefillFields" in screen ? screen.urlPrefillFields : undefined;
131
+ }
132
+
133
+ describe("buildAppSchema — urlPrefillFields derived from navigate params", () => {
134
+ test("a cross-entity entityEdit-create target gets exactly the declared params keys", () => {
135
+ expect(urlPrefillFieldsOf("leases", "item-create")).toEqual(["leaseId", "note"]);
136
+ });
137
+
138
+ test("keys from several sources and features are unioned; an authored value is overwritten", () => {
139
+ expect(urlPrefillFieldsOf("leases", "record-payment")).toEqual(["name", "partyId"]);
140
+ });
141
+
142
+ test("a form no navigate params targets gets an empty list — drawer params do not count", () => {
143
+ expect(urlPrefillFieldsOf("leases", "drawer-only-form")).toEqual([]);
144
+ });
145
+
146
+ test("a same-entity entityEdit target opens in update mode and gets no URL prefill", () => {
147
+ expect(urlPrefillFieldsOf("leases", "lease-edit")).toEqual([]);
148
+ });
149
+
150
+ test("non-form screens carry no urlPrefillFields", () => {
151
+ expect(urlPrefillFieldsOf("leases", "lease-list")).toBeUndefined();
152
+ });
153
+ });
@@ -420,10 +420,10 @@ describe("buildAppSchema", () => {
420
420
  "default", // array-valued for multiSelect — projectField's
421
421
  // isJsonSafeValue() (fw#2497) now recurses into arrays/plain
422
422
  // objects instead of only string/number/boolean/null.
423
+ "sensitive", // write-response redaction server-side AND the renderer's prefill block
423
424
  ] as const;
424
425
 
425
426
  const SERVER_ONLY_KEYS = [
426
- "sensitive", // controls write-response redaction, never rendered
427
427
  "access", // server-side authz check, not a renderer concern
428
428
  "pii", // PII classification, drives crypto/storage — not client-relevant
429
429
  "userOwned", // same: subject-key annotation, server/crypto-only
@@ -582,6 +582,95 @@ describe("buildAppSchema", () => {
582
582
  expect(fields["avatar"]?.["capture"]).toBeUndefined();
583
583
  });
584
584
 
585
+ test("number unit (static and sibling-field form) and entity defaultCurrency survive the projection", () => {
586
+ // Regression: the edit view-model reads `unit` and `entity.defaultCurrency`,
587
+ // but the client schema never carried them — the suffix never rendered and
588
+ // money fields always fell back to "EUR".
589
+ const entity = {
590
+ defaultCurrency: "USD",
591
+ fields: {
592
+ distance: { type: "number", unit: "mi" },
593
+ mileage: { type: "number", unit: { field: "mileageUnit" } },
594
+ mileageUnit: { type: "text" },
595
+ count: { type: "number" },
596
+ bogus: { type: "number", unit: { field: () => "km" } },
597
+ },
598
+ } as unknown as EntityDefinition;
599
+
600
+ const f = defineFeature("ent", (r) => {
601
+ r.entity("thing", entity);
602
+ });
603
+ const app = buildAppSchema(createRegistry([f]));
604
+ const projected = app.features[0]!.entities["thing"] as unknown as {
605
+ defaultCurrency?: string;
606
+ fields: Record<string, Record<string, unknown>>;
607
+ };
608
+
609
+ expect(projected.defaultCurrency).toBe("USD");
610
+ expect(projected.fields["distance"]?.["unit"]).toBe("mi");
611
+ expect(projected.fields["mileage"]?.["unit"]).toEqual({ field: "mileageUnit" });
612
+ expect(projected.fields["count"]?.["unit"]).toBeUndefined();
613
+ expect(projected.fields["bogus"]?.["unit"]).toBeUndefined();
614
+ expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
615
+ });
616
+
617
+ test("entity without defaultCurrency omits the key", () => {
618
+ const f = defineFeature("ent", (r) => {
619
+ r.entity("thing", { fields: { label: { type: "text" } } } as unknown as EntityDefinition);
620
+ });
621
+ const app = buildAppSchema(createRegistry([f]));
622
+ expect("defaultCurrency" in app.features[0]!.entities["thing"]!).toBe(false);
623
+ });
624
+
625
+ test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
626
+ // Regression: all of these are read by the edit view-model (password
627
+ // masking, wall-clock input, upload constraints, preview variant, derived
628
+ // embedded-cell rounding) but were missing from the client schema.
629
+ const entity = {
630
+ fields: {
631
+ secret: { type: "text", format: "password" },
632
+ pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
633
+ pickupTz: { type: "tz" },
634
+ contract: { type: "file", accept: ["application/pdf"], maxSize: "5mb" },
635
+ photo: {
636
+ type: "image",
637
+ accept: ["image/*"],
638
+ maxSize: "10mb",
639
+ variants: { thumb: { fit: "cover", size: { width: 120, height: 120 } } },
640
+ },
641
+ positions: {
642
+ type: "embedded",
643
+ multiple: true,
644
+ schema: { price: { type: "decimal", scale: 2 } },
645
+ },
646
+ },
647
+ } as unknown as EntityDefinition;
648
+
649
+ const f = defineFeature("ent", (r) => {
650
+ r.entity("thing", entity);
651
+ });
652
+ const app = buildAppSchema(createRegistry([f]));
653
+ const fields = (
654
+ app.features[0]!.entities["thing"] as unknown as {
655
+ fields: Record<string, Record<string, unknown>>;
656
+ }
657
+ ).fields;
658
+
659
+ expect(fields["secret"]?.["format"]).toBe("password");
660
+ expect(fields["pickupAt"]?.["locatedBy"]).toBe("pickupTz");
661
+ expect(fields["contract"]?.["accept"]).toEqual(["application/pdf"]);
662
+ expect(fields["contract"]?.["maxSize"]).toBe("5mb");
663
+ expect(fields["photo"]?.["accept"]).toEqual(["image/*"]);
664
+ expect(fields["photo"]?.["variants"]).toEqual({
665
+ thumb: { fit: "cover", size: { width: 120, height: 120 } },
666
+ });
667
+ const positionsSchema = fields["positions"]?.["schema"] as Record<
668
+ string,
669
+ Record<string, unknown>
670
+ >;
671
+ expect(positionsSchema["price"]?.["scale"]).toBe(2);
672
+ });
673
+
585
674
  test("embedded: minItems/maxItems/derived/totals/totalsMatch überleben die Projection (fw#2497)", () => {
586
675
  // Regression: `totals` carries "Renderer metadata: numeric sub-field
587
676
  // names to sum in a totals row" in its own doc-comment but was never
@@ -101,6 +101,23 @@ function validateNoWidgetRequiredField(
101
101
  );
102
102
  }
103
103
 
104
+ export function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[] {
105
+ return "pick" in params ? params.pick : Object.keys(params.map);
106
+ }
107
+
108
+ // Mirrors the renderer's navigate: an explicit entityId, or a same-entity
109
+ // entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
110
+ export function readsNavigateParamsAsFormPrefill(
111
+ target: ScreenDefinition,
112
+ explicitEntityId: string | undefined,
113
+ sourceScreenEntity: string | undefined,
114
+ ): boolean {
115
+ if (target.type === "actionForm" || target.type === "secretMint") return true;
116
+ if (target.type !== "entityEdit") return false;
117
+ if (explicitEntityId !== undefined) return false;
118
+ return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
119
+ }
120
+
104
121
  // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
105
122
  // entityList and projectionList (framework#1708) — projectionList has no
106
123
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
@@ -142,8 +159,7 @@ function validateRowActionNavigateParams(
142
159
  `target dashboard so it has somewhere to read the value from.`,
143
160
  );
144
161
  }
145
- const extractedKeys =
146
- "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
162
+ const extractedKeys = rowFieldExtractorKeys(action.params);
147
163
  if (!extractedKeys.includes(filter.id)) {
148
164
  throw new Error(
149
165
  `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
@@ -156,16 +172,8 @@ function validateRowActionNavigateParams(
156
172
  return;
157
173
  }
158
174
 
159
- const isEntityEditUpdate =
160
- target.screen.type === "entityEdit" &&
161
- (action.entityId !== undefined ||
162
- (screenEntity !== undefined && target.screen.entity === screenEntity));
163
- if (
164
- (target.screen.type !== "actionForm" &&
165
- target.screen.type !== "secretMint" &&
166
- target.screen.type !== "entityEdit") ||
167
- isEntityEditUpdate
168
- ) {
175
+ if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
176
+ const isEntityEditUpdate = target.screen.type === "entityEdit";
169
177
  const reason = isEntityEditUpdate
170
178
  ? `resolves to UPDATE mode (${
171
179
  action.entityId !== undefined
@@ -417,9 +425,7 @@ function validateDrawerTargetAction(
417
425
  // the typo would only show up as an empty field at click time.
418
426
  // skip: no params extractor — nothing to check against the target's fields.
419
427
  if (action.params === undefined) return;
420
- const prefilledFields =
421
- "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
422
- for (const fieldName of prefilledFields) {
428
+ for (const fieldName of rowFieldExtractorKeys(action.params)) {
423
429
  if (Object.hasOwn(target.fields, fieldName)) continue;
424
430
  throw new Error(
425
431
  `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
@@ -0,0 +1,159 @@
1
+ import { qualifyEntityName } from "../qualified-name";
2
+ import type { FeatureDefinition } from "../types";
3
+ import type {
4
+ ProjectionDetailScreenDefinition,
5
+ RowFieldExtractor,
6
+ ScreenDefinition,
7
+ } from "../types/screen";
8
+ import {
9
+ collectScreensByShortId,
10
+ readsNavigateParamsAsFormPrefill,
11
+ rowFieldExtractorKeys,
12
+ } from "./screens";
13
+
14
+ type NavigateParamsSource = {
15
+ readonly screen?: string;
16
+ readonly entity?: string;
17
+ readonly entityId?: string;
18
+ readonly params: RowFieldExtractor;
19
+ readonly sourceScreenEntity: string | undefined;
20
+ };
21
+
22
+ type NavigateActionLike = {
23
+ readonly kind?: string;
24
+ readonly screen?: string;
25
+ readonly entity?: string;
26
+ readonly entityId?: string;
27
+ readonly params?: RowFieldExtractor;
28
+ };
29
+ type NavigateActionWithParams = NavigateActionLike & { readonly params: RowFieldExtractor };
30
+ type ScopedNavigateAction = {
31
+ readonly action: NavigateActionLike;
32
+ readonly sourceScreenEntity: string | undefined;
33
+ };
34
+
35
+ function isNavigateKind(action: NavigateActionLike): boolean {
36
+ return action.kind === undefined || action.kind === "navigate";
37
+ }
38
+ function isNavigateWithParams(action: NavigateActionLike): action is NavigateActionWithParams {
39
+ return isNavigateKind(action) && action.params !== undefined;
40
+ }
41
+ function scopeActions(
42
+ actions: readonly NavigateActionLike[] | undefined,
43
+ sourceScreenEntity: string | undefined,
44
+ ): ScopedNavigateAction[] {
45
+ return (actions ?? []).map((action) => ({ action, sourceScreenEntity }));
46
+ }
47
+ function projectionDetailNavigateActions(
48
+ screen: ProjectionDetailScreenDefinition,
49
+ ): ScopedNavigateAction[] {
50
+ return [
51
+ ...scopeActions(screen.actions, screen.detailFor),
52
+ ...screen.layout.sections.flatMap((section) =>
53
+ section.kind === "relatedList" ? scopeActions(section.rowActions, undefined) : [],
54
+ ),
55
+ ...(screen.metrics ?? []).flatMap((metric) =>
56
+ typeof metric === "string" || metric.navigate === undefined
57
+ ? []
58
+ : scopeActions([metric.navigate], undefined),
59
+ ),
60
+ ];
61
+ }
62
+ function navigateActionsOf(screen: ScreenDefinition): ScopedNavigateAction[] {
63
+ switch (screen.type) {
64
+ case "entityList":
65
+ return scopeActions(screen.rowActions, screen.entity);
66
+ case "projectionList":
67
+ return scopeActions(screen.rowActions, undefined);
68
+ case "entityEdit":
69
+ return scopeActions(screen.actions, screen.entity);
70
+ case "projectionDetail":
71
+ return projectionDetailNavigateActions(screen);
72
+ default:
73
+ return [];
74
+ }
75
+ }
76
+ function toNavigateParamsSource(
77
+ action: NavigateActionWithParams,
78
+ sourceScreenEntity: string | undefined,
79
+ ): NavigateParamsSource {
80
+ return {
81
+ ...(action.screen !== undefined && { screen: action.screen }),
82
+ ...(action.entity !== undefined && { entity: action.entity }),
83
+ ...(action.entityId !== undefined && { entityId: action.entityId }),
84
+ params: action.params,
85
+ sourceScreenEntity,
86
+ };
87
+ }
88
+
89
+ // Every declarative navigate that writes `params` into the target's URL —
90
+ // the same sources validateScreens pairs with validateRowActionNavigateParams,
91
+ // plus projectionDetail metrics (runMetricNavigate), which reuse that shape.
92
+ function navigateParamsSources(screen: ScreenDefinition): NavigateParamsSource[] {
93
+ return navigateActionsOf(screen).flatMap(({ action, sourceScreenEntity }) =>
94
+ isNavigateWithParams(action) ? [toNavigateParamsSource(action, sourceScreenEntity)] : [],
95
+ );
96
+ }
97
+
98
+ // Per target screen QN, the union of field names any navigate `params` may
99
+ // prefill from the URL. A form screen absent from the map accepts no URL prefill.
100
+ export function collectUrlPrefillFieldsByScreenQn(
101
+ features: readonly FeatureDefinition[],
102
+ ): Map<string, Set<string>> {
103
+ const screensByShortId = collectScreensByShortId(features);
104
+ const detailForByEntity = new Map<
105
+ string,
106
+ { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
107
+ >();
108
+ for (const feature of features) {
109
+ for (const [shortId, screen] of Object.entries(feature.screens)) {
110
+ if (screen.detailFor !== undefined && !detailForByEntity.has(screen.detailFor)) {
111
+ detailForByEntity.set(screen.detailFor, { featureName: feature.name, shortId, screen });
112
+ }
113
+ }
114
+ }
115
+
116
+ const result = new Map<string, Set<string>>();
117
+ for (const feature of features) {
118
+ for (const screen of Object.values(feature.screens)) {
119
+ for (const source of navigateParamsSources(screen)) {
120
+ const target = resolveTarget(source, screensByShortId, detailForByEntity);
121
+ if (target === undefined) continue;
122
+ const explicitEntityId =
123
+ source.entity !== undefined ? (source.entityId ?? "id") : source.entityId;
124
+ if (
125
+ !readsNavigateParamsAsFormPrefill(
126
+ target.screen,
127
+ explicitEntityId,
128
+ source.sourceScreenEntity,
129
+ )
130
+ ) {
131
+ continue;
132
+ }
133
+ const qn = qualifyEntityName(target.featureName, "screen", target.shortId);
134
+ const fields = result.get(qn) ?? new Set<string>();
135
+ for (const key of rowFieldExtractorKeys(source.params)) fields.add(key);
136
+ result.set(qn, fields);
137
+ }
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+
143
+ // Runtime resolution: a bare screen id matches the first feature registering
144
+ // it (create-app's router); an entity target opens its detailFor screen with an id.
145
+ function resolveTarget(
146
+ source: NavigateParamsSource,
147
+ screensByShortId: ReturnType<typeof collectScreensByShortId>,
148
+ detailForByEntity: ReadonlyMap<
149
+ string,
150
+ { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
151
+ >,
152
+ ):
153
+ | { readonly featureName: string; readonly shortId: string; readonly screen: ScreenDefinition }
154
+ | undefined {
155
+ if (source.entity !== undefined) return detailForByEntity.get(source.entity);
156
+ if (source.screen === undefined) return undefined;
157
+ const match = screensByShortId.get(source.screen)?.[0];
158
+ return match === undefined ? undefined : { ...match, shortId: source.screen };
159
+ }
@@ -36,11 +36,13 @@ import type {
36
36
  ScreenDefinition,
37
37
  WorkspaceSchema,
38
38
  } from "../ui-types";
39
+ import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields";
39
40
  import {
40
41
  buildConfigFeatureSchema,
41
42
  type ConfigFeatureSchema,
42
43
  SETTINGS_HUB_FEATURE,
43
44
  } from "./build-config-feature-schema";
45
+ import { qualifyEntityName } from "./qualified-name";
44
46
  import type { Registry } from "./types/feature";
45
47
  import type { ClientDerivedFieldDef, DerivedFieldDef, FieldDefinition } from "./types/fields";
46
48
 
@@ -57,6 +59,9 @@ export type BuildAppSchemaOptions = {
57
59
 
58
60
  export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema {
59
61
  const features: FeatureSchema[] = [];
62
+ const urlPrefillFieldsByScreenQn = collectUrlPrefillFieldsByScreenQn([
63
+ ...registry.features.values(),
64
+ ]);
60
65
  for (const [featureName, feature] of registry.features) {
61
66
  const navs = Object.values(feature.navs);
62
67
  // The nav entry alone doesn't say which kind a collection lists, so the
@@ -71,7 +76,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
71
76
  const featureSchema: FeatureSchema = {
72
77
  featureName,
73
78
  entities: projectEntities(feature.entities ?? {}),
74
- screens: projectScreens(feature.screens, registry),
79
+ screens: projectScreens(featureName, feature.screens, registry, urlPrefillFieldsByScreenQn),
75
80
  ...(navs.length > 0 && { navs }),
76
81
  ...(contentCollections.length > 0 && { contentCollections }),
77
82
  // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
@@ -308,12 +313,25 @@ export function findNonJsonSafePath(value: unknown, path: string): string | null
308
313
  // default (screen.searchable ?? derived); the boot-validator (3a) rejects
309
314
  // one that contradicts the schema.
310
315
  function projectScreens(
316
+ featureName: string,
311
317
  screens: Readonly<Record<string, ScreenDefinition>>,
312
318
  registry: Registry,
319
+ urlPrefillFieldsByScreenQn: ReadonlyMap<string, ReadonlySet<string>>,
313
320
  ): ScreenDefinition[] {
314
- return Object.values(screens).map((screen) =>
315
- screen.type === "projectionList" ? projectProjectionListScreen(screen, registry) : screen,
316
- );
321
+ return Object.entries(screens).map(([shortId, screen]) => {
322
+ if (screen.type === "projectionList") return projectProjectionListScreen(screen, registry);
323
+ if (
324
+ screen.type === "actionForm" ||
325
+ screen.type === "secretMint" ||
326
+ screen.type === "entityEdit"
327
+ ) {
328
+ const declared = urlPrefillFieldsByScreenQn.get(
329
+ qualifyEntityName(featureName, "screen", shortId),
330
+ );
331
+ return { ...screen, urlPrefillFields: declared === undefined ? [] : [...declared].sort() };
332
+ }
333
+ return screen;
334
+ });
317
335
  }
318
336
 
319
337
  function projectProjectionListScreen(
@@ -390,6 +408,9 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
390
408
  derivedFields: derivedOut as unknown as Record<string, DerivedFieldDef>,
391
409
  }),
392
410
  ...(typeof entity.table === "string" && { table: entity.table }),
411
+ ...(typeof entity.defaultCurrency === "string" && {
412
+ defaultCurrency: entity.defaultCurrency,
413
+ }),
393
414
  };
394
415
  }
395
416
 
@@ -457,6 +478,25 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
457
478
  if (typeof def["locale"] === "string") out["locale"] = def["locale"];
458
479
  // image: which camera a mobile capture opens (fw#2497).
459
480
  if (typeof def["capture"] === "string") out["capture"] = def["capture"];
481
+ // number: display-only suffix, static or read from a sibling field of the row.
482
+ const unit = def["unit"];
483
+ if (typeof unit === "string" || (isPlainObject(unit) && typeof unit["field"] === "string"))
484
+ out["unit"] = unit;
485
+ // text: "password" masks the input and blocks URL prefill in the renderer.
486
+ if (typeof def["format"] === "string") out["format"] = def["format"];
487
+ // Write-response redaction stays server-side; the renderer needs the flag to
488
+ // refuse any prefill (URL or handoff) into the field.
489
+ if (def["sensitive"] === true) out["sensitive"] = true;
490
+ // timestamp: the edit view-model derives its wall-clock input mode from this.
491
+ if (typeof def["locatedBy"] === "string") out["locatedBy"] = def["locatedBy"];
492
+ // file/image/images: upload picker constraints; the first variant key picks
493
+ // the preview variant.
494
+ if (Array.isArray(def["accept"]) && isJsonSafeValue(def["accept"])) out["accept"] = def["accept"];
495
+ if (typeof def["maxSize"] === "string") out["maxSize"] = def["maxSize"];
496
+ if (isPlainObject(def["variants"]) && isJsonSafeValue(def["variants"]))
497
+ out["variants"] = def["variants"];
498
+ // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
499
+ if (typeof def["scale"] === "number") out["scale"] = def["scale"];
460
500
  // embedded lists: row-count bounds, computed cells, totals row, and the
461
501
  // sibling-money-field totals check (fw#2497).
462
502
  if (typeof def["minItems"] === "number") out["minItems"] = def["minItems"];