@specific.dev/spectest 0.24.0 → 0.27.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1807
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,738 @@
1
+ // Record/replay fake — a VCR-style cassette fake.
2
+ //
3
+ // `replayFake({...})` returns a plain `FakeDefinition`, so it drops
4
+ // straight into `fakes: { stripe: replayFake({...}) }` and inherits all
5
+ // the existing fakes machinery: ingress lowering mints the TLS leaf cert
6
+ // and registers the hostname, dispatch routes by `Host` header to the
7
+ // generated `handler`, and `state` forks per test like any fake.
8
+ //
9
+ // You fake the REAL host directly — `host: "api.stripe.com"`, not a
10
+ // stand-in. The fake answers for that name on both `http://` (:80) and
11
+ // `https://` (:443, in-VM-CA leaf cert), and the SAME name is what gets
12
+ // forwarded to the real upstream in record mode. The two modes:
13
+ //
14
+ // - REPLAY (hermetic): the request is matched against the cassette the
15
+ // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
+ // recorded response is returned. No network. Nothing is auto-loaded:
17
+ // a test that loads no cassette gets a fail-loud 599 on every request.
18
+ // This is what runs under `spectest test`.
19
+ // - RECORD (MITM): the request is forwarded to the REAL host and the
20
+ // request/response pair is captured (decoded, redacted), and the real
21
+ // response is returned to the app so a manual session behaves like
22
+ // production. This runs under `spectest_eval` / a manual env.
23
+ //
24
+ // Because the fake's hostname IS the real host, the in-VM resolver points
25
+ // that name at the daemon — so a naive `fetch("https://api.stripe.com")`
26
+ // from the record-mode forwarder would loop straight back into us. The
27
+ // forwarder therefore resolves the real IP itself against an EXTERNAL DNS
28
+ // server (default 1.1.1.1, `SPECTEST_UPSTREAM_DNS`), then opens a real TLS
29
+ // connection to that IP with the correct SNI/`Host` (validating the real
30
+ // public cert). This is a transparent MITM: the app's leg is terminated
31
+ // with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
32
+ // handshake to the real host.
33
+ //
34
+ // Mode is chosen by `isRecording()`: it is true only inside an active
35
+ // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
+ // (the default) replays under test and records under eval — no new
37
+ // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
+ //
39
+ // Credential brokering (per-fake): `inject` rules set
40
+ // headers on the egress forward — overwriting whatever the app sent — so
41
+ // app code never holds the credential. Header values embed `{{secret:REF}}`
42
+ // tokens; each REF is resolved server-side from the platform Secrets store
43
+ // and pushed eval-scoped from the control plane (via {@link getRecordSecret}).
44
+ // The real value lives only on the outbound wire to the real upstream and
45
+ // is redacted from the cassette (fail-closed) — it never enters project
46
+ // code, the tarball, the warm-cache hash, or any snapshot a hermetic run
47
+ // could fork.
48
+ //
49
+ // AWS SigV4 (`sign: { type: "awsSigv4", ... }`): static header injection
50
+ // can't broker AWS auth — the `Authorization` value is a keyed HMAC over the
51
+ // entire request, not a static token. So the forward instead RE-SIGNS: the
52
+ // app signs with throwaway dummy creds (any AWS SDK refuses to build a request
53
+ // with none), we strip that signature and re-sign the exact outbound request
54
+ // with the real credentials (also direct secret refs, same eval-scoped push +
55
+ // redaction). Region/service are inferred from the request. See
56
+ // `../aws-sigv4.ts`.
57
+ import { createSocket } from "node:dgram";
58
+ import { createHash } from "node:crypto";
59
+ import { existsSync, readFileSync } from "node:fs";
60
+ import { request as httpsRequest } from "node:https";
61
+ import path from "node:path";
62
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
63
+ import * as dnsPacket from "dns-packet";
64
+ import { isRecording } from "../recorder.js";
65
+ import { getRecordSecret } from "../record-secrets.js";
66
+ import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
67
+ // ────────────────────────────────────────────────────────────────────────
68
+ // Helpers
69
+ // ────────────────────────────────────────────────────────────────────────
70
+ const SENSITIVE_HEADERS = new Set([
71
+ "authorization",
72
+ "cookie",
73
+ "set-cookie",
74
+ "proxy-authorization",
75
+ "x-api-key",
76
+ "x-amz-security-token",
77
+ ]);
78
+ /** Hop-by-hop + body-framing headers never forwarded / never replayed
79
+ * (we decompress on the way through, so the encoding/length no longer
80
+ * describe the bytes the app receives). */
81
+ const STRIP_FORWARD_HEADERS = new Set([
82
+ "host",
83
+ "connection",
84
+ "keep-alive",
85
+ "proxy-authenticate",
86
+ "proxy-authorization",
87
+ "te",
88
+ "trailers",
89
+ "transfer-encoding",
90
+ "upgrade",
91
+ "content-length",
92
+ "content-encoding",
93
+ // We force `accept-encoding: identity` on the forward and decompress any
94
+ // residual encoding, so the app never sees a stale accept-encoding.
95
+ "accept-encoding",
96
+ ]);
97
+ /** External resolver used to find the REAL upstream IP, bypassing the in-VM
98
+ * resolver (which points the faked host back at the daemon). */
99
+ const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
100
+ const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
101
+ function defaultMatch(m) {
102
+ return {
103
+ method: m?.method ?? true,
104
+ path: m?.path ?? true,
105
+ query: m?.query ?? true,
106
+ body: m?.body,
107
+ headers: m?.headers,
108
+ };
109
+ }
110
+ /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
111
+ * in the query for presigned URLs — they change every call and would poison
112
+ * query matching, so they're dropped from both the match key and the forward
113
+ * when signing is active. */
114
+ function isAmzParam(key) {
115
+ return key.toLowerCase().startsWith("x-amz-");
116
+ }
117
+ function stripAmzParams(search) {
118
+ const out = new URLSearchParams();
119
+ for (const [k, v] of search)
120
+ if (!isAmzParam(k))
121
+ out.append(k, v);
122
+ return out;
123
+ }
124
+ function normalizeQuery(search, stripAmz) {
125
+ const out = {};
126
+ for (const key of [...search.keys()].sort()) {
127
+ if (stripAmz && isAmzParam(key))
128
+ continue;
129
+ out[key] = search.getAll(key).slice().sort();
130
+ }
131
+ return out;
132
+ }
133
+ function sha256Bytes(bytes) {
134
+ return "sha256:" + createHash("sha256").update(Buffer.from(bytes)).digest("hex");
135
+ }
136
+ function recordable(headers, want) {
137
+ const out = {};
138
+ for (const name of want) {
139
+ const lower = name.toLowerCase();
140
+ if (SENSITIVE_HEADERS.has(lower))
141
+ continue;
142
+ const v = headers.get(lower);
143
+ if (v !== null)
144
+ out[lower] = v;
145
+ }
146
+ return out;
147
+ }
148
+ /** Canonical match signature for an interaction's request, used as both
149
+ * the replay-cursor key and the equality basis (two requests match iff
150
+ * their signatures are equal). Computed identically for an incoming
151
+ * request and a stored `CassetteRequest`. */
152
+ function signature(req, match) {
153
+ const sig = {};
154
+ if (match.method)
155
+ sig.method = req.method.toUpperCase();
156
+ if (match.path)
157
+ sig.path = req.path;
158
+ if (match.query)
159
+ sig.query = req.query;
160
+ if (match.body === true)
161
+ sig.body = req.body ?? "";
162
+ else if (match.body === "hash")
163
+ sig.bodyHash = req.bodyHash ?? "";
164
+ if (match.headers && match.headers.length > 0) {
165
+ const h = {};
166
+ for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
167
+ h[name] = req.headers[name] ?? "";
168
+ }
169
+ sig.headers = h;
170
+ }
171
+ return JSON.stringify(sig);
172
+ }
173
+ /** The project's `spectest/tests/` dir — cassettes live alongside the
174
+ * tests, and a `replay(path)` argument is a path relative to here. Keeping
175
+ * them under `tests/` means re-recording is picked up on the next run with
176
+ * no environment rebuild (that subtree is re-imported with the tests). */
177
+ function cassetteRoot() {
178
+ const spectestDir = process.env.SPECTEST_PROJECT_DIR ??
179
+ path.join(process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app", "spectest");
180
+ return path.join(spectestDir, "tests");
181
+ }
182
+ function emptyCassette(name, host) {
183
+ return { version: 1, fake: name, host, interactions: [] };
184
+ }
185
+ /** Validate + normalize a parsed cassette (from a file or passed inline). */
186
+ function coerceCassette(parsed, name, host) {
187
+ if (!parsed || !Array.isArray(parsed.interactions)) {
188
+ throw new Error(`replayFake(${name}): cassette has no \`interactions\` array`);
189
+ }
190
+ return {
191
+ version: 1,
192
+ fake: parsed.fake ?? name,
193
+ host: parsed.host ?? host,
194
+ interactions: parsed.interactions,
195
+ };
196
+ }
197
+ /** Read a cassette by its path relative to `spectest/tests/` (e.g.
198
+ * `"recordings/stripe.json"`). The path must stay under `spectest/tests/`,
199
+ * and explicit loads must point at a real file — a missing one throws (fail
200
+ * loud) rather than silently replaying nothing. */
201
+ function readCassetteFile(name, file, host) {
202
+ const base = cassetteRoot();
203
+ const p = path.resolve(base, file);
204
+ if (p !== base && !p.startsWith(base + path.sep)) {
205
+ throw new Error(`replayFake(${name}): cassette path ${JSON.stringify(file)} must be under spectest/tests/`);
206
+ }
207
+ if (!existsSync(p)) {
208
+ throw new Error(`replayFake(${name}): cassette not found at ${p} — record one first, or pass a cassette object.`);
209
+ }
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(readFileSync(p, "utf8"));
213
+ }
214
+ catch (err) {
215
+ throw new Error(`replayFake(${name}): failed to read cassette ${p}: ${err.message}`);
216
+ }
217
+ return coerceCassette(parsed, name, host);
218
+ }
219
+ /** Decode response bytes to a string, falling back to base64 for binary. */
220
+ function decodeBody(bytes) {
221
+ try {
222
+ const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
223
+ return { body: text, bodyEncoding: "utf8" };
224
+ }
225
+ catch {
226
+ return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
227
+ }
228
+ }
229
+ function makeRedactor(secrets, patterns) {
230
+ return (s) => {
231
+ let out = s;
232
+ for (const { ref, value } of secrets) {
233
+ if (value.length > 0)
234
+ out = out.split(value).join(`<REDACTED:${ref}>`);
235
+ }
236
+ for (const p of patterns ?? []) {
237
+ if (typeof p === "string") {
238
+ if (p.length > 0)
239
+ out = out.split(p).join("<REDACTED>");
240
+ }
241
+ else {
242
+ const g = p.flags.includes("g") ? p : new RegExp(p.source, p.flags + "g");
243
+ out = out.replace(g, "<REDACTED>");
244
+ }
245
+ }
246
+ return out;
247
+ };
248
+ }
249
+ // ────────────────────────────────────────────────────────────────────────
250
+ // Credential brokering: matchers + secret-token resolution
251
+ // ────────────────────────────────────────────────────────────────────────
252
+ /** `{{secret:REF}}` template token embedded in an injected header value. */
253
+ const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
254
+ /** Every secret ref referenced by any inject rule's header templates plus the
255
+ * SigV4 signing credentials (direct refs, not templates). */
256
+ function collectSecretRefs(rules, sign) {
257
+ const refs = new Set();
258
+ for (const rule of rules) {
259
+ for (const value of Object.values(rule.headers)) {
260
+ for (const m of value.matchAll(SECRET_TOKEN))
261
+ refs.add(m[1]);
262
+ }
263
+ }
264
+ if (sign?.type === "awsSigv4") {
265
+ refs.add(sign.accessKeyId);
266
+ refs.add(sign.secretAccessKey);
267
+ if (sign.sessionToken)
268
+ refs.add(sign.sessionToken);
269
+ }
270
+ return [...refs];
271
+ }
272
+ function matchString(m, value) {
273
+ if (typeof m === "string")
274
+ return value === m;
275
+ if ("exact" in m)
276
+ return value === m.exact;
277
+ if ("startsWith" in m)
278
+ return value.startsWith(m.startsWith);
279
+ if ("regex" in m)
280
+ return new RegExp(m.regex).test(value);
281
+ return false;
282
+ }
283
+ /** True if every dimension the matcher specifies is satisfied (AND across
284
+ * dimensions). No `match` → always true. */
285
+ function ruleMatches(match, req) {
286
+ if (!match)
287
+ return true;
288
+ if (match.path !== undefined && !matchString(match.path, req.path))
289
+ return false;
290
+ if (match.method !== undefined) {
291
+ const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) => s.toUpperCase());
292
+ if (!methods.includes(req.method.toUpperCase()))
293
+ return false;
294
+ }
295
+ if (match.query) {
296
+ for (const [k, m] of Object.entries(match.query)) {
297
+ if (!req.query.getAll(k).some((v) => matchString(m, v)))
298
+ return false;
299
+ }
300
+ }
301
+ if (match.headers) {
302
+ for (const [k, m] of Object.entries(match.headers)) {
303
+ const v = req.headers.get(k.toLowerCase());
304
+ if (v === null || !matchString(m, v))
305
+ return false;
306
+ }
307
+ }
308
+ return true;
309
+ }
310
+ /** Resolve a rule's header templates, substituting every `{{secret:REF}}`
311
+ * with the eval-scoped value. Collects resolved secrets (for redaction)
312
+ * and any refs the control plane didn't supply (fail loud). */
313
+ function brokerHeaders(rule) {
314
+ const headers = {};
315
+ const secrets = [];
316
+ const missing = [];
317
+ const seen = new Set();
318
+ for (const [name, template] of Object.entries(rule.headers)) {
319
+ headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref) => {
320
+ const value = getRecordSecret(ref);
321
+ if (value === undefined) {
322
+ missing.push(ref);
323
+ return "";
324
+ }
325
+ if (!seen.has(ref)) {
326
+ seen.add(ref);
327
+ secrets.push({ ref, value });
328
+ }
329
+ return value;
330
+ });
331
+ }
332
+ return { headers, secrets, missing };
333
+ }
334
+ // ────────────────────────────────────────────────────────────────────────
335
+ // Record-mode egress: resolve the real host externally, then MITM-forward
336
+ // ────────────────────────────────────────────────────────────────────────
337
+ /** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
338
+ * in-VM resolver (which would answer with the daemon's own gateway for a
339
+ * faked host). Mirrors spectest-resolver's own upstream-forward transport
340
+ * (dns-packet over UDP) so it works identically in-VM. */
341
+ function resolveExternalA(host) {
342
+ const query = dnsPacket.encode({
343
+ type: "query",
344
+ id: 1,
345
+ flags: dnsPacket.RECURSION_DESIRED,
346
+ questions: [{ type: "A", name: host }],
347
+ });
348
+ return new Promise((resolve) => {
349
+ const sock = createSocket("udp4");
350
+ let done = false;
351
+ const finish = (ips) => {
352
+ if (done)
353
+ return;
354
+ done = true;
355
+ try {
356
+ sock.close();
357
+ }
358
+ catch {
359
+ // ignore
360
+ }
361
+ resolve(ips);
362
+ };
363
+ sock.on("message", (msg) => {
364
+ try {
365
+ const res = dnsPacket.decode(msg);
366
+ const ips = (res.answers ?? []).flatMap((a) => a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : []);
367
+ finish(ips);
368
+ }
369
+ catch {
370
+ finish([]);
371
+ }
372
+ });
373
+ sock.on("error", () => finish([]));
374
+ sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
375
+ if (err)
376
+ finish([]);
377
+ });
378
+ setTimeout(() => finish([]), 3_000);
379
+ });
380
+ }
381
+ /** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
382
+ * the public certificate validates against the real hostname (the in-VM CA
383
+ * is not involved on this leg). Decompresses the response so the stored and
384
+ * returned bytes are the plain payload. */
385
+ function forwardToRealUpstream(args) {
386
+ return new Promise((resolve, reject) => {
387
+ const req = httpsRequest({
388
+ host: args.ip,
389
+ port: 443,
390
+ method: args.method,
391
+ path: args.pathWithQuery,
392
+ servername: args.host,
393
+ headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
394
+ }, (res) => {
395
+ const chunks = [];
396
+ res.on("data", (c) => chunks.push(c));
397
+ res.on("end", () => {
398
+ let bytes = Buffer.concat(chunks);
399
+ const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
400
+ try {
401
+ if (enc.includes("br"))
402
+ bytes = brotliDecompressSync(bytes);
403
+ else if (enc.includes("gzip"))
404
+ bytes = gunzipSync(bytes);
405
+ else if (enc.includes("deflate"))
406
+ bytes = inflateSync(bytes);
407
+ }
408
+ catch {
409
+ // Leave the bytes as-is if decompression fails.
410
+ }
411
+ const headers = {};
412
+ for (const [k, v] of Object.entries(res.headers)) {
413
+ if (typeof v === "string")
414
+ headers[k.toLowerCase()] = v;
415
+ else if (Array.isArray(v))
416
+ headers[k.toLowerCase()] = v.join(", ");
417
+ }
418
+ resolve({ status: res.statusCode ?? 0, headers, bytes });
419
+ });
420
+ res.on("error", reject);
421
+ });
422
+ req.on("error", reject);
423
+ if (args.body.length > 0)
424
+ req.write(Buffer.from(args.body));
425
+ req.end();
426
+ });
427
+ }
428
+ // ────────────────────────────────────────────────────────────────────────
429
+ // replayFake
430
+ // ────────────────────────────────────────────────────────────────────────
431
+ export function replayFake(opts) {
432
+ if (!opts.name)
433
+ throw new Error("replayFake: `name` is required");
434
+ if (!opts.host || typeof opts.host !== "string") {
435
+ throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
436
+ }
437
+ const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
438
+ const match = defaultMatch(opts.match);
439
+ const injectRules = opts.inject ?? [];
440
+ // Refs every header template references — the control plane resolves these
441
+ // server-side and pushes them eval-scoped (see the daemon's
442
+ // /record-secret-refs endpoint, which reads `def.secretRefs`).
443
+ const secretRefs = collectSecretRefs(injectRules, opts.sign);
444
+ const resolveMode = () => {
445
+ if (opts.mode === "record")
446
+ return "record";
447
+ if (opts.mode === "replay")
448
+ return "replay";
449
+ return isRecording() ? "replay" : "record";
450
+ };
451
+ // ── REPLAY ──────────────────────────────────────────────────────────
452
+ function replay(reqLike, state) {
453
+ const sig = signature(reqLike, match);
454
+ const matches = [];
455
+ for (const it of state.cassette.interactions) {
456
+ if (signature(it.request, match) === sig)
457
+ matches.push(it);
458
+ }
459
+ const idx = state.cursor.get(sig) ?? 0;
460
+ const hit = matches[idx];
461
+ if (!hit) {
462
+ const body = `replayFake(${opts.name}): no cassette interaction for ` +
463
+ `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
464
+ `Re-record the cassette, or relax \`match\`.\n`;
465
+ return new Response(body, {
466
+ status: 599,
467
+ headers: { "content-type": "text/plain" },
468
+ });
469
+ }
470
+ state.cursor.set(sig, idx + 1);
471
+ const headers = new Headers(hit.response.headers);
472
+ const payload = hit.response.bodyEncoding === "base64"
473
+ ? Buffer.from(hit.response.body, "base64")
474
+ : hit.response.body;
475
+ return new Response(payload, { status: hit.response.status, headers });
476
+ }
477
+ // ── RECORD (MITM) ───────────────────────────────────────────────────
478
+ async function record(req, url, reqLike, bodyBytes, state) {
479
+ // Credential brokering (static header inject): first matching rule wins.
480
+ // Resolve its `{{secret:REF}}` tokens from the eval-scoped store.
481
+ const rule = injectRules.find((r) => ruleMatches(r.match, {
482
+ method: reqLike.method,
483
+ path: reqLike.path,
484
+ query: url.searchParams,
485
+ headers: req.headers,
486
+ }));
487
+ const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
488
+ // SigV4 signing: resolve the credential secret refs (direct refs, not
489
+ // `{{secret:}}` templates). Missing required refs fail loud alongside any
490
+ // missing inject refs.
491
+ let signCreds;
492
+ const signMissing = [];
493
+ if (opts.sign?.type === "awsSigv4") {
494
+ const s = opts.sign;
495
+ const accessKeyId = getRecordSecret(s.accessKeyId);
496
+ const secretAccessKey = getRecordSecret(s.secretAccessKey);
497
+ const sessionToken = s.sessionToken ? getRecordSecret(s.sessionToken) : undefined;
498
+ if (accessKeyId === undefined)
499
+ signMissing.push(s.accessKeyId);
500
+ if (secretAccessKey === undefined)
501
+ signMissing.push(s.secretAccessKey);
502
+ if (s.sessionToken && sessionToken === undefined)
503
+ signMissing.push(s.sessionToken);
504
+ if (accessKeyId !== undefined && secretAccessKey !== undefined) {
505
+ signCreds = { accessKeyId, secretAccessKey, sessionToken };
506
+ }
507
+ }
508
+ const missing = [...brokered.missing, ...signMissing];
509
+ if (missing.length > 0) {
510
+ const refs = [...new Set(missing)];
511
+ return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
512
+ `(configure them on the project's Secrets page, and record via spectest_eval).\n`, { status: 599, headers: { "content-type": "text/plain" } });
513
+ }
514
+ // Resolve the REAL host's IP via an external resolver so we don't loop
515
+ // back into the daemon (the in-VM resolver answers our own gateway for
516
+ // this faked name). See the module header.
517
+ const ips = await resolveExternalA(host);
518
+ if (ips.length === 0) {
519
+ return new Response(`replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
520
+ `via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`, { status: 599, headers: { "content-type": "text/plain" } });
521
+ }
522
+ // Forward headers: drop hop-by-hop/encoding, then SET the brokered
523
+ // headers — overwriting whatever the app sent (so the credential can't
524
+ // be smuggled or spoofed by app code). The real values only ever appear
525
+ // on this outbound wire.
526
+ const fwdHeaders = {};
527
+ req.headers.forEach((v, k) => {
528
+ if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase()))
529
+ fwdHeaders[k.toLowerCase()] = v;
530
+ });
531
+ for (const [k, v] of Object.entries(brokered.headers))
532
+ fwdHeaders[k] = v;
533
+ // SigV4 re-signing: strip the app's dummy signature and re-sign the exact
534
+ // outbound request (method/path/query/signed-headers/body-hash) with the
535
+ // brokered credentials, so `authorization`/`x-amz-date`/`x-amz-content-sha256`
536
+ // are internally consistent.
537
+ const signSecrets = [];
538
+ let wireQuery = url.searchParams.toString();
539
+ if (opts.sign?.type === "awsSigv4" && signCreds) {
540
+ const scope = inferSigV4Scope({
541
+ authorization: req.headers.get("authorization"),
542
+ credentialParam: url.searchParams.get("X-Amz-Credential"),
543
+ host,
544
+ });
545
+ if (!scope) {
546
+ return new Response(`replayFake(${opts.name}): could not infer the AWS region/service to sign ` +
547
+ `${reqLike.method} ${reqLike.path} (no SigV4 Credential on the request, and host ` +
548
+ `${JSON.stringify(host)} is not a <service>.<region>.amazonaws.com name).\n`, { status: 599, headers: { "content-type": "text/plain" } });
549
+ }
550
+ // Drop the app's own (dummy-signed) signing headers before re-signing.
551
+ for (const h of ["authorization", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]) {
552
+ delete fwdHeaders[h];
553
+ }
554
+ // Fold the app's remaining `x-amz-*`/content-type headers (e.g.
555
+ // `x-amz-target`) into the signature; they stay on the outbound wire.
556
+ const additionalHeaders = {};
557
+ for (const [k, v] of Object.entries(fwdHeaders)) {
558
+ if (k === "content-type" ||
559
+ (k.startsWith("x-amz-") &&
560
+ k !== "x-amz-date" &&
561
+ k !== "x-amz-content-sha256" &&
562
+ k !== "x-amz-security-token")) {
563
+ additionalHeaders[k] = v;
564
+ }
565
+ }
566
+ const forwardParams = stripAmzParams(url.searchParams);
567
+ const signed = signAwsV4Request({
568
+ method: reqLike.method,
569
+ path: reqLike.path,
570
+ query: forwardParams,
571
+ host,
572
+ body: bodyBytes,
573
+ credentials: signCreds,
574
+ scope,
575
+ additionalHeaders,
576
+ });
577
+ for (const [k, v] of Object.entries(signed.headers))
578
+ fwdHeaders[k] = v;
579
+ // Send the canonical query we signed (URLSearchParams' `+`-for-space
580
+ // encoding would diverge from the signed `%20`).
581
+ wireQuery = signed.canonicalQuery;
582
+ signSecrets.push({ ref: opts.sign.accessKeyId, value: signCreds.accessKeyId }, { ref: opts.sign.secretAccessKey, value: signCreds.secretAccessKey });
583
+ if (signCreds.sessionToken && opts.sign.sessionToken) {
584
+ signSecrets.push({ ref: opts.sign.sessionToken, value: signCreds.sessionToken });
585
+ }
586
+ }
587
+ const pathWithQuery = reqLike.path + (wireQuery ? `?${wireQuery}` : "");
588
+ let upstream;
589
+ try {
590
+ upstream = await forwardToRealUpstream({
591
+ ip: ips[0],
592
+ host,
593
+ method: reqLike.method,
594
+ pathWithQuery,
595
+ headers: fwdHeaders,
596
+ body: bodyBytes,
597
+ });
598
+ }
599
+ catch (err) {
600
+ return new Response(`replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${err.message}\n`, { status: 599, headers: { "content-type": "text/plain" } });
601
+ }
602
+ // Decode + redact for storage; return the real (un-redacted) bytes to
603
+ // the app so the manual session behaves like production.
604
+ const redactSecrets = [...brokered.secrets, ...signSecrets];
605
+ const redact = makeRedactor(redactSecrets, opts.redactPatterns);
606
+ const decoded = decodeBody(upstream.bytes);
607
+ const respHeaders = {};
608
+ for (const [k, v] of Object.entries(upstream.headers)) {
609
+ if (SENSITIVE_HEADERS.has(k) || STRIP_FORWARD_HEADERS.has(k))
610
+ continue;
611
+ respHeaders[k] = v;
612
+ }
613
+ const redactedQuery = {};
614
+ for (const [k, vs] of Object.entries(reqLike.query)) {
615
+ redactedQuery[k] = vs.map(redact);
616
+ }
617
+ const interaction = {
618
+ request: {
619
+ method: reqLike.method,
620
+ path: reqLike.path,
621
+ query: redactedQuery,
622
+ headers: reqLike.headers,
623
+ ...(reqLike.body !== undefined
624
+ ? {
625
+ bodyHash: reqLike.bodyHash,
626
+ // Redact text bodies; leave base64 (binary) bodies verbatim — a
627
+ // secret is vanishingly unlikely to survive base64, and the
628
+ // fail-closed check below still catches it if it does.
629
+ body: reqLike.bodyEncoding === "base64" ? reqLike.body : redact(reqLike.body),
630
+ bodyEncoding: reqLike.bodyEncoding,
631
+ }
632
+ : {}),
633
+ },
634
+ response: {
635
+ status: upstream.status,
636
+ headers: respHeaders,
637
+ body: decoded.bodyEncoding === "utf8" ? redact(decoded.body) : decoded.body,
638
+ bodyEncoding: decoded.bodyEncoding,
639
+ },
640
+ };
641
+ // Fail-closed: never persist a cassette that still carries any raw
642
+ // secret value (a missed redaction is a leak, not a warning).
643
+ const serialized = JSON.stringify(interaction);
644
+ for (const { ref, value } of redactSecrets) {
645
+ if (value.length > 0 && serialized.includes(value)) {
646
+ throw new Error(`replayFake(${opts.name}): refusing to record — the value for secret ${JSON.stringify(ref)} ` +
647
+ `survived redaction into the interaction for ${reqLike.method} ${reqLike.path}. ` +
648
+ `Add a redactPatterns entry.`);
649
+ }
650
+ }
651
+ state.recorded.push(interaction);
652
+ state.dirty = true;
653
+ return new Response(upstream.bytes, {
654
+ status: upstream.status,
655
+ headers: new Headers(respHeaders),
656
+ });
657
+ }
658
+ // ── handler ─────────────────────────────────────────────────────────
659
+ const handler = async (req, state, _ctx) => {
660
+ const url = new URL(req.url);
661
+ const bodyBytes = req.method === "GET" || req.method === "HEAD"
662
+ ? new Uint8Array(0)
663
+ : new Uint8Array(await req.arrayBuffer());
664
+ const decodedBody = bodyBytes.length > 0 ? decodeBody(bodyBytes) : undefined;
665
+ const reqLike = {
666
+ method: req.method.toUpperCase(),
667
+ path: url.pathname,
668
+ // Strip SigV4 `x-amz-*` query params from the match key when signing —
669
+ // presigned URLs carry a per-request signature there.
670
+ query: normalizeQuery(url.searchParams, opts.sign !== undefined),
671
+ headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
672
+ ...(decodedBody
673
+ ? {
674
+ bodyHash: sha256Bytes(bodyBytes),
675
+ body: decodedBody.body,
676
+ bodyEncoding: decodedBody.bodyEncoding,
677
+ }
678
+ : {}),
679
+ };
680
+ if (resolveMode() === "replay")
681
+ return replay(reqLike, state);
682
+ return record(req, url, reqLike, bodyBytes, state);
683
+ };
684
+ const def = {
685
+ name: opts.name,
686
+ // Fake = real: we answer for the real host on :80 and :443.
687
+ hostnames: [host],
688
+ secretRefs,
689
+ // Cassettes are NOT auto-loaded — a fork starts with an empty replay
690
+ // set, so a test that loads nothing fails loud (599). Tests call
691
+ // `replay(...)` to populate it.
692
+ state: () => ({
693
+ cassette: emptyCassette(opts.name, host),
694
+ cursor: new Map(),
695
+ recorded: [],
696
+ dirty: false,
697
+ }),
698
+ handler,
699
+ helpers: ({ state }) => ({
700
+ replay(file) {
701
+ let loaded;
702
+ let label;
703
+ if (file && typeof file === "object") {
704
+ loaded = coerceCassette(file, opts.name, host);
705
+ label = file.fake || "(inline)";
706
+ }
707
+ else if (typeof file === "string" && file.length > 0) {
708
+ label = file;
709
+ loaded = readCassetteFile(opts.name, file, host);
710
+ }
711
+ else {
712
+ throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
713
+ `(e.g. "recordings/${opts.name}.json").`);
714
+ }
715
+ // Mutate the live state object (the handler reads the same
716
+ // reference); reset the cursor so replay starts from the top.
717
+ state.cassette = loaded;
718
+ state.cursor = new Map();
719
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
720
+ },
721
+ dump() {
722
+ return {
723
+ version: 1,
724
+ fake: opts.name,
725
+ host,
726
+ interactions: [...state.cassette.interactions, ...state.recorded],
727
+ };
728
+ },
729
+ recorded() {
730
+ return state.recorded.slice();
731
+ },
732
+ count() {
733
+ return state.recorded.length;
734
+ },
735
+ }),
736
+ };
737
+ return def;
738
+ }