@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
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
|
|
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
|
-
|
|
3
|
-
export
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
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
|
-
`
|
|
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, [
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
|
41
|
-
*
|
|
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
|
|
57
|
-
*
|
|
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
|
|
68
|
-
*
|
|
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
|
|
95
|
-
*
|
|
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
|
+
}
|