@cosmicdrift/kumiko-framework 0.261.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 +3 -3
- package/src/changes.json +26 -0
- package/src/engine/__tests__/build-app-schema-url-prefill.test.ts +153 -0
- package/src/engine/__tests__/build-app-schema.test.ts +90 -1
- package/src/engine/boot-validator/__tests__/security-baseline.test.ts +107 -0
- package/src/engine/boot-validator/index.ts +3 -0
- package/src/engine/boot-validator/screens.ts +21 -15
- package/src/engine/boot-validator/security-baseline.ts +26 -0
- package/src/engine/boot-validator/url-prefill-fields.ts +159 -0
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/build-app-schema.ts +44 -4
- package/src/engine/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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,30 @@
|
|
|
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
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.262.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "Boot validator warns when NODE_ENV=production boots without the security baseline (fw#2857).",
|
|
25
|
+
"detail": "`SECURITY_BASELINE_FEATURE_NAMES` (`@cosmicdrift/kumiko-framework/engine`) names the four security-relevant feature names the boot validator expects in a production app: `sessions`, `crypto-shredding`, `rate-limiting`, `audit`. `validateBoot` now calls `warnOnMissingSecurityBaseline(features)` (`engine/boot-validator/security-baseline.ts`) after every other check; when `process.env.NODE_ENV === \"production\"` and one or more of those feature names is absent from the mounted feature list (matched by exact `FeatureDefinition.name`, not by require-chain inference), it logs one `console.warn` naming the missing feature(s) and pointing at `securityBaselineFeatures()`. It never throws and is a no-op for any other `NODE_ENV` value (including `undefined`). Mount `securityBaselineFeatures()` from `@cosmicdrift/kumiko-bundled-features/presets` to silence it.",
|
|
26
|
+
"migration": "No action required for existing apps outside production. Apps that boot with `NODE_ENV=production` and don't already mount `sessions`/`crypto-shredding`/`rate-limiting`/`audit` will see a new boot-time `console.warn` — mount the missing feature(s) individually or via `securityBaselineFeatures()` to remove it."
|
|
27
|
+
},
|
|
2
28
|
{
|
|
3
29
|
"version": "0.261.0",
|
|
4
30
|
"type": "breaking",
|
|
@@ -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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../../define-feature";
|
|
3
|
+
import type { FeatureDefinition } from "../../types";
|
|
4
|
+
import { validateBoot } from "../index";
|
|
5
|
+
import {
|
|
6
|
+
SECURITY_BASELINE_FEATURE_NAMES,
|
|
7
|
+
warnOnMissingSecurityBaseline,
|
|
8
|
+
} from "../security-baseline";
|
|
9
|
+
|
|
10
|
+
function stubFeature(name: string): FeatureDefinition {
|
|
11
|
+
return defineFeature(name, () => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("warnOnMissingSecurityBaseline", () => {
|
|
15
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
warnSpy.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("production + no baseline features → warns once, naming all four", () => {
|
|
26
|
+
warnOnMissingSecurityBaseline([], "production");
|
|
27
|
+
|
|
28
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
29
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
30
|
+
for (const name of SECURITY_BASELINE_FEATURE_NAMES) {
|
|
31
|
+
expect(msg).toContain(name);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("production + only sessions/audit mounted → warns about the missing two only", () => {
|
|
36
|
+
const features = [stubFeature("sessions"), stubFeature("audit")];
|
|
37
|
+
|
|
38
|
+
warnOnMissingSecurityBaseline(features, "production");
|
|
39
|
+
|
|
40
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
41
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
42
|
+
expect(msg).toContain("crypto-shredding");
|
|
43
|
+
expect(msg).toContain("rate-limiting");
|
|
44
|
+
expect(msg).not.toContain("missing feature(s): sessions");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("production + all four baseline features mounted → no warning", () => {
|
|
48
|
+
const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
|
|
49
|
+
|
|
50
|
+
warnOnMissingSecurityBaseline(features, "production");
|
|
51
|
+
|
|
52
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test.each(["development", "test", undefined])(
|
|
56
|
+
"nodeEnv=%p + no baseline features → no warning",
|
|
57
|
+
(nodeEnv) => {
|
|
58
|
+
warnOnMissingSecurityBaseline([], nodeEnv);
|
|
59
|
+
|
|
60
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
test("never throws, regardless of nodeEnv or mounted features", () => {
|
|
65
|
+
expect(() => warnOnMissingSecurityBaseline([], "production")).not.toThrow();
|
|
66
|
+
expect(() => warnOnMissingSecurityBaseline([], "development")).not.toThrow();
|
|
67
|
+
expect(() =>
|
|
68
|
+
warnOnMissingSecurityBaseline(
|
|
69
|
+
SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name)),
|
|
70
|
+
"production",
|
|
71
|
+
),
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("validateBoot + security baseline warning", () => {
|
|
77
|
+
const originalNodeEnv = process.env["NODE_ENV"];
|
|
78
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
warnSpy.mockRestore();
|
|
86
|
+
if (originalNodeEnv === undefined) {
|
|
87
|
+
delete process.env["NODE_ENV"];
|
|
88
|
+
} else {
|
|
89
|
+
process.env["NODE_ENV"] = originalNodeEnv;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("production boot with all four baseline stubs mounted does not throw and does not warn", () => {
|
|
94
|
+
process.env["NODE_ENV"] = "production";
|
|
95
|
+
const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
|
|
96
|
+
|
|
97
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
98
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("production boot with no baseline features mounted warns but does not throw", () => {
|
|
102
|
+
process.env["NODE_ENV"] = "production";
|
|
103
|
+
|
|
104
|
+
expect(() => validateBoot([])).not.toThrow();
|
|
105
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
validateScreenShortIdCollisions,
|
|
62
62
|
validateScreens,
|
|
63
63
|
} from "./screens";
|
|
64
|
+
import { warnOnMissingSecurityBaseline } from "./security-baseline";
|
|
64
65
|
import {
|
|
65
66
|
collectWorkspaceQns,
|
|
66
67
|
validateDefaultWorkspaceUniqueness,
|
|
@@ -72,6 +73,7 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
|
72
73
|
// an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
|
|
73
74
|
// dieselbe Extraktionslogik.
|
|
74
75
|
export { collectWriteHandlerQns } from "./nav";
|
|
76
|
+
export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline";
|
|
75
77
|
|
|
76
78
|
export type ValidateBootOptions = {
|
|
77
79
|
/** Warn when an access role is used by exactly one handler/config-key/
|
|
@@ -254,6 +256,7 @@ export function validateBoot(
|
|
|
254
256
|
|
|
255
257
|
validateConfigReads(features, allConfigKeys);
|
|
256
258
|
warnOnToggleableDependencies(features, featureMap);
|
|
259
|
+
warnOnMissingSecurityBaseline(features);
|
|
257
260
|
if (options?.warnOnUniqueAccessRoles === true) {
|
|
258
261
|
warnOnUniqueAccessRoles(features);
|
|
259
262
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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,26 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
export const SECURITY_BASELINE_FEATURE_NAMES = [
|
|
4
|
+
"sessions",
|
|
5
|
+
"crypto-shredding",
|
|
6
|
+
"rate-limiting",
|
|
7
|
+
"audit",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export function warnOnMissingSecurityBaseline(
|
|
11
|
+
features: readonly FeatureDefinition[],
|
|
12
|
+
nodeEnv: string | undefined = process.env["NODE_ENV"],
|
|
13
|
+
): void {
|
|
14
|
+
// skip: baseline is only enforced for production boots
|
|
15
|
+
if (nodeEnv !== "production") return;
|
|
16
|
+
|
|
17
|
+
const mountedNames = new Set(features.map((f) => f.name));
|
|
18
|
+
const missing = SECURITY_BASELINE_FEATURE_NAMES.filter((name) => !mountedNames.has(name));
|
|
19
|
+
// skip: every baseline feature is mounted
|
|
20
|
+
if (missing.length === 0) return;
|
|
21
|
+
|
|
22
|
+
// biome-ignore lint/suspicious/noConsole: boot-time hint, no logger available yet
|
|
23
|
+
console.warn(
|
|
24
|
+
`[kumiko:boot] NODE_ENV=production but the security baseline is incomplete — missing feature(s): ${missing.join(", ")}. Mount securityBaselineFeatures() from @cosmicdrift/kumiko-bundled-features/presets (or the missing features individually).`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -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.
|
|
315
|
-
screen.type === "projectionList"
|
|
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"];
|