@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/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