@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.
@@ -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;
@@ -58,11 +58,13 @@ import { dirname, join } from "node:path";
58
58
 
59
59
  import {
60
60
  serviceGroup,
61
+ type CorsMode,
61
62
  type ServiceDefinition,
62
63
  type ServiceGroup,
63
64
  type ServiceSetupContext,
64
65
  type ServicesMap,
65
66
  } from "../index.js";
67
+ import { validateCorsMode } from "../ingress.js";
66
68
  import { SQL, type SqlClient } from "../sql.js";
67
69
  import { apple } from "./apple.js";
68
70
  import type { OAuthClient } from "./emulate/service.js";
@@ -280,6 +282,10 @@ export interface SupabaseOptions {
280
282
  * test hardcodes a Supabase URL it cannot be told to override.
281
283
  */
282
284
  hostname?: string;
285
+ /** CORS handling at the daemon ingress for `hostname`. Defaults to
286
+ * "passthrough", preserving gateway/interceptor policy. "permissive"
287
+ * restores automatic preflight replies and credentialed CORS headers. */
288
+ cors?: CorsMode;
283
289
 
284
290
  /**
285
291
  * Capture GoTrue's outgoing mail (signup confirmations, magic links, OTPs,
@@ -1640,6 +1646,7 @@ async function applyMigrations(
1640
1646
  * for hitting the REST/auth/storage APIs through the gateway.
1641
1647
  */
1642
1648
  export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1649
+ validateCorsMode(opts.cors);
1643
1650
  const name = opts.name ?? "supabase";
1644
1651
  const dbPassword = opts.dbPassword ?? "postgres";
1645
1652
  const jwtSecret = opts.jwtSecret ?? DEFAULT_JWT_SECRET;
@@ -2366,7 +2373,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2366
2373
  },
2367
2374
  ],
2368
2375
  ports: [8000, 8443],
2369
- ...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000 }] } : {}),
2376
+ ...(opts.hostname ? { tls: [{ hostname: opts.hostname, port: 8000, ...(opts.cors ? { cors: opts.cors } : {}) }] } : {}),
2370
2377
  readyCheck: { type: "exec", command: "kong health", timeoutSecs: 90 },
2371
2378
  };
2372
2379
 
package/src/daemon.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  normalizeServiceImage,
42
42
  proxy as makeProxyDecl,
43
43
  } from "./index.js";
44
- import type { DnsTarget, LoweredIngress } from "./index.js";
44
+ import type { CorsMode, DnsTarget, LoweredIngress } from "./index.js";
45
45
  import {
46
46
  COVERAGE_CONTAINER_DIR,
47
47
  coverageBundleRef,
@@ -127,9 +127,7 @@ import {
127
127
  } from "./harness/intercept.js";
128
128
  import {
129
129
  HOP_BY_HOP_HEADERS,
130
- augmentCorsResponse,
131
- corsPreflightResponse,
132
- isCorsPreflight,
130
+ withIngressCors,
133
131
  upstreamRequestHeaders,
134
132
  } from "./harness/http-proxy.js";
135
133
  import {
@@ -2146,7 +2144,7 @@ async function startIngress(): Promise<void> {
2146
2144
  if (LOWERED.proxies.length > 0) {
2147
2145
  const routes = ensurePort(INGRESS_HTTP_PORT);
2148
2146
  for (const p of LOWERED.proxies) {
2149
- routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
2147
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}), ...(p.cors ? { cors: p.cors } : {}) });
2150
2148
  }
2151
2149
  }
2152
2150
  // The :443 route table mirrors every handler whose hostname a cert
@@ -2162,7 +2160,7 @@ async function startIngress(): Promise<void> {
2162
2160
  }
2163
2161
  for (const p of LOWERED.proxies) {
2164
2162
  if (certCovers(p.hostname)) {
2165
- httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
2163
+ httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}), ...(p.cors ? { cors: p.cors } : {}) });
2166
2164
  }
2167
2165
  }
2168
2166
  }
@@ -2319,10 +2317,10 @@ function certCovers(hostname: string): boolean {
2319
2317
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
2320
2318
  * it, siblings forked from an earlier snapshot never see it.
2321
2319
  */
2322
- async function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean): Promise<void> {
2320
+ async function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
2323
2321
  // Reuse the boot primitives for validation + lowercasing; throws on a
2324
2322
  // malformed hostname / upstream just like a boot `tls` would at load.
2325
- const decl = makeProxyDecl(hostname, { service, port, http2 });
2323
+ const decl = makeProxyDecl(hostname, { service, port, http2, cors });
2326
2324
  const host = decl.hostname;
2327
2325
  if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
2328
2326
  throw new Error(
@@ -2330,7 +2328,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2330
2328
  );
2331
2329
  }
2332
2330
  const Bun = requireBun();
2333
- const route: Route = { kind: "proxy", service, port, http2 };
2331
+ const route: Route = { kind: "proxy", service, port, http2, cors };
2334
2332
 
2335
2333
  // Decide the cost before mutating anything: an already-covered hostname
2336
2334
  // (an exact duplicate, or one under a boot wildcard) needs only a route.
@@ -2500,11 +2498,6 @@ async function dispatchIngress(
2500
2498
  { status: 404, headers: { "content-type": "text/plain" } },
2501
2499
  );
2502
2500
  }
2503
- // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
2504
- // cross-origin browser request carrying any header — Authorization,
2505
- // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
2506
- // to list in Access-Control-Allow-Headers.
2507
- if (isCorsPreflight(req)) return corsPreflightResponse(req);
2508
2501
  // The real answer — a fake handled in-process, or the proxied container.
2509
2502
  const upstream = async (current: Request): Promise<Response> => {
2510
2503
  if (route.kind === "fake") {
@@ -2522,16 +2515,15 @@ async function dispatchIngress(
2522
2515
  }
2523
2516
  return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2524
2517
  };
2525
- // `ctx.intercept` middleware runs first, in registration order, and reaches
2526
- // the upstream through `next()`. The CORS headers go on *after* the chain,
2527
- // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2528
- // the trap `page.route`-style interception falls into.
2529
- const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2530
- const res =
2531
- chain.length === 0
2532
- ? await upstream(req)
2533
- : await runChain(chain, req, upstream, recordInterceptedRequest);
2534
- return augmentCorsResponse(req, res);
2518
+ // Passthrough (the default) lets middleware and the upstream handle every
2519
+ // request and preserves their CORS policy, including absent headers.
2520
+ const cors = route.kind === "fake" ? route.fake.def.cors : route.cors;
2521
+ return withIngressCors(req, cors, async (current) => {
2522
+ const chain = INTERCEPTORS.chainFor(host, new URL(current.url).pathname);
2523
+ return chain.length === 0
2524
+ ? upstream(current)
2525
+ : runChain(chain, current, upstream, recordInterceptedRequest);
2526
+ });
2535
2527
  }
2536
2528
 
2537
2529
  /**
@@ -3152,7 +3144,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
3152
3144
  // `tls` is the runtime twin of a boot service's: stand up a
3153
3145
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
3154
3146
  for (const entry of svc.tls ?? []) {
3155
- await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
3147
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
3156
3148
  }
3157
3149
  RUNTIME_SERVICES.set(svc.name, svc);
3158
3150
  recordEnv({
@@ -176,11 +176,12 @@ describe("preflight response", () => {
176
176
  });
177
177
 
178
178
  /** Without Vary, a cache can serve one origin's preflight to another. */
179
- test("varies on origin and requested headers", () => {
179
+ test("varies on origin, requested method and headers", () => {
180
180
  const res = preflight({ origin: "http://a.test", "access-control-request-method": "GET" });
181
181
  const vary = res.headers.get("vary") ?? "";
182
182
  expect(vary.toLowerCase()).toContain("origin");
183
183
  expect(vary.toLowerCase()).toContain("access-control-request-headers");
184
+ expect(vary.toLowerCase()).toContain("access-control-request-method");
184
185
  });
185
186
  });
186
187
 
@@ -1,3 +1,5 @@
1
+ import type { CorsMode } from "../ingress.js";
2
+
1
3
  /**
2
4
  * HTTP semantics the ingress proxy has to get right: which headers must
3
5
  * not be forwarded, and how CORS is answered.
@@ -75,9 +77,8 @@ export function isCorsPreflight(req: {
75
77
  }
76
78
 
77
79
  /**
78
- * Answer a preflight permissively — the environment is hermetic, so
79
- * there is nothing here to protect from itself, and a test failing on
80
- * CORS teaches nothing about the app.
80
+ * Answer a preflight for explicitly permissive ingress only. Passthrough
81
+ * must let the application or interceptor decide, including rejecting it.
81
82
  *
82
83
  * The one rule that is not a matter of taste: the **specific** origin is
83
84
  * echoed rather than `*`. `Allow-Origin: *` together with
@@ -104,16 +105,14 @@ export function corsPreflightResponse(req: {
104
105
  // one origin's preflight to another.
105
106
  headers.append("vary", "Origin");
106
107
  headers.append("vary", "Access-Control-Request-Headers");
108
+ headers.append("vary", "Access-Control-Request-Method");
107
109
  return new Response(null, { status: 204, headers });
108
110
  }
109
111
 
110
112
  /**
111
- * Add CORS headers to a proxied response, without overriding the
112
- * upstream's own.
113
- *
114
- * An app that already sets `Access-Control-Allow-Origin` has an opinion —
115
- * possibly a deliberately restrictive one that a test is checking — and
116
- * replacing it would make the environment lie about the app's behaviour.
113
+ * Add CORS headers only when permissive ingress was explicitly requested.
114
+ * An absent allow-origin can be a deliberate rejection; callers must never
115
+ * use this augmentation in passthrough mode. Existing allow-origin is kept.
117
116
  */
118
117
  export function augmentCorsResponse(
119
118
  req: { headers: { get(n: string): string | null } },
@@ -133,3 +132,15 @@ export function augmentCorsResponse(
133
132
  }
134
133
  return res;
135
134
  }
135
+
136
+ /** Apply the same policy around the entire fake/proxy + interceptor chain.
137
+ * Passthrough forwards even preflights and preserves the response unchanged. */
138
+ export async function withIngressCors(
139
+ req: Request,
140
+ cors: CorsMode | undefined,
141
+ handle: (req: Request) => Response | Promise<Response>,
142
+ ): Promise<Response> {
143
+ if (cors === "permissive" && isCorsPreflight(req)) return corsPreflightResponse(req);
144
+ const res = await handle(req);
145
+ return cors === "permissive" ? augmentCorsResponse(req, res) : res;
146
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment, defineFake, lowerIngress, provides, proxy, type CorsMode } from "../index.js";
3
+ import { supabase } from "../components/supabase.js";
4
+ import { withIngressCors, upstreamRequestHeaders } from "./http-proxy.js";
5
+ import { InterceptRegistry, runChain } from "./intercept.js";
6
+
7
+ const allowed = "https://app.tangent.test";
8
+ const denied = "https://untrusted.test";
9
+ const preflight = (origin: string, method = "PUT"): RequestInit => ({
10
+ method: "OPTIONS",
11
+ headers: { origin, "access-control-request-method": method, "access-control-request-headers": "authorization" },
12
+ });
13
+
14
+ for (const kind of ["fake", "service"] as const) {
15
+ for (const cors of [undefined, "passthrough"] as const) {
16
+ describe(`${kind} ingress, cors=${cors ?? "default"}`, () => {
17
+ test("preserves application CORS, rejections, range reads, OPTIONS and interceptor responses", async () => {
18
+ const seen: string[] = [];
19
+ const handler = async (req: Request): Promise<Response> => {
20
+ const path = new URL(req.url).pathname;
21
+ seen.push(`${req.method} ${path}`);
22
+ if (path === "/no-policy") return new Response("private", { headers: { vary: "Accept-Encoding" } });
23
+ if (req.method === "OPTIONS" && !req.headers.has("access-control-request-method")) {
24
+ return new Response("options", { status: 200, headers: { allow: "GET, PUT" } });
25
+ }
26
+ if (req.headers.get("origin") !== allowed) return new Response("origin denied", { status: 403 });
27
+ if (req.method === "OPTIONS" && req.headers.get("access-control-request-method") !== "PUT") {
28
+ return new Response("method denied", { status: 405, headers: { allow: "PUT" } });
29
+ }
30
+ const headers = {
31
+ "access-control-allow-origin": allowed,
32
+ "access-control-allow-credentials": "true",
33
+ "access-control-allow-methods": "PUT",
34
+ "access-control-allow-headers": "authorization",
35
+ "access-control-expose-headers": "Content-Range",
36
+ vary: "Origin, Accept-Encoding",
37
+ };
38
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers });
39
+ if (req.headers.get("range")) return new Response("par", {
40
+ status: 206, headers: { ...headers, "content-range": "bytes 0-2/7" },
41
+ });
42
+ return new Response(req.headers.get("cookie"), { headers });
43
+ };
44
+ const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler });
45
+ const interceptors = new InterceptRegistry();
46
+ const pass = interceptors.register("bucket.test", undefined, (_req, next) => next());
47
+ const blocked = interceptors.register("bucket.test", "/intercepted", () =>
48
+ new Response("intercepted", { status: 418, headers: { vary: "Accept" } }));
49
+ const fake = defineFake({ name: "bucket", hostnames: ["bucket.test"], cors, handler });
50
+ const serviceRoute = lowerIngress(defineEnvironment({ name: "cors", services: {
51
+ web: { image: "nginx", tls: [{ hostname: "bucket.test", port: upstream.port!, cors }] },
52
+ } }).project([])).proxies[0];
53
+ const mode = kind === "fake" ? fake.cors : serviceRoute.cors;
54
+ const forward = kind === "fake" ? handler : async (req: Request) => fetch(
55
+ new URL(new URL(req.url).pathname, upstream.url), {
56
+ method: req.method, headers: upstreamRequestHeaders(req.headers, { proto: "https" }),
57
+ body: req.body, redirect: "manual",
58
+ });
59
+ const ingress = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
+ withIngressCors(req, mode, current => runChain(
61
+ interceptors.chainFor("bucket.test", new URL(current.url).pathname), current, forward)),
62
+ });
63
+ try {
64
+ const send = (path: string, init: RequestInit) => fetch(new URL(path, ingress.url), init);
65
+ const ok = await send("/probe", preflight(allowed));
66
+ expect(ok.status).toBe(204);
67
+ expect(ok.headers.get("access-control-allow-origin")).toBe(allowed);
68
+ expect(ok.headers.get("access-control-allow-credentials")).toBe("true");
69
+ expect(ok.headers.get("access-control-allow-methods")).toBe("PUT");
70
+ expect(ok.headers.get("access-control-allow-headers")).toBe("authorization");
71
+ expect(ok.headers.get("vary")).toBe("Origin, Accept-Encoding");
72
+ for (const [init, status, body] of [
73
+ [preflight(denied), 403, "origin denied"],
74
+ [preflight(allowed, "DELETE"), 405, "method denied"],
75
+ [{ headers: { origin: denied } }, 403, "origin denied"],
76
+ ] as const) {
77
+ const res = await send("/probe", init);
78
+ expect(res.status).toBe(status);
79
+ expect(await res.text()).toBe(body);
80
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
81
+ expect(res.headers.get("access-control-allow-credentials")).toBeNull();
82
+ }
83
+ const credentialed = await send("/probe", { method: "PUT", headers: { origin: allowed, cookie: "session=test" } });
84
+ expect(await credentialed.text()).toBe("session=test");
85
+ expect(credentialed.headers.get("access-control-allow-origin")).toBe(allowed);
86
+ expect(credentialed.headers.get("access-control-allow-credentials")).toBe("true");
87
+ const range = await send("/probe", { headers: { origin: allowed, range: "bytes=0-2" } });
88
+ expect(range.status).toBe(206);
89
+ expect(range.headers.get("content-range")).toBe("bytes 0-2/7");
90
+ expect(range.headers.get("access-control-expose-headers")).toBe("Content-Range");
91
+ expect(await range.text()).toBe("par");
92
+ const noPolicy = await send("/no-policy", { headers: { origin: allowed } });
93
+ expect(await noPolicy.text()).toBe("private");
94
+ expect(noPolicy.headers.get("access-control-allow-origin")).toBeNull();
95
+ expect(noPolicy.headers.get("access-control-allow-credentials")).toBeNull();
96
+ expect(noPolicy.headers.get("vary")).toBe("Accept-Encoding");
97
+ const options = await send("/probe", { method: "OPTIONS", headers: { origin: allowed } });
98
+ expect(options.status).toBe(200);
99
+ expect(options.headers.get("allow")).toBe("GET, PUT");
100
+ expect(options.headers.get("access-control-allow-origin")).toBeNull();
101
+ expect(await options.text()).toBe("options");
102
+ for (const init of [preflight(denied), { headers: { origin: denied } }]) {
103
+ const res = await send("/intercepted", init);
104
+ expect(res.status).toBe(418);
105
+ expect(await res.text()).toBe("intercepted");
106
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
107
+ expect(res.headers.get("vary")).toBe("Accept");
108
+ }
109
+ expect(pass.requests).toHaveLength(10);
110
+ expect(blocked.requests.map(r => r.method)).toEqual(["OPTIONS", "GET"]);
111
+ expect(seen.filter(s => s === "OPTIONS /probe")).toHaveLength(4);
112
+ expect(seen.some(s => s.endsWith("/intercepted"))).toBe(false);
113
+ } finally {
114
+ ingress.stop(true);
115
+ upstream.stop(true);
116
+ }
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ test("passthrough preserves the exact response, including interceptor CORS and repeated cookies", async () => {
123
+ const res = new Response("error", { status: 500, headers: {
124
+ "access-control-allow-origin": allowed, "access-control-allow-credentials": "false", vary: "Accept",
125
+ } });
126
+ res.headers.append("set-cookie", "a=1");
127
+ res.headers.append("set-cookie", "b=2");
128
+ const headers = [...res.headers];
129
+ expect(await withIngressCors(new Request("http://bucket.test", preflight(denied)), undefined, () => res)).toBe(res);
130
+ expect([...res.headers]).toEqual(headers);
131
+ });
132
+
133
+ test("permissive is opt-in for preflight interception and response augmentation", async () => {
134
+ let calls = 0;
135
+ const handle = () => { calls++; return new Response("denied", { status: 403 }); };
136
+ const pre = await withIngressCors(new Request("http://bucket.test", preflight(denied)), "permissive", handle);
137
+ expect(pre.status).toBe(204);
138
+ expect(pre.headers.get("access-control-allow-origin")).toBe(denied);
139
+ expect(pre.headers.get("access-control-allow-credentials")).toBe("true");
140
+ expect(calls).toBe(0);
141
+ const ordinary = await withIngressCors(new Request("http://bucket.test", { method: "OPTIONS" }), "permissive", handle);
142
+ expect(ordinary.status).toBe(403);
143
+ expect(calls).toBe(1);
144
+ const res = await withIngressCors(new Request("http://bucket.test", { headers: { origin: denied } }), "permissive", handle);
145
+ expect(res.status).toBe(403);
146
+ expect(res.headers.get("access-control-allow-origin")).toBe(denied);
147
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
148
+ expect(calls).toBe(2);
149
+ });
150
+
151
+ test.each(["passthrough", "permissive"] as CorsMode[])("public config retains %s for fakes, TLS and component proxies", cors => {
152
+ const fake = defineFake({ name: "fake", hostnames: ["fake.test"], cors, handler: () => new Response() });
153
+ const env = defineEnvironment({ name: "cors", fakes: { fake }, services: {
154
+ web: provides({ image: "nginx", tls: [{ hostname: "web.test", port: 81, cors }] }, [
155
+ proxy("*.proxy.test", { service: "web", port: 81, cors }),
156
+ ]),
157
+ } });
158
+ const project = env.project([]);
159
+ expect(project.fakes?.fake.cors).toBe(cors);
160
+ expect(lowerIngress(project).proxies.map(p => p.cors)).toEqual([cors, cors]);
161
+ });
162
+
163
+ test("invalid CORS modes fail at configuration time", () => {
164
+ const cors = "typo" as CorsMode;
165
+ expect(() => defineFake({ name: "fake", hostnames: ["fake.test"], cors, handler: () => new Response() })).toThrow("cors must be");
166
+ expect(() => proxy("proxy.test", { service: "web", port: 81, cors })).toThrow("cors must be");
167
+ expect(() => defineEnvironment({ name: "cors", services: {
168
+ web: { image: "nginx", tls: [{ hostname: "web.test", port: 81, cors }] },
169
+ } })).toThrow("cors must be");
170
+ });
171
+
172
+ for (const cors of [undefined, "passthrough", "permissive"] as const) {
173
+ test(`Supabase gateway carries ${cors ?? "default"} CORS through group expansion`, () => {
174
+ const sb = supabase({ hostname: "supabase.test", cors });
175
+ const project = defineEnvironment({ name: "cors", services: { supabase: sb.group } }).project([]);
176
+ const gateway = lowerIngress(project).proxies.find(p => p.hostname === "supabase.test");
177
+ expect(gateway).toBeDefined();
178
+ expect(gateway?.cors).toBe(cors);
179
+ });
180
+ }
181
+
182
+ test("Supabase rejects an invalid CORS mode", () => {
183
+ expect(() => supabase({ hostname: "supabase.test", cors: "typo" as CorsMode })).toThrow("cors must be");
184
+ });