@specific.dev/spectest 0.98.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.
package/README.md CHANGED
@@ -128,3 +128,35 @@ routes each stream by `:authority`, and preserves streaming bodies, metadata,
128
128
  trailers, and cancellation. HTTP/1.1 requests still use the existing HTTP proxy.
129
129
  Runtime services support the same option. HTTP/2 streams currently bypass
130
130
  `ctx.intercept` and HTTP request recording.
131
+
132
+ ### CORS at ingress
133
+
134
+ Fake and service ingress default to `cors: "passthrough"`: OPTIONS requests
135
+ reach the handler or upstream through `ctx.intercept`, and response CORS headers
136
+ are preserved, including their absence on rejections or responses with no CORS
137
+ policy. Allowed credentialed requests depend on the application's own headers.
138
+
139
+ Automatic CORS is opt-in per fake or service hostname:
140
+
141
+ ```ts
142
+ defineFake({ name: "api", hostnames: ["api.test"], cors: "permissive", handler });
143
+ // On a boot or runtime service:
144
+ tls: [{ hostname: "api.test", port: 3000, cors: "permissive" }]
145
+ // On a Supabase gateway (including responses from interceptors):
146
+ supabase({ hostname: "supabase.example.com", cors: "permissive" });
147
+ // On a low-level component proxy:
148
+ proxy("api.test", { service: "api", port: 3000, cors: "permissive" });
149
+ ```
150
+
151
+ Permissive HTTP/1 ingress answers CORS preflights before interceptors and adds
152
+ reflected allow-origin plus allow-credentials to responses without allow-origin,
153
+ including errors. Ordinary OPTIONS still reaches the handler. Use passthrough
154
+ when testing application CORS policies. HTTP/2 streams always pass CORS through.
155
+
156
+ Prefer the application's production hostnames for the frontend and API inside
157
+ the environment: Spectest resolves declared names to its test services and
158
+ provides certificates trusted inside the environment. This preserves existing
159
+ origin allowlists and cookie relationships. Different subdomains are still
160
+ different origins; a synthetic response must still supply the intended CORS
161
+ headers. Permissive mode is a convenience override and should not be used to
162
+ verify application CORS restrictions.
@@ -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;
@@ -1,4 +1,4 @@
1
- import { type ServiceDefinition, type ServiceGroup, type ServicesMap } from "../index.js";
1
+ import { type CorsMode, type ServiceDefinition, type ServiceGroup, type ServicesMap } from "../index.js";
2
2
  import { type SqlClient } from "../sql.js";
3
3
  import { type EmailHelpers } from "./email.js";
4
4
  export type { MailTemplateId, MailTemplateSpec } from "./supabase-config.js";
@@ -63,6 +63,10 @@ export interface SupabaseOptions {
63
63
  * test hardcodes a Supabase URL it cannot be told to override.
64
64
  */
65
65
  hostname?: string;
66
+ /** CORS handling at the daemon ingress for `hostname`. Defaults to
67
+ * "passthrough", preserving gateway/interceptor policy. "permissive"
68
+ * restores automatic preflight replies and credentialed CORS headers. */
69
+ cors?: CorsMode;
66
70
  /**
67
71
  * Capture GoTrue's outgoing mail (signup confirmations, magic links, OTPs,
68
72
  * password recovery, invites) with the standard `email()` component. **On
@@ -55,6 +55,7 @@ import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
55
55
  import { readdir, stat } from "node:fs/promises";
56
56
  import { dirname, join } from "node:path";
57
57
  import { serviceGroup, } from "../index.js";
58
+ import { validateCorsMode } from "../ingress.js";
58
59
  import { SQL } from "../sql.js";
59
60
  import { apple } from "./apple.js";
60
61
  import { email, emailHelpers } from "./email.js";
@@ -1211,6 +1212,7 @@ db) {
1211
1212
  * for hitting the REST/auth/storage APIs through the gateway.
1212
1213
  */
1213
1214
  export function supabase(opts = {}) {
1215
+ validateCorsMode(opts.cors);
1214
1216
  const name = opts.name ?? "supabase";
1215
1217
  const dbPassword = opts.dbPassword ?? "postgres";
1216
1218
  const jwtSecret = opts.jwtSecret ?? DEFAULT_JWT_SECRET;
@@ -1866,7 +1868,7 @@ export function supabase(opts = {}) {
1866
1868
  },
1867
1869
  ],
1868
1870
  ports: [8000, 8443],
1869
- ...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000 }] } : {}),
1871
+ ...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000, ...(opts.cors ? { cors: opts.cors } : {}) }] } : {}),
1870
1872
  readyCheck: { type: "exec", command: "kong health", timeoutSecs: 90 },
1871
1873
  };
1872
1874
  return parts;
package/dist/daemon.js CHANGED
@@ -56,7 +56,7 @@ import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
56
56
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
57
57
  import { encodeRegistry } from "./harness/names-registry.js";
58
58
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
59
- import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, upstreamRequestHeaders, } from "./harness/http-proxy.js";
59
+ import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
60
60
  import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
61
61
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
62
62
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
@@ -1707,7 +1707,7 @@ async function startIngress() {
1707
1707
  if (LOWERED.proxies.length > 0) {
1708
1708
  const routes = ensurePort(INGRESS_HTTP_PORT);
1709
1709
  for (const p of LOWERED.proxies) {
1710
- routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
1710
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}), ...(p.cors ? { cors: p.cors } : {}) });
1711
1711
  }
1712
1712
  }
1713
1713
  // The :443 route table mirrors every handler whose hostname a cert
@@ -1724,7 +1724,7 @@ async function startIngress() {
1724
1724
  }
1725
1725
  for (const p of LOWERED.proxies) {
1726
1726
  if (certCovers(p.hostname)) {
1727
- httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
1727
+ httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}), ...(p.cors ? { cors: p.cors } : {}) });
1728
1728
  }
1729
1729
  }
1730
1730
  }
@@ -1876,16 +1876,16 @@ function certCovers(hostname) {
1876
1876
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1877
1877
  * it, siblings forked from an earlier snapshot never see it.
1878
1878
  */
1879
- async function bindRuntimeTls(hostname, service, port, http2) {
1879
+ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1880
1880
  // Reuse the boot primitives for validation + lowercasing; throws on a
1881
1881
  // malformed hostname / upstream just like a boot `tls` would at load.
1882
- const decl = makeProxyDecl(hostname, { service, port, http2 });
1882
+ const decl = makeProxyDecl(hostname, { service, port, http2, cors });
1883
1883
  const host = decl.hostname;
1884
1884
  if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1885
1885
  throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
1886
1886
  }
1887
1887
  const Bun = requireBun();
1888
- const route = { kind: "proxy", service, port, http2 };
1888
+ const route = { kind: "proxy", service, port, http2, cors };
1889
1889
  // Decide the cost before mutating anything: an already-covered hostname
1890
1890
  // (an exact duplicate, or one under a boot wildcard) needs only a route.
1891
1891
  const plan = planBind(INGRESS, host, {
@@ -2030,12 +2030,6 @@ server, byHost, listenerLabel, proto) {
2030
2030
  if (!route) {
2031
2031
  return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
2032
2032
  }
2033
- // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
2034
- // cross-origin browser request carrying any header — Authorization,
2035
- // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
2036
- // to list in Access-Control-Allow-Headers.
2037
- if (isCorsPreflight(req))
2038
- return corsPreflightResponse(req);
2039
2033
  // The real answer — a fake handled in-process, or the proxied container.
2040
2034
  const upstream = async (current) => {
2041
2035
  if (route.kind === "fake") {
@@ -2049,15 +2043,15 @@ server, byHost, listenerLabel, proto) {
2049
2043
  }
2050
2044
  return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2051
2045
  };
2052
- // `ctx.intercept` middleware runs first, in registration order, and reaches
2053
- // the upstream through `next()`. The CORS headers go on *after* the chain,
2054
- // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2055
- // the trap `page.route`-style interception falls into.
2056
- const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2057
- const res = chain.length === 0
2058
- ? await upstream(req)
2059
- : await runChain(chain, req, upstream, recordInterceptedRequest);
2060
- return augmentCorsResponse(req, res);
2046
+ // Passthrough (the default) lets middleware and the upstream handle every
2047
+ // request and preserves their CORS policy, including absent headers.
2048
+ const cors = route.kind === "fake" ? route.fake.def.cors : route.cors;
2049
+ return withIngressCors(req, cors, async (current) => {
2050
+ const chain = INTERCEPTORS.chainFor(host, new URL(current.url).pathname);
2051
+ return chain.length === 0
2052
+ ? upstream(current)
2053
+ : runChain(chain, current, upstream, recordInterceptedRequest);
2054
+ });
2061
2055
  }
2062
2056
  /**
2063
2057
  * The address to report as the client's in `X-Forwarded-For`.
@@ -2619,7 +2613,7 @@ async function startRuntimeService(spec) {
2619
2613
  // `tls` is the runtime twin of a boot service's: stand up a
2620
2614
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2621
2615
  for (const entry of svc.tls ?? []) {
2622
- await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
2616
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
2623
2617
  }
2624
2618
  RUNTIME_SERVICES.set(svc.name, svc);
2625
2619
  recordEnv({
@@ -1,3 +1,4 @@
1
+ import type { CorsMode } from "../ingress.js";
1
2
  /**
2
3
  * HTTP semantics the ingress proxy has to get right: which headers must
3
4
  * not be forwarded, and how CORS is answered.
@@ -37,9 +38,8 @@ export declare function isCorsPreflight(req: {
37
38
  };
38
39
  }): boolean;
39
40
  /**
40
- * Answer a preflight permissively — the environment is hermetic, so
41
- * there is nothing here to protect from itself, and a test failing on
42
- * CORS teaches nothing about the app.
41
+ * Answer a preflight for explicitly permissive ingress only. Passthrough
42
+ * must let the application or interceptor decide, including rejecting it.
43
43
  *
44
44
  * The one rule that is not a matter of taste: the **specific** origin is
45
45
  * echoed rather than `*`. `Allow-Origin: *` together with
@@ -53,15 +53,15 @@ export declare function corsPreflightResponse(req: {
53
53
  };
54
54
  }): Response;
55
55
  /**
56
- * Add CORS headers to a proxied response, without overriding the
57
- * upstream's own.
58
- *
59
- * An app that already sets `Access-Control-Allow-Origin` has an opinion —
60
- * possibly a deliberately restrictive one that a test is checking — and
61
- * replacing it would make the environment lie about the app's behaviour.
56
+ * Add CORS headers only when permissive ingress was explicitly requested.
57
+ * An absent allow-origin can be a deliberate rejection; callers must never
58
+ * use this augmentation in passthrough mode. Existing allow-origin is kept.
62
59
  */
63
60
  export declare function augmentCorsResponse(req: {
64
61
  headers: {
65
62
  get(n: string): string | null;
66
63
  };
67
64
  }, res: Response): Response;
65
+ /** Apply the same policy around the entire fake/proxy + interceptor chain.
66
+ * Passthrough forwards even preflights and preserves the response unchanged. */
67
+ export declare function withIngressCors(req: Request, cors: CorsMode | undefined, handle: (req: Request) => Response | Promise<Response>): Promise<Response>;
@@ -64,9 +64,8 @@ export function isCorsPreflight(req) {
64
64
  req.headers.has("access-control-request-method"));
65
65
  }
66
66
  /**
67
- * Answer a preflight permissively — the environment is hermetic, so
68
- * there is nothing here to protect from itself, and a test failing on
69
- * CORS teaches nothing about the app.
67
+ * Answer a preflight for explicitly permissive ingress only. Passthrough
68
+ * must let the application or interceptor decide, including rejecting it.
70
69
  *
71
70
  * The one rule that is not a matter of taste: the **specific** origin is
72
71
  * echoed rather than `*`. `Allow-Origin: *` together with
@@ -88,15 +87,13 @@ export function corsPreflightResponse(req) {
88
87
  // one origin's preflight to another.
89
88
  headers.append("vary", "Origin");
90
89
  headers.append("vary", "Access-Control-Request-Headers");
90
+ headers.append("vary", "Access-Control-Request-Method");
91
91
  return new Response(null, { status: 204, headers });
92
92
  }
93
93
  /**
94
- * Add CORS headers to a proxied response, without overriding the
95
- * upstream's own.
96
- *
97
- * An app that already sets `Access-Control-Allow-Origin` has an opinion —
98
- * possibly a deliberately restrictive one that a test is checking — and
99
- * replacing it would make the environment lie about the app's behaviour.
94
+ * Add CORS headers only when permissive ingress was explicitly requested.
95
+ * An absent allow-origin can be a deliberate rejection; callers must never
96
+ * use this augmentation in passthrough mode. Existing allow-origin is kept.
100
97
  */
101
98
  export function augmentCorsResponse(req, res) {
102
99
  const origin = req.headers.get("origin");
@@ -116,3 +113,11 @@ export function augmentCorsResponse(req, res) {
116
113
  }
117
114
  return res;
118
115
  }
116
+ /** Apply the same policy around the entire fake/proxy + interceptor chain.
117
+ * Passthrough forwards even preflights and preserves the response unchanged. */
118
+ export async function withIngressCors(req, cors, handle) {
119
+ if (cors === "permissive" && isCorsPreflight(req))
120
+ return corsPreflightResponse(req);
121
+ const res = await handle(req);
122
+ return cors === "permissive" ? augmentCorsResponse(req, res) : res;
123
+ }