@specific.dev/spectest 0.99.0 → 0.100.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.
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
13
13
  export { apple, type AppleOptions } from "./apple.js";
14
14
  export { microsoft, type MicrosoftOptions } from "./microsoft.js";
15
15
  export { okta, type OktaOptions } from "./okta.js";
16
- export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
16
+ export { replayFake, type ReplayFakeOptions, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
17
17
  export { electron, type ElectronOptions } from "./electron.js";
18
18
  export { android, type AndroidOptions } from "./android.js";
@@ -0,0 +1,27 @@
1
+ import type { CassetteRequest } from "./replayFake.js";
2
+ export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
3
+ readonly [key: string]: JsonValue;
4
+ };
5
+ /** Identity content. Objects compare independently of property order; arrays
6
+ * preserve order. Bytes are distinct from strings, arrays, and objects. */
7
+ export type MatchKey = null | boolean | number | string | Uint8Array | readonly MatchKey[] | {
8
+ readonly [key: string]: MatchKey;
9
+ };
10
+ /** A buffered request view shared by incoming and recorded requests.
11
+ * Headers are lowercase and exclude credentials. Recorded values may be
12
+ * redacted; choose identity fields that survive recording. */
13
+ export interface ReplayRequest {
14
+ readonly method: string;
15
+ readonly path: string;
16
+ readonly query: Readonly<Record<string, readonly string[]>>;
17
+ readonly headers: Readonly<Record<string, string>>;
18
+ /** Decode UTF-8, throwing on invalid text. */
19
+ text(): string;
20
+ /** Parse JSON. The type parameter is not runtime validation. */
21
+ json<T = JsonValue>(): T;
22
+ /** Return an independent copy of the body bytes. */
23
+ bytes(): Uint8Array;
24
+ }
25
+ export type ReplayKey = (request: ReplayRequest) => MatchKey;
26
+ /** Internal only; the SDK exports the request/key types, not this function. */
27
+ export declare function replaySignature(request: CassetteRequest, key?: ReplayKey): string;
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Stream a typed, length-delimited canonical representation into the hash.
3
+ * Never call toJSON: unsupported values must fail rather than silently lose
4
+ * identity fields. In particular, an async callback must not become `{}`. */
5
+ function hashKey(key) {
6
+ const hash = createHash("sha256");
7
+ const ancestors = new Set();
8
+ const visit = (value) => {
9
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
10
+ hash.update(JSON.stringify(value));
11
+ return;
12
+ }
13
+ if (typeof value === "number" && Number.isFinite(value)) {
14
+ hash.update(JSON.stringify(value));
15
+ return;
16
+ }
17
+ if (value instanceof Uint8Array) {
18
+ hash.update(`bytes:${value.byteLength}:`);
19
+ hash.update(value);
20
+ return;
21
+ }
22
+ if (value instanceof Promise) {
23
+ // Report the invalid callback synchronously without leaving a rejected
24
+ // async callback to become an unrelated unhandled-rejection failure.
25
+ void value.catch(() => { });
26
+ throw new Error("key callbacks must be synchronous, not return promises");
27
+ }
28
+ if (typeof value !== "object" || value === null) {
29
+ throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
30
+ }
31
+ if (ancestors.has(value))
32
+ throw new Error("key must not contain cycles");
33
+ if (Object.getOwnPropertySymbols(value).length > 0) {
34
+ throw new Error("key must not contain symbol properties");
35
+ }
36
+ ancestors.add(value);
37
+ if (Array.isArray(value)) {
38
+ if (Object.keys(value).length !== value.length) {
39
+ throw new Error("key arrays must be dense and have no extra properties");
40
+ }
41
+ hash.update("[");
42
+ for (let i = 0; i < value.length; i++) {
43
+ if (!Object.hasOwn(value, i))
44
+ throw new Error("key arrays must be dense");
45
+ visit(value[i]);
46
+ hash.update(",");
47
+ }
48
+ hash.update("]");
49
+ }
50
+ else {
51
+ const proto = Object.getPrototypeOf(value);
52
+ if (proto !== Object.prototype && proto !== null) {
53
+ throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
54
+ }
55
+ hash.update("{");
56
+ for (const name of Object.keys(value).sort()) {
57
+ hash.update(JSON.stringify(name));
58
+ hash.update(":");
59
+ visit(value[name]);
60
+ hash.update(",");
61
+ }
62
+ hash.update("}");
63
+ }
64
+ ancestors.delete(value);
65
+ };
66
+ visit(key);
67
+ return hash.digest("hex");
68
+ }
69
+ function bodyBytes(request) {
70
+ return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
71
+ }
72
+ function requestView(request) {
73
+ let bytes;
74
+ let text;
75
+ const readBytes = () => bytes ??= bodyBytes(request);
76
+ const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
77
+ const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
78
+ return Object.freeze({
79
+ method: request.method.toUpperCase(),
80
+ path: request.path,
81
+ query: Object.freeze(query),
82
+ headers: Object.freeze({ ...request.headers }),
83
+ text: readText,
84
+ json: () => JSON.parse(readText()),
85
+ bytes: () => Uint8Array.from(readBytes()),
86
+ });
87
+ }
88
+ /** Internal only; the SDK exports the request/key types, not this function. */
89
+ export function replaySignature(request, key) {
90
+ if (key)
91
+ return hashKey(key(requestView(request)));
92
+ // Existing cassettes retain the original byte hash even when their stored
93
+ // text was redacted. Older/hand-written cassettes may only have the body.
94
+ const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
95
+ return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
96
+ }
@@ -1,18 +1,6 @@
1
1
  import type { FakeDefinition } from "../index.js";
2
- /** Which parts of a request define a match against the cassette. */
3
- export interface ReplayMatch {
4
- /** Match on HTTP method. Default `true`. */
5
- method?: boolean;
6
- /** Match on path (no query). Default `true`. */
7
- path?: boolean;
8
- /** Match on normalized query string. Default `true`. */
9
- query?: boolean;
10
- /** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
11
- * Default off. */
12
- body?: boolean | "hash";
13
- /** Header names (lowercase) to include in the match. Default none. */
14
- headers?: string[];
15
- }
2
+ import { type ReplayKey } from "./replay-key.js";
3
+ export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
16
4
  /** Match one request dimension. A bare string is an exact match; the
17
5
  * object forms cover prefix and regex matching. */
18
6
  export type StringMatch = string | {
@@ -75,8 +63,12 @@ export interface ReplayFakeOptions {
75
63
  * resolved via an external DNS server — see the module header). Your
76
64
  * app points at this real host directly; no separate stand-in. */
77
65
  host: string;
78
- /** What defines a request match. Default `{ method, path, query }`. */
79
- match?: ReplayMatch;
66
+ /** Complete request identity. Default: method, path, normalized query,
67
+ * and exact body bytes. The same synchronous, deterministic callback runs
68
+ * on recorded and incoming requests. Return JSON values and/or Uint8Array
69
+ * bytes; objects compare independently of property order. No implicit
70
+ * matching dimensions or fallback apply when a key is supplied. */
71
+ key?: ReplayKey;
80
72
  /** Per-fake credential brokering. Rules are evaluated in
81
73
  * order; the first whose `match` matches wins (a rule without `match`
82
74
  * matches everything and shadows later rules). Applied at the
@@ -101,7 +93,8 @@ export interface CassetteRequest {
101
93
  path: string;
102
94
  /** Normalized: keys + repeated values sorted, so matching is order-free. */
103
95
  query: Record<string, string[]>;
104
- /** Matched/readable header subset only — never `authorization`/`cookie`. */
96
+ /** Captured non-sensitive headers — never `authorization`/`cookie`.
97
+ * Older cassettes may contain only content-type and selected headers. */
105
98
  headers: Record<string, string>;
106
99
  bodyHash?: string;
107
100
  /** Request body (omitted when empty): decoded + redacted UTF-8 text, or
@@ -131,6 +124,8 @@ export interface Cassette {
131
124
  /** Forked-per-test state for one replay fake. */
132
125
  export interface CassetteState {
133
126
  cassette: Cassette;
127
+ /** Complete signature → cassette interaction indexes, in cassette order. */
128
+ index: Map<string, number[]>;
134
129
  /** Per-match-signature replay cursor (call-order sequencing). */
135
130
  cursor: Map<string, number>;
136
131
  /** Interactions captured this record session. */
@@ -62,6 +62,7 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
62
62
  import * as dnsPacket from "dns-packet";
63
63
  import { isRecording } from "../recorder.js";
64
64
  import { getRecordSecret } from "../record-secrets.js";
65
+ import { replaySignature } from "./replay-key.js";
65
66
  import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
66
67
  // ────────────────────────────────────────────────────────────────────────
67
68
  // Helpers
@@ -71,7 +72,9 @@ const SENSITIVE_HEADERS = new Set([
71
72
  "cookie",
72
73
  "set-cookie",
73
74
  "proxy-authorization",
75
+ "api-key",
74
76
  "x-api-key",
77
+ "x-goog-api-key",
75
78
  "x-amz-security-token",
76
79
  ]);
77
80
  /** Hop-by-hop + body-framing headers never forwarded / never replayed
@@ -97,15 +100,6 @@ const STRIP_FORWARD_HEADERS = new Set([
97
100
  * resolver (which points the faked host back at the daemon). */
98
101
  const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
99
102
  const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
100
- function defaultMatch(m) {
101
- return {
102
- method: m?.method ?? true,
103
- path: m?.path ?? true,
104
- query: m?.query ?? true,
105
- body: m?.body,
106
- headers: m?.headers,
107
- };
108
- }
109
103
  /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
110
104
  * in the query for presigned URLs — they change every call and would poison
111
105
  * query matching, so they're dropped from both the match key and the forward
@@ -144,31 +138,6 @@ function recordable(headers, want) {
144
138
  }
145
139
  return out;
146
140
  }
147
- /** Canonical match signature for an interaction's request, used as both
148
- * the replay-cursor key and the equality basis (two requests match iff
149
- * their signatures are equal). Computed identically for an incoming
150
- * request and a stored `CassetteRequest`. */
151
- function signature(req, match) {
152
- const sig = {};
153
- if (match.method)
154
- sig.method = req.method.toUpperCase();
155
- if (match.path)
156
- sig.path = req.path;
157
- if (match.query)
158
- sig.query = req.query;
159
- if (match.body === true)
160
- sig.body = req.body ?? "";
161
- else if (match.body === "hash")
162
- sig.bodyHash = req.bodyHash ?? "";
163
- if (match.headers && match.headers.length > 0) {
164
- const h = {};
165
- for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
166
- h[name] = req.headers[name] ?? "";
167
- }
168
- sig.headers = h;
169
- }
170
- return JSON.stringify(sig);
171
- }
172
141
  /** The project's `spectest/tests/` dir — cassettes live alongside the
173
142
  * tests, and a `replay(path)` argument is a path relative to here. Keeping
174
143
  * them under `tests/` means re-recording is picked up on the next run with
@@ -190,7 +159,7 @@ function coerceCassette(parsed, name, host) {
190
159
  version: 1,
191
160
  fake: parsed.fake ?? name,
192
161
  host: parsed.host ?? host,
193
- interactions: parsed.interactions,
162
+ interactions: structuredClone(parsed.interactions),
194
163
  };
195
164
  }
196
165
  /** Read a cassette by its path relative to `spectest/tests/` (e.g.
@@ -218,7 +187,7 @@ function readCassetteFile(name, file, host) {
218
187
  /** Decode response bytes to a string, falling back to base64 for binary. */
219
188
  function decodeBody(bytes) {
220
189
  try {
221
- const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
190
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
222
191
  return { body: text, bodyEncoding: "utf8" };
223
192
  }
224
193
  catch {
@@ -434,7 +403,12 @@ export function replayFake(opts) {
434
403
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
435
404
  }
436
405
  const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
437
- const match = defaultMatch(opts.match);
406
+ if ("match" in opts) {
407
+ throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
408
+ }
409
+ if (opts.key !== undefined && typeof opts.key !== "function") {
410
+ throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
411
+ }
438
412
  const injectRules = opts.inject ?? [];
439
413
  // Refs every header template references — the control plane resolves these
440
414
  // server-side and pushes them for the environment lifetime (see the daemon's
@@ -448,19 +422,33 @@ export function replayFake(opts) {
448
422
  return isRecording() ? "replay" : "record";
449
423
  };
450
424
  // ── REPLAY ──────────────────────────────────────────────────────────
425
+ function signature(request, location) {
426
+ try {
427
+ return replaySignature(request, opts.key);
428
+ }
429
+ catch (error) {
430
+ const message = error instanceof Error ? error.message : String(error);
431
+ throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
432
+ }
433
+ }
451
434
  function replay(reqLike, state) {
452
- const sig = signature(reqLike, match);
453
- const matches = [];
454
- for (const it of state.cassette.interactions) {
455
- if (signature(it.request, match) === sig)
456
- matches.push(it);
435
+ let sig;
436
+ try {
437
+ sig = signature(reqLike, "incoming request");
438
+ }
439
+ catch (error) {
440
+ return new Response(error.message + "\n", {
441
+ status: 599, headers: { "content-type": "text/plain" },
442
+ });
457
443
  }
444
+ const matches = state.index.get(sig) ?? [];
458
445
  const idx = state.cursor.get(sig) ?? 0;
459
- const hit = matches[idx];
446
+ const interactionIndex = matches[idx];
447
+ const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
460
448
  if (!hit) {
461
449
  const body = `replayFake(${opts.name}): no cassette interaction for ` +
462
- `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
463
- `Re-record the cassette, or relax \`match\`.\n`;
450
+ `${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
451
+ `Check \`key\` or re-record the cassette.\n`;
464
452
  return new Response(body, {
465
453
  status: 599,
466
454
  headers: { "content-type": "text/plain" },
@@ -618,7 +606,7 @@ export function replayFake(opts) {
618
606
  method: reqLike.method,
619
607
  path: reqLike.path,
620
608
  query: redactedQuery,
621
- headers: reqLike.headers,
609
+ headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
622
610
  ...(reqLike.body !== undefined
623
611
  ? {
624
612
  bodyHash: reqLike.bodyHash,
@@ -667,7 +655,7 @@ export function replayFake(opts) {
667
655
  // Strip SigV4 `x-amz-*` query params from the match key when signing —
668
656
  // presigned URLs carry a per-request signature there.
669
657
  query: normalizeQuery(url.searchParams, opts.sign !== undefined),
670
- headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
658
+ headers: recordable(req.headers, [...req.headers.keys()]),
671
659
  ...(decodedBody
672
660
  ? {
673
661
  bodyHash: sha256Bytes(bodyBytes),
@@ -690,6 +678,7 @@ export function replayFake(opts) {
690
678
  // `replay(...)` to populate it.
691
679
  state: () => ({
692
680
  cassette: emptyCassette(opts.name, host),
681
+ index: new Map(),
693
682
  cursor: new Map(),
694
683
  recorded: [],
695
684
  dirty: false,
@@ -711,22 +700,34 @@ export function replayFake(opts) {
711
700
  throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
712
701
  `(e.g. "recordings/${opts.name}.json").`);
713
702
  }
703
+ // Build/validate before replacing live state: a bad key/cassette
704
+ // must leave the previous cassette and consumption cursors intact.
705
+ const index = new Map();
706
+ for (const [i, interaction] of loaded.interactions.entries()) {
707
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
708
+ const queue = index.get(sig);
709
+ if (queue)
710
+ queue.push(i);
711
+ else
712
+ index.set(sig, [i]);
713
+ }
714
714
  // Mutate the live state object (the handler reads the same
715
715
  // reference); reset the cursor so replay starts from the top.
716
716
  state.cassette = loaded;
717
+ state.index = index;
717
718
  state.cursor = new Map();
718
719
  return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
719
720
  },
720
721
  dump() {
721
- return {
722
+ return structuredClone({
722
723
  version: 1,
723
724
  fake: opts.name,
724
725
  host,
725
726
  interactions: [...state.cassette.interactions, ...state.recorded],
726
- };
727
+ });
727
728
  },
728
729
  recorded() {
729
- return state.recorded.slice();
730
+ return structuredClone(state.recorded);
730
731
  },
731
732
  count() {
732
733
  return state.recorded.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.99.0",
3
+ "version": "0.100.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -85,7 +85,10 @@ export { okta, type OktaOptions } from "./okta.js";
85
85
  export {
86
86
  replayFake,
87
87
  type ReplayFakeOptions,
88
- type ReplayMatch,
88
+ type ReplayKey,
89
+ type ReplayRequest,
90
+ type MatchKey,
91
+ type JsonValue,
89
92
  type InjectRule,
90
93
  type InjectMatch,
91
94
  type StringMatch,
@@ -0,0 +1,127 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { CassetteRequest } from "./replayFake.js";
3
+
4
+ export type JsonValue =
5
+ | null | boolean | number | string
6
+ | readonly JsonValue[]
7
+ | { readonly [key: string]: JsonValue };
8
+
9
+ /** Identity content. Objects compare independently of property order; arrays
10
+ * preserve order. Bytes are distinct from strings, arrays, and objects. */
11
+ export type MatchKey =
12
+ | null | boolean | number | string | Uint8Array
13
+ | readonly MatchKey[]
14
+ | { readonly [key: string]: MatchKey };
15
+
16
+ /** A buffered request view shared by incoming and recorded requests.
17
+ * Headers are lowercase and exclude credentials. Recorded values may be
18
+ * redacted; choose identity fields that survive recording. */
19
+ export interface ReplayRequest {
20
+ readonly method: string;
21
+ readonly path: string;
22
+ readonly query: Readonly<Record<string, readonly string[]>>;
23
+ readonly headers: Readonly<Record<string, string>>;
24
+ /** Decode UTF-8, throwing on invalid text. */
25
+ text(): string;
26
+ /** Parse JSON. The type parameter is not runtime validation. */
27
+ json<T = JsonValue>(): T;
28
+ /** Return an independent copy of the body bytes. */
29
+ bytes(): Uint8Array;
30
+ }
31
+
32
+ export type ReplayKey = (request: ReplayRequest) => MatchKey;
33
+
34
+ /** Stream a typed, length-delimited canonical representation into the hash.
35
+ * Never call toJSON: unsupported values must fail rather than silently lose
36
+ * identity fields. In particular, an async callback must not become `{}`. */
37
+ function hashKey(key: MatchKey): string {
38
+ const hash = createHash("sha256");
39
+ const ancestors = new Set<object>();
40
+ const visit = (value: unknown): void => {
41
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
42
+ hash.update(JSON.stringify(value));
43
+ return;
44
+ }
45
+ if (typeof value === "number" && Number.isFinite(value)) {
46
+ hash.update(JSON.stringify(value));
47
+ return;
48
+ }
49
+ if (value instanceof Uint8Array) {
50
+ hash.update(`bytes:${value.byteLength}:`);
51
+ hash.update(value);
52
+ return;
53
+ }
54
+ if (value instanceof Promise) {
55
+ // Report the invalid callback synchronously without leaving a rejected
56
+ // async callback to become an unrelated unhandled-rejection failure.
57
+ void value.catch(() => {});
58
+ throw new Error("key callbacks must be synchronous, not return promises");
59
+ }
60
+ if (typeof value !== "object" || value === null) {
61
+ throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
62
+ }
63
+ if (ancestors.has(value)) throw new Error("key must not contain cycles");
64
+ if (Object.getOwnPropertySymbols(value).length > 0) {
65
+ throw new Error("key must not contain symbol properties");
66
+ }
67
+ ancestors.add(value);
68
+ if (Array.isArray(value)) {
69
+ if (Object.keys(value).length !== value.length) {
70
+ throw new Error("key arrays must be dense and have no extra properties");
71
+ }
72
+ hash.update("[");
73
+ for (let i = 0; i < value.length; i++) {
74
+ if (!Object.hasOwn(value, i)) throw new Error("key arrays must be dense");
75
+ visit(value[i]);
76
+ hash.update(",");
77
+ }
78
+ hash.update("]");
79
+ } else {
80
+ const proto = Object.getPrototypeOf(value);
81
+ if (proto !== Object.prototype && proto !== null) {
82
+ throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
83
+ }
84
+ hash.update("{");
85
+ for (const name of Object.keys(value).sort()) {
86
+ hash.update(JSON.stringify(name));
87
+ hash.update(":");
88
+ visit((value as Record<string, unknown>)[name]);
89
+ hash.update(",");
90
+ }
91
+ hash.update("}");
92
+ }
93
+ ancestors.delete(value);
94
+ };
95
+ visit(key);
96
+ return hash.digest("hex");
97
+ }
98
+
99
+ function bodyBytes(request: CassetteRequest): Uint8Array {
100
+ return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
101
+ }
102
+
103
+ function requestView(request: CassetteRequest): ReplayRequest {
104
+ let bytes: Uint8Array | undefined;
105
+ let text: string | undefined;
106
+ const readBytes = () => bytes ??= bodyBytes(request);
107
+ const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
108
+ const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
109
+ return Object.freeze({
110
+ method: request.method.toUpperCase(),
111
+ path: request.path,
112
+ query: Object.freeze(query),
113
+ headers: Object.freeze({ ...request.headers }),
114
+ text: readText,
115
+ json: <T = JsonValue>(): T => JSON.parse(readText()) as T,
116
+ bytes: () => Uint8Array.from(readBytes()),
117
+ });
118
+ }
119
+
120
+ /** Internal only; the SDK exports the request/key types, not this function. */
121
+ export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
122
+ if (key) return hashKey(key(requestView(request)));
123
+ // Existing cassettes retain the original byte hash even when their stored
124
+ // text was redacted. Older/hand-written cassettes may only have the body.
125
+ const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
126
+ return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
127
+ }
@@ -0,0 +1,114 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Isolate module mocks from other tests. Drive the actual record handler,
7
+ // replacing only DNS/HTTPS transport; no credentials or external egress.
8
+ test("recorded headers, redaction, and exact body bytes survive record → replay", async () => {
9
+ const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
10
+ const script = `
11
+ import { strict as assert } from "node:assert";
12
+ import { EventEmitter } from "node:events";
13
+ import { mock, test } from "bun:test";
14
+ import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
15
+
16
+ test("record round trip", async () => {
17
+ mock.module("node:dgram", () => ({ createSocket() {
18
+ const socket = new EventEmitter();
19
+ socket.close = () => {};
20
+ socket.send = (_query, _port, _host, done) => {
21
+ done();
22
+ queueMicrotask(() => socket.emit("message", dnsPacket.encode({
23
+ type: "response", id: 1,
24
+ answers: [{ type: "A", name: "api.example.test", ttl: 60, data: "127.0.0.1" }],
25
+ })));
26
+ };
27
+ return socket;
28
+ } }));
29
+
30
+ const upstreamHeaders = [];
31
+ mock.module("node:https", () => ({ request(options, callback) {
32
+ upstreamHeaders.push(options.headers);
33
+ const req = new EventEmitter();
34
+ req.write = () => {};
35
+ req.end = () => queueMicrotask(() => {
36
+ const res = new EventEmitter();
37
+ res.statusCode = 200;
38
+ res.headers = { "content-type": "text/plain" };
39
+ callback(res);
40
+ res.emit("data", Buffer.from("response"));
41
+ res.emit("end");
42
+ });
43
+ return req;
44
+ } }));
45
+
46
+ const { replayFake } = await import(${moduleUrl("./replayFake.ts")});
47
+ const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
48
+ setRecordSecrets({ API_KEY: "recording-secret" });
49
+ const options = {
50
+ name: "test", host: "api.example.test",
51
+ inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
52
+ redactPatterns: ["private-field"],
53
+ };
54
+ const record = replayFake({ ...options, mode: "record" });
55
+ const state = record.state();
56
+ const helpers = record.helpers({ name: "test", state, ctx: {} });
57
+ const bodies = ["\\ufeffhello", new Uint8Array([0, 128, 255]), "recording-secret private-field"];
58
+ for (const [i, body] of bodies.entries()) {
59
+ const res = await record.handler(new Request("https://api.example.test/messages", {
60
+ method: "POST", body,
61
+ headers: {
62
+ "X-Task": "task-" + i,
63
+ authorization: "Bearer dummy", cookie: "dummy-cookie",
64
+ "api-key": "dummy-api-key", "x-goog-api-key": "dummy-google-key",
65
+ "x-extra": "recording-secret private-field",
66
+ },
67
+ }), state, {});
68
+ assert.equal(res.status, 200, await res.text());
69
+ }
70
+ assert.equal(helpers.count(), 3);
71
+ assert.equal(upstreamHeaders[0].authorization, "Bearer recording-secret");
72
+ const cassette = JSON.parse(JSON.stringify(helpers.dump()));
73
+ assert.equal(cassette.interactions[0].request.body, "\\ufeffhello");
74
+ assert.equal(cassette.interactions[1].request.bodyEncoding, "base64");
75
+ assert.equal(cassette.interactions[2].request.body, "<REDACTED:API_KEY> <REDACTED>");
76
+ for (const [i, it] of cassette.interactions.entries()) {
77
+ assert.equal(it.request.headers["x-task"], "task-" + i);
78
+ assert.equal(it.request.headers["x-extra"], "<REDACTED:API_KEY> <REDACTED>");
79
+ for (const name of ["authorization", "cookie", "api-key", "x-goog-api-key"]) {
80
+ assert.equal(it.request.headers[name], undefined);
81
+ }
82
+ }
83
+ assert.equal(JSON.stringify(cassette).includes("recording-secret"), false);
84
+
85
+ for (const key of [undefined, r => r.headers["x-task"]]) {
86
+ const replay = replayFake({ ...options, mode: "replay", key });
87
+ const replayState = replay.state();
88
+ replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
89
+ for (const i of [2, 0, 1]) {
90
+ const res = await replay.handler(new Request("https://api.example.test/messages", {
91
+ method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
92
+ }), replayState, {});
93
+ assert.equal(res.status, 200);
94
+ assert.equal(await res.text(), "response");
95
+ }
96
+ }
97
+ assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
98
+ });
99
+ `;
100
+ const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
101
+ try {
102
+ const file = join(dir, "record.test.ts");
103
+ await writeFile(file, script);
104
+ const child = Bun.spawn([process.execPath, "test", file], {
105
+ stdout: "pipe", stderr: "pipe",
106
+ });
107
+ const [exit, stdout, stderr] = await Promise.all([
108
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
109
+ ]);
110
+ expect(exit, stdout + stderr).toBe(0);
111
+ } finally {
112
+ await rm(dir, { recursive: true, force: true });
113
+ }
114
+ }, 10_000);
@@ -0,0 +1,254 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import type { FakeContext } from "../index.js";
4
+ import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOptions, type ReplayRequest, type ReplayKey, type MatchKey } from "./index.js";
5
+
6
+ const context = {} as FakeContext;
7
+ const host = "api.example.test";
8
+ const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
9
+
10
+ function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
11
+ return {
12
+ request: {
13
+ method: "POST", path, query: {}, headers: { "content-type": "application/json" },
14
+ body: typeof body === "string" ? body : Buffer.from(body).toString("base64"),
15
+ bodyEncoding: typeof body === "string" ? "utf8" : "base64",
16
+ bodyHash: sha(body),
17
+ },
18
+ response: { status: 200, headers: {}, body: answer, bodyEncoding: "utf8" },
19
+ };
20
+ }
21
+
22
+ function cassette(interactions: CassetteInteraction[]): Cassette {
23
+ return { version: 1, fake: "test", host, interactions };
24
+ }
25
+
26
+ async function harness(options: Partial<ReplayFakeOptions> = {}) {
27
+ const fake = replayFake({ name: "test", host, mode: "replay", ...options });
28
+ const state = await fake.state!();
29
+ const helpers = await fake.helpers!({ name: "test", state, ctx: context });
30
+ return {
31
+ state, helpers,
32
+ async send(body: string | Uint8Array = "", path = "/messages", init: RequestInit = {}) {
33
+ return await fake.handler(new Request(`https://${host}${path}`, {
34
+ method: "POST", body: body as BodyInit, ...init,
35
+ }), state, context);
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("replay request identity", () => {
41
+ test("default matches 654 concurrent requests by body, independently of arrival order", async () => {
42
+ const h = await harness();
43
+ const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
44
+ h.helpers.replay(cassette(calls));
45
+ // A permutation visits every identity once in a different order.
46
+ const results = await Promise.all(calls.map(async (_, i) => {
47
+ const id = (i * 37) % calls.length;
48
+ const response = await h.send(JSON.stringify({ id }));
49
+ expect(response.status).toBe(200);
50
+ expect(await response.text()).toBe(`answer-${id}`);
51
+ }));
52
+ expect(results).toHaveLength(654);
53
+ expect(h.state.cursor.size).toBe(654);
54
+ });
55
+
56
+ test("default uses method/path/normalized query/exact bytes, including old cassettes without hashes", async () => {
57
+ const h = await harness();
58
+ const it = interaction('{"a":1}', "ok");
59
+ delete it.request.bodyHash;
60
+ delete it.request.bodyEncoding;
61
+ it.request.query = { b: ["1", "2"], a: ["x"] };
62
+ h.helpers.replay(cassette([it]));
63
+ for (const [body, path, init] of [
64
+ ['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
65
+ ['{"a":1}', "/other?a=x&b=2&b=1", {}],
66
+ ['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
67
+ ['{"a":1}', "/messages?a=y&b=2&b=1", {}],
68
+ ] as const) expect((await h.send(body, path, init)).status).toBe(599);
69
+ expect(await (await h.send('{"a":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
70
+ });
71
+
72
+ test("the existing committed HTTP cassette still replays with the new default", async () => {
73
+ const h = await harness();
74
+ const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
75
+ h.helpers.replay(existing);
76
+ const response = await h.send("", "/get?hello=world", { method: "GET", body: null });
77
+ expect(response.status).toBe(200);
78
+ expect((await response.json()).args).toEqual({ hello: ["world"] });
79
+ });
80
+
81
+ test("SigV4 query credentials stay outside default and custom identity", async () => {
82
+ for (const key of [undefined, (r: ReplayRequest) => ({ path: r.path, query: r.query })]) {
83
+ const h = await harness({ key, sign: { type: "awsSigv4", accessKeyId: "ACCESS", secretAccessKey: "SECRET" } });
84
+ h.helpers.replay(cassette([interaction("body", "ok")]));
85
+ expect(await (await h.send("body", "/messages?X-Amz-Signature=changing&X-Amz-Date=20261001")).text()).toBe("ok");
86
+ }
87
+ });
88
+
89
+ test("Dentio keys separate model/tasks while ignoring a changing date", async () => {
90
+ let evaluations = 0;
91
+ const h = await harness({ key(request) {
92
+ evaluations++;
93
+ const body = request.json<{ system: { text: string }[] }>();
94
+ return { model: request.path, task: body.system[0].text.slice(0, 200) };
95
+ } });
96
+ const calls = Array.from({ length: 8 }, (_, i) => ({
97
+ task: `task-${i}`.padEnd(200, "."),
98
+ path: `/model/${i < 4 ? "haiku" : "sonnet"}/converse`,
99
+ }));
100
+ const body = (task: string, date: string) => JSON.stringify({ system: [{ text: `${task} Today: ${date}` }] });
101
+ h.helpers.replay(cassette(calls.map((call, i) => interaction(body(call.task, "2026-10-01"), i === 4 ? "Karies 16o" : `task-${i}`, call.path))));
102
+ expect(evaluations).toBe(8);
103
+ for (const i of [7, 2, 4, 0, 6, 1, 5, 3]) {
104
+ expect(await (await h.send(body(calls[i].task, "2026-10-02"), calls[i].path)).text()).toBe(i === 4 ? "Karies 16o" : `task-${i}`);
105
+ }
106
+ expect(evaluations).toBe(16); // No rescanning/recomputing cassette keys.
107
+ });
108
+
109
+ test("custom key is the entire identity, with no implicit HTTP dimensions", async () => {
110
+ const h = await harness({ key: request => request.json<{ task: string }>().task });
111
+ h.helpers.replay(cassette([interaction('{"task":"same","date":"yesterday"}', "ok", "/old")]));
112
+ expect(await (await h.send('{"task":"same","date":"today"}', "/new?nonce=123", { method: "PUT" })).text()).toBe("ok");
113
+ });
114
+
115
+ test("JSON objects compare structurally but arrays preserve order", async () => {
116
+ const h = await harness({ key: request => request.json() });
117
+ h.helpers.replay(cassette([interaction('{"b":2,"a":{"x":[1,2]}}', "ok")]));
118
+ expect((await h.send('{"a":{"x":[2,1]},"b":2}')).status).toBe(599);
119
+ expect(await (await h.send('{ "a": {"x":[1,2]}, "b": 2 }')).text()).toBe("ok");
120
+ });
121
+
122
+ test("binary keys are lossless and distinct from JSON lookalikes", async () => {
123
+ const h = await harness({ key: request => ({ body: request.bytes() }) });
124
+ const bytes = new Uint8Array([0, 255, 128]);
125
+ h.helpers.replay(cassette([interaction(bytes, "binary")]));
126
+ expect((await h.send("[0,255,128]")).status).toBe(599);
127
+ expect(await (await h.send(bytes)).text()).toBe("binary");
128
+
129
+ const mixed = await harness({ key: r => r.path === "/binary" ? new Uint8Array([1, 2]) : r.json() });
130
+ mixed.helpers.replay(cassette([interaction("", "bytes", "/binary"), interaction("[1,2]", "array"), interaction('{"0":1,"1":2}', "object")]));
131
+ expect(await (await mixed.send("[1,2]")).text()).toBe("array");
132
+ expect(await (await mixed.send('{"0":1,"1":2}')).text()).toBe("object");
133
+ expect(await (await mixed.send("", "/binary")).text()).toBe("bytes");
134
+ });
135
+
136
+ test("empty and BOM-prefixed bodies preserve their bytes", async () => {
137
+ for (const key of [undefined, (r: ReplayRequest) => r.bytes()]) {
138
+ const h = await harness({ key });
139
+ h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
140
+ expect(await (await h.send("")).text()).toBe("empty");
141
+ expect((await h.send("hello")).status).toBe(599);
142
+ expect(await (await h.send("\ufeffhello")).text()).toBe("bom");
143
+ }
144
+ });
145
+
146
+ test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
147
+ const h = await harness();
148
+ const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
149
+ h.helpers.replay(c);
150
+ expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
151
+ const exhausted = await h.send("same");
152
+ expect(exhausted.status).toBe(599);
153
+ expect(await exhausted.text()).toContain("exhausted key");
154
+ expect(await (await h.send("missing")).text()).toContain("unknown key");
155
+ expect(await (await h.send("other")).text()).toBe("other");
156
+ h.helpers.replay(c);
157
+ expect(await (await h.send("same")).text()).toBe("first");
158
+ });
159
+
160
+ test("cassette keys and cursors are isolated between fake states", async () => {
161
+ const a = await harness(), b = await harness();
162
+ const c = cassette([interaction("same", "ok")]);
163
+ a.helpers.replay(c); b.helpers.replay(c);
164
+ expect(await (await a.send("same")).text()).toBe("ok");
165
+ expect((await a.send("same")).status).toBe(599);
166
+ expect(await (await b.send("same")).text()).toBe("ok");
167
+ // Inline input cannot mutate a loaded/indexed cassette.
168
+ c.interactions[0].response.body = "mutated";
169
+ expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
170
+ a.helpers.dump().interactions[0].response.body = "mutated dump";
171
+ expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
172
+ });
173
+
174
+ test("request accessors are synchronous, reusable, and omit internal hashes/credentials", async () => {
175
+ const h = await harness({ key(r) {
176
+ expect("bodyHash" in r).toBe(false);
177
+ expect(r.headers.authorization).toBeUndefined();
178
+ expect(r.headers.cookie).toBeUndefined();
179
+ expect(Object.isFrozen(r)).toBe(true);
180
+ expect(Object.isFrozen(r.query)).toBe(true);
181
+ const bytes = r.bytes(); bytes.fill(0);
182
+ expect(r.text()).toBe('{"a":1}');
183
+ expect(r.json<{ a: number }>()).toEqual({ a: 1 });
184
+ return r.headers["x-task"]!;
185
+ } });
186
+ const it = interaction('{"a":1}', "ok");
187
+ it.request.headers["x-task"] = "journal";
188
+ h.helpers.replay(cassette([it]));
189
+ expect(await (await h.send('{"a":1}', "/messages", { headers: { "X-Task": "journal", authorization: "secret", cookie: "secret" } })).text()).toBe("ok");
190
+ });
191
+
192
+ test("bad incoming JSON fails without consuming a response; bad loads preserve live state", async () => {
193
+ const h = await harness({ key: r => r.json() });
194
+ h.helpers.replay(cassette([interaction("{}", "ok")]));
195
+ expect(() => h.helpers.replay(cassette([interaction("invalid", "bad")]))).toThrow("interaction #1");
196
+ const bad = await h.send("invalid");
197
+ expect(bad.status).toBe(599);
198
+ expect(await bad.text()).toContain("invalid replay key");
199
+ expect(await (await h.send("{}")).text()).toBe("ok");
200
+ });
201
+
202
+ test.each([
203
+ undefined, NaN, Infinity, BigInt(1), new Date(), new Map(), Promise.resolve("x"),
204
+ { missing: undefined }, [undefined], Array(1), () => "x",
205
+ ])("rejects unsupported keys instead of silently collapsing identities: %p", async value => {
206
+ const h = await harness({ key: () => value as MatchKey });
207
+ expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("invalid replay key");
208
+ });
209
+
210
+ test("rejects cyclic keys but permits shared subobjects", async () => {
211
+ const value: Record<string, MatchKey> = {};
212
+ value.self = value;
213
+ const bad = await harness({ key: () => value });
214
+ expect(() => bad.helpers.replay(cassette([interaction("", "bad")]))).toThrow("cycles");
215
+ const shared = { a: 1 };
216
+ const good = await harness({ key: () => [shared, shared] });
217
+ good.helpers.replay(cassette([interaction("", "ok")]));
218
+ expect(await (await good.send()).text()).toBe("ok");
219
+ });
220
+
221
+ test("rejects an async callback that throws without leaking an unhandled rejection", async () => {
222
+ const h = await harness({ key: (async () => { throw new Error("async failure"); }) as unknown as ReplayFakeOptions["key"] });
223
+ expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("synchronous");
224
+ await Promise.resolve();
225
+ });
226
+
227
+ test("old match options fail with migration guidance", () => {
228
+ expect(() => replayFake({ name: "test", host, match: { body: "hash" } } as unknown as ReplayFakeOptions)).toThrow("has been replaced by `key(request)`");
229
+ });
230
+ });
231
+
232
+ // Compile-time API checks, verified by the targeted TypeScript check as well
233
+ // as the SDK build. Never invoked: invalid callbacks must not run.
234
+ function publicTypes(request: ReplayRequest): void {
235
+ const json: ReplayKey = r => r.json();
236
+ const bytes: ReplayKey = r => ({ body: r.bytes(), query: r.query });
237
+ void [json, bytes];
238
+ // @ts-expect-error bodyHash is internal, not part of the callback view.
239
+ request.bodyHash;
240
+ // @ts-expect-error Request metadata is read-only.
241
+ request.method = "POST";
242
+ // @ts-expect-error Query values are read-only.
243
+ request.query.id.push("another");
244
+ // @ts-expect-error Callbacks must be synchronous.
245
+ const asyncKey: ReplayKey = async r => r.text();
246
+ // @ts-expect-error Undefined is not an identity.
247
+ const absentKey: ReplayKey = () => undefined;
248
+ void [asyncKey, absentKey];
249
+ replayFake({ name: "test", host,
250
+ // @ts-expect-error Per-dimension matching switches have been removed.
251
+ match: { body: "hash" },
252
+ });
253
+ }
254
+ void publicTypes;
@@ -65,6 +65,8 @@ import * as dnsPacket from "dns-packet";
65
65
  import type { FakeContext, FakeDefinition } from "../index.js";
66
66
  import { isRecording } from "../recorder.js";
67
67
  import { getRecordSecret } from "../record-secrets.js";
68
+ import { replaySignature, type ReplayKey } from "./replay-key.js";
69
+ export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
68
70
  import {
69
71
  inferSigV4Scope,
70
72
  signAwsV4Request,
@@ -75,21 +77,6 @@ import {
75
77
  // Options + cassette format
76
78
  // ────────────────────────────────────────────────────────────────────────
77
79
 
78
- /** Which parts of a request define a match against the cassette. */
79
- export interface ReplayMatch {
80
- /** Match on HTTP method. Default `true`. */
81
- method?: boolean;
82
- /** Match on path (no query). Default `true`. */
83
- path?: boolean;
84
- /** Match on normalized query string. Default `true`. */
85
- query?: boolean;
86
- /** Match on body: `true` = exact decoded body, `"hash"` = sha256 only.
87
- * Default off. */
88
- body?: boolean | "hash";
89
- /** Header names (lowercase) to include in the match. Default none. */
90
- headers?: string[];
91
- }
92
-
93
80
  /** Match one request dimension. A bare string is an exact match; the
94
81
  * object forms cover prefix and regex matching. */
95
82
  export type StringMatch =
@@ -155,8 +142,12 @@ export interface ReplayFakeOptions {
155
142
  * resolved via an external DNS server — see the module header). Your
156
143
  * app points at this real host directly; no separate stand-in. */
157
144
  host: string;
158
- /** What defines a request match. Default `{ method, path, query }`. */
159
- match?: ReplayMatch;
145
+ /** Complete request identity. Default: method, path, normalized query,
146
+ * and exact body bytes. The same synchronous, deterministic callback runs
147
+ * on recorded and incoming requests. Return JSON values and/or Uint8Array
148
+ * bytes; objects compare independently of property order. No implicit
149
+ * matching dimensions or fallback apply when a key is supplied. */
150
+ key?: ReplayKey;
160
151
  /** Per-fake credential brokering. Rules are evaluated in
161
152
  * order; the first whose `match` matches wins (a rule without `match`
162
153
  * matches everything and shadows later rules). Applied at the
@@ -182,7 +173,8 @@ export interface CassetteRequest {
182
173
  path: string;
183
174
  /** Normalized: keys + repeated values sorted, so matching is order-free. */
184
175
  query: Record<string, string[]>;
185
- /** Matched/readable header subset only — never `authorization`/`cookie`. */
176
+ /** Captured non-sensitive headers — never `authorization`/`cookie`.
177
+ * Older cassettes may contain only content-type and selected headers. */
186
178
  headers: Record<string, string>;
187
179
  bodyHash?: string;
188
180
  /** Request body (omitted when empty): decoded + redacted UTF-8 text, or
@@ -216,6 +208,8 @@ export interface Cassette {
216
208
  /** Forked-per-test state for one replay fake. */
217
209
  export interface CassetteState {
218
210
  cassette: Cassette;
211
+ /** Complete signature → cassette interaction indexes, in cassette order. */
212
+ index: Map<string, number[]>;
219
213
  /** Per-match-signature replay cursor (call-order sequencing). */
220
214
  cursor: Map<string, number>;
221
215
  /** Interactions captured this record session. */
@@ -263,7 +257,9 @@ const SENSITIVE_HEADERS = new Set([
263
257
  "cookie",
264
258
  "set-cookie",
265
259
  "proxy-authorization",
260
+ "api-key",
266
261
  "x-api-key",
262
+ "x-goog-api-key",
267
263
  "x-amz-security-token",
268
264
  ]);
269
265
 
@@ -292,17 +288,6 @@ const STRIP_FORWARD_HEADERS = new Set([
292
288
  const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
293
289
  const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
294
290
 
295
- function defaultMatch(m: ReplayMatch | undefined): Required<Omit<ReplayMatch, "body" | "headers">> &
296
- Pick<ReplayMatch, "body" | "headers"> {
297
- return {
298
- method: m?.method ?? true,
299
- path: m?.path ?? true,
300
- query: m?.query ?? true,
301
- body: m?.body,
302
- headers: m?.headers,
303
- };
304
- }
305
-
306
291
  /** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
307
292
  * in the query for presigned URLs — they change every call and would poison
308
293
  * query matching, so they're dropped from both the match key and the forward
@@ -341,27 +326,6 @@ function recordable(headers: Headers, want: string[]): Record<string, string> {
341
326
  return out;
342
327
  }
343
328
 
344
- /** Canonical match signature for an interaction's request, used as both
345
- * the replay-cursor key and the equality basis (two requests match iff
346
- * their signatures are equal). Computed identically for an incoming
347
- * request and a stored `CassetteRequest`. */
348
- function signature(req: CassetteRequest, match: ReturnType<typeof defaultMatch>): string {
349
- const sig: Record<string, unknown> = {};
350
- if (match.method) sig.method = req.method.toUpperCase();
351
- if (match.path) sig.path = req.path;
352
- if (match.query) sig.query = req.query;
353
- if (match.body === true) sig.body = req.body ?? "";
354
- else if (match.body === "hash") sig.bodyHash = req.bodyHash ?? "";
355
- if (match.headers && match.headers.length > 0) {
356
- const h: Record<string, string> = {};
357
- for (const name of match.headers.map((n) => n.toLowerCase()).sort()) {
358
- h[name] = req.headers[name] ?? "";
359
- }
360
- sig.headers = h;
361
- }
362
- return JSON.stringify(sig);
363
- }
364
-
365
329
  /** The project's `spectest/tests/` dir — cassettes live alongside the
366
330
  * tests, and a `replay(path)` argument is a path relative to here. Keeping
367
331
  * them under `tests/` means re-recording is picked up on the next run with
@@ -386,7 +350,7 @@ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette
386
350
  version: 1,
387
351
  fake: parsed.fake ?? name,
388
352
  host: parsed.host ?? host,
389
- interactions: parsed.interactions,
353
+ interactions: structuredClone(parsed.interactions),
390
354
  };
391
355
  }
392
356
 
@@ -421,7 +385,7 @@ function readCassetteFile(name: string, file: string, host: string): Cassette {
421
385
  /** Decode response bytes to a string, falling back to base64 for binary. */
422
386
  function decodeBody(bytes: Uint8Array): { body: string; bodyEncoding: "utf8" | "base64" } {
423
387
  try {
424
- const text = new TextDecoder("utf8", { fatal: true }).decode(bytes);
388
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
425
389
  return { body: text, bodyEncoding: "utf8" };
426
390
  } catch {
427
391
  return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
@@ -671,7 +635,12 @@ export function replayFake(
671
635
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
672
636
  }
673
637
  const host = opts.host.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
674
- const match = defaultMatch(opts.match);
638
+ if ("match" in opts) {
639
+ throw new Error(`replayFake(${opts.name}): \`match\` has been replaced by \`key(request)\`, which defines the complete request identity`);
640
+ }
641
+ if (opts.key !== undefined && typeof opts.key !== "function") {
642
+ throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
643
+ }
675
644
  const injectRules = opts.inject ?? [];
676
645
  // Refs every header template references — the control plane resolves these
677
646
  // server-side and pushes them for the environment lifetime (see the daemon's
@@ -685,19 +654,33 @@ export function replayFake(
685
654
  };
686
655
 
687
656
  // ── REPLAY ──────────────────────────────────────────────────────────
657
+ function signature(request: CassetteRequest, location: string): string {
658
+ try {
659
+ return replaySignature(request, opts.key);
660
+ } catch (error) {
661
+ const message = error instanceof Error ? error.message : String(error);
662
+ throw new Error(`replayFake(${opts.name}): invalid replay key for ${location}: ${message}`);
663
+ }
664
+ }
665
+
688
666
  function replay(reqLike: CassetteRequest, state: CassetteState): Response {
689
- const sig = signature(reqLike, match);
690
- const matches: CassetteInteraction[] = [];
691
- for (const it of state.cassette.interactions) {
692
- if (signature(it.request, match) === sig) matches.push(it);
667
+ let sig: string;
668
+ try {
669
+ sig = signature(reqLike, "incoming request");
670
+ } catch (error) {
671
+ return new Response((error as Error).message + "\n", {
672
+ status: 599, headers: { "content-type": "text/plain" },
673
+ });
693
674
  }
675
+ const matches = state.index.get(sig) ?? [];
694
676
  const idx = state.cursor.get(sig) ?? 0;
695
- const hit = matches[idx];
677
+ const interactionIndex = matches[idx];
678
+ const hit = interactionIndex === undefined ? undefined : state.cassette.interactions[interactionIndex];
696
679
  if (!hit) {
697
680
  const body =
698
681
  `replayFake(${opts.name}): no cassette interaction for ` +
699
- `${reqLike.method} ${reqLike.path} (matched ${matches.length}, wanted #${idx + 1}). ` +
700
- `Re-record the cassette, or relax \`match\`.\n`;
682
+ `${reqLike.method} ${reqLike.path} (${matches.length === 0 ? "unknown key" : "exhausted key"}; matched ${matches.length}, wanted #${idx + 1}). ` +
683
+ `Check \`key\` or re-record the cassette.\n`;
701
684
  return new Response(body, {
702
685
  status: 599,
703
686
  headers: { "content-type": "text/plain" },
@@ -883,7 +866,7 @@ export function replayFake(
883
866
  method: reqLike.method,
884
867
  path: reqLike.path,
885
868
  query: redactedQuery,
886
- headers: reqLike.headers,
869
+ headers: Object.fromEntries(Object.entries(reqLike.headers).map(([k, v]) => [k, redact(v)])),
887
870
  ...(reqLike.body !== undefined
888
871
  ? {
889
872
  bodyHash: reqLike.bodyHash,
@@ -943,7 +926,7 @@ export function replayFake(
943
926
  // Strip SigV4 `x-amz-*` query params from the match key when signing —
944
927
  // presigned URLs carry a per-request signature there.
945
928
  query: normalizeQuery(url.searchParams, opts.sign !== undefined),
946
- headers: recordable(req.headers, ["content-type", ...(match.headers ?? [])]),
929
+ headers: recordable(req.headers, [...req.headers.keys()]),
947
930
  ...(decodedBody
948
931
  ? {
949
932
  bodyHash: sha256Bytes(bodyBytes),
@@ -967,6 +950,7 @@ export function replayFake(
967
950
  // `replay(...)` to populate it.
968
951
  state: (): CassetteState => ({
969
952
  cassette: emptyCassette(opts.name, host),
953
+ index: new Map(),
970
954
  cursor: new Map(),
971
955
  recorded: [],
972
956
  dirty: false,
@@ -988,22 +972,32 @@ export function replayFake(
988
972
  `(e.g. "recordings/${opts.name}.json").`,
989
973
  );
990
974
  }
975
+ // Build/validate before replacing live state: a bad key/cassette
976
+ // must leave the previous cassette and consumption cursors intact.
977
+ const index = new Map<string, number[]>();
978
+ for (const [i, interaction] of loaded.interactions.entries()) {
979
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
980
+ const queue = index.get(sig);
981
+ if (queue) queue.push(i);
982
+ else index.set(sig, [i]);
983
+ }
991
984
  // Mutate the live state object (the handler reads the same
992
985
  // reference); reset the cursor so replay starts from the top.
993
986
  state.cassette = loaded;
987
+ state.index = index;
994
988
  state.cursor = new Map();
995
989
  return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
996
990
  },
997
991
  dump(): Cassette {
998
- return {
992
+ return structuredClone({
999
993
  version: 1,
1000
994
  fake: opts.name,
1001
995
  host,
1002
996
  interactions: [...state.cassette.interactions, ...state.recorded],
1003
- };
997
+ });
1004
998
  },
1005
999
  recorded(): CassetteInteraction[] {
1006
- return state.recorded.slice();
1000
+ return structuredClone(state.recorded);
1007
1001
  },
1008
1002
  count(): number {
1009
1003
  return state.recorded.length;