@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,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-construction fixture adapter (schema-ui-docs v2.7.0).
|
|
3
|
+
*
|
|
4
|
+
* Builds HTTP request / navigation / modal outcomes from declarative mappings.
|
|
5
|
+
* Batch kinds are out of scope for MVP callers (Q1); this module still implements
|
|
6
|
+
* non-batch kinds used by stage3 execution.
|
|
7
|
+
*/
|
|
8
|
+
import { encodeRFC3986, serializeQueryNumber } from "./query-serialize.js";
|
|
9
|
+
const PROTOCOL_URL_RE = /^\/(?!\/)[^\s\\]*$/;
|
|
10
|
+
const UNSAFE_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function fail(code, path) {
|
|
15
|
+
return { ok: false, code, path };
|
|
16
|
+
}
|
|
17
|
+
function isProtocolRelativeUrl(url) {
|
|
18
|
+
return PROTOCOL_URL_RE.test(url);
|
|
19
|
+
}
|
|
20
|
+
// safeDecode tolerates malformed percent-encoding: decodeURIComponent throws
|
|
21
|
+
// URIError on inputs like "%zz" or a trailing "%", and an uncaught throw here
|
|
22
|
+
// would crash the whole action path. The raw part is passed through unchanged
|
|
23
|
+
// so a later encodeRFC3986 round-trip produces a valid URL (D4).
|
|
24
|
+
function safeDecode(part) {
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(part);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return part;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function splitUrl(url) {
|
|
33
|
+
const qIndex = url.indexOf("?");
|
|
34
|
+
if (qIndex < 0) {
|
|
35
|
+
return { path: url, query: new Map() };
|
|
36
|
+
}
|
|
37
|
+
const path = url.slice(0, qIndex);
|
|
38
|
+
const query = new Map();
|
|
39
|
+
const raw = url.slice(qIndex + 1);
|
|
40
|
+
if (raw.length === 0) {
|
|
41
|
+
return { path, query };
|
|
42
|
+
}
|
|
43
|
+
for (const part of raw.split("&")) {
|
|
44
|
+
if (part === "")
|
|
45
|
+
continue;
|
|
46
|
+
const eq = part.indexOf("=");
|
|
47
|
+
const key = eq < 0 ? part : part.slice(0, eq);
|
|
48
|
+
const value = eq < 0 ? "" : part.slice(eq + 1);
|
|
49
|
+
// Base query values in fixtures are already literal (not double-encoded).
|
|
50
|
+
query.set(safeDecode(key), safeDecode(value));
|
|
51
|
+
}
|
|
52
|
+
return { path, query };
|
|
53
|
+
}
|
|
54
|
+
function serializeQueryValue(value) {
|
|
55
|
+
if (value === null)
|
|
56
|
+
return "";
|
|
57
|
+
if (typeof value === "boolean")
|
|
58
|
+
return value ? "true" : "false";
|
|
59
|
+
if (typeof value === "number")
|
|
60
|
+
return serializeQueryNumber(value);
|
|
61
|
+
if (typeof value === "string")
|
|
62
|
+
return value;
|
|
63
|
+
throw new Error("non-scalar query value");
|
|
64
|
+
}
|
|
65
|
+
function buildUrl(path, query) {
|
|
66
|
+
if (query.size === 0)
|
|
67
|
+
return path;
|
|
68
|
+
const keys = [...query.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
69
|
+
const parts = keys.map((key) => `${encodeRFC3986(key)}=${encodeRFC3986(query.get(key) ?? "")}`);
|
|
70
|
+
return `${path}?${parts.join("&")}`;
|
|
71
|
+
}
|
|
72
|
+
function extractPathParams(path) {
|
|
73
|
+
const params = [];
|
|
74
|
+
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
75
|
+
let match;
|
|
76
|
+
while ((match = re.exec(path)) !== null) {
|
|
77
|
+
params.push(match[1]);
|
|
78
|
+
}
|
|
79
|
+
return params;
|
|
80
|
+
}
|
|
81
|
+
function applyPathBindings(path, bindings, mappingPathPrefix) {
|
|
82
|
+
const needed = extractPathParams(path);
|
|
83
|
+
const provided = Object.keys(bindings);
|
|
84
|
+
for (const key of needed) {
|
|
85
|
+
if (!(key in bindings)) {
|
|
86
|
+
return { ok: false, code: "MISSING_PATH_BINDING", path: `${mappingPathPrefix}.${key}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const key of provided) {
|
|
90
|
+
if (!needed.includes(key)) {
|
|
91
|
+
return { ok: false, code: "EXTRA_PATH_BINDING", path: `${mappingPathPrefix}.${key}` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let result = path;
|
|
95
|
+
for (const key of needed) {
|
|
96
|
+
result = result.replaceAll(`{${key}}`, encodeRFC3986(bindings[key]));
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, path: result };
|
|
99
|
+
}
|
|
100
|
+
function resolveRowPath(expr, row, path) {
|
|
101
|
+
if (!expr.startsWith("$row.")) {
|
|
102
|
+
return { ok: false, code: "INVALID_MAPPING_VALUE", path };
|
|
103
|
+
}
|
|
104
|
+
const segments = expr.slice("$row.".length).split(".");
|
|
105
|
+
if (segments.some((s) => UNSAFE_SEGMENTS.has(s) || s === "")) {
|
|
106
|
+
return { ok: false, code: "UNSAFE_ROW_PATH", path };
|
|
107
|
+
}
|
|
108
|
+
let current = row;
|
|
109
|
+
for (const seg of segments) {
|
|
110
|
+
if (!isObject(current) || !Object.prototype.hasOwnProperty.call(current, seg)) {
|
|
111
|
+
return { ok: false, code: "UNRESOLVED_ROW_VALUE", path };
|
|
112
|
+
}
|
|
113
|
+
current = current[seg];
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, value: current };
|
|
116
|
+
}
|
|
117
|
+
function resolveMappingValue(expr, row, path, opts) {
|
|
118
|
+
if (typeof expr !== "string") {
|
|
119
|
+
return { ok: true, value: expr };
|
|
120
|
+
}
|
|
121
|
+
if (!expr.startsWith("$")) {
|
|
122
|
+
return { ok: true, value: expr };
|
|
123
|
+
}
|
|
124
|
+
if (expr.startsWith("$row.")) {
|
|
125
|
+
const resolved = resolveRowPath(expr, row, path);
|
|
126
|
+
if (!resolved.ok)
|
|
127
|
+
return resolved;
|
|
128
|
+
if (resolved.value === null) {
|
|
129
|
+
if (opts.pathSlot) {
|
|
130
|
+
return { ok: false, code: "NULL_PATH_VALUE", path };
|
|
131
|
+
}
|
|
132
|
+
if (opts.allowNullAsTombstone) {
|
|
133
|
+
return { ok: true, value: null, tombstone: true };
|
|
134
|
+
}
|
|
135
|
+
return { ok: false, code: "NULL_PATH_VALUE", path };
|
|
136
|
+
}
|
|
137
|
+
if (opts.pathSlot) {
|
|
138
|
+
if (typeof resolved.value !== "string" &&
|
|
139
|
+
typeof resolved.value !== "number" &&
|
|
140
|
+
typeof resolved.value !== "boolean") {
|
|
141
|
+
return { ok: false, code: "INVALID_ROW_VALUE", path };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, value: String(resolved.value) };
|
|
144
|
+
}
|
|
145
|
+
// body/query scalars only (no nested objects/arrays for row body)
|
|
146
|
+
if (isObject(resolved.value) || Array.isArray(resolved.value)) {
|
|
147
|
+
return { ok: false, code: "INVALID_ROW_VALUE", path };
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, value: resolved.value };
|
|
150
|
+
}
|
|
151
|
+
return { ok: false, code: "INVALID_MAPPING_VALUE", path };
|
|
152
|
+
}
|
|
153
|
+
function resolveRouteExpr(expr, route, path) {
|
|
154
|
+
const queryPrefix = "$context.route.query.";
|
|
155
|
+
const paramsPrefix = "$context.route.params.";
|
|
156
|
+
if (expr.startsWith(queryPrefix)) {
|
|
157
|
+
const key = expr.slice(queryPrefix.length);
|
|
158
|
+
const value = route.query?.[key];
|
|
159
|
+
if (value === undefined || value === null) {
|
|
160
|
+
return { ok: false, code: "UNRESOLVED_ROUTE_VALUE", path };
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, value: String(value) };
|
|
163
|
+
}
|
|
164
|
+
if (expr.startsWith(paramsPrefix)) {
|
|
165
|
+
const key = expr.slice(paramsPrefix.length);
|
|
166
|
+
const value = route.params?.[key];
|
|
167
|
+
if (value === undefined || value === null) {
|
|
168
|
+
return { ok: false, code: "UNRESOLVED_ROUTE_VALUE", path };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, value: String(value) };
|
|
171
|
+
}
|
|
172
|
+
return { ok: false, code: "INVALID_MAPPING_VALUE", path };
|
|
173
|
+
}
|
|
174
|
+
function joinBase(base, url) {
|
|
175
|
+
if (!base)
|
|
176
|
+
return url;
|
|
177
|
+
if (!url.startsWith("/"))
|
|
178
|
+
return url;
|
|
179
|
+
return `${base.replace(/\/$/, "")}${url}`;
|
|
180
|
+
}
|
|
181
|
+
function checkConfirm(input) {
|
|
182
|
+
if (input.confirm === undefined)
|
|
183
|
+
return null;
|
|
184
|
+
if (input.confirmAccepted === false) {
|
|
185
|
+
return fail("CONFIRM_REJECTED", "confirm");
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
function applyIdempotency(action, invocationId, pathPrefix) {
|
|
190
|
+
const policy = action.retryPolicy;
|
|
191
|
+
if (policy === undefined || policy === "never") {
|
|
192
|
+
return { ok: true };
|
|
193
|
+
}
|
|
194
|
+
if (policy !== "idempotent") {
|
|
195
|
+
return { ok: false, code: "INVALID_RETRY_POLICY", path: `${pathPrefix}.retryPolicy` };
|
|
196
|
+
}
|
|
197
|
+
if (typeof invocationId !== "string" || invocationId.length === 0) {
|
|
198
|
+
return { ok: false, code: "MISSING_INVOCATION_ID", path: "invocationId" };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, headers: { "Idempotency-Key": invocationId } };
|
|
201
|
+
}
|
|
202
|
+
function buildDataRef(input) {
|
|
203
|
+
const dataRef = input.dataRef;
|
|
204
|
+
const url = dataRef.url;
|
|
205
|
+
if (typeof url !== "string" || !isProtocolRelativeUrl(url)) {
|
|
206
|
+
return fail("INVALID_PROTOCOL_URL", "dataRef.url");
|
|
207
|
+
}
|
|
208
|
+
const method = dataRef.method ?? "GET";
|
|
209
|
+
if (method !== "GET") {
|
|
210
|
+
return fail("DATA_REF_METHOD_NOT_READ_ONLY", "dataRef.method");
|
|
211
|
+
}
|
|
212
|
+
if (dataRef.requestInterceptor !== undefined) {
|
|
213
|
+
return fail("INTERCEPTOR_VIOLATION", "requestInterceptor");
|
|
214
|
+
}
|
|
215
|
+
// ADR-0039 (since 2.9): params may bind whole $context.route.query.* /
|
|
216
|
+
// $context.route.params.* values (any context). A missing route key (or an
|
|
217
|
+
// absent route input) is a tombstone: the parameter is deleted from the URL
|
|
218
|
+
// and the request still succeeds (ADR-0010 semantics, data-ref-route-* cases).
|
|
219
|
+
const route = input.route ?? {};
|
|
220
|
+
const { path, query } = splitUrl(url);
|
|
221
|
+
const params = dataRef.params ?? {};
|
|
222
|
+
for (const [key, value] of Object.entries(params)) {
|
|
223
|
+
if (typeof value === "string" && value.startsWith("$context.route.")) {
|
|
224
|
+
const resolved = resolveRouteExpr(value, route, "dataRef.params." + key);
|
|
225
|
+
if (!resolved.ok) {
|
|
226
|
+
// Missing route key / no route context: tombstone delete, keep going.
|
|
227
|
+
query.delete(key);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
query.set(key, resolved.value);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (value === null) {
|
|
234
|
+
query.delete(key);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
query.set(key, serializeQueryValue(value));
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
request: { method: "GET", url: buildUrl(path, query), body: null },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function isRelativeProtocolUrl(url) {
|
|
245
|
+
if (typeof url !== "string" || !url.startsWith("/") || url.startsWith("//")) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
const pathOnly = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
|
|
249
|
+
return isProtocolRelativeUrl(pathOnly) || isProtocolRelativeUrl(url);
|
|
250
|
+
}
|
|
251
|
+
function buildRowAction(input) {
|
|
252
|
+
const action = input.action;
|
|
253
|
+
const method = action.method;
|
|
254
|
+
const url = action.url;
|
|
255
|
+
if (!isRelativeProtocolUrl(url)) {
|
|
256
|
+
return fail("INVALID_PROTOCOL_URL", "action.url");
|
|
257
|
+
}
|
|
258
|
+
const mapping = input.requestMapping ?? {};
|
|
259
|
+
const row = input.row;
|
|
260
|
+
const { path: basePath, query } = splitUrl(url);
|
|
261
|
+
const pathMap = mapping.path ?? {};
|
|
262
|
+
const bindings = {};
|
|
263
|
+
for (const [key, expr] of Object.entries(pathMap)) {
|
|
264
|
+
const resolved = resolveMappingValue(expr, row, `requestMapping.path.${key}`, {
|
|
265
|
+
pathSlot: true,
|
|
266
|
+
});
|
|
267
|
+
if (!resolved.ok)
|
|
268
|
+
return resolved;
|
|
269
|
+
bindings[key] = String(resolved.value);
|
|
270
|
+
}
|
|
271
|
+
const bound = applyPathBindings(basePath, bindings, "requestMapping.path");
|
|
272
|
+
if (!bound.ok)
|
|
273
|
+
return bound;
|
|
274
|
+
const queryMap = mapping.query ?? {};
|
|
275
|
+
for (const [key, expr] of Object.entries(queryMap)) {
|
|
276
|
+
const resolved = resolveMappingValue(expr, row, `requestMapping.query.${key}`, {
|
|
277
|
+
allowNullAsTombstone: true,
|
|
278
|
+
});
|
|
279
|
+
if (!resolved.ok)
|
|
280
|
+
return resolved;
|
|
281
|
+
if (resolved.tombstone) {
|
|
282
|
+
query.delete(key);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
query.set(key, serializeQueryValue(resolved.value));
|
|
286
|
+
}
|
|
287
|
+
let body = null;
|
|
288
|
+
const bodyMap = mapping.body;
|
|
289
|
+
if (bodyMap !== undefined) {
|
|
290
|
+
const out = {};
|
|
291
|
+
for (const [key, expr] of Object.entries(bodyMap)) {
|
|
292
|
+
const resolved = resolveMappingValue(expr, row, `requestMapping.body.${key}`, {});
|
|
293
|
+
if (!resolved.ok)
|
|
294
|
+
return resolved;
|
|
295
|
+
out[key] = resolved.value;
|
|
296
|
+
}
|
|
297
|
+
body = out;
|
|
298
|
+
}
|
|
299
|
+
const idem = applyIdempotency(action, input.invocationId, "action");
|
|
300
|
+
if (!idem.ok)
|
|
301
|
+
return idem;
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
request: {
|
|
305
|
+
method,
|
|
306
|
+
url: buildUrl(bound.path, query),
|
|
307
|
+
body,
|
|
308
|
+
...(idem.headers ? { headers: idem.headers } : {}),
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function formFieldIncluded(proj) {
|
|
313
|
+
if (!proj)
|
|
314
|
+
return true;
|
|
315
|
+
if (proj.visible === false)
|
|
316
|
+
return false;
|
|
317
|
+
if (proj.disabled === true)
|
|
318
|
+
return false;
|
|
319
|
+
if (proj.uploadStatus === "error")
|
|
320
|
+
return false;
|
|
321
|
+
return Object.prototype.hasOwnProperty.call(proj, "value");
|
|
322
|
+
}
|
|
323
|
+
function buildFormAction(input) {
|
|
324
|
+
const action = input.action;
|
|
325
|
+
const method = action.method;
|
|
326
|
+
const url = action.url;
|
|
327
|
+
if (typeof url !== "string" || !isProtocolRelativeUrl(url)) {
|
|
328
|
+
return fail("INVALID_PROTOCOL_URL", "action.url");
|
|
329
|
+
}
|
|
330
|
+
if (method === "GET") {
|
|
331
|
+
return fail("FORM_GET_NOT_ALLOWED", "action.method");
|
|
332
|
+
}
|
|
333
|
+
const formValues = input.formValues ?? {};
|
|
334
|
+
const formProjection = input.formProjection;
|
|
335
|
+
const bodyMapping = action.bodyMapping;
|
|
336
|
+
let sourceValues = {};
|
|
337
|
+
if (formProjection) {
|
|
338
|
+
for (const [field, proj] of Object.entries(formProjection)) {
|
|
339
|
+
if (!formFieldIncluded(proj))
|
|
340
|
+
continue;
|
|
341
|
+
sourceValues[field] = proj.value;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
sourceValues = { ...formValues };
|
|
346
|
+
}
|
|
347
|
+
let body = {};
|
|
348
|
+
if (bodyMapping) {
|
|
349
|
+
for (const [source, target] of Object.entries(bodyMapping)) {
|
|
350
|
+
if (!Object.prototype.hasOwnProperty.call(sourceValues, source)) {
|
|
351
|
+
// When projection omits field (no value key) or formValues missing
|
|
352
|
+
return fail("UNRESOLVED_FORM_VALUE", `bodyMapping.${source}`);
|
|
353
|
+
}
|
|
354
|
+
body[target] = sourceValues[source];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
body = { ...sourceValues };
|
|
359
|
+
}
|
|
360
|
+
const idem = applyIdempotency(action, input.invocationId, "action");
|
|
361
|
+
if (!idem.ok)
|
|
362
|
+
return idem;
|
|
363
|
+
const finalUrl = joinBase(input.baseURL, url);
|
|
364
|
+
const result = {
|
|
365
|
+
ok: true,
|
|
366
|
+
request: {
|
|
367
|
+
method,
|
|
368
|
+
url: finalUrl,
|
|
369
|
+
body,
|
|
370
|
+
...(idem.headers ? { headers: idem.headers } : {}),
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
if (input.baseURL) {
|
|
374
|
+
result.resolvedBase = "api.baseURL";
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
function buildRowNavigate(input) {
|
|
379
|
+
const action = input.action;
|
|
380
|
+
const url = action.url;
|
|
381
|
+
const mapping = input.navigateMapping;
|
|
382
|
+
if (!mapping || Object.keys(mapping).length === 0) {
|
|
383
|
+
return fail("EMPTY_NAVIGATE_MAPPING", "navigateMapping");
|
|
384
|
+
}
|
|
385
|
+
if (mapping.body !== undefined) {
|
|
386
|
+
return fail("NAVIGATE_BODY_NOT_ALLOWED", "navigateMapping.body");
|
|
387
|
+
}
|
|
388
|
+
const row = input.row;
|
|
389
|
+
const { path: basePath, query } = splitUrl(url);
|
|
390
|
+
const pathMap = mapping.path ?? {};
|
|
391
|
+
const bindings = {};
|
|
392
|
+
for (const [key, expr] of Object.entries(pathMap)) {
|
|
393
|
+
const resolved = resolveMappingValue(expr, row, `navigateMapping.path.${key}`, {
|
|
394
|
+
pathSlot: true,
|
|
395
|
+
});
|
|
396
|
+
if (!resolved.ok)
|
|
397
|
+
return resolved;
|
|
398
|
+
bindings[key] = String(resolved.value);
|
|
399
|
+
}
|
|
400
|
+
const bound = applyPathBindings(basePath, bindings, "navigateMapping.path");
|
|
401
|
+
if (!bound.ok)
|
|
402
|
+
return bound;
|
|
403
|
+
const queryMap = mapping.query ?? {};
|
|
404
|
+
for (const [key, expr] of Object.entries(queryMap)) {
|
|
405
|
+
if (typeof expr === "string" && expr.startsWith("$") && !expr.startsWith("$row.")) {
|
|
406
|
+
return fail("INVALID_MAPPING_VALUE", `navigateMapping.query.${key}`);
|
|
407
|
+
}
|
|
408
|
+
const resolved = resolveMappingValue(expr, row, `navigateMapping.query.${key}`, {
|
|
409
|
+
allowNullAsTombstone: true,
|
|
410
|
+
});
|
|
411
|
+
if (!resolved.ok)
|
|
412
|
+
return resolved;
|
|
413
|
+
if (resolved.tombstone) {
|
|
414
|
+
query.delete(key);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
query.set(key, serializeQueryValue(resolved.value));
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
navigation: { url: buildUrl(bound.path, query) },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function buildRecordSource(input) {
|
|
425
|
+
const rs = input.recordSource;
|
|
426
|
+
if (rs.ref !== undefined) {
|
|
427
|
+
return fail("RECORD_SOURCE_REF_NOT_ALLOWED", "recordSource");
|
|
428
|
+
}
|
|
429
|
+
if (rs.method === undefined) {
|
|
430
|
+
return fail("MISSING_RECORD_SOURCE_METHOD", "recordSource.method");
|
|
431
|
+
}
|
|
432
|
+
if (rs.method !== "GET") {
|
|
433
|
+
return fail("RECORD_SOURCE_METHOD_NOT_GET", "recordSource.method");
|
|
434
|
+
}
|
|
435
|
+
const responseMapping = rs.responseMapping;
|
|
436
|
+
if (!responseMapping || Object.keys(responseMapping).length === 0) {
|
|
437
|
+
return fail("EMPTY_RESPONSE_MAPPING", "recordSource.responseMapping");
|
|
438
|
+
}
|
|
439
|
+
const url = rs.url;
|
|
440
|
+
// D-001 P0: recordSource must be a strict relative protocol URL (single
|
|
441
|
+
// slash, no `//`), matching rowAction/upload validation. `//host` would be
|
|
442
|
+
// resolved as a protocol-relative external URL, and authFetch would attach
|
|
443
|
+
// the Bearer access token to the external request.
|
|
444
|
+
if (typeof url !== "string" || !isRelativeProtocolUrl(url)) {
|
|
445
|
+
return fail("INVALID_PROTOCOL_URL", "recordSource.url");
|
|
446
|
+
}
|
|
447
|
+
const route = input.route ?? {};
|
|
448
|
+
const { path: basePath, query } = splitUrl(url);
|
|
449
|
+
const pathMap = rs.path ?? {};
|
|
450
|
+
const bindings = {};
|
|
451
|
+
for (const [key, expr] of Object.entries(pathMap)) {
|
|
452
|
+
if (typeof expr !== "string") {
|
|
453
|
+
return fail("INVALID_MAPPING_VALUE", `recordSource.path.${key}`);
|
|
454
|
+
}
|
|
455
|
+
const resolved = resolveRouteExpr(expr, route, `recordSource.path.${key}`);
|
|
456
|
+
if (!resolved.ok)
|
|
457
|
+
return resolved;
|
|
458
|
+
bindings[key] = resolved.value;
|
|
459
|
+
}
|
|
460
|
+
const bound = applyPathBindings(basePath, bindings, "recordSource.path");
|
|
461
|
+
if (!bound.ok)
|
|
462
|
+
return bound;
|
|
463
|
+
const queryMap = rs.query ?? {};
|
|
464
|
+
for (const [key, value] of Object.entries(queryMap)) {
|
|
465
|
+
query.set(key, serializeQueryValue(value));
|
|
466
|
+
}
|
|
467
|
+
const finalUrl = joinBase(input.baseURL, buildUrl(bound.path, query));
|
|
468
|
+
const result = {
|
|
469
|
+
ok: true,
|
|
470
|
+
request: { method: "GET", url: finalUrl, body: null },
|
|
471
|
+
};
|
|
472
|
+
if (input.baseURL) {
|
|
473
|
+
result.resolvedBase = "api.baseURL";
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
function buildPageTriggerRequest(input) {
|
|
478
|
+
const confirm = checkConfirm(input);
|
|
479
|
+
if (confirm)
|
|
480
|
+
return confirm;
|
|
481
|
+
const action = input.action;
|
|
482
|
+
const method = action.method;
|
|
483
|
+
const url = action.url;
|
|
484
|
+
if (method === "GET") {
|
|
485
|
+
return fail("PAGE_TRIGGER_METHOD_NOT_ALLOWED", "action.method");
|
|
486
|
+
}
|
|
487
|
+
// W4 P1-2: same strict relative-protocol check as every other builder
|
|
488
|
+
// (rejects `//`, backslash, whitespace — `/\\evil.com` would otherwise be
|
|
489
|
+
// parsed by the browser as the external host `//evil.com`).
|
|
490
|
+
if (!isRelativeProtocolUrl(url)) {
|
|
491
|
+
return fail("INVALID_PROTOCOL_URL", "action.url");
|
|
492
|
+
}
|
|
493
|
+
if (extractPathParams(url).length > 0) {
|
|
494
|
+
return fail("UNBOUND_URL_TEMPLATE", "action.url");
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
ok: true,
|
|
498
|
+
request: { method, url, body: null },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function buildPageTriggerNavigate(input) {
|
|
502
|
+
const confirm = checkConfirm(input);
|
|
503
|
+
if (confirm)
|
|
504
|
+
return confirm;
|
|
505
|
+
const action = input.action;
|
|
506
|
+
const url = action.url;
|
|
507
|
+
// W4 P1-2: same strict relative-protocol check as buildPageTriggerRequest.
|
|
508
|
+
if (!isRelativeProtocolUrl(url)) {
|
|
509
|
+
return fail("INVALID_PROTOCOL_URL", "action.url");
|
|
510
|
+
}
|
|
511
|
+
if (extractPathParams(url).length > 0) {
|
|
512
|
+
return fail("UNBOUND_URL_TEMPLATE", "action.url");
|
|
513
|
+
}
|
|
514
|
+
const finalUrl = joinBase(input.appRouteRoot, url);
|
|
515
|
+
const result = {
|
|
516
|
+
ok: true,
|
|
517
|
+
navigation: { url: finalUrl },
|
|
518
|
+
};
|
|
519
|
+
if (input.appRouteRoot) {
|
|
520
|
+
result.resolvedBase = "app.routeRoot";
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
function buildPageTriggerModal(input) {
|
|
525
|
+
const confirm = checkConfirm(input);
|
|
526
|
+
if (confirm)
|
|
527
|
+
return confirm;
|
|
528
|
+
const action = input.action;
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
modalOpen: { modalId: String(action.modalId) },
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function buildOutcomeNavigate(input) {
|
|
535
|
+
const url = input.url;
|
|
536
|
+
// W4 P1-2: navigation outcomes must stay inside the app — reject absolute
|
|
537
|
+
// URLs (open-redirect surface) and protocol-relative/backslash smuggling.
|
|
538
|
+
// `input.appRouteRoot` is applied on top of a validated relative path.
|
|
539
|
+
if (!isRelativeProtocolUrl(url)) {
|
|
540
|
+
return fail("INVALID_PROTOCOL_URL", "navigation.url");
|
|
541
|
+
}
|
|
542
|
+
const finalUrl = joinBase(input.appRouteRoot, url);
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
navigation: { url: finalUrl },
|
|
546
|
+
resolvedBase: "app.routeRoot",
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// --- Batch request (ADR-0022 D3/D5 · I-PROTO-FULL-001 include) ---
|
|
550
|
+
function isScalarSelectionKey(value) {
|
|
551
|
+
if (typeof value === "string") {
|
|
552
|
+
return value !== "";
|
|
553
|
+
}
|
|
554
|
+
if (typeof value === "number") {
|
|
555
|
+
return Number.isFinite(value);
|
|
556
|
+
}
|
|
557
|
+
return typeof value === "boolean";
|
|
558
|
+
}
|
|
559
|
+
/** D3 invariants: scalar keys only, dedupe preserving order, count = keys.length. */
|
|
560
|
+
export function normalizeSelection(keys) {
|
|
561
|
+
const seen = new Set();
|
|
562
|
+
const out = [];
|
|
563
|
+
for (const key of keys) {
|
|
564
|
+
if (!isScalarSelectionKey(key)) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
// Strings and numbers do not interconvert ("1" and 1 are distinct keys).
|
|
568
|
+
const token = `${typeof key}:${String(key)}`;
|
|
569
|
+
if (seen.has(token)) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
seen.add(token);
|
|
573
|
+
out.push(key);
|
|
574
|
+
}
|
|
575
|
+
return { keys: out, count: out.length };
|
|
576
|
+
}
|
|
577
|
+
function buildBatchRequest(input) {
|
|
578
|
+
const confirm = checkConfirm(input);
|
|
579
|
+
if (confirm)
|
|
580
|
+
return confirm;
|
|
581
|
+
const action = input.action;
|
|
582
|
+
const method = action.method;
|
|
583
|
+
const url = action.url;
|
|
584
|
+
if (method === "GET") {
|
|
585
|
+
return fail("PAGE_TRIGGER_METHOD_NOT_ALLOWED", "action.method");
|
|
586
|
+
}
|
|
587
|
+
if (typeof url !== "string" || !isProtocolRelativeUrl(url)) {
|
|
588
|
+
return fail("INVALID_PROTOCOL_URL", "action.url");
|
|
589
|
+
}
|
|
590
|
+
const batchMapping = input.batchMapping;
|
|
591
|
+
if (!batchMapping) {
|
|
592
|
+
return fail("EMPTY_BATCH_MAPPING", "batchMapping");
|
|
593
|
+
}
|
|
594
|
+
const selection = input.selection;
|
|
595
|
+
const rawKeys = Array.isArray(selection?.keys) ? selection.keys : [];
|
|
596
|
+
// D3/V274/V281: normalize before any request (ignores host-provided count).
|
|
597
|
+
const normalized = normalizeSelection(rawKeys);
|
|
598
|
+
if (normalized.count === 0) {
|
|
599
|
+
return fail("EMPTY_SELECTION", "selection");
|
|
600
|
+
}
|
|
601
|
+
const { path: basePath, query } = splitUrl(url);
|
|
602
|
+
// path: literals only; bindings must exactly match url placeholders (V267).
|
|
603
|
+
const pathMap = batchMapping.path ?? {};
|
|
604
|
+
const bindings = {};
|
|
605
|
+
for (const [key, expr] of Object.entries(pathMap)) {
|
|
606
|
+
if (typeof expr === "string" && expr.startsWith("$")) {
|
|
607
|
+
return fail("INVALID_MAPPING_VALUE", `batchMapping.path.${key}`);
|
|
608
|
+
}
|
|
609
|
+
bindings[key] = String(expr);
|
|
610
|
+
}
|
|
611
|
+
const bound = applyPathBindings(basePath, bindings, "batchMapping.path");
|
|
612
|
+
if (!bound.ok)
|
|
613
|
+
return bound;
|
|
614
|
+
// query: literals or $selection.count (scalar); $selection.keys is body-only.
|
|
615
|
+
const queryMap = batchMapping.query ?? {};
|
|
616
|
+
for (const [key, expr] of Object.entries(queryMap)) {
|
|
617
|
+
if (expr === "$selection.keys") {
|
|
618
|
+
return fail("SELECTION_KEYS_BODY_ONLY", `batchMapping.query.${key}`);
|
|
619
|
+
}
|
|
620
|
+
const resolved = resolveBatchValue(expr, normalized, `batchMapping.query.${key}`, {
|
|
621
|
+
scalarOnly: true,
|
|
622
|
+
});
|
|
623
|
+
if (!resolved.ok)
|
|
624
|
+
return resolved;
|
|
625
|
+
query.set(key, serializeQueryValue(resolved.value));
|
|
626
|
+
}
|
|
627
|
+
// body: flat map; values are literals, $selection.keys (array) or
|
|
628
|
+
// $selection.count (scalar).
|
|
629
|
+
let body = null;
|
|
630
|
+
const bodyMap = batchMapping.body;
|
|
631
|
+
if (bodyMap !== undefined) {
|
|
632
|
+
const out = {};
|
|
633
|
+
for (const [key, expr] of Object.entries(bodyMap)) {
|
|
634
|
+
const resolved = resolveBatchValue(expr, normalized, `batchMapping.body.${key}`, {});
|
|
635
|
+
if (!resolved.ok)
|
|
636
|
+
return resolved;
|
|
637
|
+
out[key] = resolved.value;
|
|
638
|
+
}
|
|
639
|
+
body = out;
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
request: { method, url: buildUrl(bound.path, query), body },
|
|
644
|
+
selectionAfterSuccessReload: { keys: [], count: 0 },
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function resolveBatchValue(expr, selection, path, opts) {
|
|
648
|
+
if (typeof expr !== "string" || !expr.includes("$")) {
|
|
649
|
+
if (expr === null) {
|
|
650
|
+
return { ok: true, value: null };
|
|
651
|
+
}
|
|
652
|
+
return { ok: true, value: expr };
|
|
653
|
+
}
|
|
654
|
+
if (expr === "$selection.keys") {
|
|
655
|
+
if (opts.scalarOnly) {
|
|
656
|
+
return { ok: false, code: "INVALID_QUERY_VALUE", path };
|
|
657
|
+
}
|
|
658
|
+
return { ok: true, value: [...selection.keys] };
|
|
659
|
+
}
|
|
660
|
+
if (expr === "$selection.count") {
|
|
661
|
+
return { ok: true, value: selection.count };
|
|
662
|
+
}
|
|
663
|
+
return { ok: false, code: "INVALID_MAPPING_VALUE", path };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Run one request-construction fixture case.
|
|
667
|
+
* Batch kinds return a structured error; stage3 excludes them via Q1.
|
|
668
|
+
*/
|
|
669
|
+
export function constructRequest(input) {
|
|
670
|
+
const kind = input.kind;
|
|
671
|
+
switch (kind) {
|
|
672
|
+
case "dataRef":
|
|
673
|
+
return buildDataRef(input);
|
|
674
|
+
case "rowAction":
|
|
675
|
+
return buildRowAction(input);
|
|
676
|
+
case "formAction":
|
|
677
|
+
return buildFormAction(input);
|
|
678
|
+
case "rowNavigate":
|
|
679
|
+
return buildRowNavigate(input);
|
|
680
|
+
case "recordSource":
|
|
681
|
+
return buildRecordSource(input);
|
|
682
|
+
case "pageTriggerRequest":
|
|
683
|
+
return buildPageTriggerRequest(input);
|
|
684
|
+
case "pageTriggerNavigate":
|
|
685
|
+
return buildPageTriggerNavigate(input);
|
|
686
|
+
case "pageTriggerModal":
|
|
687
|
+
return buildPageTriggerModal(input);
|
|
688
|
+
case "outcomeNavigate":
|
|
689
|
+
return buildOutcomeNavigate(input);
|
|
690
|
+
case "batchRequest":
|
|
691
|
+
return buildBatchRequest(input);
|
|
692
|
+
default:
|
|
693
|
+
return fail("INVALID_MAPPING_VALUE", "kind");
|
|
694
|
+
}
|
|
695
|
+
}
|