@specific.dev/spectest 0.98.0 → 0.99.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/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/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.
|
|
@@ -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
|
+
}
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* thing {@link planBind} still has to say is whether the listener exists
|
|
42
42
|
* yet.
|
|
43
43
|
*/
|
|
44
|
+
import type { CorsMode } from "../ingress.js";
|
|
44
45
|
/**
|
|
45
46
|
* One hostname's upstream: a fake handled in-process, or a container.
|
|
46
47
|
*
|
|
@@ -56,6 +57,7 @@ export type Route<F = unknown> = {
|
|
|
56
57
|
service: string;
|
|
57
58
|
port: number;
|
|
58
59
|
http2?: boolean;
|
|
60
|
+
cors?: CorsMode;
|
|
59
61
|
};
|
|
60
62
|
/** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
|
|
61
63
|
export interface Leaf {
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,8 @@ export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigu
|
|
|
29
29
|
import { type ServiceCoverage } from "./coverage.js";
|
|
30
30
|
export { ca } from "./environment-ca.js";
|
|
31
31
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
32
|
-
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
33
|
-
import type
|
|
32
|
+
export type { CorsMode, CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
33
|
+
import { type CorsMode, type DnsTarget } from "./ingress.js";
|
|
34
34
|
import type { InterceptHandler, InterceptNext, InterceptedRequest } from "./harness/intercept.js";
|
|
35
35
|
export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
36
36
|
/**
|
|
@@ -744,6 +744,9 @@ export interface ServiceTls {
|
|
|
744
744
|
/** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
|
|
745
745
|
* Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
|
|
746
746
|
http2?: boolean;
|
|
747
|
+
/** Preserve upstream CORS policy by default. "permissive" opts HTTP/1
|
|
748
|
+
* ingress into answering preflights and adding credentialed CORS headers. */
|
|
749
|
+
cors?: CorsMode;
|
|
747
750
|
}
|
|
748
751
|
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
749
752
|
export type NormalizedServiceImage = {
|
|
@@ -1458,6 +1461,9 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1458
1461
|
hostnames: readonly string[];
|
|
1459
1462
|
/** TCP port the fake listens on. Default `80`. */
|
|
1460
1463
|
port?: number;
|
|
1464
|
+
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
1465
|
+
* answers preflights and adds credentialed CORS headers at ingress. */
|
|
1466
|
+
cors?: CorsMode;
|
|
1461
1467
|
/**
|
|
1462
1468
|
* Build the fake's initial state. Called once when the project loads.
|
|
1463
1469
|
* The returned value lives in daemon memory for the rest of the
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,7 @@ export { ca } from "./environment-ca.js";
|
|
|
49
49
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
50
50
|
// `ingress.ts`.
|
|
51
51
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
52
|
+
import { validateCorsMode } from "./ingress.js";
|
|
52
53
|
import { isWildcard as isWildcardHost } from "./ingress.js";
|
|
53
54
|
// ──────────────────────────────────────────────────────────────────────────
|
|
54
55
|
// Service groups — one services-map entry that expands to several services.
|
|
@@ -363,6 +364,7 @@ function validateEnvironmentConfig(config) {
|
|
|
363
364
|
}
|
|
364
365
|
if (entry.http2 !== undefined && typeof entry.http2 !== "boolean")
|
|
365
366
|
throw new Error(`service "${name}" tls http2 must be a boolean`);
|
|
367
|
+
validateCorsMode(entry.cors);
|
|
366
368
|
const h = entry.hostname.toLowerCase();
|
|
367
369
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
368
370
|
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
|
|
@@ -408,6 +410,7 @@ export function defineFake(opts) {
|
|
|
408
410
|
throw new Error(`defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`);
|
|
409
411
|
}
|
|
410
412
|
}
|
|
413
|
+
validateCorsMode(opts.cors);
|
|
411
414
|
if (typeof opts.handler !== "function") {
|
|
412
415
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
|
413
416
|
}
|
package/dist/ingress.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export type DnsTarget = {
|
|
|
7
7
|
} | {
|
|
8
8
|
service: string;
|
|
9
9
|
};
|
|
10
|
+
/** Passthrough preserves application CORS policy (the default). Permissive
|
|
11
|
+
* answers preflights and adds credentialed CORS headers at HTTP/1 ingress. */
|
|
12
|
+
export type CorsMode = "passthrough" | "permissive";
|
|
13
|
+
/** @internal Validate JavaScript config as well as TypeScript callers. */
|
|
14
|
+
export declare function validateCorsMode(cors: unknown): void;
|
|
10
15
|
export interface CertificateDecl {
|
|
11
16
|
kind: "certificate";
|
|
12
17
|
/** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
|
|
@@ -24,6 +29,7 @@ export interface ProxyDecl {
|
|
|
24
29
|
service: string;
|
|
25
30
|
port: number;
|
|
26
31
|
http2?: boolean;
|
|
32
|
+
cors?: CorsMode;
|
|
27
33
|
};
|
|
28
34
|
}
|
|
29
35
|
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
@@ -71,6 +77,7 @@ export declare function proxy(hostname: string, upstream: {
|
|
|
71
77
|
service: string;
|
|
72
78
|
port: number;
|
|
73
79
|
http2?: boolean;
|
|
80
|
+
cors?: CorsMode;
|
|
74
81
|
}): ProxyDecl;
|
|
75
82
|
/**
|
|
76
83
|
* Attach low-level ingress decls to a service definition a component
|
|
@@ -100,6 +107,7 @@ export interface LoweredIngress {
|
|
|
100
107
|
service: string;
|
|
101
108
|
port: number;
|
|
102
109
|
http2?: boolean;
|
|
110
|
+
cors?: CorsMode;
|
|
103
111
|
}[];
|
|
104
112
|
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
105
113
|
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
package/dist/ingress.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// deserialises: the decls live on the `Project` (daemon-side only) and,
|
|
18
18
|
// when attached to a service, ride on a Symbol key that `JSON.stringify`
|
|
19
19
|
// drops — so Rust's `deny_unknown_fields` never sees them.
|
|
20
|
+
/** @internal Validate JavaScript config as well as TypeScript callers. */
|
|
21
|
+
export function validateCorsMode(cors) {
|
|
22
|
+
if (cors !== undefined && cors !== "passthrough" && cors !== "permissive") {
|
|
23
|
+
throw new Error('cors must be "passthrough" or "permissive"');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
20
26
|
/** Token a component can use in a decl where it can't know its own
|
|
21
27
|
* services-map key yet — resolved to that key during `lowerIngress`.
|
|
22
28
|
* Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
|
|
@@ -101,6 +107,7 @@ export function proxy(hostname, upstream) {
|
|
|
101
107
|
}
|
|
102
108
|
if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean")
|
|
103
109
|
throw new Error("proxy(): http2 must be a boolean");
|
|
110
|
+
validateCorsMode(upstream.cors);
|
|
104
111
|
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
105
112
|
}
|
|
106
113
|
/** Symbol key under which `provides(...)` stashes a component's ingress
|
|
@@ -165,7 +172,7 @@ export function lowerIngress(project) {
|
|
|
165
172
|
break;
|
|
166
173
|
case "proxy": {
|
|
167
174
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
168
|
-
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
|
|
175
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}), ...(decl.upstream.cors ? { cors: decl.upstream.cors } : {}) });
|
|
169
176
|
// A proxied hostname must route to the daemon. A wildcard one can
|
|
170
177
|
// only be answered by the resolver (there is no `--add-host` for a
|
|
171
178
|
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
@@ -204,7 +211,7 @@ export function lowerIngress(project) {
|
|
|
204
211
|
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
205
212
|
for (const entry of svc.tls ?? []) {
|
|
206
213
|
applyDecl(certificate([entry.hostname]), name);
|
|
207
|
-
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
|
|
214
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}), ...(entry.cors ? { cors: entry.cors } : {}) }), name);
|
|
208
215
|
}
|
|
209
216
|
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
210
217
|
for (const h of svc.hostnames ?? []) {
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* yet.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import type { CorsMode } from "../ingress.js";
|
|
45
46
|
import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -53,7 +54,7 @@ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
|
53
54
|
*/
|
|
54
55
|
export type Route<F = unknown> =
|
|
55
56
|
| { kind: "fake"; fake: F }
|
|
56
|
-
| { kind: "proxy"; service: string; port: number; http2?: boolean };
|
|
57
|
+
| { kind: "proxy"; service: string; port: number; http2?: boolean; cors?: CorsMode };
|
|
57
58
|
|
|
58
59
|
/** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
|
|
59
60
|
export interface Leaf {
|
package/src/index.ts
CHANGED
|
@@ -190,6 +190,7 @@ export {
|
|
|
190
190
|
SELF_SERVICE_TOKEN,
|
|
191
191
|
} from "./ingress.js";
|
|
192
192
|
export type {
|
|
193
|
+
CorsMode,
|
|
193
194
|
CertificateDecl,
|
|
194
195
|
DnsDecl,
|
|
195
196
|
ProxyDecl,
|
|
@@ -197,7 +198,7 @@ export type {
|
|
|
197
198
|
DnsTarget,
|
|
198
199
|
LoweredIngress,
|
|
199
200
|
} from "./ingress.js";
|
|
200
|
-
import type
|
|
201
|
+
import { validateCorsMode, type CorsMode, type DnsTarget } from "./ingress.js";
|
|
201
202
|
import type {
|
|
202
203
|
InterceptHandler,
|
|
203
204
|
InterceptNext,
|
|
@@ -1164,6 +1165,9 @@ export interface ServiceTls {
|
|
|
1164
1165
|
/** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
|
|
1165
1166
|
* Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
|
|
1166
1167
|
http2?: boolean;
|
|
1168
|
+
/** Preserve upstream CORS policy by default. "permissive" opts HTTP/1
|
|
1169
|
+
* ingress into answering preflights and adding credentialed CORS headers. */
|
|
1170
|
+
cors?: CorsMode;
|
|
1167
1171
|
}
|
|
1168
1172
|
|
|
1169
1173
|
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
@@ -1665,6 +1669,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
|
|
|
1665
1669
|
);
|
|
1666
1670
|
}
|
|
1667
1671
|
if (entry.http2 !== undefined && typeof entry.http2 !== "boolean") throw new Error(`service "${name}" tls http2 must be a boolean`);
|
|
1672
|
+
validateCorsMode(entry.cors);
|
|
1668
1673
|
const h = entry.hostname.toLowerCase();
|
|
1669
1674
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
1670
1675
|
throw new Error(
|
|
@@ -2144,6 +2149,9 @@ export interface FakeDefinition<
|
|
|
2144
2149
|
hostnames: readonly string[];
|
|
2145
2150
|
/** TCP port the fake listens on. Default `80`. */
|
|
2146
2151
|
port?: number;
|
|
2152
|
+
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
2153
|
+
* answers preflights and adds credentialed CORS headers at ingress. */
|
|
2154
|
+
cors?: CorsMode;
|
|
2147
2155
|
/**
|
|
2148
2156
|
* Build the fake's initial state. Called once when the project loads.
|
|
2149
2157
|
* The returned value lives in daemon memory for the rest of the
|
|
@@ -2227,6 +2235,7 @@ export function defineFake<
|
|
|
2227
2235
|
);
|
|
2228
2236
|
}
|
|
2229
2237
|
}
|
|
2238
|
+
validateCorsMode(opts.cors);
|
|
2230
2239
|
if (typeof opts.handler !== "function") {
|
|
2231
2240
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
|
2232
2241
|
}
|
package/src/ingress.ts
CHANGED
|
@@ -25,6 +25,17 @@ import type { FakeDefinition, Project, ServiceConfig } from "./index.js";
|
|
|
25
25
|
* live IP on spectest-net (a plain peer alias, no daemon hop). */
|
|
26
26
|
export type DnsTarget = { ingress: true } | { service: string };
|
|
27
27
|
|
|
28
|
+
/** Passthrough preserves application CORS policy (the default). Permissive
|
|
29
|
+
* answers preflights and adds credentialed CORS headers at HTTP/1 ingress. */
|
|
30
|
+
export type CorsMode = "passthrough" | "permissive";
|
|
31
|
+
|
|
32
|
+
/** @internal Validate JavaScript config as well as TypeScript callers. */
|
|
33
|
+
export function validateCorsMode(cors: unknown): void {
|
|
34
|
+
if (cors !== undefined && cors !== "passthrough" && cors !== "permissive") {
|
|
35
|
+
throw new Error('cors must be "passthrough" or "permissive"');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
export interface CertificateDecl {
|
|
29
40
|
kind: "certificate";
|
|
30
41
|
/** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
|
|
@@ -40,7 +51,7 @@ export interface DnsDecl {
|
|
|
40
51
|
export interface ProxyDecl {
|
|
41
52
|
kind: "proxy";
|
|
42
53
|
hostname: string;
|
|
43
|
-
upstream: { service: string; port: number; http2?: boolean };
|
|
54
|
+
upstream: { service: string; port: number; http2?: boolean; cors?: CorsMode };
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
@@ -137,7 +148,7 @@ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
|
|
|
137
148
|
*/
|
|
138
149
|
export function proxy(
|
|
139
150
|
hostname: string,
|
|
140
|
-
upstream: { service: string; port: number; http2?: boolean },
|
|
151
|
+
upstream: { service: string; port: number; http2?: boolean; cors?: CorsMode },
|
|
141
152
|
): ProxyDecl {
|
|
142
153
|
assertNameOrWildcard(hostname, "proxy()");
|
|
143
154
|
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
@@ -146,6 +157,7 @@ export function proxy(
|
|
|
146
157
|
);
|
|
147
158
|
}
|
|
148
159
|
if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean") throw new Error("proxy(): http2 must be a boolean");
|
|
160
|
+
validateCorsMode(upstream.cors);
|
|
149
161
|
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
150
162
|
}
|
|
151
163
|
|
|
@@ -198,7 +210,7 @@ export interface LoweredIngress {
|
|
|
198
210
|
/** One leaf cert per group; SANs = the group's hostnames. */
|
|
199
211
|
certificates: { hostnames: string[] }[];
|
|
200
212
|
/** Reverse-proxy routes: hostname → upstream service:port. */
|
|
201
|
-
proxies: { hostname: string; service: string; port: number; http2?: boolean }[];
|
|
213
|
+
proxies: { hostname: string; service: string; port: number; http2?: boolean; cors?: CorsMode }[];
|
|
202
214
|
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
203
215
|
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
|
204
216
|
* any `dnsName(h, { ingress: true })`. */
|
|
@@ -227,7 +239,7 @@ function resolveSelf(service: string, selfKey: string): string {
|
|
|
227
239
|
*/
|
|
228
240
|
export function lowerIngress(project: Project): LoweredIngress {
|
|
229
241
|
const certificates: { hostnames: string[] }[] = [];
|
|
230
|
-
const proxies: { hostname: string; service: string; port: number; http2?: boolean }[] = [];
|
|
242
|
+
const proxies: { hostname: string; service: string; port: number; http2?: boolean; cors?: CorsMode }[] = [];
|
|
231
243
|
const ingressSet = new Set<string>();
|
|
232
244
|
const aliasesByService: Record<string, string[]> = {};
|
|
233
245
|
const wildcards: { pattern: string; target: DnsTarget }[] = [];
|
|
@@ -242,7 +254,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
242
254
|
break;
|
|
243
255
|
case "proxy": {
|
|
244
256
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
245
|
-
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
|
|
257
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}), ...(decl.upstream.cors ? { cors: decl.upstream.cors } : {}) });
|
|
246
258
|
// A proxied hostname must route to the daemon. A wildcard one can
|
|
247
259
|
// only be answered by the resolver (there is no `--add-host` for a
|
|
248
260
|
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
@@ -279,7 +291,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
279
291
|
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
280
292
|
for (const entry of (svc as ServiceConfig).tls ?? []) {
|
|
281
293
|
applyDecl(certificate([entry.hostname]), name);
|
|
282
|
-
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
|
|
294
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}), ...(entry.cors ? { cors: entry.cors } : {}) }), name);
|
|
283
295
|
}
|
|
284
296
|
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
285
297
|
for (const h of (svc as ServiceConfig).hostnames ?? []) {
|