@sackville-mcp/api 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/index.d.mts +898 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3221 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +44 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3221 @@
|
|
|
1
|
+
import { applyOp } from "@sackville-mcp/assert";
|
|
2
|
+
import { JSONPath } from "jsonpath-plus";
|
|
3
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
|
+
import addFormatsModule from "ajv-formats";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { bruToEnvJsonV2, bruToJsonV2, envJsonToBruV2, jsonToBruV2 } from "@usebruno/lang";
|
|
8
|
+
import { parse } from "yaml";
|
|
9
|
+
import { GraphQLError, Kind, TypeInfo, buildSchema, getNamedType, getVariableValues, isInputObjectType, isNonNullType, isScalarType, parse as parse$1, print, typeFromAST, validate, visit, visitWithTypeInfo } from "graphql";
|
|
10
|
+
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { performance } from "node:perf_hooks";
|
|
13
|
+
import { Redactor, SsrfError, resolveAndPin } from "@sackville-mcp/safety";
|
|
14
|
+
import { FormData, request } from "undici";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { getQuickJS, shouldInterruptAfterDeadline } from "quickjs-emscripten";
|
|
17
|
+
//#region src/artifacts.ts
|
|
18
|
+
/**
|
|
19
|
+
* In-memory store for response bodies, addressed by a `sackville://run/<id>/body`
|
|
20
|
+
* handle. Agents/CLIs fetch bodies by handle so large payloads are never inlined
|
|
21
|
+
* into tool results. (A persistent backend can replace this later.)
|
|
22
|
+
*/
|
|
23
|
+
var ArtifactStore = class {
|
|
24
|
+
artifacts = /* @__PURE__ */ new Map();
|
|
25
|
+
put(runId, body, contentType) {
|
|
26
|
+
const handle = `sackville://run/${runId}/body`;
|
|
27
|
+
this.artifacts.set(handle, {
|
|
28
|
+
body,
|
|
29
|
+
contentType
|
|
30
|
+
});
|
|
31
|
+
return handle;
|
|
32
|
+
}
|
|
33
|
+
get(handle) {
|
|
34
|
+
return this.artifacts.get(handle);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/schema.ts
|
|
39
|
+
/**
|
|
40
|
+
* JSON Schema validation (draft 2020-12) via ajv. Shared by the `schema`
|
|
41
|
+
* assertion source and the OpenAPI 3.1 contract validator — OpenAPI 3.1's
|
|
42
|
+
* Schema Object *is* JSON Schema 2020-12, so one validator serves both.
|
|
43
|
+
*/
|
|
44
|
+
const addFormats = addFormatsModule.default;
|
|
45
|
+
const ajv = new Ajv2020({
|
|
46
|
+
allErrors: true,
|
|
47
|
+
strict: false
|
|
48
|
+
});
|
|
49
|
+
addFormats(ajv);
|
|
50
|
+
function toError(e) {
|
|
51
|
+
const where = e.instancePath || "(root)";
|
|
52
|
+
const detail = e.message ?? "is invalid";
|
|
53
|
+
const extra = e.keyword === "required" && typeof e.params?.missingProperty === "string" ? `: '${e.params.missingProperty}'` : "";
|
|
54
|
+
return {
|
|
55
|
+
instancePath: e.instancePath,
|
|
56
|
+
message: `${where} ${detail}${extra}`
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Validate `data` against a JSON Schema. Never throws on data; an invalid
|
|
60
|
+
* *schema* surfaces as a single error rather than propagating. */
|
|
61
|
+
function validateSchema(schema, data) {
|
|
62
|
+
let validate;
|
|
63
|
+
try {
|
|
64
|
+
validate = ajv.compile(schema);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
errors: [{
|
|
69
|
+
instancePath: "",
|
|
70
|
+
message: `invalid schema: ${err.message}`
|
|
71
|
+
}]
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const valid = validate(data);
|
|
75
|
+
return {
|
|
76
|
+
valid,
|
|
77
|
+
errors: valid ? [] : (validate.errors ?? []).map(toError)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/assert.ts
|
|
82
|
+
/** Resolve a value from a response by source kind (shared by assertions + captures). */
|
|
83
|
+
function valueFrom(source, ctx, opts) {
|
|
84
|
+
switch (source) {
|
|
85
|
+
case "status": return ctx.status;
|
|
86
|
+
case "statusText": return ctx.statusText;
|
|
87
|
+
case "responseTime": return ctx.latencyMs;
|
|
88
|
+
case "body": return ctx.bodyText;
|
|
89
|
+
case "header": return opts.name ? ctx.headers[opts.name.toLowerCase()] : void 0;
|
|
90
|
+
case "jsonpath": return JSONPath({
|
|
91
|
+
path: opts.path ?? "$",
|
|
92
|
+
json: ctx.json,
|
|
93
|
+
wrap: false,
|
|
94
|
+
eval: false
|
|
95
|
+
});
|
|
96
|
+
default: return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Evaluate declarative assertions against a response. */
|
|
100
|
+
function evaluateAssertions(specs, ctx) {
|
|
101
|
+
return specs.map((spec) => {
|
|
102
|
+
if (spec.source === "schema") {
|
|
103
|
+
const subject = spec.path ? valueFrom("jsonpath", ctx, spec) : ctx.json;
|
|
104
|
+
const { valid, errors } = validateSchema(spec.value, subject);
|
|
105
|
+
return {
|
|
106
|
+
source: spec.source,
|
|
107
|
+
op: spec.op,
|
|
108
|
+
path: spec.path,
|
|
109
|
+
expected: spec.value,
|
|
110
|
+
actual: valid ? null : errors,
|
|
111
|
+
pass: valid
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const actual = valueFrom(spec.source, ctx, spec);
|
|
115
|
+
return {
|
|
116
|
+
source: spec.source,
|
|
117
|
+
op: spec.op,
|
|
118
|
+
path: spec.path,
|
|
119
|
+
name: spec.name,
|
|
120
|
+
expected: spec.value,
|
|
121
|
+
actual,
|
|
122
|
+
pass: applyOp(spec.op, actual, spec.value)
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Extract captured variables from a response into a name→value map. */
|
|
127
|
+
function extractCaptures(specs, ctx) {
|
|
128
|
+
const captured = {};
|
|
129
|
+
for (const spec of specs) captured[spec.var] = valueFrom(spec.source, ctx, spec);
|
|
130
|
+
return captured;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/collection.ts
|
|
134
|
+
const NON_REQUEST = new Set(["collection.bru", "folder.bru"]);
|
|
135
|
+
const RAW_BODY_TYPES = new Set([
|
|
136
|
+
"json",
|
|
137
|
+
"text",
|
|
138
|
+
"xml",
|
|
139
|
+
"sparql"
|
|
140
|
+
]);
|
|
141
|
+
const BODY_TYPE_ALIASES = {
|
|
142
|
+
formUrlEncoded: "form-urlencoded",
|
|
143
|
+
multipartForm: "multipart-form"
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Load a Bruno collection directory: each `<name>.bru` request (+ optional
|
|
147
|
+
* `<name>.sackville.yml` sidecar), plus any `environments/<Env>.bru` files.
|
|
148
|
+
*/
|
|
149
|
+
function loadCollection(dir) {
|
|
150
|
+
const requests = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const file of readdirSync(dir)) {
|
|
152
|
+
if (!file.endsWith(".bru") || NON_REQUEST.has(file)) continue;
|
|
153
|
+
const stem = basename(file, ".bru");
|
|
154
|
+
const entry = {
|
|
155
|
+
request: toRequest(stem, bruToJsonV2(readFileSync(join(dir, file), "utf8"))),
|
|
156
|
+
assertions: [],
|
|
157
|
+
captures: []
|
|
158
|
+
};
|
|
159
|
+
const sidecar = join(dir, `${stem}.sackville.yml`);
|
|
160
|
+
if (existsSync(sidecar)) {
|
|
161
|
+
const yaml = parse(readFileSync(sidecar, "utf8")) ?? {};
|
|
162
|
+
entry.assertions = yaml.assertions ?? [];
|
|
163
|
+
entry.captures = yaml.captures ?? [];
|
|
164
|
+
entry.preScript = yaml.preScript;
|
|
165
|
+
entry.postScript = yaml.postScript;
|
|
166
|
+
}
|
|
167
|
+
requests.set(stem, entry);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
dir,
|
|
171
|
+
requests,
|
|
172
|
+
environments: loadEnvironments(dir)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function loadEnvironments(dir) {
|
|
176
|
+
const environments = /* @__PURE__ */ new Map();
|
|
177
|
+
const envDir = join(dir, "environments");
|
|
178
|
+
if (!existsSync(envDir)) return environments;
|
|
179
|
+
for (const file of readdirSync(envDir)) {
|
|
180
|
+
if (!file.endsWith(".bru")) continue;
|
|
181
|
+
const parsed = bruToEnvJsonV2(readFileSync(join(envDir, file), "utf8"));
|
|
182
|
+
const vars = {};
|
|
183
|
+
for (const v of parsed.variables ?? []) if (v.enabled !== false && !v.secret) vars[v.name] = v.value;
|
|
184
|
+
environments.set(basename(file, ".bru"), vars);
|
|
185
|
+
}
|
|
186
|
+
return environments;
|
|
187
|
+
}
|
|
188
|
+
function toRequest(stem, parsed) {
|
|
189
|
+
const http = parsed.http ?? {};
|
|
190
|
+
const headers = (parsed.headers ?? []).filter((h) => h.enabled !== false).map((h) => ({
|
|
191
|
+
name: h.name,
|
|
192
|
+
value: h.value
|
|
193
|
+
}));
|
|
194
|
+
return {
|
|
195
|
+
name: parsed.meta?.name ?? stem,
|
|
196
|
+
method: String(http.method ?? "get").toUpperCase(),
|
|
197
|
+
url: http.url ?? "",
|
|
198
|
+
headers,
|
|
199
|
+
body: toBody(http.body, parsed.body)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function toBody(rawType, body) {
|
|
203
|
+
if (!rawType || rawType === "none") return void 0;
|
|
204
|
+
const type = BODY_TYPE_ALIASES[rawType] ?? rawType;
|
|
205
|
+
if (type === "form-urlencoded") return {
|
|
206
|
+
type,
|
|
207
|
+
params: (body?.formUrlEncoded ?? []).filter((p) => p.enabled !== false).map((p) => ({
|
|
208
|
+
name: p.name,
|
|
209
|
+
value: p.value
|
|
210
|
+
}))
|
|
211
|
+
};
|
|
212
|
+
if (type === "graphql") {
|
|
213
|
+
const gql = body?.graphql;
|
|
214
|
+
return {
|
|
215
|
+
type,
|
|
216
|
+
graphql: {
|
|
217
|
+
query: gql?.query ?? "",
|
|
218
|
+
variables: gql?.variables
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (type === "multipart-form") return {
|
|
223
|
+
type,
|
|
224
|
+
parts: toParts(body?.multipartForm ?? [])
|
|
225
|
+
};
|
|
226
|
+
if (type === "file") {
|
|
227
|
+
const selected = (body?.file ?? []).find((f) => f.selected !== false) ?? body?.file?.[0];
|
|
228
|
+
if (selected) return {
|
|
229
|
+
type,
|
|
230
|
+
file: {
|
|
231
|
+
filePath: selected.filePath,
|
|
232
|
+
contentType: selected.contentType
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
return { type };
|
|
236
|
+
}
|
|
237
|
+
if (RAW_BODY_TYPES.has(type)) {
|
|
238
|
+
const content = body?.[type];
|
|
239
|
+
if (typeof content === "string") return {
|
|
240
|
+
type,
|
|
241
|
+
content
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return { type };
|
|
245
|
+
}
|
|
246
|
+
function toParts(parts) {
|
|
247
|
+
return parts.filter((p) => p.enabled !== false).map((p) => {
|
|
248
|
+
if (p.type === "file") {
|
|
249
|
+
const filePaths = Array.isArray(p.value) ? p.value : [p.value];
|
|
250
|
+
return {
|
|
251
|
+
name: p.name,
|
|
252
|
+
kind: "file",
|
|
253
|
+
filePaths,
|
|
254
|
+
contentType: p.contentType || void 0
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const value = Array.isArray(p.value) ? p.value[0] ?? "" : p.value;
|
|
258
|
+
return {
|
|
259
|
+
name: p.name,
|
|
260
|
+
kind: "text",
|
|
261
|
+
value
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/contract.ts
|
|
267
|
+
/**
|
|
268
|
+
* OpenAPI 3.1 response-contract validation. OpenAPI 3.1's Schema Object *is*
|
|
269
|
+
* JSON Schema 2020-12, so response bodies are validated with the same ajv
|
|
270
|
+
* validator as the `schema` assertion (see ADR 0005 for why we validate
|
|
271
|
+
* directly rather than via openapi-backend). Surfaces drift: requests to
|
|
272
|
+
* undocumented operations, undocumented status codes, and bodies that violate
|
|
273
|
+
* the declared response schema.
|
|
274
|
+
*
|
|
275
|
+
* Scope: local `#/components/schemas/...` `$ref`s are resolved (rewritten into
|
|
276
|
+
* `$defs` so ajv handles recursion natively); **external local-file** `$ref`s
|
|
277
|
+
* are inlined when a `baseDir` is supplied (JSON + YAML, incl. the file's own
|
|
278
|
+
* internal refs, cycle-guarded). OpenAPI 3.0 `nullable` is shimmed to a 3.1 type
|
|
279
|
+
* union. Still out of scope: **remote (http) `$ref`s** (SSRF) and non-schema
|
|
280
|
+
* `$ref`s (parameters, shared `responses`).
|
|
281
|
+
*/
|
|
282
|
+
const COMPONENT_PREFIX = "#/components/schemas/";
|
|
283
|
+
/** Build a regex matching a path template (`/users/{id}`) against a concrete path. */
|
|
284
|
+
function pathToRegex(template) {
|
|
285
|
+
const escaped = template.split(/\{[^}]+\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^/]+");
|
|
286
|
+
return new RegExp(`^${escaped}/?$`);
|
|
287
|
+
}
|
|
288
|
+
function matchPath(paths, reqPath) {
|
|
289
|
+
const clean = reqPath.split("?")[0] ?? reqPath;
|
|
290
|
+
const exact = paths[clean];
|
|
291
|
+
if (exact) return {
|
|
292
|
+
template: clean,
|
|
293
|
+
item: exact
|
|
294
|
+
};
|
|
295
|
+
for (const [template, item] of Object.entries(paths)) if (item && pathToRegex(template).test(clean)) return {
|
|
296
|
+
template,
|
|
297
|
+
item
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Find the response object for a status, honoring `2XX` ranges and `default`. */
|
|
301
|
+
function findResponse(responses, status) {
|
|
302
|
+
const exact = responses[String(status)];
|
|
303
|
+
if (exact) return exact;
|
|
304
|
+
const digit = Math.floor(status / 100);
|
|
305
|
+
const range = responses[`${digit}XX`] ?? responses[`${digit}xx`];
|
|
306
|
+
if (range) return range;
|
|
307
|
+
return responses.default;
|
|
308
|
+
}
|
|
309
|
+
/** Pick the JSON response schema (prefers application/json, then */*, then first). */
|
|
310
|
+
function pickJsonSchema(response) {
|
|
311
|
+
const content = response.content;
|
|
312
|
+
if (!content) return void 0;
|
|
313
|
+
return (content["application/json"] ?? content["*/*"] ?? Object.values(content)[0])?.schema;
|
|
314
|
+
}
|
|
315
|
+
/** Navigate a JSON Pointer (`#/A/B`) within a document. */
|
|
316
|
+
function navigatePointer(doc, pointer) {
|
|
317
|
+
const path = pointer.replace(/^#/, "").split("/").filter(Boolean);
|
|
318
|
+
let node = doc;
|
|
319
|
+
for (const raw of path) {
|
|
320
|
+
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
321
|
+
if (!node || typeof node !== "object") return void 0;
|
|
322
|
+
node = node[key];
|
|
323
|
+
}
|
|
324
|
+
return node;
|
|
325
|
+
}
|
|
326
|
+
/** Load + parse a referenced file (JSON or YAML by extension), cached by path. */
|
|
327
|
+
function loadRefDoc(absPath, cache) {
|
|
328
|
+
const cached = cache.get(absPath);
|
|
329
|
+
if (cached !== void 0) return cached;
|
|
330
|
+
const text = readFileSync(absPath, "utf8");
|
|
331
|
+
const doc = /\.ya?ml$/i.test(absPath) ? parse(text) : JSON.parse(text);
|
|
332
|
+
cache.set(absPath, doc);
|
|
333
|
+
return doc;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Inline external local-file `$ref`s into a self-contained schema. A `$ref`
|
|
337
|
+
* pointing at `file#/pointer` is loaded from disk (relative to `baseDir`),
|
|
338
|
+
* navigated, and FULLY dereferenced — the external file's own internal (`#/…`)
|
|
339
|
+
* and nested external refs are inlined too, relative to that file. Internal
|
|
340
|
+
* refs of the MAIN document (`#/components/schemas/…`) are left intact (handled
|
|
341
|
+
* by the `$defs` rewrite). Remote (http) refs remain out of scope (SSRF). A
|
|
342
|
+
* cycle guard caps recursion on self-referential external schemas.
|
|
343
|
+
*/
|
|
344
|
+
function inlineExternalRefs(node, baseDir, cache, doc, stack) {
|
|
345
|
+
if (Array.isArray(node)) return node.map((n) => inlineExternalRefs(n, baseDir, cache, doc, stack));
|
|
346
|
+
if (!node || typeof node !== "object") return node;
|
|
347
|
+
const ref = node.$ref;
|
|
348
|
+
if (typeof ref === "string") {
|
|
349
|
+
if (!ref.startsWith("#")) {
|
|
350
|
+
const [filePart, pointer = ""] = ref.split("#");
|
|
351
|
+
const absPath = resolve(baseDir, filePart);
|
|
352
|
+
const key = `${absPath}#${pointer}`;
|
|
353
|
+
if (stack.has(key)) return {};
|
|
354
|
+
const refDoc = loadRefDoc(absPath, cache);
|
|
355
|
+
return inlineExternalRefs(navigatePointer(refDoc, `#${pointer}`), dirname(absPath), cache, refDoc, new Set([...stack, key]));
|
|
356
|
+
}
|
|
357
|
+
if (doc !== void 0) {
|
|
358
|
+
if (stack.has(ref)) return {};
|
|
359
|
+
return inlineExternalRefs(navigatePointer(doc, ref), baseDir, cache, doc, new Set([...stack, ref]));
|
|
360
|
+
}
|
|
361
|
+
return node;
|
|
362
|
+
}
|
|
363
|
+
const out = {};
|
|
364
|
+
for (const [k, v] of Object.entries(node)) out[k] = inlineExternalRefs(v, baseDir, cache, doc, stack);
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* OpenAPI 3.0 used `nullable: true` instead of 3.1's `type: ['string', 'null']`.
|
|
369
|
+
* ajv (JSON Schema 2020-12) ignores `nullable`, so without this shim a 3.0 doc
|
|
370
|
+
* gives a false failure on an explicit null. Rewrite `{type:'X', nullable:true}`
|
|
371
|
+
* → `{type:['X','null']}` and drop the (non-2020) `nullable` keyword everywhere.
|
|
372
|
+
*/
|
|
373
|
+
function shimNullable(node) {
|
|
374
|
+
if (Array.isArray(node)) return node.map(shimNullable);
|
|
375
|
+
if (!node || typeof node !== "object") return node;
|
|
376
|
+
const out = {};
|
|
377
|
+
const src = node;
|
|
378
|
+
for (const [key, value] of Object.entries(src)) {
|
|
379
|
+
if (key === "nullable") continue;
|
|
380
|
+
out[key] = shimNullable(value);
|
|
381
|
+
}
|
|
382
|
+
if (src.nullable === true) {
|
|
383
|
+
if (typeof src.type === "string") out.type = [src.type, "null"];
|
|
384
|
+
else if (Array.isArray(src.type) && !src.type.includes("null")) out.type = [...src.type, "null"];
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
/** Recursively rewrite `#/components/schemas/X` $refs to `#/$defs/X`. */
|
|
389
|
+
function rewriteRefs(node) {
|
|
390
|
+
if (Array.isArray(node)) return node.map(rewriteRefs);
|
|
391
|
+
if (node && typeof node === "object") {
|
|
392
|
+
const out = {};
|
|
393
|
+
for (const [key, value] of Object.entries(node)) if (key === "$ref" && typeof value === "string" && value.startsWith(COMPONENT_PREFIX)) out.$ref = `#/$defs/${value.slice(21)}`;
|
|
394
|
+
else out[key] = rewriteRefs(value);
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
return node;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Resolve `METHOD path` to its OpenAPI operation via path-template matching (shared
|
|
401
|
+
* by the response AND request validators — extracted so both resolve operations the
|
|
402
|
+
* same way). Returns `undefined` when no path template / method matches.
|
|
403
|
+
*/
|
|
404
|
+
function resolveOpenApiOperation(spec, method, path) {
|
|
405
|
+
const matched = spec.paths ? matchPath(spec.paths, path) : void 0;
|
|
406
|
+
const operation = matched?.item[method.toLowerCase()];
|
|
407
|
+
if (!matched || !operation) return void 0;
|
|
408
|
+
return {
|
|
409
|
+
operation,
|
|
410
|
+
template: matched.template,
|
|
411
|
+
pathItem: matched.item
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Normalize an OpenAPI schema for ajv (2020-12) and return a self-contained schema
|
|
416
|
+
* with component schemas merged into `$defs`. Shared by the response AND request
|
|
417
|
+
* (body + parameter) validators so every schema gets the same treatment: external
|
|
418
|
+
* local-file `$ref` inlining (when `baseDir` is set), the OpenAPI 3.0 `nullable`→
|
|
419
|
+
* type-union shim, and the `#/components/schemas/X`→`#/$defs/X` rewrite. A schema's
|
|
420
|
+
* own local `$defs` win over component defs on a name clash.
|
|
421
|
+
*/
|
|
422
|
+
function normalizeOpenApiSchema(schema, spec, opts = {}) {
|
|
423
|
+
const is30 = String(spec.openapi ?? "").startsWith("3.0");
|
|
424
|
+
const cache = /* @__PURE__ */ new Map();
|
|
425
|
+
const normalize = (sub) => {
|
|
426
|
+
const inlined = opts.baseDir ? inlineExternalRefs(sub, opts.baseDir, cache, void 0, /* @__PURE__ */ new Set()) : sub;
|
|
427
|
+
return rewriteRefs(is30 ? shimNullable(inlined) : inlined);
|
|
428
|
+
};
|
|
429
|
+
const componentDefs = Object.fromEntries(Object.entries(spec.components?.schemas ?? {}).map(([name, sub]) => [name, normalize(sub)]));
|
|
430
|
+
const rewritten = normalize(schema);
|
|
431
|
+
const localDefs = rewritten.$defs ?? {};
|
|
432
|
+
return {
|
|
433
|
+
...rewritten,
|
|
434
|
+
$defs: {
|
|
435
|
+
...componentDefs,
|
|
436
|
+
...localDefs
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function validateOpenApiResponse(spec, req, res, opts = {}) {
|
|
441
|
+
const findings = [];
|
|
442
|
+
const method = req.method.toLowerCase();
|
|
443
|
+
const resolved = resolveOpenApiOperation(spec, method, req.path);
|
|
444
|
+
if (!resolved) {
|
|
445
|
+
findings.push({
|
|
446
|
+
kind: "missing-operation",
|
|
447
|
+
severity: "error",
|
|
448
|
+
message: `no operation ${req.method.toUpperCase()} ${req.path} in the OpenAPI document`
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
valid: false,
|
|
452
|
+
findings
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const op = {
|
|
456
|
+
method,
|
|
457
|
+
path: resolved.template
|
|
458
|
+
};
|
|
459
|
+
const response = findResponse(resolved.operation.responses ?? {}, res.status);
|
|
460
|
+
if (!response) {
|
|
461
|
+
findings.push({
|
|
462
|
+
kind: "undocumented-status",
|
|
463
|
+
severity: "error",
|
|
464
|
+
message: `status ${res.status} is not documented for ${method.toUpperCase()} ${resolved.template}`
|
|
465
|
+
});
|
|
466
|
+
return {
|
|
467
|
+
valid: false,
|
|
468
|
+
findings,
|
|
469
|
+
operation: op
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const schema = pickJsonSchema(response);
|
|
473
|
+
if (schema !== void 0) {
|
|
474
|
+
const { valid, errors } = validateSchema(normalizeOpenApiSchema(schema, spec, opts), res.body);
|
|
475
|
+
if (!valid) for (const err of errors) findings.push({
|
|
476
|
+
kind: "response-schema",
|
|
477
|
+
severity: "error",
|
|
478
|
+
path: err.instancePath,
|
|
479
|
+
message: err.message
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
valid: findings.every((f) => f.severity !== "error"),
|
|
484
|
+
findings,
|
|
485
|
+
operation: op
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/graphql.ts
|
|
490
|
+
/**
|
|
491
|
+
* GraphQL operation + response validation via graphql-js. The high-value check
|
|
492
|
+
* is **drift**: validate a saved query against the server's *current* schema
|
|
493
|
+
* (SDL), so a field removed or renamed upstream surfaces as a finding rather
|
|
494
|
+
* than a silent `null`. Also inspects the response payload for a non-empty
|
|
495
|
+
* top-level `errors` array, and — when `variables` are supplied (ADR 0015) —
|
|
496
|
+
* validates the runtime variable VALUES against the operation's declared types.
|
|
497
|
+
*/
|
|
498
|
+
/** The five built-in scalars graphql-js can actually coerce. A custom scalar declared in
|
|
499
|
+
* SDL via `buildSchema` uses an identity `parseValue` (validates nothing), so a variable
|
|
500
|
+
* typed over one carries no signal and must be `unverified`-skipped — UNLESS the operator
|
|
501
|
+
* registered a coercer for it (ADR 0018), making its `parseValue` actually validate. */
|
|
502
|
+
const BUILTIN_SCALARS = new Set([
|
|
503
|
+
"Int",
|
|
504
|
+
"Float",
|
|
505
|
+
"String",
|
|
506
|
+
"Boolean",
|
|
507
|
+
"ID"
|
|
508
|
+
]);
|
|
509
|
+
/**
|
|
510
|
+
* Does this resolved type (unwrapping NonNull/List, and transitively through input-object
|
|
511
|
+
* fields) bottom out in a scalar that carries NO validation signal — i.e. a custom
|
|
512
|
+
* (non-built-in) scalar WITHOUT a registered coercer? Cycle-guarded by `seen` keyed on the
|
|
513
|
+
* input-object type name. Uses the schema-RESOLVED `GraphQLType` (via `typeFromAST`), never
|
|
514
|
+
* the AST node. `registered` is the set of custom scalars with an operator coercer (ADR 0018);
|
|
515
|
+
* pass an empty set for a coercer-INDEPENDENT check (e.g. the D2 directive-literal pass).
|
|
516
|
+
*/
|
|
517
|
+
function typeInvolvesCustomScalar(type, seen, registered) {
|
|
518
|
+
const named = getNamedType(type);
|
|
519
|
+
if (isScalarType(named)) return !BUILTIN_SCALARS.has(named.name) && !registered.has(named.name);
|
|
520
|
+
if (isInputObjectType(named)) {
|
|
521
|
+
if (seen.has(named.name)) return false;
|
|
522
|
+
seen.add(named.name);
|
|
523
|
+
return Object.values(named.getFields()).some((f) => typeInvolvesCustomScalar(f.type, seen, registered));
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Patch the operator-registered custom-scalar coercers onto the freshly-built schema (ADR
|
|
529
|
+
* 0018). Overwrites `parseValue` ONLY — NEVER `parseLiteral` (the redaction-leak guard, §3:
|
|
530
|
+
* `validate()` invokes `parseLiteral` on every custom-scalar LITERAL, and a coercer throw
|
|
531
|
+
* there would land the raw value + message into a finding). Variables traverse `parseValue`
|
|
532
|
+
* via `getVariableValues`, so that is all Feature B needs. Built-in scalar names are silently
|
|
533
|
+
* ignored (§8.5 — a built-in shadow could false-fire on a valid `@skip` Boolean variable).
|
|
534
|
+
* Returns the set of scalar names actually patched (the `registered` set). Safe to mutate the
|
|
535
|
+
* schema in place: `validateGraphqlOperation` builds a fresh, never-shared schema per call.
|
|
536
|
+
*/
|
|
537
|
+
function patchRegisteredScalars(schema, coercers) {
|
|
538
|
+
const registered = /* @__PURE__ */ new Set();
|
|
539
|
+
if (!coercers) return registered;
|
|
540
|
+
for (const [name, coercer] of Object.entries(coercers)) {
|
|
541
|
+
if (BUILTIN_SCALARS.has(name)) continue;
|
|
542
|
+
const t = schema.getType(name);
|
|
543
|
+
if (t && isScalarType(t)) {
|
|
544
|
+
t.parseValue = coercer;
|
|
545
|
+
registered.add(name);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return registered;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Validate the runtime `variables` of a SINGLE resolved operation against its declared
|
|
552
|
+
* variable types. Appends findings (reconstructed from variable NAME + declared TYPE +
|
|
553
|
+
* category — NEVER from graphql-js messages, which echo raw values) and returns whether
|
|
554
|
+
* anything was `unverified`.
|
|
555
|
+
*/
|
|
556
|
+
function validateVariables(schema, operation, variables, authoritative, registered, findings) {
|
|
557
|
+
if (typeof variables !== "object" || variables === null || Array.isArray(variables)) return true;
|
|
558
|
+
const vars = variables;
|
|
559
|
+
const varDefs = operation.variableDefinitions ?? [];
|
|
560
|
+
const declared = /* @__PURE__ */ new Set();
|
|
561
|
+
let unverified = false;
|
|
562
|
+
for (const vd of varDefs) {
|
|
563
|
+
const name = vd.variable.name.value;
|
|
564
|
+
declared.add(name);
|
|
565
|
+
const resolved = typeFromAST(schema, vd.type);
|
|
566
|
+
if (!resolved) continue;
|
|
567
|
+
const typeStr = print(vd.type);
|
|
568
|
+
if (typeInvolvesCustomScalar(resolved, /* @__PURE__ */ new Set(), registered)) {
|
|
569
|
+
unverified = true;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const result = getVariableValues(schema, [vd], vars);
|
|
573
|
+
if (!("errors" in result) || !result.errors || result.errors.length === 0) continue;
|
|
574
|
+
if (!(name in vars)) if (authoritative) findings.push({
|
|
575
|
+
kind: "graphql-variable-missing",
|
|
576
|
+
severity: "error",
|
|
577
|
+
message: `required variable "$${name}" of type "${typeStr}" was not provided`
|
|
578
|
+
});
|
|
579
|
+
else unverified = true;
|
|
580
|
+
else if (vars[name] === null && isNonNullType(resolved)) findings.push({
|
|
581
|
+
kind: "graphql-variable-invalid",
|
|
582
|
+
severity: "error",
|
|
583
|
+
message: `variable "$${name}" of non-null type "${typeStr}" must not be null`
|
|
584
|
+
});
|
|
585
|
+
else findings.push({
|
|
586
|
+
kind: "graphql-variable-invalid",
|
|
587
|
+
severity: "error",
|
|
588
|
+
message: `variable "$${name}" got an invalid value for type "${typeStr}"`
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
for (const key of Object.keys(vars)) if (!declared.has(key)) findings.push({
|
|
592
|
+
kind: "graphql-undocumented-variable",
|
|
593
|
+
severity: "warning",
|
|
594
|
+
message: `undocumented variable "$${key}" not declared by the operation`
|
|
595
|
+
});
|
|
596
|
+
return unverified;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* D2 (ADR 0018): does the document attach a directive-arg LITERAL whose type bottoms out in a
|
|
600
|
+
* custom (non-built-in) scalar? Such a literal is validated by NOTHING — a `buildSchema` custom
|
|
601
|
+
* scalar has an identity `parseLiteral` which we DELIBERATELY never patch (the redaction-leak
|
|
602
|
+
* guard, ADR 0018 §3) — so it carries no signal and must fold to `unverified`, never a finding
|
|
603
|
+
* (the literal may carry an inline secret) and never a silent pass.
|
|
604
|
+
*
|
|
605
|
+
* COERCER-INDEPENDENT (ADR 0018 BLOCKER-2): a registered variable coercer validates VARIABLE
|
|
606
|
+
* values via `parseValue`, never document literals, so this check passes NO `registered` set —
|
|
607
|
+
* a registered scalar's literal stays `unverified` all the same.
|
|
608
|
+
*
|
|
609
|
+
* Confined to DIRECTIVE-arg position (field-arg literals are out of scope, ADR 0018 S1); a
|
|
610
|
+
* variable-valued directive arg is handled by the variable loop, so it is skipped here. Reuses
|
|
611
|
+
* the transitive `typeInvolvesCustomScalar` so a list/input-object directive-arg literal with a
|
|
612
|
+
* nested custom scalar folds correctly. Caller must gate on a structurally-clean query.
|
|
613
|
+
*/
|
|
614
|
+
function hasCustomScalarDirectiveLiteral(schema, document) {
|
|
615
|
+
const typeInfo = new TypeInfo(schema);
|
|
616
|
+
let directiveDepth = 0;
|
|
617
|
+
let found = false;
|
|
618
|
+
visit(document, visitWithTypeInfo(typeInfo, {
|
|
619
|
+
Directive: {
|
|
620
|
+
enter: () => {
|
|
621
|
+
directiveDepth++;
|
|
622
|
+
},
|
|
623
|
+
leave: () => {
|
|
624
|
+
directiveDepth--;
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
Argument: (node) => {
|
|
628
|
+
if (directiveDepth === 0) return;
|
|
629
|
+
if (node.value.kind === Kind.VARIABLE) return;
|
|
630
|
+
const t = typeInfo.getInputType();
|
|
631
|
+
if (t && typeInvolvesCustomScalar(t, /* @__PURE__ */ new Set(), /* @__PURE__ */ new Set())) found = true;
|
|
632
|
+
}
|
|
633
|
+
}));
|
|
634
|
+
return found;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Validate a GraphQL `query` against a schema `sdl`; if `opts.json` is supplied,
|
|
638
|
+
* also check the response payload for returned `errors`. With `opts.operationName`
|
|
639
|
+
* the root-type drift check is scoped to that operation (which must exist). When
|
|
640
|
+
* `opts.variables` is supplied, the runtime variable values are validated against the
|
|
641
|
+
* operation's declared types (ADR 0015). `valid` is true only when no `error`-severity
|
|
642
|
+
* finding is present.
|
|
643
|
+
*/
|
|
644
|
+
function validateGraphqlOperation(sdl, query, opts = {}) {
|
|
645
|
+
const findings = [];
|
|
646
|
+
let schema;
|
|
647
|
+
try {
|
|
648
|
+
schema = buildSchema(sdl);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
findings.push({
|
|
651
|
+
kind: "graphql-validation",
|
|
652
|
+
severity: "error",
|
|
653
|
+
message: `invalid schema SDL: ${err.message}`
|
|
654
|
+
});
|
|
655
|
+
return {
|
|
656
|
+
valid: false,
|
|
657
|
+
findings
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const registered = patchRegisteredScalars(schema, opts.scalarCoercers);
|
|
661
|
+
let document;
|
|
662
|
+
try {
|
|
663
|
+
document = parse$1(query);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
const message = err instanceof GraphQLError ? err.message : err.message;
|
|
666
|
+
findings.push({
|
|
667
|
+
kind: "graphql-syntax",
|
|
668
|
+
severity: "error",
|
|
669
|
+
message
|
|
670
|
+
});
|
|
671
|
+
return {
|
|
672
|
+
valid: false,
|
|
673
|
+
findings
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
for (const err of validate(schema, document)) findings.push({
|
|
677
|
+
kind: "graphql-validation",
|
|
678
|
+
severity: "error",
|
|
679
|
+
message: err.message
|
|
680
|
+
});
|
|
681
|
+
const operations = document.definitions.filter((d) => d.kind === "OperationDefinition");
|
|
682
|
+
let targets = operations;
|
|
683
|
+
if (opts.operationName !== void 0) {
|
|
684
|
+
targets = operations.filter((d) => d.name?.value === opts.operationName);
|
|
685
|
+
if (targets.length === 0) findings.push({
|
|
686
|
+
kind: "graphql-validation",
|
|
687
|
+
severity: "error",
|
|
688
|
+
message: `no operation named "${opts.operationName}" in the document`
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
for (const def of targets) {
|
|
692
|
+
const op = def.operation;
|
|
693
|
+
if (!(op === "mutation" ? schema.getMutationType() : op === "subscription" ? schema.getSubscriptionType() : schema.getQueryType())) findings.push({
|
|
694
|
+
kind: "graphql-validation",
|
|
695
|
+
severity: "error",
|
|
696
|
+
message: `schema declares no root type for ${op} operations`
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
const queryClean = findings.every((f) => f.severity !== "error");
|
|
700
|
+
const directiveUnverified = queryClean && hasCustomScalarDirectiveLiteral(schema, document);
|
|
701
|
+
let variableUnverified = false;
|
|
702
|
+
if (opts.variables !== void 0 && queryClean) {
|
|
703
|
+
const targetOp = targets.length === 1 ? targets[0] : void 0;
|
|
704
|
+
if (!targetOp) variableUnverified = true;
|
|
705
|
+
else variableUnverified = validateVariables(schema, targetOp, opts.variables, opts.variablesAuthoritative === true, registered, findings);
|
|
706
|
+
}
|
|
707
|
+
const unverified = directiveUnverified || variableUnverified;
|
|
708
|
+
const payload = opts.json;
|
|
709
|
+
if (payload?.errors && payload.errors.length > 0) {
|
|
710
|
+
const messages = payload.errors.map((e) => e.message ?? "(no message)").join("; ");
|
|
711
|
+
findings.push({
|
|
712
|
+
kind: "graphql-errors",
|
|
713
|
+
severity: "error",
|
|
714
|
+
message: `response returned ${payload.errors.length} GraphQL error(s): ${messages}`
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
valid: findings.every((f) => f.severity !== "error"),
|
|
719
|
+
findings,
|
|
720
|
+
...unverified ? { unverified: true } : {},
|
|
721
|
+
...directiveUnverified ? { directiveUnverified: true } : {}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/request-contract.ts
|
|
726
|
+
/**
|
|
727
|
+
* OpenAPI 3.1 (and 3.0-compat) REQUEST-side contract validation — the sibling of
|
|
728
|
+
* `validateOpenApiResponse` (see ADR 0005; this milestone's design = the
|
|
729
|
+
* request-contract-validation-design fan-out). It validates the request half of an
|
|
730
|
+
* exchange against the declared operation: the request body against `requestBody`,
|
|
731
|
+
* and parameters against `parameters` (path/query/header). It reuses the response
|
|
732
|
+
* validator's extracted seams (`resolveOpenApiOperation`, `normalizeOpenApiSchema`)
|
|
733
|
+
* so operation resolution and schema treatment (3.0 `nullable` shim, local +
|
|
734
|
+
* external-local-file `$ref` deref) are identical across both halves.
|
|
735
|
+
*
|
|
736
|
+
* **Authority + the `unverified` channel (load-bearing, ADR 0013 absence-is-never-
|
|
737
|
+
* a-pass):** some request facts cannot be verified from every source. A captured HAR
|
|
738
|
+
* cannot distinguish "no body" from "a non-JSON body the bridge dropped", and cannot
|
|
739
|
+
* always supply query/header params. So callers declare what they KNOW:
|
|
740
|
+
* `bodyPresenceAuthoritative` / `paramsAuthoritative` (true for direct MCP/CLI
|
|
741
|
+
* surfaces that hold the real request; false/omitted for the capture path). When a
|
|
742
|
+
* required-but-absent body/param CANNOT be asserted as a breach (caller not
|
|
743
|
+
* authoritative), or a present body could not be schema-checked, the result carries
|
|
744
|
+
* `unverified: true` — NOT a finding — which the capture bridge folds into `noSignal`
|
|
745
|
+
* so it can never be laundered into a pass.
|
|
746
|
+
*/
|
|
747
|
+
/** True when a parsed body looks like a GraphQL-over-HTTP envelope (`{query: string,
|
|
748
|
+
* …}`). A direct surface uses this to refuse running OpenAPI body validation on a
|
|
749
|
+
* GraphQL request (which has no REST requestBody shape) — H4. */
|
|
750
|
+
function isGraphqlEnvelope(body) {
|
|
751
|
+
return !!body && typeof body === "object" && typeof body.query === "string";
|
|
752
|
+
}
|
|
753
|
+
/** Lower-cased media-type base (sans parameters), e.g. `application/json`. */
|
|
754
|
+
function mediaBase(ct) {
|
|
755
|
+
return (ct.split(";")[0] ?? "").trim().toLowerCase();
|
|
756
|
+
}
|
|
757
|
+
/** JSON-family media type: `application/json` or any `*+json` (e.g. `application/ld+json`). */
|
|
758
|
+
function isJsonMediaType(ct) {
|
|
759
|
+
const base = mediaBase(ct);
|
|
760
|
+
return base === "application/json" || base.endsWith("+json");
|
|
761
|
+
}
|
|
762
|
+
/** Merge path-item-level + operation-level `parameters`, operation winning on
|
|
763
|
+
* `(name, in)`. Returns raw params (a `$ref` param is handled by the caller). */
|
|
764
|
+
function mergeParameters(pathItem, operation) {
|
|
765
|
+
const collect = (x) => {
|
|
766
|
+
const ps = x?.parameters;
|
|
767
|
+
return Array.isArray(ps) ? ps : [];
|
|
768
|
+
};
|
|
769
|
+
const map = /* @__PURE__ */ new Map();
|
|
770
|
+
let i = 0;
|
|
771
|
+
for (const p of [...collect(pathItem), ...collect(operation)]) {
|
|
772
|
+
if (!p || typeof p !== "object") continue;
|
|
773
|
+
const key = typeof p.name === "string" && typeof p.in === "string" ? `${p.in}:${p.name}` : `#ref${i++}`;
|
|
774
|
+
map.set(key, p);
|
|
775
|
+
}
|
|
776
|
+
return [...map.values()];
|
|
777
|
+
}
|
|
778
|
+
const SCALAR_TYPES = new Set([
|
|
779
|
+
"string",
|
|
780
|
+
"number",
|
|
781
|
+
"integer",
|
|
782
|
+
"boolean"
|
|
783
|
+
]);
|
|
784
|
+
/** The scalar JSON types of a (normalized) schema, or `undefined` when the schema
|
|
785
|
+
* is non-scalar / typeless (array/object params are STAGED → inconclusive-skip). */
|
|
786
|
+
function scalarTypes(schema) {
|
|
787
|
+
const t = schema.type;
|
|
788
|
+
if (typeof t === "string") return SCALAR_TYPES.has(t) ? [t] : void 0;
|
|
789
|
+
if (Array.isArray(t)) {
|
|
790
|
+
const nonNull = t.filter((x) => x !== "null");
|
|
791
|
+
return nonNull.length > 0 && nonNull.every((x) => SCALAR_TYPES.has(x)) ? t : void 0;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/** `'array'`/`'object'` for a (normalized) non-scalar schema, else `undefined`. A
|
|
795
|
+
* `'null'`-augmented union (3.0 nullable shim) is tolerated; a union mixing
|
|
796
|
+
* array/object with a scalar (or with each other) is ambiguous → `undefined`
|
|
797
|
+
* (handled as a typeless skip, never a false validation). */
|
|
798
|
+
function nonScalarType(schema) {
|
|
799
|
+
const t = schema.type;
|
|
800
|
+
if (typeof t === "string") return t === "array" || t === "object" ? t : void 0;
|
|
801
|
+
if (Array.isArray(t)) {
|
|
802
|
+
const nonNull = t.filter((x) => x !== "null");
|
|
803
|
+
if (nonNull.length > 0 && nonNull.every((x) => x === "array")) return "array";
|
|
804
|
+
if (nonNull.length > 0 && nonNull.every((x) => x === "object")) return "object";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** A serialized array param cannot soundly prove its element COUNT from a single
|
|
808
|
+
* wire occurrence (it might be an explode-disagreement). When the array schema
|
|
809
|
+
* constrains cardinality, a single-occurrence value is `unverified`-skipped. */
|
|
810
|
+
function hasCardinalityConstraint(schema) {
|
|
811
|
+
return schema.minItems !== void 0 || schema.maxItems !== void 0 || schema.uniqueItems === true;
|
|
812
|
+
}
|
|
813
|
+
/** The delimiter joining elements of a non-exploded QUERY array for this style, or
|
|
814
|
+
* `undefined` when the query style isn't a supported array serialization. */
|
|
815
|
+
function queryArrayDelimiter(param) {
|
|
816
|
+
if (param.style === void 0 || param.style === "form") return ",";
|
|
817
|
+
if (param.style === "spaceDelimited") return " ";
|
|
818
|
+
if (param.style === "pipeDelimited") return "|";
|
|
819
|
+
}
|
|
820
|
+
/** Whether this param's (location, style, type) is a supported ARRAY serialization
|
|
821
|
+
* (query form/space/pipe-delimited, path simple/label/matrix, header simple). Explode +
|
|
822
|
+
* item-type soundness are resolved in the handler. */
|
|
823
|
+
function arraySerializationSupported(param) {
|
|
824
|
+
switch (param.in) {
|
|
825
|
+
case "query": return queryArrayDelimiter(param) !== void 0;
|
|
826
|
+
case "header": return param.style === void 0 || param.style === "simple";
|
|
827
|
+
case "path": return param.style === void 0 || param.style === "simple" || param.style === "label" || param.style === "matrix";
|
|
828
|
+
default: return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** A PATH array's split delimiter is `.` only for `label` + `explode` (RFC 6570
|
|
832
|
+
* `{.list*}` → `.a.b.c`). `.` is the one delimiter that occurs inside a JSON `number`
|
|
833
|
+
* (decimal point), so a `number`-typed label-explode array would over-split. */
|
|
834
|
+
function arraySplitUsesDot(param) {
|
|
835
|
+
return param.in === "path" && param.style === "label" && (param.explode ?? false) === true;
|
|
836
|
+
}
|
|
837
|
+
/** Decompose a serialized array value into its raw string elements, or `undefined`
|
|
838
|
+
* when the serialization can't be soundly reversed (malformed prefix / unsupported
|
|
839
|
+
* style) — the caller then `unverified`-skips. Query splits on the style delimiter;
|
|
840
|
+
* header `simple` splits on `,` and trims; PATH handles simple/label/matrix × explode
|
|
841
|
+
* (stripping the RFC 6570 `.`/`;name=` prefixes). */
|
|
842
|
+
function splitArrayValue(param, value) {
|
|
843
|
+
if (param.in === "query") {
|
|
844
|
+
const d = queryArrayDelimiter(param);
|
|
845
|
+
return d === void 0 ? void 0 : value.split(d);
|
|
846
|
+
}
|
|
847
|
+
if (param.in === "header") return value.split(",").map((s) => s.trim());
|
|
848
|
+
if (param.in === "path") {
|
|
849
|
+
const style = param.style ?? "simple";
|
|
850
|
+
if (style === "simple") return value.split(",");
|
|
851
|
+
if (style === "label") {
|
|
852
|
+
if (!value.startsWith(".")) return void 0;
|
|
853
|
+
const body = value.slice(1);
|
|
854
|
+
return param.explode ?? false ? body.split(".") : body.split(",");
|
|
855
|
+
}
|
|
856
|
+
if (style === "matrix") {
|
|
857
|
+
const name = param.name;
|
|
858
|
+
if (param.explode ?? false) {
|
|
859
|
+
if (!value.startsWith(";")) return void 0;
|
|
860
|
+
const out = [];
|
|
861
|
+
for (const part of value.slice(1).split(";")) {
|
|
862
|
+
const pre = `${name}=`;
|
|
863
|
+
if (!part.startsWith(pre)) return void 0;
|
|
864
|
+
out.push(part.slice(pre.length));
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
const pre = `;${name}=`;
|
|
869
|
+
return value.startsWith(pre) ? value.slice(pre.length).split(",") : void 0;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/** Whether every non-null item type is a scalar whose value space cannot contain the
|
|
874
|
+
* split delimiter — making a delimited split EXACT (element coercion AND cardinality
|
|
875
|
+
* sound). `integer`/`boolean` never contain any of our delimiters; `number` contains
|
|
876
|
+
* `.`, so it is excluded only when the dot delimiter is used (label-explode). String/
|
|
877
|
+
* typeless items always over-split, so they stay `unverified`. */
|
|
878
|
+
function itemTypesSplittable(itemTypes, usesDotDelimiter) {
|
|
879
|
+
const nonNull = itemTypes.filter((t) => t !== "null");
|
|
880
|
+
if (nonNull.length === 0) return false;
|
|
881
|
+
return nonNull.every((t) => t === "integer" || t === "boolean" || t === "number" && !usesDotDelimiter);
|
|
882
|
+
}
|
|
883
|
+
/** A FRACTIONAL `multipleOf` is the IEEE-754 false-positive trap: coercing a wire
|
|
884
|
+
* string to a JS float then ajv-checking e.g. `multipleOf: 0.1` reports a
|
|
885
|
+
* spec-conformant value like `0.3` as invalid (0.3/0.1 ≠ an integer in binary). We
|
|
886
|
+
* can't soundly assert conformance, so a scalar schema carrying one is `unverified`-
|
|
887
|
+
* skipped (an INTEGER `multipleOf` divides exactly and stays validated). */
|
|
888
|
+
function hasFractionalMultipleOf(schema) {
|
|
889
|
+
const m = schema.multipleOf;
|
|
890
|
+
return typeof m === "number" && !Number.isInteger(m);
|
|
891
|
+
}
|
|
892
|
+
/** Which query OBJECT serializations the validator reconstructs. deepObject
|
|
893
|
+
* (`name[prop]` discrete keys) and form/`explode=false` (`name=k,v,k,v` single string)
|
|
894
|
+
* are checkable; form/`explode=true` objects merge into the shared top-level namespace
|
|
895
|
+
* (irreducibly ambiguous — only their undoc-param SUPPRESSION is supported), and
|
|
896
|
+
* path/header/cookie objects are STAGED. */
|
|
897
|
+
function objectSerializationSupported(param) {
|
|
898
|
+
if (param.in !== "query") return false;
|
|
899
|
+
if (param.style === "deepObject") return true;
|
|
900
|
+
return (param.style === void 0 || param.style === "form") && param.explode === false;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Which (location, style, type) serializations the validator can soundly check.
|
|
904
|
+
* SCALARS: the default per location (path/header `simple`, query `form`). ARRAYS: any
|
|
905
|
+
* `arraySerializationSupported` location/style (query form/space/pipe-delimited, path
|
|
906
|
+
* simple/label/matrix, header simple); explode + item-type soundness are resolved in the
|
|
907
|
+
* handler. OBJECTS and every other style/location (deepObject/cookie/content-typed) are
|
|
908
|
+
* OBJECTS: query deepObject + form/`explode=false` (`objectSerializationSupported`).
|
|
909
|
+
* Everything else (path/header/cookie objects, form/`explode=true` objects, content-
|
|
910
|
+
* typed) is STAGED → inconclusive-skip. `schema` is the NORMALIZED param schema (so the
|
|
911
|
+
* array/scalar decision sees the 3.0 nullable shim). */
|
|
912
|
+
function styleSupported(param, schema) {
|
|
913
|
+
if (param.content) return false;
|
|
914
|
+
const nonScalar = schema ? nonScalarType(schema) : void 0;
|
|
915
|
+
if (nonScalar === "array") return arraySerializationSupported(param);
|
|
916
|
+
if (nonScalar === "object") return objectSerializationSupported(param);
|
|
917
|
+
switch (param.in) {
|
|
918
|
+
case "query": return param.style === void 0 || param.style === "form";
|
|
919
|
+
case "header":
|
|
920
|
+
case "path": return param.style === void 0 || param.style === "simple";
|
|
921
|
+
default: return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/** Strict scalar coercion of a captured string value to a declared scalar type.
|
|
925
|
+
* Numeric/boolean must match the WHOLE string (no residue); an empty value coerces
|
|
926
|
+
* to `null` only when the type union allows it. Never touches the shared ajv. */
|
|
927
|
+
function coerceScalar(raw, types) {
|
|
928
|
+
if (raw === "" && types.includes("null")) return {
|
|
929
|
+
ok: true,
|
|
930
|
+
value: null
|
|
931
|
+
};
|
|
932
|
+
for (const t of types) {
|
|
933
|
+
if (t === "string") return {
|
|
934
|
+
ok: true,
|
|
935
|
+
value: raw
|
|
936
|
+
};
|
|
937
|
+
if (t === "integer" && /^[+-]?\d+$/.test(raw)) return {
|
|
938
|
+
ok: true,
|
|
939
|
+
value: Number(raw)
|
|
940
|
+
};
|
|
941
|
+
if (t === "number" && /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return {
|
|
942
|
+
ok: true,
|
|
943
|
+
value: Number(raw)
|
|
944
|
+
};
|
|
945
|
+
if (t === "boolean") {
|
|
946
|
+
if (raw === "true") return {
|
|
947
|
+
ok: true,
|
|
948
|
+
value: true
|
|
949
|
+
};
|
|
950
|
+
if (raw === "false") return {
|
|
951
|
+
ok: true,
|
|
952
|
+
value: false
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return { ok: false };
|
|
957
|
+
}
|
|
958
|
+
/** Positionally extract path-template params from the concrete path. A segment that
|
|
959
|
+
* embeds a param but is not EXACTLY `{name}` (e.g. `{name}.{ext}`) cannot be split
|
|
960
|
+
* positionally → its params are `skipped` (inconclusive, never a false-fail). */
|
|
961
|
+
function extractPathParams(template, concrete) {
|
|
962
|
+
const tSegs = template.split("/");
|
|
963
|
+
const cSegs = (concrete.split("?")[0] ?? "").split("/");
|
|
964
|
+
const values = /* @__PURE__ */ new Map();
|
|
965
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
966
|
+
for (let i = 0; i < tSegs.length; i++) {
|
|
967
|
+
const t = tSegs[i] ?? "";
|
|
968
|
+
const exact = t.match(/^\{([^}]+)\}$/);
|
|
969
|
+
if (exact?.[1]) {
|
|
970
|
+
const raw = cSegs[i];
|
|
971
|
+
if (raw !== void 0) {
|
|
972
|
+
let decoded = raw;
|
|
973
|
+
try {
|
|
974
|
+
decoded = decodeURIComponent(raw);
|
|
975
|
+
} catch {}
|
|
976
|
+
values.set(exact[1], decoded);
|
|
977
|
+
}
|
|
978
|
+
} else if (t.includes("{")) {
|
|
979
|
+
for (const m of t.matchAll(/\{([^}]+)\}/g)) if (m[1]) skipped.add(m[1]);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
values,
|
|
984
|
+
skipped
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function lookupParamValue(param, req, pathVals) {
|
|
988
|
+
const name = param.name;
|
|
989
|
+
if (param.in === "path") {
|
|
990
|
+
if (pathVals.skipped.has(name)) return { state: "multi" };
|
|
991
|
+
const v = pathVals.values.get(name);
|
|
992
|
+
return v === void 0 ? { state: "absent" } : {
|
|
993
|
+
state: "present",
|
|
994
|
+
value: v
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
if (param.in === "query") {
|
|
998
|
+
const q = req.query?.[name];
|
|
999
|
+
if (q === void 0) return { state: "absent" };
|
|
1000
|
+
if (Array.isArray(q)) return q.length === 1 && q[0] !== void 0 ? {
|
|
1001
|
+
state: "present",
|
|
1002
|
+
value: q[0]
|
|
1003
|
+
} : {
|
|
1004
|
+
state: "array-values",
|
|
1005
|
+
values: q
|
|
1006
|
+
};
|
|
1007
|
+
return {
|
|
1008
|
+
state: "present",
|
|
1009
|
+
value: q
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
if (param.in === "header") {
|
|
1013
|
+
const h = req.headers?.[name.toLowerCase()];
|
|
1014
|
+
return h === void 0 ? { state: "absent" } : {
|
|
1015
|
+
state: "present",
|
|
1016
|
+
value: h
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return { state: "absent" };
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Validate an ARRAY param (query form/space/pipe-delimited, path/header `simple`)
|
|
1023
|
+
* against its declared schema. Resolves the wire elements per (state, style, explode):
|
|
1024
|
+
*
|
|
1025
|
+
* - `array-values` (≥2 query occurrences, explode=true) — the discrete elements, NO
|
|
1026
|
+
* split, so any scalar item type is sound.
|
|
1027
|
+
* - query `form` + explode=true, single occurrence — wrapped `[v]` ONLY when it carries
|
|
1028
|
+
* no comma (no explode-disagreement) and the schema has no cardinality constraint
|
|
1029
|
+
* (else `unverified`).
|
|
1030
|
+
* - DELIMITED single string (query explode=false form/space/pipe, path simple/label/
|
|
1031
|
+
* matrix, header simple) — decomposed by `splitArrayValue`, but ONLY for scalar items
|
|
1032
|
+
* whose value space can't contain the delimiter (`integer`/`boolean` always; `number`
|
|
1033
|
+
* unless the dot delimiter is used); string/typeless items, empty segments, and a
|
|
1034
|
+
* malformed prefix are `unverified` (embedded-delimiter / serialization ambiguity).
|
|
1035
|
+
*
|
|
1036
|
+
* Appends `param-schema` findings; returns whether the param was `unverified`-skipped.
|
|
1037
|
+
*/
|
|
1038
|
+
function validateArrayParam(param, normSchema, lk, findings) {
|
|
1039
|
+
const itemSchema = normSchema.items;
|
|
1040
|
+
const itemTypes = itemSchema && typeof itemSchema === "object" && !Array.isArray(itemSchema) ? scalarTypes(itemSchema) : void 0;
|
|
1041
|
+
if (normSchema.prefixItems !== void 0 || !itemTypes) return true;
|
|
1042
|
+
if (hasFractionalMultipleOf(itemSchema)) return true;
|
|
1043
|
+
const explodeTrue = param.in === "query" && (param.style === void 0 || param.style === "form") && (param.explode ?? true) === true;
|
|
1044
|
+
let elements;
|
|
1045
|
+
if (lk.state === "array-values") elements = lk.values;
|
|
1046
|
+
else if (lk.state === "present" && explodeTrue) {
|
|
1047
|
+
if (lk.value.includes(",") || hasCardinalityConstraint(normSchema)) return true;
|
|
1048
|
+
elements = [lk.value];
|
|
1049
|
+
} else if (lk.state === "present") {
|
|
1050
|
+
if (!itemTypesSplittable(itemTypes, arraySplitUsesDot(param))) return true;
|
|
1051
|
+
const parts = splitArrayValue(param, lk.value);
|
|
1052
|
+
if (parts === void 0 || parts.some((s) => s === "")) return true;
|
|
1053
|
+
elements = parts;
|
|
1054
|
+
} else return true;
|
|
1055
|
+
const want = itemTypes.filter((t) => t !== "null").join("|");
|
|
1056
|
+
const coerced = [];
|
|
1057
|
+
let anyBad = false;
|
|
1058
|
+
for (const el of elements) {
|
|
1059
|
+
const c = coerceScalar(el, itemTypes);
|
|
1060
|
+
if (!c.ok) {
|
|
1061
|
+
anyBad = true;
|
|
1062
|
+
findings.push({
|
|
1063
|
+
kind: "param-schema",
|
|
1064
|
+
severity: "error",
|
|
1065
|
+
path: param.name,
|
|
1066
|
+
message: `${param.in} parameter '${param.name}' value '${el}' is not a valid ${want}`
|
|
1067
|
+
});
|
|
1068
|
+
} else coerced.push(c.value);
|
|
1069
|
+
}
|
|
1070
|
+
if (anyBad) return false;
|
|
1071
|
+
const { valid, errors } = validateSchema(normSchema, coerced);
|
|
1072
|
+
if (!valid) for (const err of errors) findings.push({
|
|
1073
|
+
kind: "param-schema",
|
|
1074
|
+
severity: "error",
|
|
1075
|
+
path: param.name,
|
|
1076
|
+
message: `${param.in} parameter '${param.name}' ${err.message}`
|
|
1077
|
+
});
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
/** Escape a string for literal use inside a RegExp. */
|
|
1081
|
+
function escapeRegExp(s) {
|
|
1082
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Validate a query OBJECT param against its declared schema. deepObject reconstructs
|
|
1086
|
+
* from discrete `name[prop]` keys (so STRING props are sound — no split); form/
|
|
1087
|
+
* `explode=false` splits the single `name=k,v,k,v` string (integer/boolean props ONLY —
|
|
1088
|
+
* a string value's comma cascades, a number's float mis-coerces). Declared scalar props
|
|
1089
|
+
* are coerced; undeclared keys pass through (ajv handles them per additionalProperties);
|
|
1090
|
+
* the assembled object is ajv-validated. Returns whether the param was `unverified`.
|
|
1091
|
+
*
|
|
1092
|
+
* REFUSE (→ unverified, never a false finding) when reconstruction can't be sound: no
|
|
1093
|
+
* flat scalar `properties`; an object-form `additionalProperties` (an undeclared key we
|
|
1094
|
+
* leave uncoerced could false-fail a typed schema); any prop with a fractional
|
|
1095
|
+
* `multipleOf` (float trap); a deepObject nested (`a[b]`) or repeated key; a form/
|
|
1096
|
+
* explode=false object with any non-(integer|boolean) prop, `additionalProperties !==
|
|
1097
|
+
* false`, or an odd/empty split.
|
|
1098
|
+
*/
|
|
1099
|
+
function validateObjectParam(param, normSchema, req, opts, method, template, findings) {
|
|
1100
|
+
const props = normSchema.properties;
|
|
1101
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) return true;
|
|
1102
|
+
const propEntries = Object.entries(props);
|
|
1103
|
+
if (propEntries.length === 0) return true;
|
|
1104
|
+
const propTypes = /* @__PURE__ */ new Map();
|
|
1105
|
+
for (const [propName, sub] of propEntries) {
|
|
1106
|
+
if (!sub || typeof sub !== "object" || Array.isArray(sub)) return true;
|
|
1107
|
+
const s = sub;
|
|
1108
|
+
const t = scalarTypes(s);
|
|
1109
|
+
if (!t || hasFractionalMultipleOf(s)) return true;
|
|
1110
|
+
propTypes.set(propName, t);
|
|
1111
|
+
}
|
|
1112
|
+
const ap = normSchema.additionalProperties;
|
|
1113
|
+
if (ap !== void 0 && typeof ap !== "boolean") return true;
|
|
1114
|
+
const name = param.name;
|
|
1115
|
+
const collected = [];
|
|
1116
|
+
let present = false;
|
|
1117
|
+
if (param.style === "deepObject") {
|
|
1118
|
+
const prefix = `${name}[`;
|
|
1119
|
+
const flat = new RegExp(`^${escapeRegExp(name)}\\[([^\\]]+)\\]$`);
|
|
1120
|
+
for (const [key, val] of Object.entries(req.query ?? {})) {
|
|
1121
|
+
if (!key.startsWith(prefix)) continue;
|
|
1122
|
+
present = true;
|
|
1123
|
+
const m = flat.exec(key);
|
|
1124
|
+
if (!m || m[1] === void 0) return true;
|
|
1125
|
+
if (Array.isArray(val)) return true;
|
|
1126
|
+
collected.push([m[1], val]);
|
|
1127
|
+
}
|
|
1128
|
+
} else {
|
|
1129
|
+
if (ap !== false) return true;
|
|
1130
|
+
for (const t of propTypes.values()) if (!t.filter((x) => x !== "null").every((x) => x === "integer" || x === "boolean")) return true;
|
|
1131
|
+
const raw = req.query?.[name];
|
|
1132
|
+
if (raw === void 0) present = false;
|
|
1133
|
+
else if (Array.isArray(raw) || raw === "") return true;
|
|
1134
|
+
else {
|
|
1135
|
+
present = true;
|
|
1136
|
+
const segs = raw.split(",");
|
|
1137
|
+
if (segs.length % 2 !== 0 || segs.some((s) => s === "")) return true;
|
|
1138
|
+
for (let i = 0; i < segs.length; i += 2) collected.push([segs[i], segs[i + 1]]);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (!present) {
|
|
1142
|
+
if (param.required === true) if (opts.paramsAuthoritative) findings.push({
|
|
1143
|
+
kind: "missing-required-param",
|
|
1144
|
+
severity: "error",
|
|
1145
|
+
path: name,
|
|
1146
|
+
message: `required ${param.in} parameter '${name}' missing for ${method.toUpperCase()} ${template}`
|
|
1147
|
+
});
|
|
1148
|
+
else return true;
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
const obj = {};
|
|
1152
|
+
let anyBad = false;
|
|
1153
|
+
for (const [prop, value] of collected) {
|
|
1154
|
+
const types = propTypes.get(prop);
|
|
1155
|
+
if (types) {
|
|
1156
|
+
const c = coerceScalar(value, types);
|
|
1157
|
+
if (!c.ok) {
|
|
1158
|
+
anyBad = true;
|
|
1159
|
+
findings.push({
|
|
1160
|
+
kind: "param-schema",
|
|
1161
|
+
severity: "error",
|
|
1162
|
+
path: `${name}[${prop}]`,
|
|
1163
|
+
message: `${param.in} parameter '${name}[${prop}]' value '${value}' is not a valid ${types.filter((t) => t !== "null").join("|")}`
|
|
1164
|
+
});
|
|
1165
|
+
} else obj[prop] = c.value;
|
|
1166
|
+
} else obj[prop] = value;
|
|
1167
|
+
}
|
|
1168
|
+
if (anyBad) return false;
|
|
1169
|
+
const { valid, errors } = validateSchema(normSchema, obj);
|
|
1170
|
+
if (!valid) for (const err of errors) findings.push({
|
|
1171
|
+
kind: "param-schema",
|
|
1172
|
+
severity: "error",
|
|
1173
|
+
path: name,
|
|
1174
|
+
message: `${param.in} parameter '${name}' ${err.message}`
|
|
1175
|
+
});
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Resolve a LOCAL in-document `$ref` (`#/components/{requestBodies,parameters,…}/*`)
|
|
1180
|
+
* one level. A non-`#/`-local `$ref` (external file / remote) returns `undefined` →
|
|
1181
|
+
* the caller treats it as inconclusive-skip (never fabricates a finding). A node
|
|
1182
|
+
* without a `$ref` is returned as-is.
|
|
1183
|
+
*/
|
|
1184
|
+
function derefLocalComponent(spec, node) {
|
|
1185
|
+
if (!node || typeof node !== "object") return node;
|
|
1186
|
+
const ref = node.$ref;
|
|
1187
|
+
if (typeof ref !== "string") return node;
|
|
1188
|
+
if (!ref.startsWith("#/")) return void 0;
|
|
1189
|
+
let cur = spec;
|
|
1190
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
1191
|
+
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1192
|
+
if (!cur || typeof cur !== "object") return void 0;
|
|
1193
|
+
cur = cur[key];
|
|
1194
|
+
}
|
|
1195
|
+
return cur;
|
|
1196
|
+
}
|
|
1197
|
+
/** `'urlencoded'`/`'multipart'` for the two form media bases, else `undefined`. Form
|
|
1198
|
+
* bodies are validated by reconstructing the field map (ADR 0016 addendum 4). */
|
|
1199
|
+
function formBase(mb) {
|
|
1200
|
+
if (mb === "application/x-www-form-urlencoded") return "urlencoded";
|
|
1201
|
+
if (mb === "multipart/form-data") return "multipart";
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Select the declared request-body schema for a concrete Content-Type. When a CT is
|
|
1205
|
+
* present, match the spec's `content` keys by specificity: exact `type/subtype`, then
|
|
1206
|
+
* the subtype range, then the catch-all range. When the CT is ABSENT (the capture
|
|
1207
|
+
* path), fall back to a JSON-family key (the bridge only resolves JSON bodies) — and
|
|
1208
|
+
* `matched:false` there must NEVER become `unsupported-media-type` (C1). Also surfaces
|
|
1209
|
+
* the matched media base + its `encoding` object (form-body validation refuses any
|
|
1210
|
+
* per-property encoding — addendum 4).
|
|
1211
|
+
*/
|
|
1212
|
+
function selectContentSchema(content, ct) {
|
|
1213
|
+
if (!content) return { matched: false };
|
|
1214
|
+
const keys = Object.keys(content);
|
|
1215
|
+
if (ct) {
|
|
1216
|
+
const type = ct.split("/")[0] ?? "";
|
|
1217
|
+
const key = keys.find((k) => mediaBase(k) === ct) ?? keys.find((k) => mediaBase(k) === `${type}/*`) ?? keys.find((k) => mediaBase(k) === "*/*");
|
|
1218
|
+
if (!key) return { matched: false };
|
|
1219
|
+
return {
|
|
1220
|
+
matched: true,
|
|
1221
|
+
schema: content[key]?.schema,
|
|
1222
|
+
json: isJsonMediaType(key),
|
|
1223
|
+
mediaBase: mediaBase(key),
|
|
1224
|
+
encoding: content[key]?.encoding
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
const jsonKey = keys.find(isJsonMediaType);
|
|
1228
|
+
if (!jsonKey) return { matched: false };
|
|
1229
|
+
return {
|
|
1230
|
+
matched: true,
|
|
1231
|
+
schema: content[jsonKey]?.schema,
|
|
1232
|
+
json: true,
|
|
1233
|
+
mediaBase: "application/json",
|
|
1234
|
+
encoding: content[jsonKey]?.encoding
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
/** UTF-8 / ASCII charsets we can soundly assume the bytes→string decode preserved. */
|
|
1238
|
+
const SOUND_CHARSETS = new Set([
|
|
1239
|
+
"utf-8",
|
|
1240
|
+
"utf8",
|
|
1241
|
+
"us-ascii",
|
|
1242
|
+
"ascii"
|
|
1243
|
+
]);
|
|
1244
|
+
/**
|
|
1245
|
+
* Validate a `form`-style request body (`application/x-www-form-urlencoded` or the text
|
|
1246
|
+
* parts of `multipart/form-data`) against its declared object schema, reconstructing
|
|
1247
|
+
* typed values from the DISCRETE field map (`req.form`; repeated keys → array). Discrete
|
|
1248
|
+
* keys make even STRING array items sound (no delimiter to over-split) — form bodies are
|
|
1249
|
+
* more tractable than form *params*. Mirrors `validateObjectParam`'s coerce-then-ajv
|
|
1250
|
+
* logic. Declared scalar props are coerced to the declared type; sound scalar-item array
|
|
1251
|
+
* props are assembled from repeated keys; undeclared keys pass through as raw strings
|
|
1252
|
+
* (ajv then enforces `additionalProperties`); the assembled object is ajv-validated.
|
|
1253
|
+
*
|
|
1254
|
+
* Returns whether the body was `unverified`-skipped. REFUSE → unverified (never a false
|
|
1255
|
+
* finding) when reconstruction can't be sound: ANY per-property `encoding`; a non-UTF-8
|
|
1256
|
+
* charset; the schema isn't a flat object with `properties`; an object-form (typed)
|
|
1257
|
+
* `additionalProperties`; a non-scalar / typeless property, or an array property with
|
|
1258
|
+
* non-scalar items; a fractional `multipleOf` (float trap); a declared property satisfied
|
|
1259
|
+
* by a multipart FILE part; a scalar property arriving with repeated keys; a single-
|
|
1260
|
+
* occurrence array property carrying a cardinality constraint; an ambiguous empty value
|
|
1261
|
+
* for a non-string, non-null scalar property; or — when the caller is NOT authoritative —
|
|
1262
|
+
* any required field absent from the captured map.
|
|
1263
|
+
*/
|
|
1264
|
+
function validateFormBody(normSchema, encoding, req, opts, findings) {
|
|
1265
|
+
if (encoding !== void 0 && encoding !== null) return true;
|
|
1266
|
+
const rawCt = req.headers?.["content-type"];
|
|
1267
|
+
if (rawCt) {
|
|
1268
|
+
const m = /;\s*charset=([^;]+)/i.exec(rawCt);
|
|
1269
|
+
if (m?.[1] && !SOUND_CHARSETS.has(m[1].trim().toLowerCase())) return true;
|
|
1270
|
+
}
|
|
1271
|
+
const props = normSchema.properties;
|
|
1272
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) return true;
|
|
1273
|
+
const propEntries = Object.entries(props);
|
|
1274
|
+
if (propEntries.length === 0) return true;
|
|
1275
|
+
const ap = normSchema.additionalProperties;
|
|
1276
|
+
if (ap !== void 0 && typeof ap !== "boolean") return true;
|
|
1277
|
+
const plan = /* @__PURE__ */ new Map();
|
|
1278
|
+
for (const [name, sub] of propEntries) {
|
|
1279
|
+
if (!sub || typeof sub !== "object" || Array.isArray(sub)) return true;
|
|
1280
|
+
const s = sub;
|
|
1281
|
+
const st = scalarTypes(s);
|
|
1282
|
+
if (st) {
|
|
1283
|
+
if (hasFractionalMultipleOf(s)) return true;
|
|
1284
|
+
plan.set(name, {
|
|
1285
|
+
kind: "scalar",
|
|
1286
|
+
types: st
|
|
1287
|
+
});
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (nonScalarType(s) === "array") {
|
|
1291
|
+
const items = s.items;
|
|
1292
|
+
if (!items || typeof items !== "object" || Array.isArray(items)) return true;
|
|
1293
|
+
const it = scalarTypes(items);
|
|
1294
|
+
if (!it || hasFractionalMultipleOf(items)) return true;
|
|
1295
|
+
plan.set(name, {
|
|
1296
|
+
kind: "array",
|
|
1297
|
+
itemTypes: it,
|
|
1298
|
+
hasCard: hasCardinalityConstraint(s)
|
|
1299
|
+
});
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
const form = req.form ?? {};
|
|
1305
|
+
const fileFields = new Set(req.formFileFields ?? []);
|
|
1306
|
+
for (const name of plan.keys()) if (fileFields.has(name)) return true;
|
|
1307
|
+
const required = Array.isArray(normSchema.required) ? normSchema.required : [];
|
|
1308
|
+
if (!opts.bodyPresenceAuthoritative) {
|
|
1309
|
+
for (const rq of required) if (form[rq] === void 0) return true;
|
|
1310
|
+
}
|
|
1311
|
+
const obj = {};
|
|
1312
|
+
let anyBad = false;
|
|
1313
|
+
for (const [name, p] of plan) {
|
|
1314
|
+
const raw = form[name];
|
|
1315
|
+
if (raw === void 0) continue;
|
|
1316
|
+
if (p.kind === "scalar") {
|
|
1317
|
+
if (Array.isArray(raw)) return true;
|
|
1318
|
+
if (raw === "" && !p.types.includes("string") && !p.types.includes("null")) return true;
|
|
1319
|
+
const c = coerceScalar(raw, p.types);
|
|
1320
|
+
if (!c.ok) {
|
|
1321
|
+
anyBad = true;
|
|
1322
|
+
findings.push({
|
|
1323
|
+
kind: "request-body-schema",
|
|
1324
|
+
severity: "error",
|
|
1325
|
+
path: name,
|
|
1326
|
+
message: `request body field '${name}' value '${raw}' is not a valid ${p.types.filter((t) => t !== "null").join("|")}`
|
|
1327
|
+
});
|
|
1328
|
+
} else obj[name] = c.value;
|
|
1329
|
+
} else {
|
|
1330
|
+
const occ = Array.isArray(raw) ? raw : [raw];
|
|
1331
|
+
if (occ.length === 1 && p.hasCard) return true;
|
|
1332
|
+
const arr = [];
|
|
1333
|
+
for (const el of occ) {
|
|
1334
|
+
if (el === "" && !p.itemTypes.includes("string") && !p.itemTypes.includes("null")) return true;
|
|
1335
|
+
const c = coerceScalar(el, p.itemTypes);
|
|
1336
|
+
if (!c.ok) {
|
|
1337
|
+
anyBad = true;
|
|
1338
|
+
findings.push({
|
|
1339
|
+
kind: "request-body-schema",
|
|
1340
|
+
severity: "error",
|
|
1341
|
+
path: name,
|
|
1342
|
+
message: `request body field '${name}' value '${el}' is not a valid ${p.itemTypes.filter((t) => t !== "null").join("|")}`
|
|
1343
|
+
});
|
|
1344
|
+
} else arr.push(c.value);
|
|
1345
|
+
}
|
|
1346
|
+
if (!anyBad) obj[name] = arr;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
for (const [key, val] of Object.entries(form)) {
|
|
1350
|
+
if (plan.has(key) || fileFields.has(key)) continue;
|
|
1351
|
+
obj[key] = val;
|
|
1352
|
+
}
|
|
1353
|
+
if (anyBad) return false;
|
|
1354
|
+
const { valid, errors } = validateSchema(normSchema, obj);
|
|
1355
|
+
if (!valid) for (const err of errors) findings.push({
|
|
1356
|
+
kind: "request-body-schema",
|
|
1357
|
+
severity: "error",
|
|
1358
|
+
...err.instancePath ? { path: err.instancePath } : {},
|
|
1359
|
+
message: err.message
|
|
1360
|
+
});
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
function validateOpenApiRequest(spec, req, opts = {}) {
|
|
1364
|
+
const findings = [];
|
|
1365
|
+
let unverified = false;
|
|
1366
|
+
const method = req.method.toLowerCase();
|
|
1367
|
+
const resolved = resolveOpenApiOperation(spec, method, req.path);
|
|
1368
|
+
if (!resolved) {
|
|
1369
|
+
findings.push({
|
|
1370
|
+
kind: "missing-operation",
|
|
1371
|
+
severity: "error",
|
|
1372
|
+
message: `no operation ${req.method.toUpperCase()} ${req.path} in the OpenAPI document`
|
|
1373
|
+
});
|
|
1374
|
+
return {
|
|
1375
|
+
valid: false,
|
|
1376
|
+
findings
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
const { operation, template } = resolved;
|
|
1380
|
+
const op = {
|
|
1381
|
+
method,
|
|
1382
|
+
path: template
|
|
1383
|
+
};
|
|
1384
|
+
const requestBodyRaw = operation.requestBody;
|
|
1385
|
+
const requestBodyDeclared = requestBodyRaw !== void 0;
|
|
1386
|
+
const requestBody = derefLocalComponent(spec, requestBodyRaw);
|
|
1387
|
+
const hasBody = req.body !== void 0 || req.form !== void 0;
|
|
1388
|
+
const reqCt = req.headers?.["content-type"] ? mediaBase(req.headers["content-type"]) : void 0;
|
|
1389
|
+
if (requestBodyDeclared && (!requestBody || typeof requestBody !== "object")) unverified = true;
|
|
1390
|
+
else if (requestBody && typeof requestBody === "object") if (!hasBody) {
|
|
1391
|
+
if (requestBody.required === true) if (opts.bodyPresenceAuthoritative) findings.push({
|
|
1392
|
+
kind: "missing-required-body",
|
|
1393
|
+
severity: "error",
|
|
1394
|
+
message: `required request body missing for ${method.toUpperCase()} ${template}`
|
|
1395
|
+
});
|
|
1396
|
+
else unverified = true;
|
|
1397
|
+
} else {
|
|
1398
|
+
const sel = selectContentSchema(requestBody.content, reqCt);
|
|
1399
|
+
if (!sel.matched) {
|
|
1400
|
+
if (reqCt) findings.push({
|
|
1401
|
+
kind: "unsupported-media-type",
|
|
1402
|
+
severity: "warning",
|
|
1403
|
+
message: `content-type '${reqCt}' is not declared for ${method.toUpperCase()} ${template}`
|
|
1404
|
+
});
|
|
1405
|
+
unverified = true;
|
|
1406
|
+
} else if (formBase(sel.mediaBase) && req.form !== void 0 && sel.schema !== void 0) {
|
|
1407
|
+
if (validateFormBody(normalizeOpenApiSchema(sel.schema, spec, opts), sel.encoding, req, opts, findings)) unverified = true;
|
|
1408
|
+
} else if (!sel.json || sel.schema === void 0) unverified = true;
|
|
1409
|
+
else {
|
|
1410
|
+
const { valid, errors } = validateSchema(normalizeOpenApiSchema(sel.schema, spec, opts), req.body);
|
|
1411
|
+
if (!valid) for (const err of errors) findings.push({
|
|
1412
|
+
kind: "request-body-schema",
|
|
1413
|
+
severity: "error",
|
|
1414
|
+
path: err.instancePath,
|
|
1415
|
+
message: err.message
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
else if (hasBody) {
|
|
1420
|
+
findings.push({
|
|
1421
|
+
kind: "undocumented-body",
|
|
1422
|
+
severity: "warning",
|
|
1423
|
+
message: `request body sent to ${method.toUpperCase()} ${template} which declares no request body`
|
|
1424
|
+
});
|
|
1425
|
+
unverified = true;
|
|
1426
|
+
}
|
|
1427
|
+
const pathVals = extractPathParams(template, req.path);
|
|
1428
|
+
const declaredQuery = /* @__PURE__ */ new Set();
|
|
1429
|
+
const deepObjectPrefixes = [];
|
|
1430
|
+
let suppressUndoc = false;
|
|
1431
|
+
const params = [];
|
|
1432
|
+
for (const rp of mergeParameters(resolved.pathItem, operation)) if (typeof rp.$ref === "string") {
|
|
1433
|
+
const d = derefLocalComponent(spec, rp);
|
|
1434
|
+
if (!d || typeof d.name !== "string" || typeof d.in !== "string") {
|
|
1435
|
+
unverified = true;
|
|
1436
|
+
suppressUndoc = true;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
params.push(d);
|
|
1440
|
+
} else if (typeof rp.name === "string" && typeof rp.in === "string") params.push(rp);
|
|
1441
|
+
for (const param of params) {
|
|
1442
|
+
if (typeof param.name !== "string" || typeof param.in !== "string") continue;
|
|
1443
|
+
const normSchema = param.schema !== void 0 ? normalizeOpenApiSchema(param.schema, spec, opts) : void 0;
|
|
1444
|
+
const types = normSchema ? scalarTypes(normSchema) : void 0;
|
|
1445
|
+
const nonScalar = normSchema ? nonScalarType(normSchema) : void 0;
|
|
1446
|
+
if (param.in === "query") if (nonScalar === "object") if (param.style === "deepObject") deepObjectPrefixes.push(`${param.name}[`);
|
|
1447
|
+
else if ((param.style === void 0 || param.style === "form") && param.explode === false) declaredQuery.add(param.name);
|
|
1448
|
+
else suppressUndoc = true;
|
|
1449
|
+
else declaredQuery.add(param.name);
|
|
1450
|
+
if (!styleSupported(param, normSchema)) {
|
|
1451
|
+
unverified = true;
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (!normSchema || !types && nonScalar === void 0) {
|
|
1455
|
+
unverified = true;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (nonScalar === "object" && normSchema) {
|
|
1459
|
+
if (validateObjectParam(param, normSchema, req, opts, method, template, findings)) unverified = true;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
const lk = lookupParamValue(param, req, pathVals);
|
|
1463
|
+
if (lk.state === "absent") {
|
|
1464
|
+
if (param.in === "path" || param.required === true) if (opts.paramsAuthoritative) findings.push({
|
|
1465
|
+
kind: "missing-required-param",
|
|
1466
|
+
severity: "error",
|
|
1467
|
+
path: param.name,
|
|
1468
|
+
message: `required ${param.in} parameter '${param.name}' missing for ${method.toUpperCase()} ${template}`
|
|
1469
|
+
});
|
|
1470
|
+
else unverified = true;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (nonScalar === "array" && normSchema) {
|
|
1474
|
+
if (validateArrayParam(param, normSchema, lk, findings)) unverified = true;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
if (lk.state === "multi" || lk.state === "array-values" || !types) {
|
|
1478
|
+
unverified = true;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
if (hasFractionalMultipleOf(normSchema)) {
|
|
1482
|
+
unverified = true;
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
const coerced = coerceScalar(lk.value, types);
|
|
1486
|
+
if (!coerced.ok) {
|
|
1487
|
+
const want = types.filter((t) => t !== "null").join("|");
|
|
1488
|
+
findings.push({
|
|
1489
|
+
kind: "param-schema",
|
|
1490
|
+
severity: "error",
|
|
1491
|
+
path: param.name,
|
|
1492
|
+
message: `${param.in} parameter '${param.name}' value '${lk.value}' is not a valid ${want}`
|
|
1493
|
+
});
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const { valid, errors } = validateSchema(normSchema, coerced.value);
|
|
1497
|
+
if (!valid) for (const err of errors) findings.push({
|
|
1498
|
+
kind: "param-schema",
|
|
1499
|
+
severity: "error",
|
|
1500
|
+
path: param.name,
|
|
1501
|
+
message: `${param.in} parameter '${param.name}' ${err.message}`
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
if (req.query && !suppressUndoc) for (const key of Object.keys(req.query)) {
|
|
1505
|
+
if (declaredQuery.has(key)) continue;
|
|
1506
|
+
if (deepObjectPrefixes.some((p) => key.startsWith(p))) continue;
|
|
1507
|
+
findings.push({
|
|
1508
|
+
kind: "undocumented-param",
|
|
1509
|
+
severity: "warning",
|
|
1510
|
+
path: key,
|
|
1511
|
+
message: `undocumented query parameter '${key}' for ${method.toUpperCase()} ${template}`
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
return {
|
|
1515
|
+
valid: findings.every((f) => f.severity !== "error"),
|
|
1516
|
+
findings,
|
|
1517
|
+
operation: op,
|
|
1518
|
+
...unverified ? { unverified: true } : {}
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/har-capture.ts
|
|
1523
|
+
/**
|
|
1524
|
+
* The capture→contract bridge (ADR 0013, Phase-5 milestone 5a). Turns a stored
|
|
1525
|
+
* browser/API HAR into the records the *already-shipped* OpenAPI response
|
|
1526
|
+
* validator consumes — **no request is re-run**; this is a read over an existing
|
|
1527
|
+
* artifact. The high-leverage cross-pillar win: a captured run's traffic is
|
|
1528
|
+
* checked against the operator-supplied contract for the installed API version.
|
|
1529
|
+
*
|
|
1530
|
+
* Security posture (ADR 0013 §3): a HAR is operator-gated bytes whose redaction
|
|
1531
|
+
* is known-incomplete, so the *surface* gates resolving one (`validate_capture`,
|
|
1532
|
+
* slice 6). Here, the pure bridge MUST NOT copy raw headers/cookies into its
|
|
1533
|
+
* output (only status + the parsed body needed for schema validation) and every
|
|
1534
|
+
* finding message is routed through the operator `Redactor` before it leaves.
|
|
1535
|
+
*/
|
|
1536
|
+
/** fflate is unbounded by default; cap the inflated HAR archive (ADR 0013 §3e). */
|
|
1537
|
+
const MAX_HAR_INFLATED_BYTES$1 = 64 * 1024 * 1024;
|
|
1538
|
+
/** Build a decoded query record from a URL's search params (repeated keys → array). */
|
|
1539
|
+
function collectQuery(sp) {
|
|
1540
|
+
const out = {};
|
|
1541
|
+
for (const key of new Set(sp.keys())) {
|
|
1542
|
+
const all = sp.getAll(key);
|
|
1543
|
+
out[key] = all.length > 1 ? all : all[0] ?? "";
|
|
1544
|
+
}
|
|
1545
|
+
return out;
|
|
1546
|
+
}
|
|
1547
|
+
/** Lower-cased request header map (last value wins) from the HAR header array. */
|
|
1548
|
+
function collectHeaders(hdrs) {
|
|
1549
|
+
const out = {};
|
|
1550
|
+
for (const h of hdrs ?? []) if (typeof h?.name === "string") out[h.name.toLowerCase()] = String(h.value ?? "");
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
function findHarJson(zip) {
|
|
1554
|
+
const key = Object.keys(zip).find((k) => k.endsWith(".har"));
|
|
1555
|
+
return key ? strFromU8(zip[key]) : void 0;
|
|
1556
|
+
}
|
|
1557
|
+
function mimeOf(content) {
|
|
1558
|
+
return (content?.mimeType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1559
|
+
}
|
|
1560
|
+
/** `'urlencoded'`/`'multipart'` for the two form media bases, else `undefined`. */
|
|
1561
|
+
function formBaseOf(mime) {
|
|
1562
|
+
if (mime === "application/x-www-form-urlencoded") return "urlencoded";
|
|
1563
|
+
if (mime === "multipart/form-data") return "multipart";
|
|
1564
|
+
}
|
|
1565
|
+
/** Append a field into a repeated-keys-→-array map (the form-field channel shape). */
|
|
1566
|
+
function appendFormField(map, name, value) {
|
|
1567
|
+
const cur = map[name];
|
|
1568
|
+
if (cur === void 0) map[name] = value;
|
|
1569
|
+
else if (Array.isArray(cur)) cur.push(value);
|
|
1570
|
+
else map[name] = [cur, value];
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Resolve a `form`-style request `postData` into the structured field channel
|
|
1574
|
+
* `validateFormBody` consumes (ADR 0016 addendum 4 capture path). PREFER the
|
|
1575
|
+
* structured `params[]` (each `{name, value?, fileName?}`, already URL-decoded by
|
|
1576
|
+
* the capturer; a `fileName` marks a FILE part → names-only). For `urlencoded`
|
|
1577
|
+
* ONLY, fall back to parsing `postData.text` (well-defined percent-decoding). A
|
|
1578
|
+
* `multipart` body with no `params[]` (only raw `_file`/`text`) is NOT parsed —
|
|
1579
|
+
* boundary parsing reintroduces the embedded-delimiter trap — so `undefined` is
|
|
1580
|
+
* returned and the validator `unverified`-skips. Returns `undefined` when nothing
|
|
1581
|
+
* sound is extractable.
|
|
1582
|
+
*/
|
|
1583
|
+
function formFieldsFromPostData(pd, base) {
|
|
1584
|
+
const form = {};
|
|
1585
|
+
const fileFields = [];
|
|
1586
|
+
if (Array.isArray(pd.params) && pd.params.length > 0) {
|
|
1587
|
+
for (const p of pd.params) {
|
|
1588
|
+
if (typeof p?.name !== "string") continue;
|
|
1589
|
+
if (typeof p.fileName === "string") fileFields.push(p.name);
|
|
1590
|
+
else appendFormField(form, p.name, String(p.value ?? ""));
|
|
1591
|
+
}
|
|
1592
|
+
return {
|
|
1593
|
+
form,
|
|
1594
|
+
fileFields
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
if (base === "urlencoded" && typeof pd.text === "string") {
|
|
1598
|
+
const sp = new URLSearchParams(pd.text);
|
|
1599
|
+
for (const key of new Set(sp.keys())) {
|
|
1600
|
+
const all = sp.getAll(key);
|
|
1601
|
+
form[key] = all.length > 1 ? all : all[0] ?? "";
|
|
1602
|
+
}
|
|
1603
|
+
return {
|
|
1604
|
+
form,
|
|
1605
|
+
fileFields
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Slice 2 — parse a HAR `.zip` and resolve each entry's body. The PRIMARY path
|
|
1611
|
+
* is `content:'attach'` (the only mode the browser pillar emits): a body lives
|
|
1612
|
+
* in a separate archive entry referenced by `response.content._file`. Inline
|
|
1613
|
+
* `response.content.text` (`content:'embed'`) is the fallback. JSON bodies are
|
|
1614
|
+
* parsed; the URL is reduced to its `pathname` (+ origin kept separately).
|
|
1615
|
+
*/
|
|
1616
|
+
function harEntriesToFacts(harZip) {
|
|
1617
|
+
const zip = unzipSync(new Uint8Array(harZip), { filter: (file) => file.originalSize <= MAX_HAR_INFLATED_BYTES$1 });
|
|
1618
|
+
const harText = findHarJson(zip);
|
|
1619
|
+
if (harText === void 0) throw new Error("har-capture: no .har entry in the archive");
|
|
1620
|
+
const entries = JSON.parse(harText).log?.entries ?? [];
|
|
1621
|
+
const out = [];
|
|
1622
|
+
for (const e of entries) {
|
|
1623
|
+
const method = (e.request?.method ?? "GET").toUpperCase();
|
|
1624
|
+
const rawUrl = e.request?.url ?? "";
|
|
1625
|
+
let path = rawUrl;
|
|
1626
|
+
let origin = "";
|
|
1627
|
+
let query;
|
|
1628
|
+
try {
|
|
1629
|
+
const u = new URL(rawUrl);
|
|
1630
|
+
path = u.pathname;
|
|
1631
|
+
origin = u.origin;
|
|
1632
|
+
const q = collectQuery(u.searchParams);
|
|
1633
|
+
if (Object.keys(q).length > 0) query = q;
|
|
1634
|
+
} catch {}
|
|
1635
|
+
const headersMap = collectHeaders(e.request?.headers);
|
|
1636
|
+
const headers = Object.keys(headersMap).length > 0 ? headersMap : void 0;
|
|
1637
|
+
const status = e.response?.status ?? 0;
|
|
1638
|
+
const content = e.response?.content;
|
|
1639
|
+
const mimeType = mimeOf(content);
|
|
1640
|
+
const reqContent = e.request?.postData;
|
|
1641
|
+
let reqForm;
|
|
1642
|
+
let reqFormFiles;
|
|
1643
|
+
const reqFormBase = reqContent ? formBaseOf(mimeOf(reqContent)) : void 0;
|
|
1644
|
+
if (reqContent && reqFormBase) {
|
|
1645
|
+
const extracted = formFieldsFromPostData(reqContent, reqFormBase);
|
|
1646
|
+
if (extracted) {
|
|
1647
|
+
reqForm = extracted.form;
|
|
1648
|
+
if (extracted.fileFields.length > 0) reqFormFiles = extracted.fileFields;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
let reqBody;
|
|
1652
|
+
if (reqContent && mimeOf(reqContent).includes("json")) {
|
|
1653
|
+
let rawReq;
|
|
1654
|
+
if (reqContent._file) {
|
|
1655
|
+
const bytes = zip[reqContent._file];
|
|
1656
|
+
if (bytes) rawReq = strFromU8(bytes);
|
|
1657
|
+
} else if (typeof reqContent.text === "string") rawReq = reqContent.text;
|
|
1658
|
+
if (rawReq !== void 0 && rawReq.length > 0) try {
|
|
1659
|
+
reqBody = JSON.parse(rawReq);
|
|
1660
|
+
} catch {}
|
|
1661
|
+
}
|
|
1662
|
+
let body;
|
|
1663
|
+
let unresolvedBody;
|
|
1664
|
+
const isJson = mimeType.includes("json");
|
|
1665
|
+
let rawBody;
|
|
1666
|
+
if (content?._file) {
|
|
1667
|
+
const bytes = zip[content._file];
|
|
1668
|
+
if (bytes) rawBody = strFromU8(bytes);
|
|
1669
|
+
else unresolvedBody = `attached body ${content._file} not found in the archive`;
|
|
1670
|
+
} else if (typeof content?.text === "string") rawBody = content.text;
|
|
1671
|
+
if (unresolvedBody === void 0 && isJson) if (rawBody === void 0 || rawBody.length === 0) unresolvedBody = "json response with no resolvable body";
|
|
1672
|
+
else try {
|
|
1673
|
+
body = JSON.parse(rawBody);
|
|
1674
|
+
} catch {
|
|
1675
|
+
unresolvedBody = "json response body did not parse";
|
|
1676
|
+
}
|
|
1677
|
+
else if (!isJson) body = rawBody;
|
|
1678
|
+
out.push({
|
|
1679
|
+
req: {
|
|
1680
|
+
method,
|
|
1681
|
+
path,
|
|
1682
|
+
origin,
|
|
1683
|
+
...reqBody !== void 0 ? { body: reqBody } : {},
|
|
1684
|
+
...query ? { query } : {},
|
|
1685
|
+
...headers ? { headers } : {},
|
|
1686
|
+
...reqForm ? { form: reqForm } : {},
|
|
1687
|
+
...reqFormFiles ? { formFileFields: reqFormFiles } : {}
|
|
1688
|
+
},
|
|
1689
|
+
res: {
|
|
1690
|
+
status,
|
|
1691
|
+
body
|
|
1692
|
+
},
|
|
1693
|
+
mimeType,
|
|
1694
|
+
...unresolvedBody ? { unresolvedBody } : {}
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
return out;
|
|
1698
|
+
}
|
|
1699
|
+
function isApiEntry(entry, opts) {
|
|
1700
|
+
if (opts.allowedOrigins && entry.req.origin && !opts.allowedOrigins.includes(entry.req.origin)) return false;
|
|
1701
|
+
return entry.mimeType.includes("json");
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Slice 4 — strip the OpenAPI `servers[].url` base path so a captured
|
|
1705
|
+
* `/api/v1/widgets` matches the documented path `/widgets`. Returns the longest
|
|
1706
|
+
* matching server base path's remainder, or the original path when none match.
|
|
1707
|
+
*/
|
|
1708
|
+
function serverBasePaths(spec) {
|
|
1709
|
+
const servers = Array.isArray(spec.servers) ? spec.servers : [];
|
|
1710
|
+
const bases = [];
|
|
1711
|
+
for (const s of servers) {
|
|
1712
|
+
const url = typeof s?.url === "string" ? s.url : "";
|
|
1713
|
+
if (!url) continue;
|
|
1714
|
+
let base = url;
|
|
1715
|
+
try {
|
|
1716
|
+
base = new URL(url).pathname;
|
|
1717
|
+
} catch {}
|
|
1718
|
+
base = base.replace(/\/+$/, "");
|
|
1719
|
+
if (base && base !== "/") bases.push(base);
|
|
1720
|
+
}
|
|
1721
|
+
return bases.sort((a, b) => b.length - a.length);
|
|
1722
|
+
}
|
|
1723
|
+
function reconcileBasePath(path, bases) {
|
|
1724
|
+
for (const base of bases) {
|
|
1725
|
+
if (path === base) return "/";
|
|
1726
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length);
|
|
1727
|
+
}
|
|
1728
|
+
return path;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Extract the GraphQL operation from a captured request body. The GraphQL-over-
|
|
1732
|
+
* HTTP shape is a JSON object with a string `query` (and optional `operationName`
|
|
1733
|
+
* and `variables`). Returns `undefined` for a non-GraphQL body.
|
|
1734
|
+
*/
|
|
1735
|
+
function graphqlOperationOf(entry) {
|
|
1736
|
+
const b = entry.req.body;
|
|
1737
|
+
if (b && typeof b === "object" && typeof b.query === "string") {
|
|
1738
|
+
const opName = b.operationName;
|
|
1739
|
+
const variables = b.variables;
|
|
1740
|
+
return {
|
|
1741
|
+
query: b.query,
|
|
1742
|
+
...typeof opName === "string" ? { operationName: opName } : {},
|
|
1743
|
+
...variables !== void 0 ? { variables } : {}
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const HTTP_METHODS$1 = [
|
|
1748
|
+
"get",
|
|
1749
|
+
"put",
|
|
1750
|
+
"post",
|
|
1751
|
+
"delete",
|
|
1752
|
+
"patch",
|
|
1753
|
+
"options",
|
|
1754
|
+
"head",
|
|
1755
|
+
"trace"
|
|
1756
|
+
];
|
|
1757
|
+
/** Every documented operation as `METHOD /template` — the universe for the drift walk. */
|
|
1758
|
+
function documentedOperations(spec) {
|
|
1759
|
+
const ops = [];
|
|
1760
|
+
for (const [template, item] of Object.entries(spec.paths ?? {})) {
|
|
1761
|
+
if (!item) continue;
|
|
1762
|
+
for (const method of HTTP_METHODS$1) if (method in item) ops.push(`${method.toUpperCase()} ${template}`);
|
|
1763
|
+
}
|
|
1764
|
+
return ops;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Slice 5 (+ ADR 0013 §5 GraphQL) — drive each captured JSON entry through the
|
|
1768
|
+
* matching shipped validator. A GraphQL entry (matched by the contract's
|
|
1769
|
+
* `endpointPath` or the JSON `{query}` shape) goes to `validateGraphqlOperation`
|
|
1770
|
+
* and NEVER to the OpenAPI validator; a REST entry goes to
|
|
1771
|
+
* `validateOpenApiResponse` and feeds the exercised/unexercised drift walk. An
|
|
1772
|
+
* entry with no matching contract half is no-signal (never a pass). Every finding
|
|
1773
|
+
* message is routed through the operator `Redactor`; our own summary paths use the
|
|
1774
|
+
* matched operation template / operator-supplied endpoint, never a raw captured path.
|
|
1775
|
+
*/
|
|
1776
|
+
function validateCapturedTraffic(harZip, contract, opts = {}) {
|
|
1777
|
+
const redact = opts.redact ?? ((v) => v);
|
|
1778
|
+
const { openapi: spec, graphql } = contract;
|
|
1779
|
+
const bases = spec ? serverBasePaths(spec) : [];
|
|
1780
|
+
const entries = harEntriesToFacts(harZip).filter((e) => isApiEntry(e, opts));
|
|
1781
|
+
const results = [];
|
|
1782
|
+
const findingsByKind = {};
|
|
1783
|
+
const exercised = /* @__PURE__ */ new Set();
|
|
1784
|
+
let firstFailing;
|
|
1785
|
+
let unresolvedBodies = 0;
|
|
1786
|
+
let noSignal = 0;
|
|
1787
|
+
const bump = (kind) => {
|
|
1788
|
+
findingsByKind[kind] = (findingsByKind[kind] ?? 0) + 1;
|
|
1789
|
+
};
|
|
1790
|
+
const pushResult = (entry, raw, displayPath) => {
|
|
1791
|
+
const redactedFindings = raw.findings.map((f) => ({
|
|
1792
|
+
...f,
|
|
1793
|
+
message: redact(f.message),
|
|
1794
|
+
...f.path !== void 0 ? { path: redact(f.path) } : {}
|
|
1795
|
+
}));
|
|
1796
|
+
const result = {
|
|
1797
|
+
...raw,
|
|
1798
|
+
findings: redactedFindings
|
|
1799
|
+
};
|
|
1800
|
+
results.push(result);
|
|
1801
|
+
for (const f of redactedFindings) bump(f.kind);
|
|
1802
|
+
if (!result.valid && !firstFailing) {
|
|
1803
|
+
const f = redactedFindings.find((x) => x.severity === "error") ?? redactedFindings[0];
|
|
1804
|
+
firstFailing = {
|
|
1805
|
+
method: entry.req.method,
|
|
1806
|
+
path: displayPath,
|
|
1807
|
+
kind: f?.kind ?? "unknown",
|
|
1808
|
+
message: f?.message ?? ""
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
return result;
|
|
1812
|
+
};
|
|
1813
|
+
for (const entry of entries) {
|
|
1814
|
+
if (entry.unresolvedBody) {
|
|
1815
|
+
unresolvedBodies++;
|
|
1816
|
+
bump("unresolved-body");
|
|
1817
|
+
firstFailing ??= {
|
|
1818
|
+
method: entry.req.method,
|
|
1819
|
+
path: redact(reconcileBasePath(entry.req.path, bases)),
|
|
1820
|
+
kind: "unresolved-body",
|
|
1821
|
+
message: redact(entry.unresolvedBody)
|
|
1822
|
+
};
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
const op = graphqlOperationOf(entry);
|
|
1826
|
+
if (graphql !== void 0 && entry.req.path === graphql.endpointPath || !!op) {
|
|
1827
|
+
if (!graphql) {
|
|
1828
|
+
noSignal++;
|
|
1829
|
+
bump("graphql-sdl-not-supplied");
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
if (!op) {
|
|
1833
|
+
pushResult(entry, {
|
|
1834
|
+
valid: false,
|
|
1835
|
+
findings: [{
|
|
1836
|
+
kind: "graphql-no-query",
|
|
1837
|
+
severity: "error",
|
|
1838
|
+
message: "no GraphQL query found in the captured request body"
|
|
1839
|
+
}]
|
|
1840
|
+
}, redact(graphql.endpointPath));
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
const raw = validateGraphqlOperation(graphql.sdl, op.query, {
|
|
1844
|
+
json: entry.res.body,
|
|
1845
|
+
operationName: op.operationName,
|
|
1846
|
+
...op.variables !== void 0 ? { variables: op.variables } : {}
|
|
1847
|
+
});
|
|
1848
|
+
pushResult(entry, raw, redact(graphql.endpointPath));
|
|
1849
|
+
if (raw.unverified) {
|
|
1850
|
+
noSignal++;
|
|
1851
|
+
bump(raw.directiveUnverified ? "graphql-directive-unverified" : "graphql-variable-unverified");
|
|
1852
|
+
}
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
if (!spec) {
|
|
1856
|
+
noSignal++;
|
|
1857
|
+
bump("no-contract-for-entry");
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
const reqPath = reconcileBasePath(entry.req.path, bases);
|
|
1861
|
+
const typedSpec = spec;
|
|
1862
|
+
const resRaw = validateOpenApiResponse(typedSpec, {
|
|
1863
|
+
method: entry.req.method,
|
|
1864
|
+
path: reqPath
|
|
1865
|
+
}, entry.res, { baseDir: opts.baseDir });
|
|
1866
|
+
const reqRaw = validateOpenApiRequest(typedSpec, {
|
|
1867
|
+
method: entry.req.method,
|
|
1868
|
+
path: reqPath,
|
|
1869
|
+
body: entry.req.body,
|
|
1870
|
+
query: entry.req.query,
|
|
1871
|
+
headers: entry.req.headers,
|
|
1872
|
+
form: entry.req.form,
|
|
1873
|
+
formFileFields: entry.req.formFileFields
|
|
1874
|
+
}, { baseDir: opts.baseDir });
|
|
1875
|
+
const merged = {
|
|
1876
|
+
valid: resRaw.valid && reqRaw.valid,
|
|
1877
|
+
findings: [...resRaw.findings, ...reqRaw.findings.filter((f) => f.kind !== "missing-operation")],
|
|
1878
|
+
...resRaw.operation ?? reqRaw.operation ? { operation: resRaw.operation ?? reqRaw.operation } : {}
|
|
1879
|
+
};
|
|
1880
|
+
const result = pushResult(entry, merged, merged.operation?.path ?? redact(reqPath));
|
|
1881
|
+
if (result.operation) exercised.add(`${result.operation.method.toUpperCase()} ${result.operation.path}`);
|
|
1882
|
+
if (reqRaw.unverified) {
|
|
1883
|
+
noSignal++;
|
|
1884
|
+
bump("request-unverified");
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
const documented = spec ? documentedOperations(spec) : [];
|
|
1888
|
+
const exercisedOperations = [...exercised].sort();
|
|
1889
|
+
const unexercisedOperations = documented.filter((op) => !exercised.has(op)).sort();
|
|
1890
|
+
const clean = entries.length > 0 && unresolvedBodies === 0 && noSignal === 0 && results.every((r) => r.valid);
|
|
1891
|
+
return {
|
|
1892
|
+
entriesValidated: entries.length,
|
|
1893
|
+
findingsByKind,
|
|
1894
|
+
firstFailing,
|
|
1895
|
+
exercisedOperations,
|
|
1896
|
+
unexercisedOperations,
|
|
1897
|
+
results,
|
|
1898
|
+
unresolvedBodies,
|
|
1899
|
+
noSignal,
|
|
1900
|
+
clean
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/har-synth.ts
|
|
1905
|
+
/**
|
|
1906
|
+
* HAR synthesis + redaction for the API pillar (ADR 0013 Addendum 4, milestone 5f).
|
|
1907
|
+
* Lets `verify` PRODUCE a HAR from the `@sackville-mcp/api` runner (not just the browser
|
|
1908
|
+
* pillar's live capture), then validate it against the contract via the SHIPPED
|
|
1909
|
+
* {@link validateCapturedTraffic} — full REST + GraphQL parity.
|
|
1910
|
+
*
|
|
1911
|
+
* This module is a PURE leaf: it imports ONLY `fflate` (no runner/undici/spawn-capable
|
|
1912
|
+
* code), so the new `@sackville-mcp/browser → @sackville-mcp/api` dep edge it creates (browser's
|
|
1913
|
+
* `finalizeHar` delegates its Buffer→Buffer transform here) cannot drag heavy code into
|
|
1914
|
+
* the browser pillar, and the gate suite exercises synthesis with no network.
|
|
1915
|
+
*
|
|
1916
|
+
* Security posture (ADR 0013 §3): a synthesized HAR carries the REAL request/response
|
|
1917
|
+
* bodies + urls. {@link redactHarZip} is the blanket-redaction pass (lifted from the
|
|
1918
|
+
* browser pillar's `finalizeHar` core, shared so the 5e attach-mimeType fix is inherited),
|
|
1919
|
+
* and {@link synthesizeRedactedHarZip} folds it in so NO un-redacted buffer is ever
|
|
1920
|
+
* returned. The redactor MUST carry the run-resolved `{{secret:NAME}}` values.
|
|
1921
|
+
*/
|
|
1922
|
+
/** fflate is unbounded by default; cap the inflated HAR archive (ADR 0013 §3e). */
|
|
1923
|
+
const MAX_HAR_INFLATED_BYTES = 64 * 1024 * 1024;
|
|
1924
|
+
const HAR_TEXT_ENTRY = /\.(har|json|txt|html|htm|css|js|xml|svg)$/;
|
|
1925
|
+
const TEXT_MIME = /^text\/|(?:json|xml|javascript|ecmascript|graphql|html|urlencoded|csv|yaml)/i;
|
|
1926
|
+
/**
|
|
1927
|
+
* In `content:'attach'` mode a body lives in a SEPARATE archive entry whose name is
|
|
1928
|
+
* content-addressed — frequently WITHOUT a text extension — so {@link HAR_TEXT_ENTRY}
|
|
1929
|
+
* (a filename gate) would pass a JSON/GraphQL body through unredacted. Walk the `.har`
|
|
1930
|
+
* JSON and collect the `_file` names of every body whose DECLARED mimeType is text-like,
|
|
1931
|
+
* so they are redacted by type, not by extension.
|
|
1932
|
+
*/
|
|
1933
|
+
function textAttachFiles(harJson) {
|
|
1934
|
+
const files = /* @__PURE__ */ new Set();
|
|
1935
|
+
let entries = [];
|
|
1936
|
+
try {
|
|
1937
|
+
const log = JSON.parse(harJson).log;
|
|
1938
|
+
if (Array.isArray(log?.entries)) entries = log.entries;
|
|
1939
|
+
} catch {
|
|
1940
|
+
return files;
|
|
1941
|
+
}
|
|
1942
|
+
const consider = (part) => {
|
|
1943
|
+
if (part && typeof part._file === "string" && typeof part.mimeType === "string" && TEXT_MIME.test(part.mimeType)) files.add(part._file);
|
|
1944
|
+
};
|
|
1945
|
+
for (const e of entries) {
|
|
1946
|
+
consider(e?.request?.postData);
|
|
1947
|
+
consider(e?.response?.content);
|
|
1948
|
+
}
|
|
1949
|
+
return files;
|
|
1950
|
+
}
|
|
1951
|
+
/** Parse the `.har` JSON into compact tallies; tolerant of a malformed/empty log. */
|
|
1952
|
+
function summarizeHar(harJson) {
|
|
1953
|
+
const byStatus = {};
|
|
1954
|
+
const byMethod = {};
|
|
1955
|
+
let entries = [];
|
|
1956
|
+
try {
|
|
1957
|
+
const log = JSON.parse(harJson).log;
|
|
1958
|
+
if (Array.isArray(log?.entries)) entries = log.entries;
|
|
1959
|
+
} catch {
|
|
1960
|
+
return {
|
|
1961
|
+
entryCount: 0,
|
|
1962
|
+
byStatus,
|
|
1963
|
+
byMethod
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
for (const e of entries) {
|
|
1967
|
+
const status = e?.response?.status;
|
|
1968
|
+
if (typeof status === "number") byStatus[String(status)] = (byStatus[String(status)] ?? 0) + 1;
|
|
1969
|
+
const method = e?.request?.method;
|
|
1970
|
+
if (typeof method === "string") byMethod[method] = (byMethod[method] ?? 0) + 1;
|
|
1971
|
+
}
|
|
1972
|
+
return {
|
|
1973
|
+
entryCount: entries.length,
|
|
1974
|
+
byStatus,
|
|
1975
|
+
byMethod
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* The blanket-redaction pass over a HAR `.zip` Buffer (PURE — no file I/O). Unzips,
|
|
1980
|
+
* collects the text-like attach `_file` bodies by DECLARED mimeType (so a body stored
|
|
1981
|
+
* under a non-text filename is still scrubbed), redacts every text member — the `.har`
|
|
1982
|
+
* JSON + text-extension bodies + those attach bodies — and re-zips. A genuinely binary
|
|
1983
|
+
* member passes through byte-for-byte. Shared by the browser pillar's `finalizeHar`
|
|
1984
|
+
* (file wrapper) and api synthesis (in-memory), so they share ONE redaction code path.
|
|
1985
|
+
*/
|
|
1986
|
+
function redactHarZip(zip, redact) {
|
|
1987
|
+
const entries = unzipSync(new Uint8Array(zip), { filter: (file) => file.originalSize <= MAX_HAR_INFLATED_BYTES });
|
|
1988
|
+
let attachText = /* @__PURE__ */ new Set();
|
|
1989
|
+
for (const [name, bytes] of Object.entries(entries)) if (name.endsWith(".har")) attachText = textAttachFiles(strFromU8(bytes));
|
|
1990
|
+
const out = {};
|
|
1991
|
+
for (const [name, bytes] of Object.entries(entries)) if (HAR_TEXT_ENTRY.test(name) || attachText.has(name)) out[name] = strToU8(redact(strFromU8(bytes)));
|
|
1992
|
+
else out[name] = bytes;
|
|
1993
|
+
return Buffer.from(zipSync(out));
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Synthesize a HAR `.zip` Buffer from per-hop records and IMMEDIATELY redact it — the
|
|
1997
|
+
* ONLY public surface, so no un-redacted synthesized buffer is ever returned/stored/
|
|
1998
|
+
* validated (ADR 0013 §3b). Builds `{log:{entries}}` with ONLY the six fields the
|
|
1999
|
+
* consume bridge reads, INLINE `text` bodies (we hold the strings — no `_file` attach),
|
|
2000
|
+
* one `.har` member, then runs {@link redactHarZip}. THROWS on a status-less record.
|
|
2001
|
+
*/
|
|
2002
|
+
function synthesizeRedactedHarZip(records, redact) {
|
|
2003
|
+
const entries = [];
|
|
2004
|
+
for (const r of records) {
|
|
2005
|
+
if (typeof r.status !== "number" || !Number.isFinite(r.status)) throw new Error(`har-synth: record for ${r.method} ${r.url} has no numeric status`);
|
|
2006
|
+
const request = {
|
|
2007
|
+
method: r.method,
|
|
2008
|
+
url: r.url
|
|
2009
|
+
};
|
|
2010
|
+
if (typeof r.reqBody === "string" && r.reqContentType) request.postData = {
|
|
2011
|
+
mimeType: r.reqContentType,
|
|
2012
|
+
text: r.reqBody
|
|
2013
|
+
};
|
|
2014
|
+
const content = { mimeType: r.resContentType ?? "" };
|
|
2015
|
+
if (typeof r.resBody === "string") content.text = r.resBody;
|
|
2016
|
+
entries.push({
|
|
2017
|
+
request,
|
|
2018
|
+
response: {
|
|
2019
|
+
status: r.status,
|
|
2020
|
+
content
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
const har = { log: {
|
|
2025
|
+
version: "1.2",
|
|
2026
|
+
creator: {
|
|
2027
|
+
name: "sackville-api",
|
|
2028
|
+
version: "1.2"
|
|
2029
|
+
},
|
|
2030
|
+
entries
|
|
2031
|
+
} };
|
|
2032
|
+
return redactHarZip(Buffer.from(zipSync({ "synth.har": strToU8(JSON.stringify(har)) })), redact);
|
|
2033
|
+
}
|
|
2034
|
+
//#endregion
|
|
2035
|
+
//#region src/vars.ts
|
|
2036
|
+
const VAR_RE = /\{\{\s*([^}\s]+)\s*\}\}/g;
|
|
2037
|
+
/**
|
|
2038
|
+
* Interpolate `{{name}}` placeholders from a variable scope. Unknown names are
|
|
2039
|
+
* left intact (so callers can detect them). Secret resolution is layered on
|
|
2040
|
+
* later, at the transport boundary.
|
|
2041
|
+
*/
|
|
2042
|
+
function interpolate(template, scope) {
|
|
2043
|
+
return template.replace(VAR_RE, (match, name) => {
|
|
2044
|
+
const value = scope[name];
|
|
2045
|
+
return value === void 0 ? match : String(value);
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/prepare.ts
|
|
2050
|
+
const SECRET_RE = /\{\{\s*secret:([^}\s]+)\s*\}\}/g;
|
|
2051
|
+
const RAW_CONTENT_TYPE = {
|
|
2052
|
+
json: "application/json",
|
|
2053
|
+
text: "text/plain",
|
|
2054
|
+
xml: "application/xml",
|
|
2055
|
+
sparql: "application/sparql-query"
|
|
2056
|
+
};
|
|
2057
|
+
/** Append a field into a repeated-keys-→-array map (the form-field channel shape). */
|
|
2058
|
+
function appendField(map, name, value) {
|
|
2059
|
+
const cur = map[name];
|
|
2060
|
+
if (cur === void 0) map[name] = value;
|
|
2061
|
+
else if (Array.isArray(cur)) cur.push(value);
|
|
2062
|
+
else map[name] = [cur, value];
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Resolve `{{secret:NAME}}` (from the store, registered with the redactor) and
|
|
2066
|
+
* `{{var}}` (from the scope) across the URL, headers, AND body into the actual
|
|
2067
|
+
* values sent on the wire. Fails closed on an unresolved secret.
|
|
2068
|
+
*/
|
|
2069
|
+
async function prepareRequest(request, scope, secrets, redactor, baseDir) {
|
|
2070
|
+
const fillSecrets = await secretFiller([
|
|
2071
|
+
request.url,
|
|
2072
|
+
...request.headers.map((h) => h.value),
|
|
2073
|
+
...bodyTexts(request.body)
|
|
2074
|
+
], secrets, redactor);
|
|
2075
|
+
const fill = (text) => interpolate(fillSecrets(text), scope);
|
|
2076
|
+
const headers = {};
|
|
2077
|
+
for (const header of request.headers) headers[header.name] = fill(header.value);
|
|
2078
|
+
return {
|
|
2079
|
+
method: request.method,
|
|
2080
|
+
url: fill(request.url),
|
|
2081
|
+
headers,
|
|
2082
|
+
body: await materializeBody(request.body, fill, baseDir)
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
/** The interpolatable strings inside a body (for secret scanning). */
|
|
2086
|
+
function bodyTexts(body) {
|
|
2087
|
+
if (!body) return [];
|
|
2088
|
+
if (body.type === "graphql") {
|
|
2089
|
+
const g = body.graphql;
|
|
2090
|
+
return g ? [g.query, ...g.variables ? [g.variables] : []] : [];
|
|
2091
|
+
}
|
|
2092
|
+
if (body.type === "multipart-form") return (body.parts ?? []).flatMap((p) => p.kind === "file" ? p.filePaths ?? [] : [p.value ?? ""]);
|
|
2093
|
+
if (body.type === "file") return body.file ? [body.file.filePath] : [];
|
|
2094
|
+
if (body.content !== void 0) return [body.content];
|
|
2095
|
+
return (body.params ?? []).map((p) => p.value);
|
|
2096
|
+
}
|
|
2097
|
+
async function materializeBody(body, fill, baseDir) {
|
|
2098
|
+
if (!body || body.type === "none") return void 0;
|
|
2099
|
+
if (body.type === "form-urlencoded") {
|
|
2100
|
+
const params = new URLSearchParams();
|
|
2101
|
+
const formFields = {};
|
|
2102
|
+
for (const p of body.params ?? []) {
|
|
2103
|
+
const value = fill(p.value);
|
|
2104
|
+
params.append(p.name, value);
|
|
2105
|
+
appendField(formFields, p.name, value);
|
|
2106
|
+
}
|
|
2107
|
+
const content = params.toString();
|
|
2108
|
+
return {
|
|
2109
|
+
contentType: "application/x-www-form-urlencoded",
|
|
2110
|
+
content,
|
|
2111
|
+
preview: content,
|
|
2112
|
+
formFields
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
if (body.type === "graphql") {
|
|
2116
|
+
const content = materializeGraphql(body.graphql, fill);
|
|
2117
|
+
return {
|
|
2118
|
+
contentType: "application/json",
|
|
2119
|
+
content,
|
|
2120
|
+
preview: content
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
if (body.type === "multipart-form") return materializeMultipart(body, fill, baseDir);
|
|
2124
|
+
if (body.type === "file") return materializeFile(body, fill, baseDir);
|
|
2125
|
+
if (body.content !== void 0) {
|
|
2126
|
+
const content = fill(body.content);
|
|
2127
|
+
return {
|
|
2128
|
+
contentType: RAW_CONTENT_TYPE[body.type] ?? "text/plain",
|
|
2129
|
+
content,
|
|
2130
|
+
preview: content
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* A `multipart/form-data` body. Text parts carry interpolated values; file parts
|
|
2136
|
+
* read their bytes from disk (paths resolved against the collection dir — the
|
|
2137
|
+
* `.bru` is operator-authored config, and egress is separately gated, so paths
|
|
2138
|
+
* are not sandboxed here). undici mints the boundary, so `contentType` is left
|
|
2139
|
+
* unset. The preview summarizes parts (file by name + byte size), never inlining
|
|
2140
|
+
* file bytes; text values flow through the redactor at the surface.
|
|
2141
|
+
*/
|
|
2142
|
+
async function materializeMultipart(body, fill, baseDir) {
|
|
2143
|
+
const form = new FormData();
|
|
2144
|
+
const lines = ["multipart/form-data:"];
|
|
2145
|
+
const formFields = {};
|
|
2146
|
+
const fileFields = /* @__PURE__ */ new Set();
|
|
2147
|
+
for (const part of body.parts ?? []) if (part.kind === "file") {
|
|
2148
|
+
fileFields.add(part.name);
|
|
2149
|
+
for (const rawPath of part.filePaths ?? []) {
|
|
2150
|
+
const filled = fill(rawPath);
|
|
2151
|
+
const buf = await readFile(resolve(baseDir ?? "", filled));
|
|
2152
|
+
const blob = new Blob([buf], part.contentType ? { type: part.contentType } : {});
|
|
2153
|
+
form.append(part.name, blob, basename(filled));
|
|
2154
|
+
lines.push(` ${part.name} (file): ${filled} (${buf.byteLength} bytes)`);
|
|
2155
|
+
}
|
|
2156
|
+
} else {
|
|
2157
|
+
const value = fill(part.value ?? "");
|
|
2158
|
+
form.append(part.name, value);
|
|
2159
|
+
appendField(formFields, part.name, value);
|
|
2160
|
+
lines.push(` ${part.name} (text): ${value}`);
|
|
2161
|
+
}
|
|
2162
|
+
return {
|
|
2163
|
+
content: form,
|
|
2164
|
+
preview: lines.join("\n"),
|
|
2165
|
+
formFields,
|
|
2166
|
+
...fileFields.size > 0 ? { formFileFields: [...fileFields] } : {}
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* A raw file body — the file's bytes are sent as the request body under the
|
|
2171
|
+
* declared content type (default `application/octet-stream`). Path resolved
|
|
2172
|
+
* against the collection dir (same operator-config trust model as multipart).
|
|
2173
|
+
* The preview names the file by path + byte size + content type; the bytes are
|
|
2174
|
+
* never inlined into agent-facing output.
|
|
2175
|
+
*/
|
|
2176
|
+
async function materializeFile(body, fill, baseDir) {
|
|
2177
|
+
if (!body.file) return void 0;
|
|
2178
|
+
const filled = fill(body.file.filePath);
|
|
2179
|
+
const buf = await readFile(resolve(baseDir ?? "", filled));
|
|
2180
|
+
const contentType = body.file.contentType || "application/octet-stream";
|
|
2181
|
+
return {
|
|
2182
|
+
contentType,
|
|
2183
|
+
content: buf,
|
|
2184
|
+
preview: `<file: ${filled} (${buf.byteLength} bytes, ${contentType})>`
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
/** A GraphQL-over-HTTP envelope: `{query, variables}` as JSON. Variables (a JSON
|
|
2188
|
+
* string in the `.bru`) are interpolated then parsed into an object; an empty or
|
|
2189
|
+
* whitespace-only variables block is omitted. */
|
|
2190
|
+
function materializeGraphql(graphql, fill) {
|
|
2191
|
+
const query = fill(graphql?.query ?? "");
|
|
2192
|
+
const rawVars = graphql?.variables?.trim();
|
|
2193
|
+
if (!rawVars) return JSON.stringify({ query });
|
|
2194
|
+
const filled = fill(rawVars);
|
|
2195
|
+
let variables;
|
|
2196
|
+
try {
|
|
2197
|
+
variables = JSON.parse(filled);
|
|
2198
|
+
} catch (e) {
|
|
2199
|
+
throw new Error(`invalid graphql variables JSON: ${e.message}`);
|
|
2200
|
+
}
|
|
2201
|
+
return JSON.stringify({
|
|
2202
|
+
query,
|
|
2203
|
+
variables
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
async function secretFiller(texts, secrets, redactor) {
|
|
2207
|
+
const names = /* @__PURE__ */ new Set();
|
|
2208
|
+
for (const text of texts) for (const match of text.matchAll(SECRET_RE)) names.add(match[1]);
|
|
2209
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
2210
|
+
const missing = [];
|
|
2211
|
+
for (const name of names) {
|
|
2212
|
+
const value = await secrets.get(name);
|
|
2213
|
+
if (value === void 0) {
|
|
2214
|
+
missing.push(name);
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
resolved.set(name, value);
|
|
2218
|
+
redactor.register(name, value);
|
|
2219
|
+
}
|
|
2220
|
+
if (missing.length > 0) throw new Error(`missing secret(s): ${missing.join(", ")}`);
|
|
2221
|
+
return (text) => text.replace(SECRET_RE, (_m, name) => resolved.get(name) ?? `{{secret:${name}}}`);
|
|
2222
|
+
}
|
|
2223
|
+
//#endregion
|
|
2224
|
+
//#region src/safety.ts
|
|
2225
|
+
/** Methods that don't mutate server state run freely. */
|
|
2226
|
+
const SAFE_METHODS = new Set([
|
|
2227
|
+
"GET",
|
|
2228
|
+
"HEAD",
|
|
2229
|
+
"OPTIONS"
|
|
2230
|
+
]);
|
|
2231
|
+
/**
|
|
2232
|
+
* Refuse a request on SSRF grounds before it leaves. Applies to EVERY request
|
|
2233
|
+
* (safe + mutating) — the cloud-metadata endpoint is reachable by a plain GET.
|
|
2234
|
+
* Reuses the shared `@sackville-mcp/safety` classifier: metadata host literals and
|
|
2235
|
+
* blocked-range IPs are always refused; loopback/private are refused only when
|
|
2236
|
+
* `allowPrivate` is false; a hostname is resolved and its address vetted (so a
|
|
2237
|
+
* name pointing at an internal/metadata IP is caught). Throws `SsrfError` when
|
|
2238
|
+
* blocked. NOTE: this is a pre-flight resolve-and-refuse, not a connection pin —
|
|
2239
|
+
* a narrow DNS-rebinding TOCTOU window remains (the browser pillar's proxy does
|
|
2240
|
+
* true pinning); for operator-authored API collections that is an accepted
|
|
2241
|
+
* limitation, documented in ADR 0004.
|
|
2242
|
+
*/
|
|
2243
|
+
async function assertSsrfAllowed(url, opts = {}) {
|
|
2244
|
+
let hostname;
|
|
2245
|
+
try {
|
|
2246
|
+
hostname = new URL(url).hostname;
|
|
2247
|
+
} catch {
|
|
2248
|
+
throw new SsrfError(`invalid request URL: ${url}`);
|
|
2249
|
+
}
|
|
2250
|
+
await resolveAndPin(hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname, opts.lookup, { allowPrivate: opts.allowPrivate ?? true });
|
|
2251
|
+
}
|
|
2252
|
+
function isMutating(method) {
|
|
2253
|
+
return !SAFE_METHODS.has(method.toUpperCase());
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Decide whether a request may actually be sent. Safe methods always may.
|
|
2257
|
+
* Mutating methods are withheld (dry-run) unless explicitly unlocked
|
|
2258
|
+
* (`allowUnsafe`) AND the target host is on the allowlist — never silently fired.
|
|
2259
|
+
*/
|
|
2260
|
+
function checkGate(method, host, opts = {}) {
|
|
2261
|
+
if (!isMutating(method)) return {
|
|
2262
|
+
allowed: true,
|
|
2263
|
+
reason: `${method.toUpperCase()} is a safe method`
|
|
2264
|
+
};
|
|
2265
|
+
if (!opts.allowUnsafe) return {
|
|
2266
|
+
allowed: false,
|
|
2267
|
+
reason: `${method.toUpperCase()} is a mutating method; dry-run only (pass allowUnsafe to send)`
|
|
2268
|
+
};
|
|
2269
|
+
const allowed = opts.allowedHosts ?? [];
|
|
2270
|
+
if (!allowed.includes(host)) return {
|
|
2271
|
+
allowed: false,
|
|
2272
|
+
reason: `host ${host} is not in the allowlist for mutating requests (${allowed.join(", ") || "none"})`
|
|
2273
|
+
};
|
|
2274
|
+
return {
|
|
2275
|
+
allowed: true,
|
|
2276
|
+
reason: `${method.toUpperCase()} to ${host} is allowlisted`
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
//#endregion
|
|
2280
|
+
//#region src/script.ts
|
|
2281
|
+
const TIMEOUT_MS = 1e3;
|
|
2282
|
+
const PRELUDE = `
|
|
2283
|
+
globalThis.console = { log: (...a) => __logs.push(a.map(String).join(' ')), error: (...a) => __logs.push(a.map(String).join(' ')) };
|
|
2284
|
+
globalThis.bru = {
|
|
2285
|
+
getVar: (k) => __vars[k],
|
|
2286
|
+
setVar: (k, v) => { __vars[k] = v; },
|
|
2287
|
+
};
|
|
2288
|
+
function __mk(actual) {
|
|
2289
|
+
const fail = (m) => { throw new Error(m); };
|
|
2290
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
2291
|
+
return {
|
|
2292
|
+
toBe: (e) => { if (actual !== e) fail('expected ' + JSON.stringify(actual) + ' to be ' + JSON.stringify(e)); },
|
|
2293
|
+
toEqual: (e) => { if (!eq(actual, e)) fail('expected ' + JSON.stringify(actual) + ' to equal ' + JSON.stringify(e)); },
|
|
2294
|
+
toContain: (e) => { if (!String(actual).includes(String(e))) fail('expected ' + JSON.stringify(actual) + ' to contain ' + JSON.stringify(e)); },
|
|
2295
|
+
toBeGreaterThan: (e) => { if (!(actual > e)) fail('expected ' + actual + ' > ' + e); },
|
|
2296
|
+
toBeLessThan: (e) => { if (!(actual < e)) fail('expected ' + actual + ' < ' + e); },
|
|
2297
|
+
toBeTruthy: () => { if (!actual) fail('expected truthy, got ' + JSON.stringify(actual)); },
|
|
2298
|
+
toBeFalsy: () => { if (actual) fail('expected falsy, got ' + JSON.stringify(actual)); },
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
globalThis.expect = (actual) => __mk(actual);
|
|
2302
|
+
globalThis.test = (name, fn) => {
|
|
2303
|
+
try { fn(); __tests.push({ name: name, pass: true }); }
|
|
2304
|
+
catch (e) { __tests.push({ name: name, pass: false, error: String((e && e.message) || e) }); }
|
|
2305
|
+
};
|
|
2306
|
+
`;
|
|
2307
|
+
let modulePromise;
|
|
2308
|
+
function quickjs() {
|
|
2309
|
+
if (!modulePromise) modulePromise = getQuickJS();
|
|
2310
|
+
return modulePromise;
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Run a pre/post-request script in a QuickJS WASM sandbox with the curated
|
|
2314
|
+
* `bru`/`expect`/`test`/`console` API and a wall-clock interrupt. The script
|
|
2315
|
+
* sees `res` (post-response only) and reads/writes variables via `bru`; nothing
|
|
2316
|
+
* from the host process is reachable.
|
|
2317
|
+
*/
|
|
2318
|
+
async function runScript(code, context) {
|
|
2319
|
+
const runtime = (await quickjs()).newRuntime();
|
|
2320
|
+
runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + TIMEOUT_MS));
|
|
2321
|
+
const vm = runtime.newContext();
|
|
2322
|
+
try {
|
|
2323
|
+
const init = `globalThis.__vars = ${JSON.stringify(context.vars)};globalThis.__tests = [];globalThis.__logs = [];globalThis.res = ${context.res ? JSON.stringify(context.res) : "undefined"};` + PRELUDE;
|
|
2324
|
+
const setup = vm.evalCode(init);
|
|
2325
|
+
if (setup.error) {
|
|
2326
|
+
const message = vm.dump(setup.error);
|
|
2327
|
+
setup.error.dispose();
|
|
2328
|
+
throw new Error(`script sandbox setup failed: ${JSON.stringify(message)}`);
|
|
2329
|
+
}
|
|
2330
|
+
setup.value.dispose();
|
|
2331
|
+
let error;
|
|
2332
|
+
const run = vm.evalCode(code);
|
|
2333
|
+
if (run.error) {
|
|
2334
|
+
const dumped = vm.dump(run.error);
|
|
2335
|
+
run.error.dispose();
|
|
2336
|
+
error = typeof dumped === "object" && dumped?.message ? String(dumped.message) : String(dumped);
|
|
2337
|
+
} else run.value.dispose();
|
|
2338
|
+
const read = vm.evalCode("JSON.stringify({ vars: __vars, tests: __tests, logs: __logs })");
|
|
2339
|
+
let data = {
|
|
2340
|
+
vars: context.vars,
|
|
2341
|
+
tests: [],
|
|
2342
|
+
logs: []
|
|
2343
|
+
};
|
|
2344
|
+
if (read.error) read.error.dispose();
|
|
2345
|
+
else {
|
|
2346
|
+
data = JSON.parse(vm.dump(read.value));
|
|
2347
|
+
read.value.dispose();
|
|
2348
|
+
}
|
|
2349
|
+
return {
|
|
2350
|
+
vars: data.vars ?? {},
|
|
2351
|
+
tests: data.tests ?? [],
|
|
2352
|
+
logs: data.logs ?? [],
|
|
2353
|
+
error
|
|
2354
|
+
};
|
|
2355
|
+
} finally {
|
|
2356
|
+
vm.dispose();
|
|
2357
|
+
runtime.dispose();
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
//#endregion
|
|
2361
|
+
//#region src/secrets.ts
|
|
2362
|
+
/** In-memory store (tests / explicit injection). */
|
|
2363
|
+
var StaticSecretStore = class {
|
|
2364
|
+
values;
|
|
2365
|
+
constructor(values = {}) {
|
|
2366
|
+
this.values = values;
|
|
2367
|
+
}
|
|
2368
|
+
get(name) {
|
|
2369
|
+
return Promise.resolve(this.values[name]);
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
/**
|
|
2373
|
+
* Reads `<prefix><NAME>` from the environment — the zero-dependency default
|
|
2374
|
+
* (Linux/CI). The default prefix is `SACKVILLE_SECRET_`; the aggregate server
|
|
2375
|
+
* overrides it to `SACKVILLE_API_SECRET_` so the api pillar's secrets live in its
|
|
2376
|
+
* own namespace and can't be read via a bare, shared name (ADR 0019).
|
|
2377
|
+
*/
|
|
2378
|
+
var EnvSecretStore = class {
|
|
2379
|
+
env;
|
|
2380
|
+
prefix;
|
|
2381
|
+
constructor(env = process.env, prefix = "SACKVILLE_SECRET_") {
|
|
2382
|
+
this.env = env;
|
|
2383
|
+
this.prefix = prefix;
|
|
2384
|
+
}
|
|
2385
|
+
get(name) {
|
|
2386
|
+
return Promise.resolve(this.env[`${this.prefix}${name}`]);
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2389
|
+
/** OS keychain via @napi-rs/keyring (macOS/Windows/Linux-desktop). The native
|
|
2390
|
+
* module is loaded lazily through a non-literal specifier so importing this file
|
|
2391
|
+
* never loads it; Secret Service can throw at runtime in headless containers, so
|
|
2392
|
+
* failures resolve to undefined. */
|
|
2393
|
+
var KeyringSecretStore = class {
|
|
2394
|
+
service;
|
|
2395
|
+
constructor(service = "sackville") {
|
|
2396
|
+
this.service = service;
|
|
2397
|
+
}
|
|
2398
|
+
async get(name) {
|
|
2399
|
+
try {
|
|
2400
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
2401
|
+
const value = new Entry(this.service, name).getPassword();
|
|
2402
|
+
return typeof value === "string" ? value : void 0;
|
|
2403
|
+
} catch {
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
/** Try each store in order, first hit wins. */
|
|
2409
|
+
var ChainedSecretStore = class {
|
|
2410
|
+
stores;
|
|
2411
|
+
constructor(stores) {
|
|
2412
|
+
this.stores = stores;
|
|
2413
|
+
}
|
|
2414
|
+
async get(name) {
|
|
2415
|
+
for (const store of this.stores) {
|
|
2416
|
+
const value = await store.get(name);
|
|
2417
|
+
if (value !== void 0) return value;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
};
|
|
2421
|
+
/**
|
|
2422
|
+
* Default store: keyring (opt-in) chained ahead of env, else env only.
|
|
2423
|
+
* `env`/`envPrefix` override the environment source + variable prefix the
|
|
2424
|
+
* `EnvSecretStore` reads (the aggregate server passes `SACKVILLE_API_SECRET_`).
|
|
2425
|
+
*/
|
|
2426
|
+
function resolveSecretStore(opts = {}) {
|
|
2427
|
+
const envStore = new EnvSecretStore(opts.env, opts.envPrefix);
|
|
2428
|
+
return opts.keyring ? new ChainedSecretStore([new KeyringSecretStore(), envStore]) : envStore;
|
|
2429
|
+
}
|
|
2430
|
+
//#endregion
|
|
2431
|
+
//#region src/runner.ts
|
|
2432
|
+
const REDIRECT_CODES = new Set([
|
|
2433
|
+
301,
|
|
2434
|
+
302,
|
|
2435
|
+
303,
|
|
2436
|
+
307,
|
|
2437
|
+
308
|
|
2438
|
+
]);
|
|
2439
|
+
/** Headers dropped when a redirect crosses to a different host (browser-like). */
|
|
2440
|
+
const CROSS_ORIGIN_DROP = new Set(["authorization", "cookie"]);
|
|
2441
|
+
/** Cap a retained per-hop body so a hostile redirect body can't bloat the synthesized
|
|
2442
|
+
* HAR (the read itself is bounded by the response; slicing a JSON body just fails to
|
|
2443
|
+
* parse downstream ⇒ unresolved ⇒ inconclusive, never a false pass). 5f. */
|
|
2444
|
+
const MAX_HOP_BODY_BYTES = 4 * 1024 * 1024;
|
|
2445
|
+
/** Append one hop's facts to the capture sink (string-only request body; binary omitted). */
|
|
2446
|
+
function recordHop(capture, hop) {
|
|
2447
|
+
const record = {
|
|
2448
|
+
method: hop.method,
|
|
2449
|
+
url: hop.url,
|
|
2450
|
+
status: hop.status
|
|
2451
|
+
};
|
|
2452
|
+
if (hop.resContentType) record.resContentType = hop.resContentType;
|
|
2453
|
+
if (hop.resBody !== void 0) record.resBody = hop.resBody.slice(0, MAX_HOP_BODY_BYTES);
|
|
2454
|
+
if (typeof hop.reqBody === "string" && hop.reqContentType) {
|
|
2455
|
+
record.reqContentType = hop.reqContentType;
|
|
2456
|
+
record.reqBody = hop.reqBody.slice(0, MAX_HOP_BODY_BYTES);
|
|
2457
|
+
}
|
|
2458
|
+
capture.hops.push(record);
|
|
2459
|
+
}
|
|
2460
|
+
function headerValue(headers, name) {
|
|
2461
|
+
const v = headers[name];
|
|
2462
|
+
return Array.isArray(v) ? v[0] : v;
|
|
2463
|
+
}
|
|
2464
|
+
/** Method/body for the next hop. 303 (and POST on 301/302) downgrades to GET and
|
|
2465
|
+
* drops the body; 307/308 preserve both. */
|
|
2466
|
+
function redirectTransition(status, method, body) {
|
|
2467
|
+
if (status === 303 || (status === 301 || status === 302) && method.toUpperCase() === "POST") return {
|
|
2468
|
+
method: "GET",
|
|
2469
|
+
body: void 0
|
|
2470
|
+
};
|
|
2471
|
+
return {
|
|
2472
|
+
method,
|
|
2473
|
+
body
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
function dropCrossOriginHeaders(headers) {
|
|
2477
|
+
const out = {};
|
|
2478
|
+
for (const [key, value] of Object.entries(headers)) if (!CROSS_ORIGIN_DROP.has(key.toLowerCase())) out[key] = value;
|
|
2479
|
+
return out;
|
|
2480
|
+
}
|
|
2481
|
+
function hasHeader(headers, name) {
|
|
2482
|
+
const lower = name.toLowerCase();
|
|
2483
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
2484
|
+
}
|
|
2485
|
+
function hostOf(url) {
|
|
2486
|
+
try {
|
|
2487
|
+
return new URL(url).hostname;
|
|
2488
|
+
} catch {
|
|
2489
|
+
return "";
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
function flattenHeaders(headers) {
|
|
2493
|
+
const out = {};
|
|
2494
|
+
for (const [key, value] of Object.entries(headers)) out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
|
2495
|
+
return out;
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Execute one request: resolve vars + secrets, apply the mutation safety gate
|
|
2499
|
+
* (mutating methods dry-run unless explicitly unlocked), dispatch via undici if
|
|
2500
|
+
* allowed, evaluate assertions, and return a result whose surfaced strings
|
|
2501
|
+
* (request, response headers, body artifact) are all secret-redacted.
|
|
2502
|
+
*/
|
|
2503
|
+
async function runRequest(collection, name, opts = {}) {
|
|
2504
|
+
return runRequestImpl(collection, name, opts);
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Drive a request AND retain the raw per-hop facts a synthesized HAR is built from
|
|
2508
|
+
* (ADR 0013 Addendum 4, 5f) — the verify-driven api capture path. `RunResult` is
|
|
2509
|
+
* returned UNCHANGED (still fully redacted); `capture` is the produce-only raw channel
|
|
2510
|
+
* (never on `RunResult`). The caller (verify driver) folds `capture.registeredSecrets`
|
|
2511
|
+
* into a union redactor, then synthesizes + redacts + validates.
|
|
2512
|
+
*/
|
|
2513
|
+
async function runRequestForHar(collection, name, opts = {}) {
|
|
2514
|
+
const capture = {
|
|
2515
|
+
hops: [],
|
|
2516
|
+
registeredSecrets: [],
|
|
2517
|
+
redirectTruncated: false
|
|
2518
|
+
};
|
|
2519
|
+
return {
|
|
2520
|
+
result: await runRequestImpl(collection, name, opts, capture),
|
|
2521
|
+
capture
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Drive a request AND retain the un-redacted request facts a contract validator reads
|
|
2526
|
+
* (ADR 0014). `RunResult` is returned UNCHANGED (still fully redacted); `capture` is the
|
|
2527
|
+
* raw channel (never on `RunResult`). The caller (`api run --openapi`) folds
|
|
2528
|
+
* `capture.registeredSecrets` into a redactor, validates `capture.request` against the
|
|
2529
|
+
* contract, then redacts the findings before surfacing them.
|
|
2530
|
+
*/
|
|
2531
|
+
async function runRequestForContract(collection, name, opts = {}) {
|
|
2532
|
+
const capture = {
|
|
2533
|
+
request: {
|
|
2534
|
+
method: "",
|
|
2535
|
+
path: ""
|
|
2536
|
+
},
|
|
2537
|
+
registeredSecrets: []
|
|
2538
|
+
};
|
|
2539
|
+
return {
|
|
2540
|
+
result: await runRequestImpl(collection, name, opts, void 0, capture),
|
|
2541
|
+
capture
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
/** JSON-family media type: `application/json` or any `*+json`. */
|
|
2545
|
+
function isJsonFamily(ct) {
|
|
2546
|
+
if (!ct) return false;
|
|
2547
|
+
const base = (ct.split(";")[0] ?? "").trim().toLowerCase();
|
|
2548
|
+
return base === "application/json" || base.endsWith("+json");
|
|
2549
|
+
}
|
|
2550
|
+
/** Decode a query string into a record; a repeated key collapses to an array. */
|
|
2551
|
+
function queryRecord(sp) {
|
|
2552
|
+
const out = {};
|
|
2553
|
+
for (const key of new Set(sp.keys())) {
|
|
2554
|
+
const all = sp.getAll(key);
|
|
2555
|
+
out[key] = all.length > 1 ? all : all[0] ?? "";
|
|
2556
|
+
}
|
|
2557
|
+
return out;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Build the UN-redacted {@link RequestFacts} a contract validator reads from a prepared
|
|
2561
|
+
* request. A JSON-family body is parsed to its value (so schema validation is
|
|
2562
|
+
* meaningful); a present non-JSON / binary body (multipart/file/urlencoded/xml) is passed
|
|
2563
|
+
* through as a non-undefined value so the validator routes it to its presence-only
|
|
2564
|
+
* `unverified` path — never a false `missing-required-body`. A multipart body's
|
|
2565
|
+
* Content-Type (set by undici with the boundary, absent at prepare time) is synthesized as
|
|
2566
|
+
* a bare `multipart/form-data` so that routing is correct.
|
|
2567
|
+
*/
|
|
2568
|
+
function buildRequestFacts(prepared, sendHeaders, bodyType) {
|
|
2569
|
+
const url = new URL(prepared.url);
|
|
2570
|
+
const headers = flattenHeaders(sendHeaders);
|
|
2571
|
+
if (bodyType === "multipart-form" && headers["content-type"] === void 0) headers["content-type"] = "multipart/form-data";
|
|
2572
|
+
let body;
|
|
2573
|
+
if (prepared.body) {
|
|
2574
|
+
const content = prepared.body.content;
|
|
2575
|
+
if (typeof content === "string") if (isJsonFamily(headers["content-type"])) try {
|
|
2576
|
+
body = JSON.parse(content);
|
|
2577
|
+
} catch {
|
|
2578
|
+
body = content;
|
|
2579
|
+
}
|
|
2580
|
+
else body = content;
|
|
2581
|
+
else body = prepared.body.preview;
|
|
2582
|
+
}
|
|
2583
|
+
const query = queryRecord(url.searchParams);
|
|
2584
|
+
const facts = {
|
|
2585
|
+
method: prepared.method,
|
|
2586
|
+
path: url.pathname
|
|
2587
|
+
};
|
|
2588
|
+
if (body !== void 0) facts.body = body;
|
|
2589
|
+
if (Object.keys(query).length > 0) facts.query = query;
|
|
2590
|
+
if (Object.keys(headers).length > 0) facts.headers = headers;
|
|
2591
|
+
if (prepared.body?.formFields) facts.form = prepared.body.formFields;
|
|
2592
|
+
if (prepared.body?.formFileFields) facts.formFileFields = prepared.body.formFileFields;
|
|
2593
|
+
return facts;
|
|
2594
|
+
}
|
|
2595
|
+
async function runRequestImpl(collection, name, opts = {}, capture, contractSink) {
|
|
2596
|
+
const entry = collection.requests.get(name);
|
|
2597
|
+
if (!entry) throw new Error(`no request '${name}' in collection at ${collection.dir}`);
|
|
2598
|
+
const secrets = opts.secrets ?? new EnvSecretStore();
|
|
2599
|
+
const redactor = new Redactor();
|
|
2600
|
+
const scope = {
|
|
2601
|
+
...opts.env ? collection.environments.get(opts.env) ?? {} : {},
|
|
2602
|
+
...opts.vars ?? {}
|
|
2603
|
+
};
|
|
2604
|
+
if (entry.preScript) {
|
|
2605
|
+
const pre = await runScript(entry.preScript, { vars: scope });
|
|
2606
|
+
Object.assign(scope, pre.vars);
|
|
2607
|
+
}
|
|
2608
|
+
const prepared = await prepareRequest(entry.request, scope, secrets, redactor, collection.dir);
|
|
2609
|
+
if (capture) capture.registeredSecrets = redactor.registeredSecrets();
|
|
2610
|
+
const sendHeaders = { ...prepared.headers };
|
|
2611
|
+
if (prepared.body?.contentType && !hasHeader(sendHeaders, "content-type")) sendHeaders["Content-Type"] = prepared.body.contentType;
|
|
2612
|
+
if (contractSink) {
|
|
2613
|
+
contractSink.request = buildRequestFacts(prepared, sendHeaders, entry.request.body?.type);
|
|
2614
|
+
contractSink.registeredSecrets = redactor.registeredSecrets();
|
|
2615
|
+
}
|
|
2616
|
+
const redactedRequest = {
|
|
2617
|
+
method: prepared.method,
|
|
2618
|
+
url: redactor.redact(prepared.url),
|
|
2619
|
+
headers: redactor.redactHeaders(sendHeaders),
|
|
2620
|
+
body: prepared.body ? redactor.redact(prepared.body.preview) : void 0
|
|
2621
|
+
};
|
|
2622
|
+
const gate = checkGate(prepared.method, hostOf(prepared.url), {
|
|
2623
|
+
allowUnsafe: opts.allowUnsafe,
|
|
2624
|
+
allowedHosts: opts.allowedHosts
|
|
2625
|
+
});
|
|
2626
|
+
if (!gate.allowed) return {
|
|
2627
|
+
request: redactedRequest,
|
|
2628
|
+
sent: false,
|
|
2629
|
+
dryRun: true,
|
|
2630
|
+
reason: gate.reason
|
|
2631
|
+
};
|
|
2632
|
+
try {
|
|
2633
|
+
await assertSsrfAllowed(prepared.url, {
|
|
2634
|
+
allowPrivate: opts.allowPrivate,
|
|
2635
|
+
lookup: opts.lookup
|
|
2636
|
+
});
|
|
2637
|
+
} catch (err) {
|
|
2638
|
+
if (err instanceof SsrfError) return {
|
|
2639
|
+
request: redactedRequest,
|
|
2640
|
+
sent: false,
|
|
2641
|
+
dryRun: false,
|
|
2642
|
+
reason: `blocked: ${err.message}`
|
|
2643
|
+
};
|
|
2644
|
+
throw err;
|
|
2645
|
+
}
|
|
2646
|
+
const started = performance.now();
|
|
2647
|
+
const maxRedirects = opts.maxRedirects ?? 0;
|
|
2648
|
+
const redirects = [];
|
|
2649
|
+
let currentUrl = prepared.url;
|
|
2650
|
+
let currentMethod = prepared.method;
|
|
2651
|
+
let currentHeaders = sendHeaders;
|
|
2652
|
+
let currentBody = prepared.body?.content;
|
|
2653
|
+
let currentContentType = prepared.body?.contentType;
|
|
2654
|
+
const reqBodyStr = () => typeof currentBody === "string" ? currentBody : void 0;
|
|
2655
|
+
let res = await request(currentUrl, {
|
|
2656
|
+
method: currentMethod,
|
|
2657
|
+
headers: currentHeaders,
|
|
2658
|
+
body: currentBody
|
|
2659
|
+
});
|
|
2660
|
+
while (REDIRECT_CODES.has(res.statusCode) && redirects.length < maxRedirects) {
|
|
2661
|
+
const location = headerValue(res.headers, "location");
|
|
2662
|
+
if (!location) break;
|
|
2663
|
+
if (capture) {
|
|
2664
|
+
const hopText = await res.body.text();
|
|
2665
|
+
recordHop(capture, {
|
|
2666
|
+
method: currentMethod,
|
|
2667
|
+
url: currentUrl,
|
|
2668
|
+
status: res.statusCode,
|
|
2669
|
+
resContentType: headerValue(res.headers, "content-type"),
|
|
2670
|
+
resBody: hopText,
|
|
2671
|
+
reqBody: reqBodyStr(),
|
|
2672
|
+
reqContentType: currentContentType
|
|
2673
|
+
});
|
|
2674
|
+
} else await res.body.dump();
|
|
2675
|
+
let nextUrl;
|
|
2676
|
+
try {
|
|
2677
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
2678
|
+
} catch {
|
|
2679
|
+
break;
|
|
2680
|
+
}
|
|
2681
|
+
try {
|
|
2682
|
+
await assertSsrfAllowed(nextUrl, {
|
|
2683
|
+
allowPrivate: opts.allowPrivate,
|
|
2684
|
+
lookup: opts.lookup
|
|
2685
|
+
});
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
if (err instanceof SsrfError) return {
|
|
2688
|
+
request: redactedRequest,
|
|
2689
|
+
sent: false,
|
|
2690
|
+
dryRun: false,
|
|
2691
|
+
reason: `blocked redirect: ${err.message}`
|
|
2692
|
+
};
|
|
2693
|
+
throw err;
|
|
2694
|
+
}
|
|
2695
|
+
const next = redirectTransition(res.statusCode, currentMethod, currentBody);
|
|
2696
|
+
if (isMutating(next.method)) {
|
|
2697
|
+
const g = checkGate(next.method, hostOf(nextUrl), {
|
|
2698
|
+
allowUnsafe: opts.allowUnsafe,
|
|
2699
|
+
allowedHosts: opts.allowedHosts
|
|
2700
|
+
});
|
|
2701
|
+
if (!g.allowed) return {
|
|
2702
|
+
request: redactedRequest,
|
|
2703
|
+
sent: false,
|
|
2704
|
+
dryRun: true,
|
|
2705
|
+
reason: `redirect blocked: ${g.reason}`
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
const nextHeaders = hostOf(nextUrl) === hostOf(currentUrl) ? currentHeaders : dropCrossOriginHeaders(currentHeaders);
|
|
2709
|
+
redirects.push({
|
|
2710
|
+
status: res.statusCode,
|
|
2711
|
+
location: redactor.redact(nextUrl)
|
|
2712
|
+
});
|
|
2713
|
+
res = await request(nextUrl, {
|
|
2714
|
+
method: next.method,
|
|
2715
|
+
headers: nextHeaders,
|
|
2716
|
+
body: next.body
|
|
2717
|
+
});
|
|
2718
|
+
currentUrl = nextUrl;
|
|
2719
|
+
currentMethod = next.method;
|
|
2720
|
+
currentHeaders = nextHeaders;
|
|
2721
|
+
currentBody = next.body;
|
|
2722
|
+
if (next.body === void 0) currentContentType = void 0;
|
|
2723
|
+
}
|
|
2724
|
+
if (capture && REDIRECT_CODES.has(res.statusCode)) capture.redirectTruncated = true;
|
|
2725
|
+
const bodyText = await res.body.text();
|
|
2726
|
+
const latencyMs = performance.now() - started;
|
|
2727
|
+
const headers = flattenHeaders(res.headers);
|
|
2728
|
+
if (capture) recordHop(capture, {
|
|
2729
|
+
method: currentMethod,
|
|
2730
|
+
url: currentUrl,
|
|
2731
|
+
status: res.statusCode,
|
|
2732
|
+
resContentType: headers["content-type"],
|
|
2733
|
+
resBody: bodyText,
|
|
2734
|
+
reqBody: reqBodyStr(),
|
|
2735
|
+
reqContentType: currentContentType
|
|
2736
|
+
});
|
|
2737
|
+
let json;
|
|
2738
|
+
try {
|
|
2739
|
+
json = JSON.parse(bodyText);
|
|
2740
|
+
} catch {
|
|
2741
|
+
json = void 0;
|
|
2742
|
+
}
|
|
2743
|
+
const ctx = {
|
|
2744
|
+
status: res.statusCode,
|
|
2745
|
+
statusText: "",
|
|
2746
|
+
headers,
|
|
2747
|
+
bodyText,
|
|
2748
|
+
json,
|
|
2749
|
+
latencyMs
|
|
2750
|
+
};
|
|
2751
|
+
const assertions = evaluateAssertions(entry.assertions, ctx);
|
|
2752
|
+
const captured = extractCaptures(entry.captures, ctx);
|
|
2753
|
+
let scriptTests = [];
|
|
2754
|
+
if (entry.postScript) {
|
|
2755
|
+
const before = { ...scope };
|
|
2756
|
+
const post = await runScript(entry.postScript, {
|
|
2757
|
+
vars: scope,
|
|
2758
|
+
res: {
|
|
2759
|
+
status: res.statusCode,
|
|
2760
|
+
headers,
|
|
2761
|
+
body: bodyText,
|
|
2762
|
+
json
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
scriptTests = post.tests.map((t) => ({
|
|
2766
|
+
name: redactor.redact(t.name),
|
|
2767
|
+
pass: t.pass,
|
|
2768
|
+
error: t.error ? redactor.redact(t.error) : void 0
|
|
2769
|
+
}));
|
|
2770
|
+
for (const [key, value] of Object.entries(post.vars)) if (!(key in before) || before[key] !== value) {
|
|
2771
|
+
captured[key] = value;
|
|
2772
|
+
scope[key] = value;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
const bodyHandle = (opts.artifacts ?? new ArtifactStore()).put(randomUUID(), redactor.redact(bodyText), headers["content-type"] ?? "application/octet-stream");
|
|
2776
|
+
return {
|
|
2777
|
+
request: redactedRequest,
|
|
2778
|
+
sent: true,
|
|
2779
|
+
dryRun: false,
|
|
2780
|
+
response: {
|
|
2781
|
+
status: res.statusCode,
|
|
2782
|
+
latencyMs,
|
|
2783
|
+
headers: redactor.redactHeaders(headers),
|
|
2784
|
+
assertions,
|
|
2785
|
+
scriptTests,
|
|
2786
|
+
captured,
|
|
2787
|
+
bodyHandle,
|
|
2788
|
+
redirects
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region src/sequence.ts
|
|
2794
|
+
/**
|
|
2795
|
+
* Run requests in order, threading each response's captured variables into the
|
|
2796
|
+
* scope of the requests that follow (request chaining). Per-run options
|
|
2797
|
+
* (secrets, allowUnsafe, allowlist, artifacts) apply to every request; the
|
|
2798
|
+
* variable scope is the shared, accumulating one.
|
|
2799
|
+
*/
|
|
2800
|
+
async function runSequence(collection, names, opts = {}) {
|
|
2801
|
+
const scope = { ...opts.vars ?? {} };
|
|
2802
|
+
const captured = {};
|
|
2803
|
+
const steps = [];
|
|
2804
|
+
for (const name of names) {
|
|
2805
|
+
const result = await runRequest(collection, name, {
|
|
2806
|
+
...opts,
|
|
2807
|
+
vars: scope
|
|
2808
|
+
});
|
|
2809
|
+
steps.push({
|
|
2810
|
+
name,
|
|
2811
|
+
result
|
|
2812
|
+
});
|
|
2813
|
+
if (result.sent && result.response) {
|
|
2814
|
+
Object.assign(scope, result.response.captured);
|
|
2815
|
+
Object.assign(captured, result.response.captured);
|
|
2816
|
+
if (opts.stopOnFailure && !result.response.assertions.every((a) => a.pass)) break;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
return {
|
|
2820
|
+
steps,
|
|
2821
|
+
captured
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
/**
|
|
2825
|
+
* Like {@link runSequence} but ALSO retains the raw per-hop capture across every step
|
|
2826
|
+
* (ADR 0013 Addendum 4, 5f) — for the verify-driven api capture path. Each step's
|
|
2827
|
+
* `SequenceStep.result` is UNCHANGED (redacted); `capture` aggregates all hops + the
|
|
2828
|
+
* union of run-resolved secret pairs. The transport-completeness guard (every
|
|
2829
|
+
* `step.result.sent`) lives in the driver, which folds a non-sent step to inconclusive.
|
|
2830
|
+
*/
|
|
2831
|
+
async function runSequenceForHar(collection, names, opts = {}) {
|
|
2832
|
+
const scope = { ...opts.vars ?? {} };
|
|
2833
|
+
const captured = {};
|
|
2834
|
+
const steps = [];
|
|
2835
|
+
const capture = {
|
|
2836
|
+
hops: [],
|
|
2837
|
+
registeredSecrets: [],
|
|
2838
|
+
redirectTruncated: false
|
|
2839
|
+
};
|
|
2840
|
+
for (const name of names) {
|
|
2841
|
+
const { result, capture: hopCap } = await runRequestForHar(collection, name, {
|
|
2842
|
+
...opts,
|
|
2843
|
+
vars: scope
|
|
2844
|
+
});
|
|
2845
|
+
steps.push({
|
|
2846
|
+
name,
|
|
2847
|
+
result
|
|
2848
|
+
});
|
|
2849
|
+
capture.hops.push(...hopCap.hops);
|
|
2850
|
+
capture.registeredSecrets.push(...hopCap.registeredSecrets);
|
|
2851
|
+
if (hopCap.redirectTruncated) capture.redirectTruncated = true;
|
|
2852
|
+
if (result.sent && result.response) {
|
|
2853
|
+
Object.assign(scope, result.response.captured);
|
|
2854
|
+
Object.assign(captured, result.response.captured);
|
|
2855
|
+
if (opts.stopOnFailure && !result.response.assertions.every((a) => a.pass)) break;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
return {
|
|
2859
|
+
result: {
|
|
2860
|
+
steps,
|
|
2861
|
+
captured
|
|
2862
|
+
},
|
|
2863
|
+
capture
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
//#endregion
|
|
2867
|
+
//#region src/har-produce.ts
|
|
2868
|
+
/**
|
|
2869
|
+
* The verify-DRIVEN API capture driver (ADR 0013 Addendum 4, milestone 5f). Drives the
|
|
2870
|
+
* `@sackville-mcp/api` runner for an operator-authored request (or sequence), SYNTHESIZES a
|
|
2871
|
+
* HAR from the run, redacts + stores it, and validates it against the contract via the
|
|
2872
|
+
* SHIPPED {@link validateCapturedTraffic} — the api-runner analogue of the browser
|
|
2873
|
+
* pillar's `driveBrowserFlowToHar` (5e). Unlike `har-synth.ts` this is NOT a pure leaf
|
|
2874
|
+
* (it imports the runner), so it lives in its own module.
|
|
2875
|
+
*
|
|
2876
|
+
* "Absence is never a pass" — TRANSPORT completeness is enforced HERE by THROWING
|
|
2877
|
+
* (⇒ the verify thunk rejects ⇒ inconclusive, mirroring `driveBrowserFlowToHar`): a
|
|
2878
|
+
* withheld/dry-run/blocked request, a non-sent step in a sequence, or a truncated
|
|
2879
|
+
* redirect chain never yields a validatable HAR. CONTRACT completeness (the verdict's
|
|
2880
|
+
* `clean` flag) is folded to inconclusive downstream by `@sackville-mcp/verdict`
|
|
2881
|
+
* `fromCaptureVerdict` (slice 6) — this driver returns the FULL verdict so that fold can.
|
|
2882
|
+
*
|
|
2883
|
+
* Redaction: the driver folds the run-resolved `{{secret:NAME}}` pairs (off the runner's
|
|
2884
|
+
* out-of-band channel) into the caller's union redactor BEFORE synthesizing or
|
|
2885
|
+
* validating, and {@link synthesizeRedactedHarZip} re-redacts at store time — so no raw
|
|
2886
|
+
* secret reaches the stored artifact or the findings.
|
|
2887
|
+
*/
|
|
2888
|
+
function countsFromRecords(hops) {
|
|
2889
|
+
const byStatus = {};
|
|
2890
|
+
const byMethod = {};
|
|
2891
|
+
for (const h of hops) {
|
|
2892
|
+
byStatus[String(h.status)] = (byStatus[String(h.status)] ?? 0) + 1;
|
|
2893
|
+
byMethod[h.method] = (byMethod[h.method] ?? 0) + 1;
|
|
2894
|
+
}
|
|
2895
|
+
return {
|
|
2896
|
+
entryCount: hops.length,
|
|
2897
|
+
byStatus,
|
|
2898
|
+
byMethod
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
/** Synthesize → redact → store → validate, after the transport guards have passed.
|
|
2902
|
+
* The caller has already folded the run-resolved secrets into the union redactor. */
|
|
2903
|
+
function finishProduce(capture, deps) {
|
|
2904
|
+
if (capture.redirectTruncated) throw new Error("captured traffic did not complete its redirect chain (terminal status was a redirect)");
|
|
2905
|
+
const redact = (v) => deps.redactor.redact(v);
|
|
2906
|
+
const zip = synthesizeRedactedHarZip(capture.hops, redact);
|
|
2907
|
+
const id = (deps.idFactory ?? randomUUID)();
|
|
2908
|
+
const handle = deps.store.put(id, "har", zip, "application/zip");
|
|
2909
|
+
const verdict = validateCapturedTraffic(zip, deps.contract, {
|
|
2910
|
+
redact,
|
|
2911
|
+
baseDir: deps.validate?.baseDir,
|
|
2912
|
+
allowedOrigins: deps.validate?.allowedOrigins
|
|
2913
|
+
});
|
|
2914
|
+
return {
|
|
2915
|
+
harHandle: handle,
|
|
2916
|
+
summary: {
|
|
2917
|
+
handle,
|
|
2918
|
+
byteSize: zip.byteLength,
|
|
2919
|
+
...countsFromRecords(capture.hops)
|
|
2920
|
+
},
|
|
2921
|
+
verdict
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
/** Drive ONE request → synthesize + validate its HAR. Throws (⇒ inconclusive) when the
|
|
2925
|
+
* request was not sent (withheld/dry-run/blocked) or its redirect chain was truncated. */
|
|
2926
|
+
async function runRequestToHar(collection, name, opts, deps) {
|
|
2927
|
+
const { result, capture } = await (deps.runForHar ?? runRequestForHar)(collection, name, opts);
|
|
2928
|
+
for (const s of capture.registeredSecrets) deps.redactor.register(s.name, s.value);
|
|
2929
|
+
if (result.sent !== true) throw new Error(`captured request "${name}" was not sent (${deps.redactor.redact(result.reason ?? "withheld")})`);
|
|
2930
|
+
return finishProduce(capture, deps);
|
|
2931
|
+
}
|
|
2932
|
+
/** Drive a SEQUENCE → synthesize + validate the aggregated HAR. Throws (⇒ inconclusive)
|
|
2933
|
+
* when ANY step was not sent (per `step.result.sent`) or any hop truncated a redirect. */
|
|
2934
|
+
async function runSequenceToHar(collection, names, opts, deps) {
|
|
2935
|
+
const { result, capture } = await (deps.runSequenceForHar ?? runSequenceForHar)(collection, names, opts);
|
|
2936
|
+
for (const s of capture.registeredSecrets) deps.redactor.register(s.name, s.value);
|
|
2937
|
+
const unsent = result.steps.find((s) => s.result.sent !== true);
|
|
2938
|
+
if (unsent) throw new Error(`captured sequence step "${unsent.name}" was not sent (${deps.redactor.redact(unsent.result.reason ?? "withheld")})`);
|
|
2939
|
+
return finishProduce(capture, deps);
|
|
2940
|
+
}
|
|
2941
|
+
//#endregion
|
|
2942
|
+
//#region src/import.ts
|
|
2943
|
+
/**
|
|
2944
|
+
* Import foreign API-collection formats into a Bruno `.bru` collection on disk.
|
|
2945
|
+
*
|
|
2946
|
+
* Supported sources: **Postman** (v2.1 collection), **Insomnia** (v4 export),
|
|
2947
|
+
* **OpenAPI** (3.x — one request per operation, + an environment for the server
|
|
2948
|
+
* URL), and **HAR** (one request per logged entry). Each is normalized to a small
|
|
2949
|
+
* intermediate shape and serialized with `@usebruno/lang`'s `jsonToBruV2`, so the
|
|
2950
|
+
* output is a real Bruno collection that `loadCollection` reads back.
|
|
2951
|
+
*
|
|
2952
|
+
* Scope: method, URL, headers, and the common body types (json/text/xml +
|
|
2953
|
+
* form-urlencoded + graphql). multipart/file bodies are noted but not emitted
|
|
2954
|
+
* (no portable on-disk file to point at); auth blocks beyond a bearer/`{{var}}`
|
|
2955
|
+
* header are left to the operator. `{{var}}` templating passes through unchanged
|
|
2956
|
+
* (Postman/Bruno share the syntax).
|
|
2957
|
+
*/
|
|
2958
|
+
function headersFrom(list) {
|
|
2959
|
+
return (list ?? []).filter((h) => h.disabled !== true && h.enabled !== false).map((h) => ({
|
|
2960
|
+
name: h.name ?? h.key ?? "",
|
|
2961
|
+
value: h.value ?? ""
|
|
2962
|
+
})).filter((h) => h.name);
|
|
2963
|
+
}
|
|
2964
|
+
/** Postman v2.1 collection → requests (folders flattened, depth-first). */
|
|
2965
|
+
function importPostman(doc) {
|
|
2966
|
+
const root = doc;
|
|
2967
|
+
const requests = [];
|
|
2968
|
+
const walk = (items) => {
|
|
2969
|
+
for (const raw of items ?? []) {
|
|
2970
|
+
const item = raw;
|
|
2971
|
+
if (item.item) {
|
|
2972
|
+
walk(item.item);
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
if (!item.request) continue;
|
|
2976
|
+
const req = item.request;
|
|
2977
|
+
const url = typeof req.url === "string" ? req.url : req.url?.raw ?? "";
|
|
2978
|
+
requests.push({
|
|
2979
|
+
name: item.name ?? `${req.method ?? "GET"} ${url}`,
|
|
2980
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
2981
|
+
url,
|
|
2982
|
+
headers: headersFrom(req.header),
|
|
2983
|
+
body: postmanBody(req.body)
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
};
|
|
2987
|
+
walk(root.item);
|
|
2988
|
+
return { requests };
|
|
2989
|
+
}
|
|
2990
|
+
function postmanBody(body) {
|
|
2991
|
+
if (!body?.mode) return void 0;
|
|
2992
|
+
if (body.mode === "raw") {
|
|
2993
|
+
const lang = body.options?.raw?.language;
|
|
2994
|
+
return {
|
|
2995
|
+
type: lang === "json" || lang === "xml" ? lang : "text",
|
|
2996
|
+
content: body.raw ?? ""
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
if (body.mode === "urlencoded") return {
|
|
3000
|
+
type: "form-urlencoded",
|
|
3001
|
+
params: kvParams(body.urlencoded)
|
|
3002
|
+
};
|
|
3003
|
+
if (body.mode === "graphql") return {
|
|
3004
|
+
type: "graphql",
|
|
3005
|
+
graphql: {
|
|
3006
|
+
query: body.graphql?.query ?? "",
|
|
3007
|
+
variables: body.graphql?.variables
|
|
3008
|
+
}
|
|
3009
|
+
};
|
|
3010
|
+
}
|
|
3011
|
+
function kvParams(list) {
|
|
3012
|
+
return (list ?? []).filter((p) => p.disabled !== true && p.enabled !== false).map((p) => ({
|
|
3013
|
+
name: p.name ?? p.key ?? "",
|
|
3014
|
+
value: p.value ?? ""
|
|
3015
|
+
}));
|
|
3016
|
+
}
|
|
3017
|
+
/** Insomnia v4 export → requests. */
|
|
3018
|
+
function importInsomnia(doc) {
|
|
3019
|
+
const resources = doc.resources ?? [];
|
|
3020
|
+
const requests = [];
|
|
3021
|
+
for (const raw of resources) {
|
|
3022
|
+
const r = raw;
|
|
3023
|
+
if (r._type !== "request") continue;
|
|
3024
|
+
requests.push({
|
|
3025
|
+
name: r.name ?? `${r.method ?? "GET"} ${r.url ?? ""}`,
|
|
3026
|
+
method: (r.method ?? "GET").toUpperCase(),
|
|
3027
|
+
url: r.url ?? "",
|
|
3028
|
+
headers: headersFrom(r.headers),
|
|
3029
|
+
body: insomniaBody(r.body)
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
return { requests };
|
|
3033
|
+
}
|
|
3034
|
+
function insomniaBody(body) {
|
|
3035
|
+
if (!body?.mimeType) return void 0;
|
|
3036
|
+
if (body.mimeType.includes("json")) return {
|
|
3037
|
+
type: "json",
|
|
3038
|
+
content: body.text ?? ""
|
|
3039
|
+
};
|
|
3040
|
+
if (body.mimeType.includes("xml")) return {
|
|
3041
|
+
type: "xml",
|
|
3042
|
+
content: body.text ?? ""
|
|
3043
|
+
};
|
|
3044
|
+
if (body.mimeType.includes("x-www-form-urlencoded")) return {
|
|
3045
|
+
type: "form-urlencoded",
|
|
3046
|
+
params: kvParams(body.params)
|
|
3047
|
+
};
|
|
3048
|
+
if (body.text) return {
|
|
3049
|
+
type: "text",
|
|
3050
|
+
content: body.text
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
const HTTP_METHODS = new Set([
|
|
3054
|
+
"get",
|
|
3055
|
+
"put",
|
|
3056
|
+
"post",
|
|
3057
|
+
"delete",
|
|
3058
|
+
"patch",
|
|
3059
|
+
"head",
|
|
3060
|
+
"options",
|
|
3061
|
+
"trace"
|
|
3062
|
+
]);
|
|
3063
|
+
/** OpenAPI 3.x → one request per operation + an environment for the server URL. */
|
|
3064
|
+
function importOpenApi(doc) {
|
|
3065
|
+
const spec = doc;
|
|
3066
|
+
const requests = [];
|
|
3067
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
3068
|
+
if (!item || typeof item !== "object") continue;
|
|
3069
|
+
for (const [method, op] of Object.entries(item)) {
|
|
3070
|
+
if (!HTTP_METHODS.has(method.toLowerCase())) continue;
|
|
3071
|
+
const operation = op;
|
|
3072
|
+
requests.push({
|
|
3073
|
+
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
3074
|
+
method: method.toUpperCase(),
|
|
3075
|
+
url: `{{baseUrl}}${path}`,
|
|
3076
|
+
headers: [],
|
|
3077
|
+
body: openApiBody(operation.requestBody)
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
const serverUrl = spec.servers?.[0]?.url;
|
|
3082
|
+
return {
|
|
3083
|
+
requests,
|
|
3084
|
+
environment: serverUrl ? {
|
|
3085
|
+
name: "Imported",
|
|
3086
|
+
variables: { baseUrl: serverUrl }
|
|
3087
|
+
} : void 0
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
function openApiBody(rb) {
|
|
3091
|
+
const json = rb?.content?.["application/json"];
|
|
3092
|
+
if (!json) return void 0;
|
|
3093
|
+
const example = json.example ?? {};
|
|
3094
|
+
return {
|
|
3095
|
+
type: "json",
|
|
3096
|
+
content: JSON.stringify(example, null, 2)
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
/** HAR → one request per logged entry. */
|
|
3100
|
+
function importHar(doc) {
|
|
3101
|
+
const entries = doc.log?.entries ?? [];
|
|
3102
|
+
const requests = [];
|
|
3103
|
+
entries.forEach((raw, i) => {
|
|
3104
|
+
const req = raw.request;
|
|
3105
|
+
if (!req) return;
|
|
3106
|
+
let label = req.url ?? "";
|
|
3107
|
+
try {
|
|
3108
|
+
label = new URL(req.url ?? "").pathname;
|
|
3109
|
+
} catch {}
|
|
3110
|
+
requests.push({
|
|
3111
|
+
name: `${(req.method ?? "GET").toUpperCase()} ${label} #${i + 1}`,
|
|
3112
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
3113
|
+
url: req.url ?? "",
|
|
3114
|
+
headers: headersFrom(req.headers),
|
|
3115
|
+
body: insomniaBody(req.postData)
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
3118
|
+
return { requests };
|
|
3119
|
+
}
|
|
3120
|
+
const PARSERS = {
|
|
3121
|
+
postman: importPostman,
|
|
3122
|
+
insomnia: importInsomnia,
|
|
3123
|
+
openapi: importOpenApi,
|
|
3124
|
+
har: importHar
|
|
3125
|
+
};
|
|
3126
|
+
/** Parse a source document (JSON, or YAML for OpenAPI) into normalized requests. */
|
|
3127
|
+
function parseImport(format, source) {
|
|
3128
|
+
const doc = format === "openapi" ? parse(source) : JSON.parse(source);
|
|
3129
|
+
return PARSERS[format](doc);
|
|
3130
|
+
}
|
|
3131
|
+
/** Map our canonical body type to the `@usebruno/lang` discriminator + payload. */
|
|
3132
|
+
function bruBody(body) {
|
|
3133
|
+
if (body.type === "form-urlencoded") return {
|
|
3134
|
+
discriminator: "formUrlEncoded",
|
|
3135
|
+
body: { formUrlEncoded: (body.params ?? []).map((p) => ({
|
|
3136
|
+
...p,
|
|
3137
|
+
enabled: true
|
|
3138
|
+
})) }
|
|
3139
|
+
};
|
|
3140
|
+
if (body.type === "graphql") return {
|
|
3141
|
+
discriminator: "graphql",
|
|
3142
|
+
body: { graphql: {
|
|
3143
|
+
query: body.graphql?.query ?? "",
|
|
3144
|
+
variables: body.graphql?.variables
|
|
3145
|
+
} }
|
|
3146
|
+
};
|
|
3147
|
+
return {
|
|
3148
|
+
discriminator: body.type,
|
|
3149
|
+
body: { [body.type]: body.content ?? "" }
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
function toBruJson(req, seq) {
|
|
3153
|
+
const http = {
|
|
3154
|
+
method: req.method.toLowerCase(),
|
|
3155
|
+
url: req.url,
|
|
3156
|
+
auth: "none"
|
|
3157
|
+
};
|
|
3158
|
+
let body = {};
|
|
3159
|
+
if (req.body) {
|
|
3160
|
+
const mapped = bruBody(req.body);
|
|
3161
|
+
http.body = mapped.discriminator;
|
|
3162
|
+
body = mapped.body;
|
|
3163
|
+
}
|
|
3164
|
+
return {
|
|
3165
|
+
meta: {
|
|
3166
|
+
name: req.name,
|
|
3167
|
+
type: "http",
|
|
3168
|
+
seq
|
|
3169
|
+
},
|
|
3170
|
+
http,
|
|
3171
|
+
headers: req.headers.map((h) => ({
|
|
3172
|
+
...h,
|
|
3173
|
+
enabled: true
|
|
3174
|
+
})),
|
|
3175
|
+
body
|
|
3176
|
+
};
|
|
3177
|
+
}
|
|
3178
|
+
/** Sanitize a request name into a unique `.bru` filename stem. */
|
|
3179
|
+
function fileStem(name, used) {
|
|
3180
|
+
const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "request";
|
|
3181
|
+
let stem = base;
|
|
3182
|
+
let n = 2;
|
|
3183
|
+
while (used.has(stem)) stem = `${base}-${n++}`;
|
|
3184
|
+
used.add(stem);
|
|
3185
|
+
return stem;
|
|
3186
|
+
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Write a normalized import to `destDir` as a Bruno collection: `bruno.json`, a
|
|
3189
|
+
* `<name>.bru` per request, and (if present) an `environments/<env>.bru`. Returns
|
|
3190
|
+
* the request count written.
|
|
3191
|
+
*/
|
|
3192
|
+
function writeImported(destDir, result, opts = {}) {
|
|
3193
|
+
mkdirSync(destDir, { recursive: true });
|
|
3194
|
+
writeFileSync(join(destDir, "bruno.json"), `${JSON.stringify({
|
|
3195
|
+
version: "1",
|
|
3196
|
+
name: opts.name ?? "imported",
|
|
3197
|
+
type: "collection"
|
|
3198
|
+
}, null, 2)}\n`);
|
|
3199
|
+
const used = /* @__PURE__ */ new Set();
|
|
3200
|
+
result.requests.forEach((req, i) => {
|
|
3201
|
+
writeFileSync(join(destDir, `${fileStem(req.name, used)}.bru`), jsonToBruV2(toBruJson(req, i + 1)));
|
|
3202
|
+
});
|
|
3203
|
+
if (result.environment) {
|
|
3204
|
+
mkdirSync(join(destDir, "environments"), { recursive: true });
|
|
3205
|
+
const variables = Object.entries(result.environment.variables).map(([name, value]) => ({
|
|
3206
|
+
name,
|
|
3207
|
+
value,
|
|
3208
|
+
enabled: true
|
|
3209
|
+
}));
|
|
3210
|
+
writeFileSync(join(destDir, "environments", `${fileStem(result.environment.name, /* @__PURE__ */ new Set())}.bru`), envJsonToBruV2({ variables }));
|
|
3211
|
+
}
|
|
3212
|
+
return result.requests.length;
|
|
3213
|
+
}
|
|
3214
|
+
/** One-shot: parse a source document and write the Bruno collection to disk. */
|
|
3215
|
+
function importToCollection(format, source, destDir, opts = {}) {
|
|
3216
|
+
return writeImported(destDir, parseImport(format, source), opts);
|
|
3217
|
+
}
|
|
3218
|
+
//#endregion
|
|
3219
|
+
export { ArtifactStore, ChainedSecretStore, EnvSecretStore, KeyringSecretStore, Redactor, StaticSecretStore, checkGate, evaluateAssertions, extractCaptures, harEntriesToFacts, importHar, importInsomnia, importOpenApi, importPostman, importToCollection, interpolate, isGraphqlEnvelope, isMutating, loadCollection, normalizeOpenApiSchema, parseImport, prepareRequest, redactHarZip, resolveOpenApiOperation, resolveSecretStore, runRequest, runRequestForContract, runRequestForHar, runRequestToHar, runScript, runSequence, runSequenceForHar, runSequenceToHar, summarizeHar, synthesizeRedactedHarZip, validateCapturedTraffic, validateGraphqlOperation, validateOpenApiRequest, validateOpenApiResponse, validateSchema, writeImported };
|
|
3220
|
+
|
|
3221
|
+
//# sourceMappingURL=index.mjs.map
|