@magicvr/schema-ui-protocol 0.2.1 → 0.2.3
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/lib/fetch-timeout.js +43 -0
- package/package.json +5 -5
- package/protocol/app-manifest.js +677 -0
- package/protocol/conformance/actions-outcome.js +106 -0
- package/protocol/conformance/component-format.js +21 -0
- package/protocol/conformance/query-serialize.js +188 -0
- package/protocol/conformance/request-construction.js +695 -0
- package/protocol/conformance/request-lifecycle.js +57 -0
- package/protocol/conformance/response-mapping.js +170 -0
- package/protocol/conformance/runtime-defaults.js +75 -0
- package/protocol/conformance/runtime-schema-validate.js +82 -0
- package/protocol/conformance/schema-validate.js +119 -0
- package/protocol/conformance/search-table.js +107 -0
- package/protocol/conformance/static-data.js +87 -0
- package/protocol/conformance/table-sort.js +217 -0
- package/protocol/conformance/upload-orchestration.js +174 -0
- package/protocol/conformance/version-negotiate.js +155 -0
- package/protocol/index.d.ts +2 -2
- package/protocol/index.js +8 -0
- package/protocol/load-page.js +88 -0
- package/index.js +0 -7654
- package/protocol/conformance-claim.json +0 -81
- package/protocol/conformance-claim.json.sha256 +0 -1
- package/protocol/conformance-local-report.json +0 -60
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
export const DEFAULT_MANIFEST_PATH = "/.well-known/schema-ui/app-manifest.json";
|
|
2
|
+
/**
|
|
3
|
+
* Host manifest-version support set (strict negotiation, ADR-0009): 2.7 for
|
|
4
|
+
* existing production manifests, 2.8 for Host/App interoperability manifests
|
|
5
|
+
* (returnIntentQueryKeys etc.), 2.9 for ADR-0039/ADR-0040 (data.route-binding /
|
|
6
|
+
* form.controls.readonly). Kept additive — older manifests stay accepted.
|
|
7
|
+
*/
|
|
8
|
+
export const APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS = ["2.7", "2.8", "2.9"];
|
|
9
|
+
export const APP_MANIFEST_PROTOCOL_VERSION = "2.9";
|
|
10
|
+
export const MANIFEST_SOURCE_HEADER = "X-Schema-UI-Manifest-Source";
|
|
11
|
+
export const APP_MANIFEST_SOURCE = "https://github.com/magicvr/schema-ui-docs/tree/81aa1d8"; // v2.9.0 formal release commit
|
|
12
|
+
const APP_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
13
|
+
// v2.8: capability id segments may contain hyphens (host.failure-recovery).
|
|
14
|
+
const CAPABILITY_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/;
|
|
15
|
+
const RETURN_INTENT_KEY_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
|
|
16
|
+
const ICON_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
17
|
+
/** True when `version` (MAJOR.MINOR) is >= the given floor. */
|
|
18
|
+
function versionAtLeast(version, major, minor) {
|
|
19
|
+
const match = /^(\d+)\.(\d+)$/.exec(version);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const gotMajor = Number(match[1]);
|
|
24
|
+
const gotMinor = Number(match[2]);
|
|
25
|
+
return gotMajor > major || (gotMajor === major && gotMinor >= minor);
|
|
26
|
+
}
|
|
27
|
+
const PATH_PATTERN = /^\/(?!\/)[^\s\\]*$/;
|
|
28
|
+
const TEMPLATE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
29
|
+
const EXPRESSION_PATTERN = /^\$context\.(user|features)\.([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+(==|!=|contains)\s+(.+)$/;
|
|
30
|
+
/** True when `expression` matches the frozen $context expression grammar. */
|
|
31
|
+
export function isValidExpression(expression) {
|
|
32
|
+
if (!expression.startsWith("$context.")) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const match = EXPRESSION_PATTERN.exec(expression);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const literal = match[4].trim();
|
|
40
|
+
return (/^true$|^false$|^-?\d+(?:\.\d+)?$/.test(literal) ||
|
|
41
|
+
/^"(?:[^"\\]|\\.)*"$/.test(literal));
|
|
42
|
+
}
|
|
43
|
+
export class ManifestError extends Error {
|
|
44
|
+
code;
|
|
45
|
+
path;
|
|
46
|
+
/** Optional machine detail (e.g. the missing capability id). */
|
|
47
|
+
detail;
|
|
48
|
+
constructor(code, path, message, detail) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "ManifestError";
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.path = path;
|
|
53
|
+
if (detail !== undefined) {
|
|
54
|
+
this.detail = detail;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function fail(code, path, message, detail) {
|
|
62
|
+
throw new ManifestError(code, path, message, detail);
|
|
63
|
+
}
|
|
64
|
+
function requireRecord(value, path) {
|
|
65
|
+
if (!isRecord(value)) {
|
|
66
|
+
return fail("INVALID_MANIFEST", path, "Expected an object.");
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function requireString(value, path, minLength = 1) {
|
|
71
|
+
if (typeof value !== "string" || value.length < minLength) {
|
|
72
|
+
return fail("INVALID_MANIFEST", path, "Expected a non-empty string.");
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function ensureKeys(value, allowed, path) {
|
|
77
|
+
for (const key of Object.keys(value)) {
|
|
78
|
+
if (!allowed.includes(key)) {
|
|
79
|
+
fail("UNKNOWN_MANIFEST_FIELD", path === "$" ? key : `${path}.${key}`, `Unknown manifest field: ${key}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function requireArray(value, path) {
|
|
84
|
+
if (!Array.isArray(value)) {
|
|
85
|
+
return fail("INVALID_MANIFEST", path, "Expected an array.");
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function requireStringArray(value, path) {
|
|
90
|
+
const values = requireArray(value, path);
|
|
91
|
+
const result = values.map((item, index) => requireString(item, `${path}[${index}]`));
|
|
92
|
+
if (new Set(result).size !== result.length) {
|
|
93
|
+
fail("INVALID_MANIFEST", path, "Array values must be unique.");
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
function validateRelativePath(value, path, allowPlaceholders) {
|
|
98
|
+
const result = requireString(value, path);
|
|
99
|
+
if (!PATH_PATTERN.test(result) ||
|
|
100
|
+
result.includes("?") ||
|
|
101
|
+
result.includes("#") ||
|
|
102
|
+
(!allowPlaceholders && result.includes("{"))) {
|
|
103
|
+
fail("INVALID_PATH", path, "Expected an application-relative path.");
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function parseTemplate(route, path) {
|
|
108
|
+
if (route === "/") {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (route.includes("//")) {
|
|
112
|
+
fail("INVALID_PATH", path, "Route templates cannot contain empty segments.");
|
|
113
|
+
}
|
|
114
|
+
const names = [];
|
|
115
|
+
for (const [index, segment] of route.slice(1).split("/").entries()) {
|
|
116
|
+
if (segment.length === 0) {
|
|
117
|
+
fail("INVALID_PATH", `${path}.route[${index}]`, "Route templates cannot contain empty segments.");
|
|
118
|
+
}
|
|
119
|
+
if (segment.startsWith("{") || segment.endsWith("}")) {
|
|
120
|
+
if (!/^\{[^{}]+\}$/.test(segment)) {
|
|
121
|
+
fail("INVALID_PATH", path, "Invalid route placeholder.");
|
|
122
|
+
}
|
|
123
|
+
const name = segment.slice(1, -1);
|
|
124
|
+
if (!TEMPLATE_NAME_PATTERN.test(name) || names.includes(name)) {
|
|
125
|
+
fail("INVALID_PATH", path, "Invalid or repeated route placeholder.");
|
|
126
|
+
}
|
|
127
|
+
names.push(name);
|
|
128
|
+
}
|
|
129
|
+
else if (segment.includes("{") || segment.includes("}")) {
|
|
130
|
+
fail("INVALID_PATH", path, "Invalid route placeholder.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return names;
|
|
134
|
+
}
|
|
135
|
+
function validateExpression(value, path) {
|
|
136
|
+
const expression = requireString(value, path);
|
|
137
|
+
if (!expression.startsWith("$context.")) {
|
|
138
|
+
fail("FORBIDDEN_VARIABLE", path, "Only $context variables are allowed.");
|
|
139
|
+
}
|
|
140
|
+
const match = EXPRESSION_PATTERN.exec(expression);
|
|
141
|
+
if (!match) {
|
|
142
|
+
fail("SYNTAX", path, "Invalid navigation expression.");
|
|
143
|
+
}
|
|
144
|
+
const literal = match[4].trim();
|
|
145
|
+
if (!/^true$|^false$|^-?\d+(?:\.\d+)?$/.test(literal) &&
|
|
146
|
+
!/^"(?:[^"\\]|\\.)*"$/.test(literal)) {
|
|
147
|
+
fail("SYNTAX", path, "Invalid navigation expression literal.");
|
|
148
|
+
}
|
|
149
|
+
return expression;
|
|
150
|
+
}
|
|
151
|
+
function parseVisibleWhen(value, path) {
|
|
152
|
+
const record = requireRecord(value, path);
|
|
153
|
+
ensureKeys(record, ["when"], path);
|
|
154
|
+
return { when: validateExpression(record.when, `${path}.when`) };
|
|
155
|
+
}
|
|
156
|
+
function parsePermissions(value, path) {
|
|
157
|
+
const record = requireRecord(value, path);
|
|
158
|
+
ensureKeys(record, ["view"], path);
|
|
159
|
+
return record.view === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { view: validateExpression(record.view, `${path}.view`) };
|
|
162
|
+
}
|
|
163
|
+
function parseIcon(value, path) {
|
|
164
|
+
if (value === undefined) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
const icon = requireString(value, path);
|
|
168
|
+
if (!ICON_PATTERN.test(icon)) {
|
|
169
|
+
fail("INVALID_MANIFEST", path, "Invalid semantic icon name.");
|
|
170
|
+
}
|
|
171
|
+
return icon;
|
|
172
|
+
}
|
|
173
|
+
function parseLink(value, path, pages) {
|
|
174
|
+
const record = requireRecord(value, path);
|
|
175
|
+
ensureKeys(record, ["pageRef", "url", "label", "labelKey", "icon", "visibleWhen", "permissions"], path);
|
|
176
|
+
const hasPageRef = record.pageRef !== undefined;
|
|
177
|
+
const hasUrl = record.url !== undefined;
|
|
178
|
+
if (hasPageRef === hasUrl) {
|
|
179
|
+
fail("NAV_LINK_MUTEX", path, "A navigation link requires pageRef xor url.");
|
|
180
|
+
}
|
|
181
|
+
const pageRef = hasPageRef
|
|
182
|
+
? requireString(record.pageRef, `${path}.pageRef`)
|
|
183
|
+
: undefined;
|
|
184
|
+
if (pageRef !== undefined) {
|
|
185
|
+
if (pages.length === 0) {
|
|
186
|
+
fail("PAGE_REF_WITH_EMPTY_PAGES", `${path}.pageRef`, "No pages are registered.");
|
|
187
|
+
}
|
|
188
|
+
if (!pages.some((page) => page.pageId === pageRef)) {
|
|
189
|
+
fail("NAV_PAGE_REF_UNKNOWN", `${path}.pageRef`, `Unknown pageRef: ${pageRef}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const url = hasUrl
|
|
193
|
+
? validateRelativePath(record.url, `${path}.url`, false)
|
|
194
|
+
: undefined;
|
|
195
|
+
if (url !== undefined && record.label === undefined && record.labelKey === undefined) {
|
|
196
|
+
fail("INVALID_MANIFEST", path, "URL navigation links require a label.");
|
|
197
|
+
}
|
|
198
|
+
const label = record.label === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: requireString(record.label, `${path}.label`);
|
|
201
|
+
const labelKey = record.labelKey === undefined
|
|
202
|
+
? undefined
|
|
203
|
+
: requireString(record.labelKey, `${path}.labelKey`);
|
|
204
|
+
const visibleWhen = record.visibleWhen === undefined
|
|
205
|
+
? undefined
|
|
206
|
+
: parseVisibleWhen(record.visibleWhen, `${path}.visibleWhen`);
|
|
207
|
+
const permissions = record.permissions === undefined
|
|
208
|
+
? undefined
|
|
209
|
+
: parsePermissions(record.permissions, `${path}.permissions`);
|
|
210
|
+
const icon = parseIcon(record.icon, `${path}.icon`);
|
|
211
|
+
return {
|
|
212
|
+
...(pageRef === undefined ? {} : { pageRef }),
|
|
213
|
+
...(url === undefined ? {} : { url }),
|
|
214
|
+
...(label === undefined ? {} : { label }),
|
|
215
|
+
...(labelKey === undefined ? {} : { labelKey }),
|
|
216
|
+
...(icon === undefined ? {} : { icon }),
|
|
217
|
+
...(visibleWhen === undefined ? {} : { visibleWhen }),
|
|
218
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function parseItem(value, path, pages) {
|
|
222
|
+
const record = requireRecord(value, path);
|
|
223
|
+
if (record.items !== undefined) {
|
|
224
|
+
ensureKeys(record, ["label", "labelKey", "icon", "items", "visibleWhen", "permissions"], path);
|
|
225
|
+
const label = record.label === undefined
|
|
226
|
+
? undefined
|
|
227
|
+
: requireString(record.label, `${path}.label`);
|
|
228
|
+
const labelKey = record.labelKey === undefined
|
|
229
|
+
? undefined
|
|
230
|
+
: requireString(record.labelKey, `${path}.labelKey`);
|
|
231
|
+
if (label === undefined && labelKey === undefined) {
|
|
232
|
+
fail("INVALID_MANIFEST", path, "Navigation groups require a label.");
|
|
233
|
+
}
|
|
234
|
+
const items = requireArray(record.items, `${path}.items`);
|
|
235
|
+
if (items.length === 0) {
|
|
236
|
+
fail("INVALID_MANIFEST", `${path}.items`, "Navigation groups cannot be empty.");
|
|
237
|
+
}
|
|
238
|
+
const visibleWhen = record.visibleWhen === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: parseVisibleWhen(record.visibleWhen, `${path}.visibleWhen`);
|
|
241
|
+
const permissions = record.permissions === undefined
|
|
242
|
+
? undefined
|
|
243
|
+
: parsePermissions(record.permissions, `${path}.permissions`);
|
|
244
|
+
const icon = parseIcon(record.icon, `${path}.icon`);
|
|
245
|
+
return {
|
|
246
|
+
...(label === undefined ? {} : { label }),
|
|
247
|
+
...(labelKey === undefined ? {} : { labelKey }),
|
|
248
|
+
...(icon === undefined ? {} : { icon }),
|
|
249
|
+
items: items.map((item, index) => {
|
|
250
|
+
const childPath = `${path}.items[${index}]`;
|
|
251
|
+
const child = requireRecord(item, childPath);
|
|
252
|
+
if (child.items !== undefined) {
|
|
253
|
+
fail("NAV_GROUP_NESTED", childPath, "Navigation groups cannot be nested.");
|
|
254
|
+
}
|
|
255
|
+
return parseLink(item, childPath, pages);
|
|
256
|
+
}),
|
|
257
|
+
...(visibleWhen === undefined ? {} : { visibleWhen }),
|
|
258
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return parseLink(value, path, pages);
|
|
262
|
+
}
|
|
263
|
+
function parsePages(value) {
|
|
264
|
+
const entries = requireArray(value, "pages");
|
|
265
|
+
const pageIds = new Set();
|
|
266
|
+
const routes = new Set();
|
|
267
|
+
return entries.map((entry, index) => {
|
|
268
|
+
const path = `pages[${index}]`;
|
|
269
|
+
const record = requireRecord(entry, path);
|
|
270
|
+
ensureKeys(record, ["pageId", "title", "titleKey", "schemaUrl", "route", "returnIntentQueryKeys"], path);
|
|
271
|
+
const pageId = requireString(record.pageId, `${path}.pageId`);
|
|
272
|
+
if (pageIds.has(pageId)) {
|
|
273
|
+
fail("INVALID_MANIFEST", `${path}.pageId`, "pageId values must be unique.");
|
|
274
|
+
}
|
|
275
|
+
pageIds.add(pageId);
|
|
276
|
+
const title = record.title === undefined ? undefined : requireString(record.title, `${path}.title`);
|
|
277
|
+
const titleKey = record.titleKey === undefined
|
|
278
|
+
? undefined
|
|
279
|
+
: requireString(record.titleKey, `${path}.titleKey`);
|
|
280
|
+
if (title === undefined && titleKey === undefined) {
|
|
281
|
+
fail("INVALID_MANIFEST", path, "Pages require a title or titleKey.");
|
|
282
|
+
}
|
|
283
|
+
const schemaUrl = validateRelativePath(record.schemaUrl, `${path}.schemaUrl`, true);
|
|
284
|
+
const route = validateRelativePath(record.route, `${path}.route`, true);
|
|
285
|
+
const routeNames = parseTemplate(route, `${path}.route`);
|
|
286
|
+
const schemaNames = parseTemplate(schemaUrl, `${path}.schemaUrl`);
|
|
287
|
+
if (schemaNames.some((name) => !routeNames.includes(name))) {
|
|
288
|
+
fail("INVALID_PATH", `${path}.schemaUrl`, "schemaUrl placeholders must be bound by the page route.");
|
|
289
|
+
}
|
|
290
|
+
if (routes.has(route)) {
|
|
291
|
+
fail("INVALID_MANIFEST", `${path}.route`, "Route templates must be unique.");
|
|
292
|
+
}
|
|
293
|
+
routes.add(route);
|
|
294
|
+
let returnIntentQueryKeys;
|
|
295
|
+
if (record.returnIntentQueryKeys !== undefined) {
|
|
296
|
+
const keys = requireArray(record.returnIntentQueryKeys, `${path}.returnIntentQueryKeys`);
|
|
297
|
+
if (keys.length === 0
|
|
298
|
+
|| keys.some((key) => typeof key !== "string" || !RETURN_INTENT_KEY_PATTERN.test(key))
|
|
299
|
+
|| new Set(keys).size !== keys.length) {
|
|
300
|
+
fail("INVALID_RETURN_INTENT_QUERY_KEYS", `${path}.returnIntentQueryKeys`, "returnIntentQueryKeys must be a non-empty unique array of lowercase query keys.");
|
|
301
|
+
}
|
|
302
|
+
returnIntentQueryKeys = keys;
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
pageId,
|
|
306
|
+
...(title === undefined ? {} : { title }),
|
|
307
|
+
...(titleKey === undefined ? {} : { titleKey }),
|
|
308
|
+
schemaUrl,
|
|
309
|
+
route,
|
|
310
|
+
...(returnIntentQueryKeys === undefined ? {} : { returnIntentQueryKeys }),
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function parseApp(value, pages) {
|
|
315
|
+
const record = requireRecord(value, "app");
|
|
316
|
+
ensureKeys(record, ["appId", "name", "nameKey", "homePageRef", "logo", "description", "descriptionKey"], "app");
|
|
317
|
+
const appId = requireString(record.appId, "app.appId");
|
|
318
|
+
if (!APP_ID_PATTERN.test(appId)) {
|
|
319
|
+
fail("INVALID_MANIFEST", "app.appId", "Invalid appId.");
|
|
320
|
+
}
|
|
321
|
+
const name = record.name === undefined ? undefined : requireString(record.name, "app.name");
|
|
322
|
+
const nameKey = record.nameKey === undefined ? undefined : requireString(record.nameKey, "app.nameKey");
|
|
323
|
+
if (name === undefined && nameKey === undefined) {
|
|
324
|
+
fail("INVALID_MANIFEST", "app", "App requires a name or nameKey.");
|
|
325
|
+
}
|
|
326
|
+
if (pages.length > 0 && record.homePageRef === undefined) {
|
|
327
|
+
fail("INVALID_MANIFEST", "app.homePageRef", "Non-empty pages require homePageRef.");
|
|
328
|
+
}
|
|
329
|
+
if (pages.length === 0 && record.homePageRef !== undefined) {
|
|
330
|
+
fail("PAGE_REF_WITH_EMPTY_PAGES", "app.homePageRef", "An empty page registry cannot declare a homePageRef.");
|
|
331
|
+
}
|
|
332
|
+
const homePageRef = record.homePageRef === undefined
|
|
333
|
+
? undefined
|
|
334
|
+
: requireString(record.homePageRef, "app.homePageRef");
|
|
335
|
+
if (homePageRef !== undefined) {
|
|
336
|
+
const home = pages.find((page) => page.pageId === homePageRef);
|
|
337
|
+
if (!home) {
|
|
338
|
+
fail("MANIFEST_HOME_PAGE_UNKNOWN", "app.homePageRef", "Unknown home page.");
|
|
339
|
+
}
|
|
340
|
+
if (home.route.includes("{")) {
|
|
341
|
+
fail("MANIFEST_HOME_ROUTE_PARAMETRIC", "app.homePageRef", "Home page route cannot be parametric.");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
let logo;
|
|
345
|
+
if (record.logo !== undefined) {
|
|
346
|
+
const logoRecord = requireRecord(record.logo, "app.logo");
|
|
347
|
+
ensureKeys(logoRecord, ["light", "dark"], "app.logo");
|
|
348
|
+
const light = validateLogoUrl(logoRecord.light, "app.logo.light");
|
|
349
|
+
const dark = logoRecord.dark === undefined
|
|
350
|
+
? undefined
|
|
351
|
+
: validateLogoUrl(logoRecord.dark, "app.logo.dark");
|
|
352
|
+
logo = dark === undefined ? { light } : { light, dark };
|
|
353
|
+
}
|
|
354
|
+
const description = record.description === undefined
|
|
355
|
+
? undefined
|
|
356
|
+
: requireString(record.description, "app.description", 0);
|
|
357
|
+
const descriptionKey = record.descriptionKey === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: requireString(record.descriptionKey, "app.descriptionKey", 0);
|
|
360
|
+
return {
|
|
361
|
+
appId,
|
|
362
|
+
...(name === undefined ? {} : { name }),
|
|
363
|
+
...(nameKey === undefined ? {} : { nameKey }),
|
|
364
|
+
...(homePageRef === undefined ? {} : { homePageRef }),
|
|
365
|
+
...(logo === undefined ? {} : { logo }),
|
|
366
|
+
...(description === undefined ? {} : { description }),
|
|
367
|
+
...(descriptionKey === undefined ? {} : { descriptionKey }),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function parseNavigation(value, pages) {
|
|
371
|
+
const record = requireRecord(value, "navigation");
|
|
372
|
+
const navigation = {};
|
|
373
|
+
for (const key of Object.keys(record)) {
|
|
374
|
+
if (key !== "top" && key !== "sidebar" && key !== "user") {
|
|
375
|
+
fail("UNKNOWN_NAV_SLOT", `navigation.${key}`, `Unknown navigation slot: ${key}.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const slot of ["top", "sidebar", "user"]) {
|
|
379
|
+
if (record[slot] !== undefined) {
|
|
380
|
+
const items = requireArray(record[slot], `navigation.${slot}`);
|
|
381
|
+
navigation[slot] = items.map((item, index) => parseItem(item, `navigation.${slot}[${index}]`, pages));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return navigation;
|
|
385
|
+
}
|
|
386
|
+
export function validateAppManifest(value) {
|
|
387
|
+
const record = requireRecord(value, "$");
|
|
388
|
+
ensureKeys(record, ["protocolVersion", "requiredCapabilities", "app", "pages", "navigation"], "$");
|
|
389
|
+
if (record.protocolVersion === undefined) {
|
|
390
|
+
fail("MISSING_PROTOCOL_VERSION", "protocolVersion", "Manifest protocolVersion is required.");
|
|
391
|
+
}
|
|
392
|
+
const protocolVersion = requireString(record.protocolVersion, "protocolVersion");
|
|
393
|
+
if (!/^\d+\.\d+$/.test(protocolVersion)) {
|
|
394
|
+
fail("INVALID_PROTOCOL_VERSION", "protocolVersion", "Expected MAJOR.MINOR.");
|
|
395
|
+
}
|
|
396
|
+
const [majorText, minorText] = protocolVersion.split(".");
|
|
397
|
+
const major = Number(majorText);
|
|
398
|
+
const minor = Number(minorText);
|
|
399
|
+
if (major < 2 || (major === 2 && minor < 5)) {
|
|
400
|
+
fail("PROTOCOL_VERSION_TOO_LOW", "protocolVersion", "App manifest requires protocol >= 2.5.");
|
|
401
|
+
}
|
|
402
|
+
if (!APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
|
403
|
+
fail("UNSUPPORTED_PROTOCOL_VERSION", "protocolVersion", `This host supports ${APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS.join(" and ")}.`);
|
|
404
|
+
}
|
|
405
|
+
const requiredCapabilities = requireStringArray(record.requiredCapabilities, "requiredCapabilities");
|
|
406
|
+
if (requiredCapabilities.some((capability) => !CAPABILITY_PATTERN.test(capability))) {
|
|
407
|
+
fail("INVALID_MANIFEST", "requiredCapabilities", "Capabilities must use dotted lowercase names.");
|
|
408
|
+
}
|
|
409
|
+
if (!requiredCapabilities.includes("app.manifest")) {
|
|
410
|
+
// Upstream M1 envelope: CAPABILITY_REQUIRED with the missing id as detail
|
|
411
|
+
// (reference-js/app-manifest.js). Kept aligned so the vendored upstream
|
|
412
|
+
// app-manifest suite runs with zero exclusions.
|
|
413
|
+
fail("CAPABILITY_REQUIRED", "requiredCapabilities", "app.manifest is required.", "app.manifest");
|
|
414
|
+
}
|
|
415
|
+
const pages = parsePages(record.pages);
|
|
416
|
+
// v2.8 return-intent allowlist extension gate (ADR-0036 / 09 §6): presence
|
|
417
|
+
// requires protocolVersion >= 2.8 AND host.failure-recovery capability.
|
|
418
|
+
// v2.9 keeps the same floor (2.9 pages are accepted).
|
|
419
|
+
for (const [index, page] of pages.entries()) {
|
|
420
|
+
if (page.returnIntentQueryKeys === undefined)
|
|
421
|
+
continue;
|
|
422
|
+
if (!versionAtLeast(protocolVersion, 2, 8)) {
|
|
423
|
+
fail("PROTOCOL_VERSION_TOO_LOW", `pages[${index}].returnIntentQueryKeys`, "returnIntentQueryKeys requires manifest protocol >= 2.8.");
|
|
424
|
+
}
|
|
425
|
+
if (!requiredCapabilities.includes("host.failure-recovery")) {
|
|
426
|
+
fail("MISSING_REQUIRED_CAPABILITY", `pages[${index}].returnIntentQueryKeys`, "host.failure-recovery is required when returnIntentQueryKeys is present.", "host.failure-recovery");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const app = parseApp(record.app, pages);
|
|
430
|
+
let navigation;
|
|
431
|
+
if (record.navigation !== undefined) {
|
|
432
|
+
if (!requiredCapabilities.includes("app.navigation")) {
|
|
433
|
+
fail("CAPABILITY_REQUIRED", "requiredCapabilities", "app.navigation is required when navigation is present.", "app.navigation");
|
|
434
|
+
}
|
|
435
|
+
navigation = parseNavigation(record.navigation, pages);
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
protocolVersion,
|
|
439
|
+
requiredCapabilities,
|
|
440
|
+
app,
|
|
441
|
+
pages,
|
|
442
|
+
...(navigation === undefined ? {} : { navigation }),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function validateLogoUrl(value, path) {
|
|
446
|
+
const logo = requireString(value, path);
|
|
447
|
+
if (!((/^\/(?!\/)[^\s\\{}]*$/.test(logo) && !logo.includes("?") && !logo.includes("#")) ||
|
|
448
|
+
/^https:\/\/[^\s\\]+$/.test(logo))) {
|
|
449
|
+
fail("INVALID_LOGO_URL", path, "Logo must be a site-relative or https URL.");
|
|
450
|
+
}
|
|
451
|
+
return logo;
|
|
452
|
+
}
|
|
453
|
+
function decodeSegment(value) {
|
|
454
|
+
try {
|
|
455
|
+
return decodeURIComponent(value);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function routeSegments(path) {
|
|
462
|
+
const cleanPath = stripPathQuery(path);
|
|
463
|
+
if (!cleanPath.startsWith("/") || cleanPath.includes("//")) {
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
if (cleanPath === "/") {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const segments = cleanPath.slice(1).split("/");
|
|
470
|
+
return segments.some((segment) => segment === "") ? undefined : segments;
|
|
471
|
+
}
|
|
472
|
+
function matchSinglePage(page, path) {
|
|
473
|
+
const inputSegments = routeSegments(path);
|
|
474
|
+
const templateSegments = routeSegments(page.route);
|
|
475
|
+
if (!inputSegments || !templateSegments || inputSegments.length !== templateSegments.length) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
const params = {};
|
|
479
|
+
for (const [index, templateSegment] of templateSegments.entries()) {
|
|
480
|
+
const decoded = decodeSegment(inputSegments[index]);
|
|
481
|
+
const expected = decodeSegment(templateSegment);
|
|
482
|
+
if (decoded === undefined || expected === undefined) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
if (/^\{[^{}]+\}$/.test(templateSegment)) {
|
|
486
|
+
params[templateSegment.slice(1, -1)] = decoded;
|
|
487
|
+
}
|
|
488
|
+
else if (decoded !== expected) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return params;
|
|
493
|
+
}
|
|
494
|
+
export function matchRoute(pages, path) {
|
|
495
|
+
const inputSegments = routeSegments(path);
|
|
496
|
+
if (!inputSegments) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const candidates = pages.flatMap((page, index) => {
|
|
500
|
+
const params = matchSinglePage(page, path);
|
|
501
|
+
if (!params) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
const templateSegments = routeSegments(page.route) ?? [];
|
|
505
|
+
const literalCount = templateSegments.filter((segment) => !/^\{[^{}]+\}$/.test(segment)).length;
|
|
506
|
+
return [{ page, index, params, literalCount }];
|
|
507
|
+
});
|
|
508
|
+
candidates.sort((left, right) => {
|
|
509
|
+
if (left.literalCount !== right.literalCount) {
|
|
510
|
+
return right.literalCount - left.literalCount;
|
|
511
|
+
}
|
|
512
|
+
if (left.page.route.length !== right.page.route.length) {
|
|
513
|
+
return right.page.route.length - left.page.route.length;
|
|
514
|
+
}
|
|
515
|
+
return left.index - right.index;
|
|
516
|
+
});
|
|
517
|
+
const winner = candidates[0];
|
|
518
|
+
return winner === undefined
|
|
519
|
+
? undefined
|
|
520
|
+
: { page: winner.page, index: winner.index, params: winner.params };
|
|
521
|
+
}
|
|
522
|
+
export function stripPathQuery(path) {
|
|
523
|
+
const queryIndex = path.search(/[?#]/);
|
|
524
|
+
return queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
525
|
+
}
|
|
526
|
+
function parseQuery(path) {
|
|
527
|
+
const queryIndex = path.indexOf("?");
|
|
528
|
+
if (queryIndex === -1) {
|
|
529
|
+
return {};
|
|
530
|
+
}
|
|
531
|
+
const query = new URLSearchParams(path.slice(queryIndex + 1).split("#", 1)[0]);
|
|
532
|
+
return Object.fromEntries(query.entries());
|
|
533
|
+
}
|
|
534
|
+
export function resolveInitialRoute(manifest, requestedPath) {
|
|
535
|
+
const requested = stripPathQuery(requestedPath);
|
|
536
|
+
if (requested !== "/") {
|
|
537
|
+
const deepLink = matchRoute(manifest.pages, requested);
|
|
538
|
+
if (deepLink) {
|
|
539
|
+
return {
|
|
540
|
+
...deepLink,
|
|
541
|
+
path: requested,
|
|
542
|
+
query: parseQuery(requestedPath),
|
|
543
|
+
source: "deepLink",
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
const homePage = manifest.pages.find((page) => page.pageId === manifest.app.homePageRef);
|
|
549
|
+
if (!homePage) {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
const home = matchRoute(manifest.pages, homePage.route);
|
|
553
|
+
if (!home) {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
return { ...home, path: homePage.route, query: {}, source: "home" };
|
|
557
|
+
}
|
|
558
|
+
export function resolveSchemaUrl(baseURL, schemaUrl, params) {
|
|
559
|
+
const resolved = resolveTemplate(schemaUrl, params, "schemaUrl");
|
|
560
|
+
return joinBaseURL(baseURL, resolved);
|
|
561
|
+
}
|
|
562
|
+
export function resolveRoutePath(route, params) {
|
|
563
|
+
return resolveTemplate(route, params, "route");
|
|
564
|
+
}
|
|
565
|
+
export function resolveLogoUrl(baseURL, logoUrl) {
|
|
566
|
+
if (/^https:\/\//.test(logoUrl)) {
|
|
567
|
+
return logoUrl;
|
|
568
|
+
}
|
|
569
|
+
if (!/^\/(?!\/)[^\s\\{}]*$/.test(logoUrl)) {
|
|
570
|
+
fail("INVALID_LOGO_URL", "logoUrl", "Logo must be a site-relative or https URL.");
|
|
571
|
+
}
|
|
572
|
+
return joinBaseURL(baseURL, logoUrl);
|
|
573
|
+
}
|
|
574
|
+
function resolveTemplate(template, params, field) {
|
|
575
|
+
return template.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, name) => {
|
|
576
|
+
const value = params[name];
|
|
577
|
+
if (value === undefined) {
|
|
578
|
+
fail("MISSING_PATH_BINDING", field + ".{" + name + "}", "Missing path binding: " + name + ".");
|
|
579
|
+
}
|
|
580
|
+
return encodeURIComponent(value);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
function joinBaseURL(baseURL, relativePath) {
|
|
584
|
+
const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
|
585
|
+
return new URL(relativePath.replace(/^\/+/, ""), base).toString();
|
|
586
|
+
}
|
|
587
|
+
export function pageIdMatches(page, schemaPageId) {
|
|
588
|
+
if (page.pageId !== schemaPageId) {
|
|
589
|
+
throw new ManifestError("MANIFEST_PAGE_ID_MISMATCH", `pages[${page.pageId}].pageId`, "The page schema pageId does not match the manifest pageId.");
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
export async function loadAppManifest(options = {}) {
|
|
594
|
+
const loaded = await loadAppManifestBytes(options);
|
|
595
|
+
return loaded.manifest;
|
|
596
|
+
}
|
|
597
|
+
/** Loads the manifest with its raw 200 bytes (bootstrap integrity, ADR-0035 D6). */
|
|
598
|
+
export async function loadAppManifestBytes(options = {}) {
|
|
599
|
+
const url = options.url ?? DEFAULT_MANIFEST_PATH;
|
|
600
|
+
const fetcher = options.fetcher ?? globalThis.fetch;
|
|
601
|
+
if (!fetcher) {
|
|
602
|
+
throw new ManifestError("MANIFEST_LOAD_FAILED", url, "Fetch is unavailable.");
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const response = await fetcher(url);
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
throw new ManifestError("MANIFEST_LOAD_FAILED", url, `Manifest request failed with HTTP ${response.status}.`);
|
|
608
|
+
}
|
|
609
|
+
if (import.meta.env.DEV &&
|
|
610
|
+
response.url !== "" &&
|
|
611
|
+
response.headers.get(MANIFEST_SOURCE_HEADER) !== "api") {
|
|
612
|
+
console.warn(`[schema-ui] development manifest fixture served at ${url}; API projection was not used.`);
|
|
613
|
+
}
|
|
614
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
615
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
616
|
+
const manifest = validateAppManifest(JSON.parse(text));
|
|
617
|
+
return { manifest, bytes };
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if (error instanceof ManifestError) {
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
throw new ManifestError("MANIFEST_LOAD_FAILED", url, "Manifest could not be fetched or parsed.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function getContextValue(context, root, path) {
|
|
627
|
+
let current = context[root];
|
|
628
|
+
for (const part of path.split(".")) {
|
|
629
|
+
if (!isRecord(current)) {
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
current = current[part];
|
|
633
|
+
}
|
|
634
|
+
return current;
|
|
635
|
+
}
|
|
636
|
+
function parseLiteral(value) {
|
|
637
|
+
if (value === "true")
|
|
638
|
+
return true;
|
|
639
|
+
if (value === "false")
|
|
640
|
+
return false;
|
|
641
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value))
|
|
642
|
+
return Number(value);
|
|
643
|
+
return JSON.parse(value);
|
|
644
|
+
}
|
|
645
|
+
export function evaluateExpression(expression, context) {
|
|
646
|
+
const match = EXPRESSION_PATTERN.exec(expression);
|
|
647
|
+
if (!match) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
const actual = getContextValue(context, match[1], match[2]);
|
|
651
|
+
const expected = parseLiteral(match[4].trim());
|
|
652
|
+
switch (match[3]) {
|
|
653
|
+
case "contains":
|
|
654
|
+
return Array.isArray(actual)
|
|
655
|
+
? actual.includes(expected)
|
|
656
|
+
: typeof actual === "string" && typeof expected === "string"
|
|
657
|
+
? actual.includes(expected)
|
|
658
|
+
: false;
|
|
659
|
+
case "==":
|
|
660
|
+
return Object.is(actual, expected);
|
|
661
|
+
case "!=":
|
|
662
|
+
return !Object.is(actual, expected);
|
|
663
|
+
default:
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
export function isNavigationItemVisible(item, context) {
|
|
668
|
+
const permission = item.permissions?.view;
|
|
669
|
+
const condition = item.visibleWhen?.when;
|
|
670
|
+
return ((permission === undefined || evaluateExpression(permission, context)) &&
|
|
671
|
+
(condition === undefined || evaluateExpression(condition, context)));
|
|
672
|
+
}
|
|
673
|
+
/** Normalizes a page identifier for contribution-key matching (trim + lowercase).
|
|
674
|
+
* Added in the R4 zero-conflict upgrade drill as a protocol additive sample. */
|
|
675
|
+
export function normalizePageID(id) {
|
|
676
|
+
return id.trim().toLowerCase();
|
|
677
|
+
}
|