@morit/cli 1.0.0 → 1.1.1
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/README.md +53 -18
- package/assets/docs/README.md +105 -0
- package/assets/docs/ai-response-and-timeline.md +125 -0
- package/assets/docs/ai-skill-and-docx-workflow.md +83 -0
- package/assets/docs/app-builder.md +56 -0
- package/assets/docs/authentication.md +140 -0
- package/assets/docs/components.md +159 -0
- package/assets/docs/design-tokens-responsive.md +148 -0
- package/assets/docs/docs-index.json +93 -0
- package/assets/docs/examples-notion.md +83 -0
- package/assets/docs/examples-school-life.md +74 -0
- package/assets/docs/getting-started.md +132 -0
- package/assets/docs/information-hierarchy.md +81 -0
- package/assets/docs/instances-and-connectors.md +93 -0
- package/assets/docs/lifecycle-and-api.md +158 -0
- package/assets/docs/local-cli.md +124 -0
- package/assets/docs/manifest.md +208 -0
- package/assets/docs/packaging-and-testing.md +115 -0
- package/assets/docs/permissions-and-data.md +131 -0
- package/assets/docs/platform-compatibility.md +62 -0
- package/assets/docs/project-structure.md +102 -0
- package/assets/docs/remote-mcp.md +152 -0
- package/assets/docs/school-life-privacy.md +49 -0
- package/assets/docs/screens-layout-navigation.md +95 -0
- package/assets/docs/sdk-and-mcp.md +182 -0
- package/assets/docs/tool-and-skill.md +163 -0
- package/assets/docs/troubleshooting.md +117 -0
- package/assets/docs/ui-extensions.md +70 -0
- package/assets/docs/ui-runtime-v2.md +273 -0
- package/assets/docs/verification.md +128 -0
- package/assets/plugin_contract.json +222 -1
- package/package.json +1 -1
- package/src/cli.js +27 -3
- package/src/secure-store.js +113 -43
- package/src/workspace.js +297 -86
package/src/workspace.js
CHANGED
|
@@ -41,6 +41,18 @@ const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
|
41
41
|
const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
42
42
|
const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
43
43
|
const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
44
|
+
const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
|
|
45
|
+
const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
|
|
46
|
+
const UI_HEX_COLOR = /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
47
|
+
const UI_COLOR_TOKENS = new Set([
|
|
48
|
+
"primary", "on_primary", "primary_container", "on_primary_container",
|
|
49
|
+
"secondary", "on_secondary", "secondary_container", "on_secondary_container",
|
|
50
|
+
"tertiary", "on_tertiary", "tertiary_container", "on_tertiary_container",
|
|
51
|
+
"error", "on_error", "error_container", "on_error_container", "surface",
|
|
52
|
+
"on_surface", "surface_variant", "on_surface_variant", "outline", "outline_variant",
|
|
53
|
+
"inverse_surface", "inverse_on_surface", "inverse_primary", "shadow", "scrim", "transparent",
|
|
54
|
+
]);
|
|
55
|
+
const UI_ICON_TOKENS = new Set(contract.ui_runtime.icon_tokens);
|
|
44
56
|
const MANIFEST_FIELDS = new Set(contract.manifest_fields);
|
|
45
57
|
const CAPABILITY_KINDS = new Set(contract.capability_kinds);
|
|
46
58
|
const CAPABILITY_FIELDS = new Set(contract.object_fields.capability);
|
|
@@ -135,7 +147,7 @@ export class LocalWorkspace {
|
|
|
135
147
|
version: "1.0.0",
|
|
136
148
|
cloud_project_id: null,
|
|
137
149
|
required_secrets: [],
|
|
138
|
-
min_morit_version: "1.7.
|
|
150
|
+
min_morit_version: "1.7.6",
|
|
139
151
|
max_morit_version: "1.999.999",
|
|
140
152
|
permissions: [],
|
|
141
153
|
capabilities: [],
|
|
@@ -739,11 +751,11 @@ function validateManifest(manifest, files) {
|
|
|
739
751
|
if (manifest.category !== undefined && !/^[a-z][a-z0-9_-]{1,39}$/.test(String(manifest.category))) {
|
|
740
752
|
throw new Error("manifest.category is invalid");
|
|
741
753
|
}
|
|
742
|
-
const keywords = manifest.keywords
|
|
754
|
+
const keywords = manifest.keywords === undefined ? [] : manifest.keywords;
|
|
743
755
|
if (!Array.isArray(keywords) || keywords.length > 12 || new Set(keywords).size !== keywords.length || keywords.some(
|
|
744
756
|
(value) => typeof value !== "string" || !value.trim() || value.length > 40
|
|
745
757
|
)) throw new Error("manifest.keywords must contain up to 12 unique short strings");
|
|
746
|
-
const developer = manifest.developer
|
|
758
|
+
const developer = manifest.developer === undefined ? {} : manifest.developer;
|
|
747
759
|
assertObject(developer, "manifest.developer");
|
|
748
760
|
rejectUnknownFields(developer, DEVELOPER_FIELDS, "manifest.developer");
|
|
749
761
|
if (developer.name !== undefined && (typeof developer.name !== "string" || developer.name.length > 120)) {
|
|
@@ -762,15 +774,15 @@ function validateManifest(manifest, files) {
|
|
|
762
774
|
for (const field of ["version", "min_morit_version", "max_morit_version"]) {
|
|
763
775
|
if (!VERSION.test(String(manifest[field] || ""))) throw new Error(`manifest.${field} must be a semantic version`);
|
|
764
776
|
}
|
|
765
|
-
const requestedPermissions = validatePermissions(manifest.permissions
|
|
777
|
+
const requestedPermissions = validatePermissions(manifest.permissions, "manifest.permissions");
|
|
766
778
|
if (!["purge", "retain"].includes(manifest.data_policy)) throw new Error("manifest.data_policy must be purge or retain");
|
|
767
779
|
const capabilities = objectCollection(manifest.capabilities, "manifest.capabilities", 64);
|
|
768
780
|
const uiExtensions = objectCollection(manifest.ui_extensions, "manifest.ui_extensions", 64);
|
|
769
|
-
const credentials = objectCollection(manifest.credentials
|
|
770
|
-
const slashCommands = objectCollection(manifest.slash_commands
|
|
771
|
-
const connectors = objectCollection(manifest.connectors
|
|
772
|
-
const dependencies = objectCollection(manifest.dependencies
|
|
773
|
-
const requiredSecrets = objectCollection(manifest.required_secrets
|
|
781
|
+
const credentials = objectCollection(manifest.credentials === undefined ? [] : manifest.credentials, "manifest.credentials", 16);
|
|
782
|
+
const slashCommands = objectCollection(manifest.slash_commands === undefined ? [] : manifest.slash_commands, "manifest.slash_commands", 32);
|
|
783
|
+
const connectors = objectCollection(manifest.connectors === undefined ? [] : manifest.connectors, "manifest.connectors", 32);
|
|
784
|
+
const dependencies = objectCollection(manifest.dependencies === undefined ? [] : manifest.dependencies, "manifest.dependencies", 16);
|
|
785
|
+
const requiredSecrets = objectCollection(manifest.required_secrets === undefined ? [] : manifest.required_secrets, "manifest.required_secrets", 32);
|
|
774
786
|
if (!capabilities.length && !uiExtensions.length) throw new Error("plugin does not declare any extension");
|
|
775
787
|
|
|
776
788
|
const capabilityIds = new Set();
|
|
@@ -782,13 +794,13 @@ function validateManifest(manifest, files) {
|
|
|
782
794
|
if (!CAPABILITY_KINDS.has(capability.kind)) throw new Error(`unknown capability kind: ${capability.kind}`);
|
|
783
795
|
requireText(capability.title, 80, `capability ${capability.id} title`);
|
|
784
796
|
requireText(capability.description, 500, `capability ${capability.id} description`);
|
|
785
|
-
const permissions = validatePermissions(capability.permissions
|
|
797
|
+
const permissions = validatePermissions(capability.permissions, `capability ${capability.id} permissions`);
|
|
786
798
|
requireSubset(permissions, requestedPermissions, `capability ${capability.id} permissions`);
|
|
787
|
-
const timeout = capability.timeout_seconds
|
|
799
|
+
const timeout = capability.timeout_seconds === undefined ? 10 : capability.timeout_seconds;
|
|
788
800
|
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
|
|
789
801
|
throw new Error(`capability ${capability.id} timeout must be between 0.1 and 30 seconds`);
|
|
790
802
|
}
|
|
791
|
-
const runtime = capability.runtime
|
|
803
|
+
const runtime = capability.runtime === undefined ? {} : capability.runtime;
|
|
792
804
|
assertObject(runtime, `capability ${capability.id} runtime`);
|
|
793
805
|
assertJsonSize(runtime, 32 * 1024, `capability ${capability.id} runtime`);
|
|
794
806
|
const adapter = runtime.adapter;
|
|
@@ -810,14 +822,17 @@ function validateManifest(manifest, files) {
|
|
|
810
822
|
requireUniqueIdentifier(extension.id, uiIds, "UI extension");
|
|
811
823
|
if (!UI_POINTS.has(extension.point)) throw new Error(`unknown UI extension point: ${extension.point}`);
|
|
812
824
|
requireText(extension.title, 80, `UI ${extension.id} title`);
|
|
813
|
-
const order = extension.order
|
|
825
|
+
const order = extension.order === undefined ? 0 : extension.order;
|
|
814
826
|
if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error(`UI ${extension.id} order is invalid`);
|
|
815
|
-
const permissions = validatePermissions(extension.permissions
|
|
827
|
+
const permissions = validatePermissions(extension.permissions, `UI ${extension.id} permissions`);
|
|
816
828
|
requireSubset(permissions, requestedPermissions, `UI ${extension.id} permissions`);
|
|
817
|
-
|
|
818
|
-
|
|
829
|
+
const extensionConfig = extension.config === undefined ? {} : extension.config;
|
|
830
|
+
assertObject(extensionConfig, `UI ${extension.id} config`);
|
|
831
|
+
// Twelve legal component levels add an object and a children array per
|
|
832
|
+
// level before semantic UI validation runs.
|
|
833
|
+
assertJsonSize(extensionConfig, 32 * 1024, `UI ${extension.id} config`, 40);
|
|
819
834
|
}
|
|
820
|
-
for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds);
|
|
835
|
+
for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files);
|
|
821
836
|
|
|
822
837
|
const credentialIds = validateCredentials(credentials, requestedPermissions);
|
|
823
838
|
const secretIds = validateRequiredSecrets(requiredSecrets);
|
|
@@ -896,15 +911,15 @@ function requireText(value, maximum, label) {
|
|
|
896
911
|
}
|
|
897
912
|
}
|
|
898
913
|
|
|
899
|
-
function assertJsonSize(value, maximum, label) {
|
|
900
|
-
validateJsonValue(value, 0, label);
|
|
914
|
+
function assertJsonSize(value, maximum, label, maximumDepth = 12) {
|
|
915
|
+
validateJsonValue(value, 0, label, maximumDepth);
|
|
901
916
|
if (Buffer.byteLength(JSON.stringify(value), "utf8") > maximum) {
|
|
902
917
|
throw new Error(`${label} is too large`);
|
|
903
918
|
}
|
|
904
919
|
}
|
|
905
920
|
|
|
906
|
-
function validateJsonValue(value, depth, label) {
|
|
907
|
-
if (depth >
|
|
921
|
+
function validateJsonValue(value, depth, label, maximumDepth) {
|
|
922
|
+
if (depth > maximumDepth) throw new Error(`${label} is too deeply nested`);
|
|
908
923
|
if (value === null || typeof value === "boolean" || typeof value === "string") return;
|
|
909
924
|
if (typeof value === "number") {
|
|
910
925
|
if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
|
|
@@ -912,14 +927,14 @@ function validateJsonValue(value, depth, label) {
|
|
|
912
927
|
}
|
|
913
928
|
if (Array.isArray(value)) {
|
|
914
929
|
if (value.length > 256) throw new Error(`${label} contains an oversized array`);
|
|
915
|
-
for (const item of value) validateJsonValue(item, depth + 1, label);
|
|
930
|
+
for (const item of value) validateJsonValue(item, depth + 1, label, maximumDepth);
|
|
916
931
|
return;
|
|
917
932
|
}
|
|
918
933
|
if (value && typeof value === "object") {
|
|
919
934
|
if (Object.keys(value).length > 256) throw new Error(`${label} contains an oversized object`);
|
|
920
935
|
for (const [key, item] of Object.entries(value)) {
|
|
921
936
|
if (key.length > 256) throw new Error(`${label} contains an oversized key`);
|
|
922
|
-
validateJsonValue(item, depth + 1, label);
|
|
937
|
+
validateJsonValue(item, depth + 1, label, maximumDepth);
|
|
923
938
|
}
|
|
924
939
|
return;
|
|
925
940
|
}
|
|
@@ -938,7 +953,7 @@ function validateCredentials(credentials, requestedPermissions) {
|
|
|
938
953
|
requireUniqueIdentifier(credential.id, ids, "credential");
|
|
939
954
|
requireText(credential.label, 80, `credential ${credential.id} label`);
|
|
940
955
|
requireText(credential.description, 500, `credential ${credential.id} description`);
|
|
941
|
-
const kind = credential.kind
|
|
956
|
+
const kind = credential.kind === undefined ? "api_token" : credential.kind;
|
|
942
957
|
if (!CREDENTIAL_KINDS.has(kind)) throw new Error(`credential ${credential.id} kind is invalid`);
|
|
943
958
|
for (const field of ["required", "allow_multiple"]) {
|
|
944
959
|
if (credential[field] !== undefined && typeof credential[field] !== "boolean") {
|
|
@@ -1009,20 +1024,20 @@ function validateConnectors(connectors, credentialIds, secretIds) {
|
|
|
1009
1024
|
if (endpoint !== undefined && endpoint !== null && (
|
|
1010
1025
|
typeof endpoint !== "string" || !endpoint.startsWith("https://") || endpoint.length > 2048 || /[\r\n@]/.test(endpoint)
|
|
1011
1026
|
)) throw new Error(`connector ${connector.id} endpoint is invalid`);
|
|
1012
|
-
const timeout = connector.timeout_seconds
|
|
1027
|
+
const timeout = connector.timeout_seconds === undefined ? 10 : connector.timeout_seconds;
|
|
1013
1028
|
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
|
|
1014
1029
|
throw new Error(`connector ${connector.id} timeout is invalid`);
|
|
1015
1030
|
}
|
|
1016
|
-
const retry = connector.retry
|
|
1031
|
+
const retry = connector.retry === undefined ? {} : connector.retry;
|
|
1017
1032
|
assertObject(retry, `connector ${connector.id} retry`);
|
|
1018
1033
|
rejectUnknownFields(retry, new Set(["max_attempts"]), `connector ${connector.id} retry`);
|
|
1019
|
-
const attempts = retry.max_attempts
|
|
1034
|
+
const attempts = retry.max_attempts === undefined ? 2 : retry.max_attempts;
|
|
1020
1035
|
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 4) throw new Error(`connector ${connector.id} retry count is invalid`);
|
|
1021
|
-
const rate = connector.rate_limit
|
|
1036
|
+
const rate = connector.rate_limit === undefined ? {} : connector.rate_limit;
|
|
1022
1037
|
assertObject(rate, `connector ${connector.id} rate_limit`);
|
|
1023
1038
|
rejectUnknownFields(rate, new Set(["requests", "period_seconds"]), `connector ${connector.id} rate_limit`);
|
|
1024
|
-
const requests = rate.requests
|
|
1025
|
-
const period = rate.period_seconds
|
|
1039
|
+
const requests = rate.requests === undefined ? 60 : rate.requests;
|
|
1040
|
+
const period = rate.period_seconds === undefined ? 60 : rate.period_seconds;
|
|
1026
1041
|
if (!Number.isInteger(requests) || requests < 1 || requests > 10000 || !Number.isInteger(period) || period < 1 || period > 3600) {
|
|
1027
1042
|
throw new Error(`connector ${connector.id} rate limit is invalid`);
|
|
1028
1043
|
}
|
|
@@ -1053,15 +1068,16 @@ function validateDependencies(dependencies, pluginId) {
|
|
|
1053
1068
|
}
|
|
1054
1069
|
if (dependency.required !== undefined && typeof dependency.required !== "boolean") throw new Error(`dependency ${dependency.id} required must be boolean`);
|
|
1055
1070
|
for (const [field, fallback] of [["min_version", "0.0.0"], ["max_version", "999.999.999"]]) {
|
|
1056
|
-
|
|
1071
|
+
const version = dependency[field] === undefined ? fallback : dependency[field];
|
|
1072
|
+
if (typeof version !== "string" || !VERSION.test(version)) throw new Error(`dependency ${dependency.id} ${field} is invalid`);
|
|
1057
1073
|
}
|
|
1058
1074
|
if (dependency.package_path !== undefined && dependency.package_path !== null) {
|
|
1059
1075
|
const path = String(dependency.package_path).replaceAll("\\", "/");
|
|
1060
1076
|
if (!/^children\/[A-Za-z0-9._-]+\.mplg$/.test(path) || path.length > 240) throw new Error(`dependency ${dependency.id} package_path is invalid`);
|
|
1061
1077
|
dependency.package_path = path;
|
|
1062
1078
|
}
|
|
1063
|
-
const exposed = dependency.exposed_capabilities
|
|
1064
|
-
if (!Array.isArray(exposed) || exposed.length > 32 || new Set(exposed).size !== exposed.length || exposed.some((value) => !IDENTIFIER.test(
|
|
1079
|
+
const exposed = dependency.exposed_capabilities === undefined ? [] : dependency.exposed_capabilities;
|
|
1080
|
+
if (!Array.isArray(exposed) || exposed.length > 32 || new Set(exposed).size !== exposed.length || exposed.some((value) => typeof value !== "string" || !IDENTIFIER.test(value))) {
|
|
1065
1081
|
throw new Error(`dependency ${dependency.id} exposed_capabilities are invalid`);
|
|
1066
1082
|
}
|
|
1067
1083
|
}
|
|
@@ -1081,8 +1097,9 @@ function validateSlashCommands(commands, capabilities) {
|
|
|
1081
1097
|
requireText(command.title, 80, `slash command ${command.id} title`);
|
|
1082
1098
|
requireText(command.description, 500, `slash command ${command.id} description`);
|
|
1083
1099
|
if (!executable.has(command.capability)) throw new Error(`slash command ${command.id} targets a non-executable capability`);
|
|
1084
|
-
|
|
1085
|
-
|
|
1100
|
+
const argumentTemplate = command.argument_template === undefined ? {} : command.argument_template;
|
|
1101
|
+
assertObject(argumentTemplate, `slash command ${command.id} argument_template`);
|
|
1102
|
+
assertJsonSize(argumentTemplate, 16 * 1024, `slash command ${command.id} argument_template`);
|
|
1086
1103
|
}
|
|
1087
1104
|
}
|
|
1088
1105
|
|
|
@@ -1107,7 +1124,8 @@ function validateRuntime(capability, runtime, permissions, credentials, connecto
|
|
|
1107
1124
|
}
|
|
1108
1125
|
if (adapter === "child_plugin") {
|
|
1109
1126
|
const dependency = dependencyById.get(runtime.dependency_id);
|
|
1110
|
-
|
|
1127
|
+
const exposed = dependency?.exposed_capabilities === undefined ? [] : dependency.exposed_capabilities;
|
|
1128
|
+
if (!dependency || !Array.isArray(exposed) || !exposed.includes(runtime.capability)) throw new Error("child_plugin requires an exposed dependency capability");
|
|
1111
1129
|
}
|
|
1112
1130
|
if (adapter === "calendar_store") {
|
|
1113
1131
|
if (!permissions.has("storage")) throw new Error("calendar_store requires storage permission");
|
|
@@ -1130,7 +1148,7 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1130
1148
|
const connectorById = new Map(connectors.map((value) => [value.id, value]));
|
|
1131
1149
|
const dependencyIds = new Set(dependencies.map((value) => value.id));
|
|
1132
1150
|
for (const capability of capabilities) {
|
|
1133
|
-
const runtime = capability.runtime
|
|
1151
|
+
const runtime = capability.runtime === undefined ? {} : capability.runtime;
|
|
1134
1152
|
if (runtime.connector_id) {
|
|
1135
1153
|
if (!connectorIds.has(runtime.connector_id)) throw new Error(`capability ${capability.id} references an unknown connector`);
|
|
1136
1154
|
if (!["http_json", "mcp_http"].includes(runtime.adapter)) throw new Error(`capability ${capability.id} connector is unsupported by its runtime`);
|
|
@@ -1142,34 +1160,38 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1142
1160
|
}
|
|
1143
1161
|
}
|
|
1144
1162
|
|
|
1145
|
-
function validateUiConfig(extension, capabilities, routeIds) {
|
|
1146
|
-
const config = extension.config
|
|
1163
|
+
function validateUiConfig(extension, capabilities, routeIds, files) {
|
|
1164
|
+
const config = extension.config === undefined ? {} : extension.config;
|
|
1147
1165
|
const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
|
|
1148
1166
|
if (config.ui_schema === 2) {
|
|
1149
|
-
if (config.placement
|
|
1167
|
+
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1150
1168
|
validateUiRuntimeV2(config, executable, routeIds);
|
|
1169
|
+
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1170
|
+
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
1171
|
+
}
|
|
1151
1172
|
return;
|
|
1152
1173
|
}
|
|
1153
1174
|
const allowed = new Set(["icon", "description", "component", "sections", "actions", "capability", "label", "form"]);
|
|
1154
1175
|
rejectUnknownFields(config, allowed, `UI ${extension.id} config`);
|
|
1155
|
-
if (config.icon !== undefined && !IDENTIFIER.test(
|
|
1176
|
+
if (config.icon !== undefined && (typeof config.icon !== "string" || !IDENTIFIER.test(config.icon))) throw new Error(`UI ${extension.id} icon is invalid`);
|
|
1156
1177
|
if (config.description !== undefined) requireText(config.description, 240, `UI ${extension.id} description`);
|
|
1157
1178
|
if (config.component !== undefined && !["action", "card", "settings_action"].includes(config.component)) throw new Error(`UI ${extension.id} component is invalid`);
|
|
1158
|
-
const sections = config.sections
|
|
1179
|
+
const sections = config.sections === undefined ? [] : config.sections;
|
|
1159
1180
|
if (!Array.isArray(sections) || sections.length > 12) throw new Error(`UI ${extension.id} sections are invalid`);
|
|
1160
1181
|
for (const section of sections) {
|
|
1161
1182
|
rejectUnknownFields(section, new Set(["heading", "body"]), `UI ${extension.id} section`);
|
|
1162
1183
|
if (section.heading !== undefined) requireText(section.heading, 80, `UI ${extension.id} section heading`);
|
|
1163
1184
|
requireText(section.body, 1200, `UI ${extension.id} section body`);
|
|
1164
1185
|
}
|
|
1165
|
-
const actions = config.actions
|
|
1186
|
+
const actions = config.actions === undefined ? [] : config.actions;
|
|
1166
1187
|
if (!Array.isArray(actions) || actions.length > 6) throw new Error(`UI ${extension.id} actions are invalid`);
|
|
1167
1188
|
const actionIds = new Set();
|
|
1168
1189
|
for (const action of actions) {
|
|
1169
1190
|
rejectUnknownFields(action, new Set(["id", "label", "capability", "style"]), `UI ${extension.id} action`);
|
|
1170
1191
|
requireUniqueIdentifier(action.id, actionIds, `UI ${extension.id} action`);
|
|
1171
1192
|
requireText(action.label, 60, `UI ${extension.id} action label`);
|
|
1172
|
-
|
|
1193
|
+
const style = action.style === undefined ? "secondary" : action.style;
|
|
1194
|
+
if (!executable.has(action.capability) || !["primary", "secondary"].includes(style)) throw new Error(`UI ${extension.id} action is invalid`);
|
|
1173
1195
|
}
|
|
1174
1196
|
if ((config.capability === undefined) !== (config.label === undefined)) throw new Error(`UI ${extension.id} shorthand action is incomplete`);
|
|
1175
1197
|
if (config.capability !== undefined && (actions.length || !executable.has(config.capability))) throw new Error(`UI ${extension.id} shorthand action is invalid`);
|
|
@@ -1207,22 +1229,24 @@ function validateLegacyUiForm(form, executable, extensionId) {
|
|
|
1207
1229
|
}
|
|
1208
1230
|
|
|
1209
1231
|
function validateUiRuntimeV2(config, executable, routes) {
|
|
1210
|
-
rejectUnknownFields(config, new Set(
|
|
1232
|
+
rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
|
|
1211
1233
|
if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
|
|
1212
|
-
|
|
1234
|
+
validateUiIcon(config.icon);
|
|
1213
1235
|
optionalUiText(config.description, 240);
|
|
1214
|
-
if (config.placement
|
|
1236
|
+
if (config.placement != null) {
|
|
1215
1237
|
const placement = config.placement;
|
|
1216
1238
|
rejectUnknownFields(placement, new Set(["section_id", "section_title", "section_order", "layout", "show_header"]), "UI placement");
|
|
1217
1239
|
optionalUiText(placement.section_id, 128, { required: true, identifier: true });
|
|
1218
1240
|
optionalUiText(placement.section_title, 80, { required: true });
|
|
1219
|
-
const order = placement.section_order
|
|
1241
|
+
const order = placement.section_order === undefined ? 0 : placement.section_order;
|
|
1220
1242
|
if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error("UI placement order is invalid");
|
|
1221
|
-
|
|
1222
|
-
if (
|
|
1243
|
+
const layout = placement.layout === undefined ? "stack" : placement.layout;
|
|
1244
|
+
if (!["stack", "horizontal", "grid"].includes(layout)) throw new Error("UI placement layout is invalid");
|
|
1245
|
+
const showHeader = placement.show_header === undefined ? true : placement.show_header;
|
|
1246
|
+
if (typeof showHeader !== "boolean") throw new Error("UI placement show_header must be boolean");
|
|
1223
1247
|
}
|
|
1224
1248
|
|
|
1225
|
-
const state = config.initial_state
|
|
1249
|
+
const state = config.initial_state === undefined ? {} : config.initial_state;
|
|
1226
1250
|
assertObject(state, "UI initial_state");
|
|
1227
1251
|
if (Object.keys(state).length > 32) throw new Error("UI initial_state is too large");
|
|
1228
1252
|
for (const [key, value] of Object.entries(state)) {
|
|
@@ -1231,12 +1255,13 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1231
1255
|
}
|
|
1232
1256
|
const stateKeys = new Set(Object.keys(state));
|
|
1233
1257
|
|
|
1234
|
-
const sources = config.data_sources
|
|
1258
|
+
const sources = config.data_sources === undefined ? [] : config.data_sources;
|
|
1235
1259
|
if (!Array.isArray(sources) || sources.length > 8) throw new Error("UI data_sources are invalid");
|
|
1236
1260
|
const sourceIds = new Set();
|
|
1237
1261
|
for (const source of sources) {
|
|
1238
1262
|
rejectUnknownFields(source, new Set(["id", "capability", "trigger", "query", "arguments", "refresh_seconds"]), "UI data source");
|
|
1239
|
-
|
|
1263
|
+
const trigger = source.trigger === undefined ? "load" : source.trigger;
|
|
1264
|
+
if (typeof source.id !== "string" || !UI_IDENTIFIER.test(source.id) || sourceIds.has(source.id) || !executable.has(source.capability) || !["load", "manual"].includes(trigger)) {
|
|
1240
1265
|
throw new Error("UI data source reference is invalid");
|
|
1241
1266
|
}
|
|
1242
1267
|
sourceIds.add(source.id);
|
|
@@ -1244,68 +1269,205 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1244
1269
|
for (const source of sources) {
|
|
1245
1270
|
optionalUiTemplate(source.query, 2000);
|
|
1246
1271
|
validateTemplateReferences(source.query, stateKeys, sourceIds);
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1272
|
+
const sourceArguments = source.arguments === undefined ? {} : source.arguments;
|
|
1273
|
+
assertObject(sourceArguments, "UI data source arguments");
|
|
1274
|
+
validateUiBindingValue(sourceArguments, 0, stateKeys, sourceIds);
|
|
1275
|
+
if (source.refresh_seconds != null && (!Number.isInteger(source.refresh_seconds) || source.refresh_seconds < 30 || source.refresh_seconds > 86400)) {
|
|
1250
1276
|
throw new Error("UI refresh must be between 30 and 86400 seconds");
|
|
1251
1277
|
}
|
|
1252
1278
|
}
|
|
1279
|
+
validateUiTheme(config.theme);
|
|
1280
|
+
validateUiAppBar(config.app_bar, executable, routes, sourceIds, stateKeys);
|
|
1281
|
+
validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
|
|
1253
1282
|
assertObject(config.view, "UI Runtime v2 view");
|
|
1254
|
-
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys);
|
|
1283
|
+
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function validateUiTheme(theme) {
|
|
1287
|
+
if (theme == null) return;
|
|
1288
|
+
rejectUnknownFields(theme, new Set(contract.ui_runtime.theme_fields), "UI theme");
|
|
1289
|
+
const colors = theme.color_scheme === undefined ? {} : theme.color_scheme;
|
|
1290
|
+
assertObject(colors, "UI color scheme");
|
|
1291
|
+
const roles = new Set([...UI_COLOR_TOKENS].filter((value) => value !== "transparent"));
|
|
1292
|
+
rejectUnknownFields(colors, roles, "UI color scheme");
|
|
1293
|
+
for (const value of Object.values(colors)) {
|
|
1294
|
+
if (typeof value !== "string" || !UI_HEX_COLOR.test(value)) throw new Error("UI theme color is invalid");
|
|
1295
|
+
}
|
|
1296
|
+
if (theme.radius !== undefined) validateUiNumber(theme.radius, "UI theme radius", 0, 64);
|
|
1297
|
+
if (theme.spacing !== undefined) validateUiNumber(theme.spacing, "UI theme spacing", 0, 32);
|
|
1298
|
+
const density = theme.density === undefined ? "standard" : theme.density;
|
|
1299
|
+
if (!["compact", "standard", "comfortable"].includes(density)) throw new Error("UI theme density is invalid");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function validateUiAppBar(appBar, executable, routes, sources, stateKeys) {
|
|
1303
|
+
if (appBar == null) return;
|
|
1304
|
+
rejectUnknownFields(appBar, new Set(contract.ui_runtime.app_bar_fields), "UI app bar");
|
|
1305
|
+
optionalUiTemplate(appBar.title, 120, true);
|
|
1306
|
+
validateTemplateReferences(appBar.title, stateKeys, sources);
|
|
1307
|
+
optionalUiTemplate(appBar.subtitle, 160);
|
|
1308
|
+
validateTemplateReferences(appBar.subtitle, stateKeys, sources);
|
|
1309
|
+
for (const key of ["center_title", "pinned"]) if (appBar[key] !== undefined && typeof appBar[key] !== "boolean") throw new Error("UI app bar flag is invalid");
|
|
1310
|
+
const ids = new Set();
|
|
1311
|
+
if (appBar.leading != null) ids.add(validateUiMenuItem(appBar.leading, executable, routes, sources, stateKeys, false));
|
|
1312
|
+
const actions = appBar.actions === undefined ? [] : appBar.actions;
|
|
1313
|
+
if (!Array.isArray(actions) || actions.length > 6) throw new Error("UI app bar actions are invalid");
|
|
1314
|
+
for (const action of actions) {
|
|
1315
|
+
const id = validateUiMenuItem(action, executable, routes, sources, stateKeys, false);
|
|
1316
|
+
if (ids.has(id)) throw new Error("UI app bar action is duplicated");
|
|
1317
|
+
ids.add(id);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function validateUiNavigation(navigation, executable, routes, sources, stateKeys) {
|
|
1322
|
+
if (navigation == null) return;
|
|
1323
|
+
rejectUnknownFields(navigation, new Set(contract.ui_runtime.navigation_fields), "UI navigation");
|
|
1324
|
+
const navigationType = navigation.type === undefined ? "adaptive" : navigation.type;
|
|
1325
|
+
if (!["tabs", "bar", "rail", "drawer", "adaptive"].includes(navigationType)) throw new Error("UI navigation type is invalid");
|
|
1326
|
+
if (!Array.isArray(navigation.items) || navigation.items.length < 2 || navigation.items.length > 8) throw new Error("UI navigation items are invalid");
|
|
1327
|
+
const selectedKey = navigation.selected_state_key;
|
|
1328
|
+
if (selectedKey != null && (typeof selectedKey !== "string" || !stateKeys.has(selectedKey))) throw new Error("UI navigation state is unknown");
|
|
1329
|
+
const persist = navigation.persist === undefined ? false : navigation.persist;
|
|
1330
|
+
if (typeof persist !== "boolean") throw new Error("UI navigation persistence is invalid");
|
|
1331
|
+
const labelBehavior = navigation.label_behavior === undefined ? "auto" : navigation.label_behavior;
|
|
1332
|
+
if (!["auto", "always", "selected", "never"].includes(labelBehavior)) throw new Error("UI navigation label behavior is invalid");
|
|
1333
|
+
if (navigation.rail_breakpoint !== undefined && (!Number.isInteger(navigation.rail_breakpoint) || navigation.rail_breakpoint < 480 || navigation.rail_breakpoint > 1600)) throw new Error("UI navigation breakpoint is invalid");
|
|
1334
|
+
const ids = new Set();
|
|
1335
|
+
for (const item of navigation.items) {
|
|
1336
|
+
const id = validateUiMenuItem(item, executable, routes, sources, stateKeys, true);
|
|
1337
|
+
if (ids.has(id)) throw new Error("UI navigation item is duplicated");
|
|
1338
|
+
ids.add(id);
|
|
1339
|
+
if (selectedKey != null && !("value" in item)) throw new Error("stateful UI navigation item requires a value");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function validateUiMenuItem(item, executable, routes, sources, stateKeys, navigation) {
|
|
1344
|
+
rejectUnknownFields(item, new Set(contract.ui_runtime.menu_item_fields), "UI menu item");
|
|
1345
|
+
optionalUiText(item.id, 128, { required: true, identifier: true });
|
|
1346
|
+
optionalUiTemplate(item.label, 80, true);
|
|
1347
|
+
validateTemplateReferences(item.label, stateKeys, sources);
|
|
1348
|
+
for (const key of ["icon", "selected_icon"]) validateUiIcon(item[key]);
|
|
1349
|
+
if (!navigation && item.selected_icon !== undefined) throw new Error("UI app bar action cannot have a selected icon");
|
|
1350
|
+
const showAs = item.show_as === undefined ? "auto" : item.show_as;
|
|
1351
|
+
if (!["auto", "always", "overflow"].includes(showAs)) throw new Error("UI menu presentation is invalid");
|
|
1352
|
+
if (navigation && item.show_as !== undefined) throw new Error("UI navigation item cannot set show_as");
|
|
1353
|
+
if ("value" in item) {
|
|
1354
|
+
if (!navigation) throw new Error("UI app bar action cannot have a value");
|
|
1355
|
+
validateUiJson(item.value, 0);
|
|
1356
|
+
}
|
|
1357
|
+
validateUiAction(item.action, executable, routes, sources, stateKeys, true);
|
|
1358
|
+
return item.id;
|
|
1255
1359
|
}
|
|
1256
1360
|
|
|
1257
|
-
function
|
|
1258
|
-
const
|
|
1361
|
+
function uiRuntimeAssetPaths(view) {
|
|
1362
|
+
const result = new Set();
|
|
1363
|
+
function visit(node) {
|
|
1364
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
1365
|
+
if (typeof node.props?.asset === "string") result.add(node.props.asset);
|
|
1366
|
+
if (Array.isArray(node.children)) for (const child of node.children) visit(child);
|
|
1367
|
+
}
|
|
1368
|
+
visit(view);
|
|
1369
|
+
return result;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType) {
|
|
1373
|
+
const nodeKeys = new Set(contract.ui_runtime.node_fields);
|
|
1259
1374
|
rejectUnknownFields(node, nodeKeys, "UI component");
|
|
1260
1375
|
counter.value += 1;
|
|
1261
1376
|
if (depth > 12 || counter.value > 160) throw new Error("UI component tree is too large");
|
|
1262
1377
|
if (!new Set(contract.ui_nodes).has(node.type)) throw new Error(`unknown UI component type: ${node.type}`);
|
|
1263
|
-
if (node.id
|
|
1264
|
-
const props = node.props
|
|
1378
|
+
if (node.id != null && (typeof node.id !== "string" || !UI_IDENTIFIER.test(node.id))) throw new Error("UI component id is invalid");
|
|
1379
|
+
const props = node.props === undefined ? {} : node.props;
|
|
1265
1380
|
validateUiProps(node.type, props, sources, stateKeys);
|
|
1266
1381
|
validateUiCondition(node.visible_when, stateKeys, sources);
|
|
1382
|
+
if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
|
|
1383
|
+
throw new Error("UI action requires an interactive component");
|
|
1384
|
+
}
|
|
1267
1385
|
validateUiAction(node.action, executable, routes, sources, stateKeys);
|
|
1268
|
-
const children = node.children
|
|
1386
|
+
const children = node.children === undefined ? [] : node.children;
|
|
1269
1387
|
if (!Array.isArray(children) || children.length > 32) throw new Error("UI component children are invalid");
|
|
1270
|
-
const leaves = new Set(["text", "icon", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
|
|
1388
|
+
const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
|
|
1271
1389
|
if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
|
|
1272
1390
|
if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
|
|
1391
|
+
if (["positioned", "scroll", "padding", "center", "expanded", "badge"].includes(node.type) && children.length !== 1) throw new Error("UI component requires exactly one child");
|
|
1392
|
+
if (node.type === "surface" && !children.length) throw new Error("surface UI component requires composed children");
|
|
1393
|
+
if (node.type === "stack" && !children.some((child) => child?.type !== "positioned")) {
|
|
1394
|
+
const boundedWidth = props.width !== undefined || props.max_width !== undefined;
|
|
1395
|
+
const boundedHeight = props.height !== undefined || props.max_height !== undefined;
|
|
1396
|
+
if (!boundedWidth || !boundedHeight) throw new Error("positioned-only stack requires bounded width and height");
|
|
1397
|
+
}
|
|
1398
|
+
if (node.type === "positioned" && parentType !== "stack") throw new Error("positioned UI component requires a stack parent");
|
|
1399
|
+
if (node.type === "expanded" && !["row", "column"].includes(parentType)) throw new Error("expanded UI component requires a row or column parent");
|
|
1273
1400
|
for (const child of children) {
|
|
1274
1401
|
assertObject(child, "UI component child");
|
|
1275
|
-
validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys);
|
|
1402
|
+
validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type);
|
|
1276
1403
|
}
|
|
1277
1404
|
}
|
|
1278
1405
|
|
|
1279
1406
|
function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
1280
|
-
const allowed = new Set(
|
|
1281
|
-
"text", "title", "subtitle", "label", "supporting", "icon", "style", "tone", "align",
|
|
1282
|
-
"max_lines", "spacing", "padding", "columns", "stack_at", "min_item_width", "size", "value",
|
|
1283
|
-
"source", "empty_text", "limit", "state_key", "placeholder", "input_type", "options", "persist",
|
|
1284
|
-
"selected", "dense", "chart_type", "x_key", "y_key", "date_key", "title_key", "show_legend", "full_width",
|
|
1285
|
-
]);
|
|
1407
|
+
const allowed = new Set(contract.ui_runtime.prop_fields);
|
|
1286
1408
|
rejectUnknownFields(props, allowed, "UI component props");
|
|
1287
|
-
|
|
1409
|
+
const surfaceProps = new Set([
|
|
1410
|
+
"spacing", "padding", "margin", "width", "height", "min_width", "max_width", "min_height", "max_height",
|
|
1411
|
+
"alignment", "color", "background_color", "foreground_color", "border_color", "border_width",
|
|
1412
|
+
"border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
|
|
1413
|
+
]);
|
|
1414
|
+
if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
|
|
1415
|
+
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
|
|
1288
1416
|
if (props[key] !== undefined) {
|
|
1289
1417
|
optionalUiTemplate(props[key], 1200, true);
|
|
1290
1418
|
validateTemplateReferences(props[key], stateKeys, sources);
|
|
1291
1419
|
}
|
|
1292
1420
|
}
|
|
1293
|
-
for (const key of ["
|
|
1421
|
+
for (const key of ["style", "tone", "align", "input_type", "chart_type", "x_key", "y_key", "date_key", "title_key"]) {
|
|
1294
1422
|
if (props[key] !== undefined) optionalUiText(props[key], 80, { required: true, identifier: true });
|
|
1295
1423
|
}
|
|
1296
|
-
|
|
1424
|
+
if (props.icon !== undefined) validateUiIcon(props.icon, true);
|
|
1425
|
+
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");
|
|
1426
|
+
for (const key of ["columns", "size", "limit"]) {
|
|
1297
1427
|
if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
|
|
1298
1428
|
}
|
|
1429
|
+
if (props.spacing !== undefined) validateUiNumber(props.spacing, "UI spacing", 0, 128);
|
|
1430
|
+
for (const key of ["padding", "margin"]) if (props[key] !== undefined) validateUiInsets(props[key], `UI ${key}`);
|
|
1431
|
+
for (const key of ["width", "height", "min_width", "max_width", "min_height", "max_height"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, 0, 4096);
|
|
1432
|
+
for (const [minimum, maximum] of [["min_width", "max_width"], ["min_height", "max_height"]]) {
|
|
1433
|
+
if (props[minimum] !== undefined && props[maximum] !== undefined && props[minimum] > props[maximum]) throw new Error("UI size constraints are invalid");
|
|
1434
|
+
}
|
|
1435
|
+
const alignments = new Set(["top_left", "top_center", "top_right", "center_left", "center", "center_right", "bottom_left", "bottom_center", "bottom_right"]);
|
|
1436
|
+
if (props.alignment !== undefined && !alignments.has(props.alignment)) throw new Error("UI alignment is invalid");
|
|
1437
|
+
if (props.main_axis_alignment !== undefined && !["start", "end", "center", "space_between", "space_around", "space_evenly"].includes(props.main_axis_alignment)) throw new Error("UI main-axis alignment is invalid");
|
|
1438
|
+
if (props.cross_axis_alignment !== undefined && !["start", "end", "center", "stretch", "baseline"].includes(props.cross_axis_alignment)) throw new Error("UI cross-axis alignment is invalid");
|
|
1439
|
+
if (props.main_axis_size !== undefined && !["min", "max"].includes(props.main_axis_size)) throw new Error("UI main-axis size is invalid");
|
|
1440
|
+
for (const key of ["color", "background_color", "foreground_color", "border_color"]) if (props[key] !== undefined) validateUiColor(props[key], `UI ${key}`);
|
|
1441
|
+
if (props.border_width !== undefined) validateUiNumber(props.border_width, "UI border width", 0, 8);
|
|
1442
|
+
if (props.border_radius !== undefined) validateUiNumber(props.border_radius, "UI border radius", 0, 64);
|
|
1443
|
+
if (props.elevation !== undefined) validateUiNumber(props.elevation, "UI elevation", 0, 24);
|
|
1444
|
+
if (props.opacity !== undefined) validateUiNumber(props.opacity, "UI opacity", 0, 1);
|
|
1445
|
+
if (props.aspect_ratio !== undefined) validateUiNumber(props.aspect_ratio, "UI aspect ratio", 0.1, 20);
|
|
1446
|
+
if (props.flex !== undefined && (!Number.isInteger(props.flex) || props.flex < 1 || props.flex > 24)) throw new Error("UI flex is invalid");
|
|
1447
|
+
for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
|
|
1299
1448
|
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");
|
|
1300
1449
|
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");
|
|
1301
|
-
for (const key of ["persist", "selected", "dense", "show_legend", "full_width"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1450
|
+
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`);
|
|
1451
|
+
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");
|
|
1452
|
+
if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
|
|
1453
|
+
if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
|
|
1454
|
+
else if (["asset", "url", "fit"].some((key) => props[key] !== undefined)) throw new Error("image properties require an image or avatar component");
|
|
1455
|
+
if (nodeType === "positioned" && !["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("positioned UI component requires an offset");
|
|
1456
|
+
if (nodeType === "positioned" && (
|
|
1457
|
+
["left", "right", "width"].every((key) => props[key] !== undefined)
|
|
1458
|
+
|| ["top", "bottom", "height"].every((key) => props[key] !== undefined)
|
|
1459
|
+
)) throw new Error("positioned UI component over-constrains an axis");
|
|
1460
|
+
if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
|
|
1461
|
+
if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
|
|
1462
|
+
if (nodeType !== "scroll" && ["scroll_direction", "shrink_wrap"].some((key) => props[key] !== undefined)) throw new Error("scroll properties require a scroll UI component");
|
|
1302
1463
|
if (["list", "timeline", "calendar", "chart"].includes(nodeType)) {
|
|
1303
1464
|
if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
|
|
1304
1465
|
}
|
|
1305
|
-
|
|
1306
|
-
if (nodeType === "
|
|
1466
|
+
const chartType = props.chart_type === undefined ? "bar" : props.chart_type;
|
|
1467
|
+
if (nodeType === "chart" && !["bar", "line", "donut"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1468
|
+
if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
|
|
1307
1469
|
if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
|
|
1308
|
-
if (props.options
|
|
1470
|
+
if (props.options != null) {
|
|
1309
1471
|
if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
|
|
1310
1472
|
for (const option of props.options) {
|
|
1311
1473
|
if (!option || typeof option !== "object" || Array.isArray(option) || Object.keys(option).sort().join(",") !== "label,value") throw new Error("UI select option is invalid");
|
|
@@ -1315,8 +1477,47 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1315
1477
|
}
|
|
1316
1478
|
}
|
|
1317
1479
|
|
|
1480
|
+
function validateUiImageSource(props, sources) {
|
|
1481
|
+
const supplied = ["asset", "url"].filter((key) => props[key] !== undefined);
|
|
1482
|
+
if (supplied.length !== 1) throw new Error("image UI component requires exactly one safe source");
|
|
1483
|
+
if (props.asset !== undefined) {
|
|
1484
|
+
if (typeof props.asset !== "string" || props.asset.length > 240 || !UI_ASSET_PATH.test(props.asset) || props.asset.split("/").includes("..")) throw new Error("UI image asset is invalid");
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (typeof props.url !== "string" || props.url.length > 2000) throw new Error("UI image URL binding is invalid");
|
|
1488
|
+
const match = UI_SINGLE_TEMPLATE_BINDING.exec(props.url);
|
|
1489
|
+
if (!match || !UI_BINDING.test(match[1].trim())) throw new Error("UI image URL must be a capability result binding");
|
|
1490
|
+
const parts = match[1].trim().split(".");
|
|
1491
|
+
if (!["data", "item"].includes(parts[0])) throw new Error("UI image URL binding is invalid");
|
|
1492
|
+
if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI image data source is unknown");
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function validateUiColor(value, label) {
|
|
1496
|
+
if (typeof value !== "string" || (!UI_COLOR_TOKENS.has(value) && !UI_HEX_COLOR.test(value))) throw new Error(`${label} is invalid`);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function validateUiIcon(value, required = false) {
|
|
1500
|
+
if (value == null && !required) return;
|
|
1501
|
+
if (typeof value !== "string" || !UI_ICON_TOKENS.has(value)) throw new Error("UI icon token is invalid");
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function validateUiNumber(value, label, minimum, maximum) {
|
|
1505
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${label} is invalid`);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function validateUiInsets(value, label) {
|
|
1509
|
+
if (typeof value === "number") {
|
|
1510
|
+
validateUiNumber(value, label, 0, 128);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
assertObject(value, label);
|
|
1514
|
+
const keys = Object.keys(value).sort().join(",");
|
|
1515
|
+
if (!["all", "horizontal,vertical", "bottom,left,right,top"].includes(keys)) throw new Error(`${label} is invalid`);
|
|
1516
|
+
for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1318
1519
|
function validateUiCondition(condition, stateKeys, sources) {
|
|
1319
|
-
if (condition
|
|
1520
|
+
if (condition == null) return;
|
|
1320
1521
|
rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
|
|
1321
1522
|
if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
|
|
1322
1523
|
validateKnownBinding(condition.path, stateKeys, sources);
|
|
@@ -1325,27 +1526,37 @@ function validateUiCondition(condition, stateKeys, sources) {
|
|
|
1325
1526
|
if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
|
|
1326
1527
|
}
|
|
1327
1528
|
|
|
1328
|
-
function validateUiAction(action, executable, routes, sources, stateKeys) {
|
|
1329
|
-
if (action
|
|
1330
|
-
|
|
1529
|
+
function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
|
|
1530
|
+
if (action == null) {
|
|
1531
|
+
if (required) throw new Error("UI action is required");
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
|
|
1331
1535
|
if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1332
1536
|
if (action.type === "invoke") {
|
|
1333
1537
|
if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
|
|
1334
1538
|
optionalUiTemplate(action.query, 2000);
|
|
1335
1539
|
validateTemplateReferences(action.query, stateKeys, sources);
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1540
|
+
const actionArguments = action.arguments === undefined ? {} : action.arguments;
|
|
1541
|
+
assertObject(actionArguments, "UI action arguments");
|
|
1542
|
+
validateUiBindingValue(actionArguments, 0, stateKeys, sources);
|
|
1543
|
+
if (action.store != null && !sources.has(action.store)) throw new Error("UI action store is unknown");
|
|
1339
1544
|
} else if (action.type === "navigate") {
|
|
1340
1545
|
if (!routes.has(action.target)) throw new Error("UI navigation target is unknown");
|
|
1546
|
+
const transition = action.transition === undefined ? "platform" : action.transition;
|
|
1547
|
+
if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
|
|
1548
|
+
const replace = action.replace === undefined ? false : action.replace;
|
|
1549
|
+
if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
|
|
1341
1550
|
} else if (action.type === "set_state") {
|
|
1342
1551
|
assertObject(action.values, "UI state action values");
|
|
1343
1552
|
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");
|
|
1344
1553
|
validateUiBindingValue(action.values, 0, stateKeys, sources);
|
|
1345
|
-
|
|
1554
|
+
const persist = action.persist === undefined ? false : action.persist;
|
|
1555
|
+
if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
|
|
1346
1556
|
} else if (action.type === "refresh") {
|
|
1347
1557
|
if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
|
|
1348
1558
|
} else if (Object.keys(action).length !== 1) throw new Error("back UI action cannot contain arguments");
|
|
1559
|
+
if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
|
|
1349
1560
|
}
|
|
1350
1561
|
|
|
1351
1562
|
function validateUiBindingValue(value, depth, stateKeys, sources) {
|
|
@@ -1467,7 +1678,7 @@ function normalizeCapabilitySource(value, label) {
|
|
|
1467
1678
|
throw new Error(`${label}.runtime must be an object`);
|
|
1468
1679
|
}
|
|
1469
1680
|
if (shorthand.length) {
|
|
1470
|
-
const runtime = { ...(capability.runtime
|
|
1681
|
+
const runtime = { ...(capability.runtime === undefined ? {} : capability.runtime) };
|
|
1471
1682
|
for (const name of shorthand) {
|
|
1472
1683
|
if (Object.hasOwn(runtime, name) && !canonicalJson(runtime[name]).equals(canonicalJson(capability[name]))) {
|
|
1473
1684
|
throw new Error(`${label}.${name} conflicts with ${label}.runtime.${name}`);
|