@morit/cli 1.2.0 → 1.4.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/assets/docs/ai-response-and-timeline.md +82 -21
- package/assets/docs/components.md +24 -5
- package/assets/docs/examples-school-life.md +4 -4
- package/assets/docs/getting-started.md +1 -1
- package/assets/docs/manifest.md +18 -1
- package/assets/docs/packaging-and-testing.md +14 -4
- package/assets/docs/remote-mcp.md +1 -1
- package/assets/docs/sdk-and-mcp.md +9 -3
- package/assets/docs/tool-and-skill.md +17 -1
- package/assets/docs/troubleshooting.md +4 -0
- package/assets/docs/ui-extensions.md +7 -2
- package/assets/docs/ui-runtime-v2.md +76 -10
- package/assets/docs/verification.md +6 -1
- package/assets/plugin_contract.json +45 -8
- package/package.json +1 -1
- package/src/preview.js +254 -17
- package/src/workspace.js +226 -11
package/src/workspace.js
CHANGED
|
@@ -40,7 +40,7 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
|
40
40
|
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
41
41
|
const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
42
42
|
const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
43
|
-
const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
43
|
+
const UI_BINDING = /^(?:state|data|item|context)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
44
44
|
const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
45
45
|
const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
|
|
46
46
|
const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
|
|
@@ -152,7 +152,7 @@ export class LocalWorkspace {
|
|
|
152
152
|
version: "1.0.0",
|
|
153
153
|
cloud_project_id: null,
|
|
154
154
|
required_secrets: [],
|
|
155
|
-
min_morit_version: "1.7.
|
|
155
|
+
min_morit_version: "1.7.12",
|
|
156
156
|
max_morit_version: "1.999.999",
|
|
157
157
|
permissions: [],
|
|
158
158
|
capabilities: [],
|
|
@@ -813,6 +813,12 @@ function validateManifest(manifest, files) {
|
|
|
813
813
|
throw new Error(`unsupported runtime adapter: ${adapter || "missing"}`);
|
|
814
814
|
}
|
|
815
815
|
validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies);
|
|
816
|
+
for (const [field, schema] of [["input_schema", capability.input_schema], ["output_schema", capability.output_schema]]) {
|
|
817
|
+
if (schema === undefined) continue;
|
|
818
|
+
assertObject(schema, `capability ${capability.id} ${field}`);
|
|
819
|
+
if (Buffer.byteLength(JSON.stringify(schema), "utf8") > 16 * 1024) throw new Error(`capability ${capability.id} ${field} is too large`);
|
|
820
|
+
validateResponseSchema(schema, 0, true);
|
|
821
|
+
}
|
|
816
822
|
if (adapter === "sandbox_python") {
|
|
817
823
|
const entrypoint = normalizeEntrypointPath(runtime.entrypoint);
|
|
818
824
|
if (!entrypoint.startsWith("src/") || !entrypoint.endsWith(".py")) throw new Error("sandbox_python entrypoint must be src/*.py");
|
|
@@ -1242,10 +1248,16 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1242
1248
|
function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
|
|
1243
1249
|
const config = extension.config === undefined ? {} : extension.config;
|
|
1244
1250
|
const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
|
|
1251
|
+
const capabilitySchemas = new Map(capabilities.map((value) => [value.id, {
|
|
1252
|
+
input_schema: value.input_schema,
|
|
1253
|
+
output_schema: value.output_schema,
|
|
1254
|
+
}]));
|
|
1245
1255
|
if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
|
|
1256
|
+
if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
|
|
1246
1257
|
if (config.ui_schema === 2) {
|
|
1247
1258
|
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1248
|
-
validateUiRuntimeV2(config, executable, routeIds);
|
|
1259
|
+
validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
|
|
1260
|
+
validateResponseComponent(config, extension.point === "response");
|
|
1249
1261
|
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1250
1262
|
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
1251
1263
|
}
|
|
@@ -1279,6 +1291,72 @@ function validateUiConfig(extension, capabilities, routeIds, files, hasStorage =
|
|
|
1279
1291
|
if (config.form !== undefined) validateLegacyUiForm(config.form, executable, extension.id);
|
|
1280
1292
|
}
|
|
1281
1293
|
|
|
1294
|
+
function validateResponseComponent(config, responsePoint) {
|
|
1295
|
+
if (!responsePoint) {
|
|
1296
|
+
if (Object.hasOwn(config, "a2ui")) throw new Error("A2UI metadata is only valid for response UI");
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const value = config.a2ui;
|
|
1300
|
+
assertObject(value, "response A2UI metadata");
|
|
1301
|
+
rejectUnknownFields(value, new Set(["version", "description", "schema"]), "response A2UI metadata");
|
|
1302
|
+
if (Object.keys(value).length !== 3 || value.version !== "v0.9") throw new Error("response UI requires complete A2UI v0.9 metadata");
|
|
1303
|
+
requireText(value.description, 500, "response A2UI description");
|
|
1304
|
+
assertObject(value.schema, "response A2UI schema");
|
|
1305
|
+
if (Buffer.byteLength(JSON.stringify(value.schema), "utf8") > 16 * 1024) throw new Error("response A2UI schema is too large");
|
|
1306
|
+
validateResponseSchema(value.schema, 0, true);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function validateResponseSchema(schema, depth, root = false) {
|
|
1310
|
+
const fields = new Set([
|
|
1311
|
+
"type", "properties", "required", "additionalProperties", "items", "enum",
|
|
1312
|
+
"minItems", "maxItems", "minLength", "maxLength", "minimum", "maximum",
|
|
1313
|
+
]);
|
|
1314
|
+
if (depth > 6) throw new Error("response A2UI schema is too deep");
|
|
1315
|
+
rejectUnknownFields(schema, fields, "response A2UI schema");
|
|
1316
|
+
const types = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
|
|
1317
|
+
if (!types.has(schema.type) || (root && schema.type !== "object")) throw new Error("response A2UI schema type is invalid");
|
|
1318
|
+
if (schema.enum !== undefined) {
|
|
1319
|
+
if (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 32 || schema.enum.some((item) => item !== null && typeof item === "object")) throw new Error("response A2UI schema enum is invalid");
|
|
1320
|
+
for (const item of schema.enum) validateUiJson(item, 0);
|
|
1321
|
+
}
|
|
1322
|
+
const byType = {
|
|
1323
|
+
object: new Set(["properties", "required", "additionalProperties"]),
|
|
1324
|
+
array: new Set(["items", "minItems", "maxItems"]),
|
|
1325
|
+
string: new Set(["minLength", "maxLength"]),
|
|
1326
|
+
number: new Set(["minimum", "maximum"]),
|
|
1327
|
+
integer: new Set(["minimum", "maximum"]),
|
|
1328
|
+
boolean: new Set(),
|
|
1329
|
+
null: new Set(),
|
|
1330
|
+
}[schema.type];
|
|
1331
|
+
if (Object.keys(schema).some((key) => !["type", "enum"].includes(key) && !byType.has(key))) throw new Error("response A2UI schema keyword does not match its type");
|
|
1332
|
+
if (schema.type === "object") {
|
|
1333
|
+
const properties = schema.properties === undefined ? {} : schema.properties;
|
|
1334
|
+
const required = schema.required === undefined ? [] : schema.required;
|
|
1335
|
+
const additional = schema.additionalProperties === undefined ? true : schema.additionalProperties;
|
|
1336
|
+
assertObject(properties, "response A2UI properties");
|
|
1337
|
+
if (Object.keys(properties).length > 64 || !Array.isArray(required) || required.length > 64 || new Set(required).size !== required.length || typeof additional !== "boolean") throw new Error("response A2UI object schema is invalid");
|
|
1338
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
1339
|
+
if (!UI_STATE_KEY.test(key)) throw new Error("response A2UI property is invalid");
|
|
1340
|
+
assertObject(child, "response A2UI property schema");
|
|
1341
|
+
validateResponseSchema(child, depth + 1);
|
|
1342
|
+
}
|
|
1343
|
+
if (required.some((key) => typeof key !== "string" || !Object.hasOwn(properties, key))) throw new Error("response A2UI required property is invalid");
|
|
1344
|
+
} else if (schema.type === "array") {
|
|
1345
|
+
assertObject(schema.items, "response A2UI item schema");
|
|
1346
|
+
const minimum = schema.minItems === undefined ? 0 : schema.minItems;
|
|
1347
|
+
const maximum = schema.maxItems === undefined ? 64 : schema.maxItems;
|
|
1348
|
+
if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 64) throw new Error("response A2UI array schema is invalid");
|
|
1349
|
+
validateResponseSchema(schema.items, depth + 1);
|
|
1350
|
+
} else if (schema.type === "string") {
|
|
1351
|
+
const minimum = schema.minLength === undefined ? 0 : schema.minLength;
|
|
1352
|
+
const maximum = schema.maxLength === undefined ? 2000 : schema.maxLength;
|
|
1353
|
+
if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 2000) throw new Error("response A2UI string schema is invalid");
|
|
1354
|
+
} else if (["number", "integer"].includes(schema.type)) {
|
|
1355
|
+
for (const bound of [schema.minimum, schema.maximum]) if (bound !== undefined && (typeof bound !== "number" || !Number.isFinite(bound))) throw new Error("response A2UI number bound is invalid");
|
|
1356
|
+
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum) throw new Error("response A2UI number range is invalid");
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1282
1360
|
function validateLegacyUiForm(form, executable, extensionId) {
|
|
1283
1361
|
rejectUnknownFields(form, new Set(["submit_capability", "submit_label", "fields"]), `UI ${extensionId} form`);
|
|
1284
1362
|
if (!executable.has(form.submit_capability)) throw new Error(`UI ${extensionId} form targets an unknown capability`);
|
|
@@ -1308,7 +1386,7 @@ function validateLegacyUiForm(form, executable, extensionId) {
|
|
|
1308
1386
|
}
|
|
1309
1387
|
}
|
|
1310
1388
|
|
|
1311
|
-
function validateUiRuntimeV2(config, executable, routes) {
|
|
1389
|
+
function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new Map()) {
|
|
1312
1390
|
rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
|
|
1313
1391
|
if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
|
|
1314
1392
|
validateUiIcon(config.icon);
|
|
@@ -1361,6 +1439,7 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1361
1439
|
validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
|
|
1362
1440
|
assertObject(config.view, "UI Runtime v2 view");
|
|
1363
1441
|
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
|
|
1442
|
+
validateUiCapabilityBindings(config, sources, capabilitySchemas);
|
|
1364
1443
|
}
|
|
1365
1444
|
|
|
1366
1445
|
function validateUiTheme(theme, variant = false) {
|
|
@@ -1532,7 +1611,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1532
1611
|
const props = node.props === undefined ? {} : node.props;
|
|
1533
1612
|
validateUiProps(node.type, props, sources, stateKeys);
|
|
1534
1613
|
validateUiCondition(node.visible_when, stateKeys, sources);
|
|
1535
|
-
if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
|
|
1614
|
+
if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
|
|
1536
1615
|
throw new Error("UI action requires an interactive component");
|
|
1537
1616
|
}
|
|
1538
1617
|
validateUiAction(node.action, executable, routes, sources, stateKeys);
|
|
@@ -1565,7 +1644,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1565
1644
|
"border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
|
|
1566
1645
|
]);
|
|
1567
1646
|
if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
|
|
1568
|
-
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
|
|
1647
|
+
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label", "error_text", "submit_label"]) {
|
|
1569
1648
|
if (props[key] !== undefined) {
|
|
1570
1649
|
optionalUiTemplate(props[key], 1200, true);
|
|
1571
1650
|
validateTemplateReferences(props[key], stateKeys, sources);
|
|
@@ -1576,6 +1655,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1576
1655
|
}
|
|
1577
1656
|
if (props.icon !== undefined) validateUiIcon(props.icon, true);
|
|
1578
1657
|
if (props.max_lines !== undefined && (!Number.isInteger(props.max_lines) || props.max_lines < 1 || props.max_lines > 100)) throw new Error("UI numeric property max_lines is invalid");
|
|
1658
|
+
if (props.input_type !== undefined && !["text", "number", "email", "url"].includes(props.input_type)) throw new Error("UI input type is invalid");
|
|
1659
|
+
for (const key of ["min_length", "max_length"]) if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 2000)) throw new Error(`UI ${key} is invalid`);
|
|
1660
|
+
if ((props.min_length ?? 0) > (props.max_length ?? 2000)) throw new Error("UI text length constraints are invalid");
|
|
1661
|
+
for (const key of ["minimum", "maximum"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, -1000000000, 1000000000);
|
|
1662
|
+
if ((props.minimum ?? -1000000000) > (props.maximum ?? 1000000000)) throw new Error("UI numeric constraints are invalid");
|
|
1579
1663
|
for (const key of ["columns", "size", "limit"]) {
|
|
1580
1664
|
if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
|
|
1581
1665
|
}
|
|
@@ -1600,7 +1684,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1600
1684
|
for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
|
|
1601
1685
|
if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
|
|
1602
1686
|
if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
|
|
1603
|
-
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1687
|
+
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled", "required", "searchable"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1604
1688
|
if (props.fit !== undefined && !["contain", "cover", "fill", "fit_width", "fit_height", "none", "scale_down"].includes(props.fit)) throw new Error("UI image fit is invalid");
|
|
1605
1689
|
if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
|
|
1606
1690
|
if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
|
|
@@ -1620,6 +1704,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1620
1704
|
if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1621
1705
|
if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
|
|
1622
1706
|
if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
|
|
1707
|
+
if (props.suggestions !== undefined || props.suggestions_source !== undefined) {
|
|
1708
|
+
if (nodeType !== "field" || (props.suggestions !== undefined && props.suggestions_source !== undefined)) throw new Error("UI autocomplete options are invalid");
|
|
1709
|
+
if (props.suggestions !== undefined && (!Array.isArray(props.suggestions) || props.suggestions.length < 1 || props.suggestions.length > 32 || props.suggestions.some((value) => typeof value !== "string" || !value.trim() || value.length > 120))) throw new Error("UI autocomplete options are invalid");
|
|
1710
|
+
if (props.suggestions_source !== undefined) validateUiDataBinding(props.suggestions_source, sources, "autocomplete");
|
|
1711
|
+
}
|
|
1623
1712
|
if (props.options != null) {
|
|
1624
1713
|
if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
|
|
1625
1714
|
for (const option of props.options) {
|
|
@@ -1628,6 +1717,18 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1628
1717
|
optionalUiText(option.label, 80, { required: true });
|
|
1629
1718
|
}
|
|
1630
1719
|
}
|
|
1720
|
+
if (props.options !== undefined && props.options_source !== undefined) throw new Error("UI select cannot declare two options sources");
|
|
1721
|
+
if (props.options_source !== undefined) {
|
|
1722
|
+
if (nodeType !== "select") throw new Error("UI options source requires a select");
|
|
1723
|
+
validateUiDataBinding(props.options_source, sources, "select options");
|
|
1724
|
+
}
|
|
1725
|
+
if (props.searchable !== undefined && nodeType !== "select") throw new Error("UI searchable requires a select");
|
|
1726
|
+
for (const key of ["required", "min_length", "max_length", "minimum", "maximum", "error_text"]) if (props[key] !== undefined && !["field", "select", "switch"].includes(nodeType)) throw new Error("UI validation properties require an input");
|
|
1727
|
+
if (props.submit_label !== undefined && nodeType !== "form") throw new Error("UI submit label requires a form");
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function validateUiDataBinding(value, sources, label) {
|
|
1731
|
+
if (typeof value !== "string" || !UI_BINDING.test(value) || !value.startsWith("data.") || !sources.has(value.split(".")[1])) throw new Error(`UI ${label} source is invalid`);
|
|
1631
1732
|
}
|
|
1632
1733
|
|
|
1633
1734
|
function validateUiImageSource(props, sources) {
|
|
@@ -1669,8 +1770,21 @@ function validateUiInsets(value, label) {
|
|
|
1669
1770
|
for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
|
|
1670
1771
|
}
|
|
1671
1772
|
|
|
1672
|
-
function validateUiCondition(condition, stateKeys, sources) {
|
|
1773
|
+
function validateUiCondition(condition, stateKeys, sources, depth = 0, counter = { value: 0 }) {
|
|
1673
1774
|
if (condition == null) return;
|
|
1775
|
+
assertObject(condition, "UI visibility condition");
|
|
1776
|
+
counter.value += 1;
|
|
1777
|
+
if (depth > 4 || counter.value > 32) throw new Error("UI visibility condition is too complex");
|
|
1778
|
+
const composites = ["all", "any", "not"].filter((key) => Object.hasOwn(condition, key));
|
|
1779
|
+
if (composites.length) {
|
|
1780
|
+
if (composites.length !== 1 || Object.keys(condition).length !== 1) throw new Error("UI visibility condition is invalid");
|
|
1781
|
+
const key = composites[0];
|
|
1782
|
+
if (key === "not") return validateUiCondition(condition.not, stateKeys, sources, depth + 1, counter);
|
|
1783
|
+
const children = condition[key];
|
|
1784
|
+
if (!Array.isArray(children) || children.length < 1 || children.length > 8) throw new Error("UI visibility condition group is invalid");
|
|
1785
|
+
for (const child of children) validateUiCondition(child, stateKeys, sources, depth + 1, counter);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1674
1788
|
rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
|
|
1675
1789
|
if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
|
|
1676
1790
|
validateKnownBinding(condition.path, stateKeys, sources);
|
|
@@ -1679,13 +1793,23 @@ function validateUiCondition(condition, stateKeys, sources) {
|
|
|
1679
1793
|
if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
|
|
1680
1794
|
}
|
|
1681
1795
|
|
|
1682
|
-
function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
|
|
1796
|
+
function validateUiAction(action, executable, routes, sources, stateKeys, required = false, depth = 0, counter = { value: 0 }) {
|
|
1683
1797
|
if (action == null) {
|
|
1684
1798
|
if (required) throw new Error("UI action is required");
|
|
1685
1799
|
return;
|
|
1686
1800
|
}
|
|
1801
|
+
assertObject(action, "UI action");
|
|
1802
|
+
counter.value += 1;
|
|
1803
|
+
if (depth > 4 || counter.value > 16) throw new Error("UI action flow is too complex");
|
|
1687
1804
|
rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
|
|
1688
|
-
if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1805
|
+
if (!["invoke", "navigate", "set_state", "refresh", "back", "flow"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1806
|
+
if (action.validate !== undefined && typeof action.validate !== "boolean") throw new Error("UI action validation flag is invalid");
|
|
1807
|
+
if (action.type === "flow") {
|
|
1808
|
+
rejectUnknownFields(action, new Set(["type", "mode", "actions", "continue_on_error", "validate"]), "UI action flow");
|
|
1809
|
+
if (!["sequential", "parallel"].includes(action.mode) || !Array.isArray(action.actions) || action.actions.length < 1 || action.actions.length > 8 || (action.continue_on_error !== undefined && typeof action.continue_on_error !== "boolean")) throw new Error("UI action flow is invalid");
|
|
1810
|
+
for (const child of action.actions) validateUiAction(child, executable, routes, sources, stateKeys, true, depth + 1, counter);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1689
1813
|
if (action.type === "invoke") {
|
|
1690
1814
|
if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
|
|
1691
1815
|
optionalUiTemplate(action.query, 2000);
|
|
@@ -1700,6 +1824,11 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1700
1824
|
if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
|
|
1701
1825
|
const replace = action.replace === undefined ? false : action.replace;
|
|
1702
1826
|
if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
|
|
1827
|
+
const parameters = action.parameters === undefined ? {} : action.parameters;
|
|
1828
|
+
assertObject(parameters, "UI navigation parameters");
|
|
1829
|
+
validateUiBindingValue(parameters, 0, stateKeys, sources);
|
|
1830
|
+
if (action.result_state !== undefined && !stateKeys.has(action.result_state)) throw new Error("UI navigation result state is unknown");
|
|
1831
|
+
if (action.result_state !== undefined && replace) throw new Error("replacement navigation cannot return a result");
|
|
1703
1832
|
} else if (action.type === "set_state") {
|
|
1704
1833
|
assertObject(action.values, "UI state action values");
|
|
1705
1834
|
if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
|
|
@@ -1708,8 +1837,21 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1708
1837
|
if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
|
|
1709
1838
|
} else if (action.type === "refresh") {
|
|
1710
1839
|
if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
|
|
1711
|
-
} else
|
|
1840
|
+
} else {
|
|
1841
|
+
rejectUnknownFields(action, new Set(["type", "result"]), "back UI action");
|
|
1842
|
+
const result = action.result === undefined ? {} : action.result;
|
|
1843
|
+
assertObject(result, "UI route result");
|
|
1844
|
+
validateUiBindingValue(result, 0, stateKeys, sources);
|
|
1845
|
+
}
|
|
1712
1846
|
if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
|
|
1847
|
+
const allowed = {
|
|
1848
|
+
invoke: new Set(["type", "capability", "query", "arguments", "store", "validate"]),
|
|
1849
|
+
navigate: new Set(["type", "target", "transition", "replace", "parameters", "result_state", "validate"]),
|
|
1850
|
+
set_state: new Set(["type", "values", "persist"]),
|
|
1851
|
+
refresh: new Set(["type", "source"]),
|
|
1852
|
+
back: new Set(["type", "result"]),
|
|
1853
|
+
}[action.type];
|
|
1854
|
+
rejectUnknownFields(action, allowed, "UI action");
|
|
1713
1855
|
}
|
|
1714
1856
|
|
|
1715
1857
|
function validateUiBindingValue(value, depth, stateKeys, sources) {
|
|
@@ -1750,6 +1892,73 @@ function validateUiJson(value, depth) {
|
|
|
1750
1892
|
for (const item of Object.values(value)) validateUiJson(item, depth + 1);
|
|
1751
1893
|
}
|
|
1752
1894
|
|
|
1895
|
+
function validateUiCapabilityBindings(config, sources, capabilitySchemas) {
|
|
1896
|
+
const sourceCapabilities = new Map(sources.map((source) => [source.id, source.capability]));
|
|
1897
|
+
for (const source of sources) {
|
|
1898
|
+
const schema = capabilitySchemas.get(source.capability)?.input_schema;
|
|
1899
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, source.arguments === undefined ? {} : source.arguments)) {
|
|
1900
|
+
throw new Error(`UI data source ${source.id} does not match capability input_schema`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function visit(value) {
|
|
1904
|
+
if (typeof value === "string") {
|
|
1905
|
+
for (const match of value.matchAll(UI_TEMPLATE_BINDING)) {
|
|
1906
|
+
const parts = match[1].trim().split(".");
|
|
1907
|
+
if (parts.length < 4 || parts[0] !== "data" || parts[2] !== "data") continue;
|
|
1908
|
+
const schema = capabilitySchemas.get(sourceCapabilities.get(parts[1]))?.output_schema;
|
|
1909
|
+
if (schema !== undefined && !uiSchemaHasPath(schema, parts.slice(3))) throw new Error("UI binding references an unknown capability output");
|
|
1910
|
+
}
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (Array.isArray(value)) return value.forEach(visit);
|
|
1914
|
+
if (!value || typeof value !== "object") return;
|
|
1915
|
+
if (value.type === "invoke") {
|
|
1916
|
+
const schema = capabilitySchemas.get(value.capability)?.input_schema;
|
|
1917
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, value.arguments === undefined ? {} : value.arguments)) throw new Error("UI action does not match capability input_schema");
|
|
1918
|
+
}
|
|
1919
|
+
Object.values(value).forEach(visit);
|
|
1920
|
+
}
|
|
1921
|
+
visit(config);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function uiSchemaAccepts(schema, value) {
|
|
1925
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
|
|
1926
|
+
if (typeof value === "string") {
|
|
1927
|
+
const exact = UI_SINGLE_TEMPLATE_BINDING.exec(value);
|
|
1928
|
+
if (exact) return true;
|
|
1929
|
+
if ([...value.matchAll(UI_TEMPLATE_BINDING)].length) return schema.type === "string";
|
|
1930
|
+
}
|
|
1931
|
+
if (schema.enum !== undefined && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) return false;
|
|
1932
|
+
if (schema.type === "null") return value === null;
|
|
1933
|
+
if (schema.type === "boolean") return typeof value === "boolean";
|
|
1934
|
+
if (schema.type === "integer") return Number.isInteger(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1935
|
+
if (schema.type === "number") return typeof value === "number" && Number.isFinite(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1936
|
+
if (schema.type === "string") return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? 2000);
|
|
1937
|
+
if (schema.type === "array") return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? 64) && value.every((item) => uiSchemaAccepts(schema.items, item));
|
|
1938
|
+
if (schema.type !== "object" || !value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1939
|
+
const properties = schema.properties || {};
|
|
1940
|
+
if ((schema.required || []).some((key) => !Object.hasOwn(value, key))) return false;
|
|
1941
|
+
if (schema.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(properties, key))) return false;
|
|
1942
|
+
return Object.entries(value).every(([key, item]) => !Object.hasOwn(properties, key) || uiSchemaAccepts(properties[key], item));
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function uiSchemaHasPath(schema, parts) {
|
|
1946
|
+
let current = schema;
|
|
1947
|
+
for (const part of parts) {
|
|
1948
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return false;
|
|
1949
|
+
if (current.type === "array") {
|
|
1950
|
+
current = current.items;
|
|
1951
|
+
if (part === "length") return true;
|
|
1952
|
+
if (/^\d+$/.test(part)) continue;
|
|
1953
|
+
}
|
|
1954
|
+
if (!current || current.type !== "object") return false;
|
|
1955
|
+
const properties = current.properties || {};
|
|
1956
|
+
if (!Object.hasOwn(properties, part)) return current.additionalProperties !== false;
|
|
1957
|
+
current = properties[part];
|
|
1958
|
+
}
|
|
1959
|
+
return true;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1753
1962
|
function optionalUiTemplate(value, maximum, required = false) {
|
|
1754
1963
|
if (value === undefined || value === null) {
|
|
1755
1964
|
if (required) throw new Error("UI text template is required");
|
|
@@ -1768,6 +1977,12 @@ function validateKnownBinding(path, stateKeys, sources) {
|
|
|
1768
1977
|
const parts = path.split(".");
|
|
1769
1978
|
if (parts[0] === "state" && (parts.length < 2 || !stateKeys.has(parts[1]))) throw new Error("UI state binding is unknown");
|
|
1770
1979
|
if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI data binding is unknown");
|
|
1980
|
+
if (parts[0] === "context") {
|
|
1981
|
+
const roots = new Set(["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"]);
|
|
1982
|
+
if (parts.length < 2 || !roots.has(parts[1])) throw new Error("UI runtime context binding is unknown");
|
|
1983
|
+
if (parts[1] === "screen" && (parts.length !== 3 || !["width", "height", "size_class"].includes(parts[2]))) throw new Error("UI screen context binding is unknown");
|
|
1984
|
+
if (["loading", "errors"].includes(parts[1]) && (parts.length !== 3 || !sources.has(parts[2]))) throw new Error("UI data-source context binding is unknown");
|
|
1985
|
+
}
|
|
1771
1986
|
}
|
|
1772
1987
|
|
|
1773
1988
|
function optionalUiText(value, maximum, { required = false, identifier = false } = {}) {
|