@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 +32 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/replay-key.d.ts +27 -0
- package/dist/components/replay-key.js +96 -0
- package/dist/components/replayFake.d.ts +12 -17
- package/dist/components/replayFake.js +51 -50
- package/dist/components/supabase.d.ts +5 -1
- package/dist/components/supabase.js +3 -1
- package/dist/daemon.js +16 -22
- package/dist/harness/http-proxy.d.ts +9 -9
- package/dist/harness/http-proxy.js +14 -9
- package/dist/harness/ingress-table.d.ts +2 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +3 -0
- package/dist/ingress.d.ts +8 -0
- package/dist/ingress.js +9 -2
- package/package.json +1 -1
- package/src/components/index.ts +4 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-record.test.ts +114 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +59 -65
- package/src/components/supabase.ts +8 -1
- package/src/daemon.ts +17 -25
- package/src/harness/http-proxy.test.ts +2 -1
- package/src/harness/http-proxy.ts +20 -9
- package/src/harness/ingress-cors.test.ts +184 -0
- package/src/harness/ingress-table.ts +2 -1
- package/src/index.ts +10 -1
- package/src/ingress.ts +18 -6
|
@@ -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
|
-
/**
|
|
159
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
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
|
-
`
|
|
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, [
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
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
|
|
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
|
|
79
|
-
*
|
|
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
|
|
112
|
-
*
|
|
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
|
+
});
|