@specific.dev/spectest 0.97.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 +37 -34
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.js +0 -1
- 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 -6
- package/src/components/index.ts +0 -2
- 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/dist/components/gcp.d.ts +0 -2
- package/dist/components/gcp.js +0 -2
- package/dist/components/pubsub-push.py +0 -83
- package/dist/components/pubsub.d.ts +0 -100
- package/dist/components/pubsub.js +0 -175
- package/src/components/gcp.ts +0 -15
- package/src/components/pubsub-push.py +0 -83
- package/src/components/pubsub.test.ts +0 -103
- package/src/components/pubsub.ts +0 -241
|
@@ -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 ?? []) {
|
package/dist/components/gcp.d.ts
DELETED
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
/** Google Cloud components for local Spectest environments. */
|
|
2
|
-
export { pubsub, pubsubClient, type PubSubOptions, type PubSubOidcToken, type PubSubPushConfig, type PubSubDeadLetterPolicy, type PubSubRetryPolicy, type PubSubClient, type PubSubHelpers, type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage, } from "./pubsub.js";
|
package/dist/components/gcp.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Authenticated push adapter; the emulator still owns scheduling and retries.
|
|
2
|
-
|
|
3
|
-
Runs only on loopback in the emulator container. Tokens come from the local
|
|
4
|
-
Google IAM fake, so recipients verify them with the same Google JWKS as usual.
|
|
5
|
-
Never follows redirects or acknowledges a failed token request/delivery.
|
|
6
|
-
"""
|
|
7
|
-
import base64
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import ssl
|
|
11
|
-
import sys
|
|
12
|
-
import urllib.error
|
|
13
|
-
import urllib.parse
|
|
14
|
-
import urllib.request
|
|
15
|
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
19
|
-
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
20
|
-
return None
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def opener():
|
|
24
|
-
context = ssl.create_default_context(cafile=os.environ.get("SSL_CERT_FILE"))
|
|
25
|
-
return urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=context))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Handler(BaseHTTPRequestHandler):
|
|
29
|
-
def do_POST(self):
|
|
30
|
-
phase = "configuration"
|
|
31
|
-
try:
|
|
32
|
-
if not self.path.startswith('/push/'):
|
|
33
|
-
self.send_error(404)
|
|
34
|
-
return
|
|
35
|
-
encoded = self.path[len('/push/'):]
|
|
36
|
-
config = json.loads(base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4)))
|
|
37
|
-
endpoint = config['endpoint']
|
|
38
|
-
if urllib.parse.urlsplit(endpoint).scheme not in ('http', 'https'):
|
|
39
|
-
raise ValueError('Invalid push endpoint')
|
|
40
|
-
size = int(self.headers.get('Content-Length', '0'))
|
|
41
|
-
if not 0 <= size <= 16 * 1024 * 1024:
|
|
42
|
-
self.send_error(413)
|
|
43
|
-
return
|
|
44
|
-
body = self.rfile.read(size)
|
|
45
|
-
account = urllib.parse.quote(config['serviceAccountEmail'], safe='')
|
|
46
|
-
token_url = 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' + account + ':generateIdToken'
|
|
47
|
-
token_body = json.dumps({'audience': config.get('audience') or endpoint, 'includeEmail': True}).encode()
|
|
48
|
-
http = opener()
|
|
49
|
-
phase = "token issuance"
|
|
50
|
-
with http.open(urllib.request.Request(token_url, data=token_body, headers={'Content-Type': 'application/json'}), timeout=10) as response:
|
|
51
|
-
token = json.load(response)['token']
|
|
52
|
-
if not isinstance(token, str) or not token or '\r' in token or '\n' in token:
|
|
53
|
-
raise ValueError('Invalid ID token from Google IAM fake')
|
|
54
|
-
request = urllib.request.Request(endpoint, data=body, headers={
|
|
55
|
-
'Content-Type': self.headers.get('Content-Type', 'application/json'),
|
|
56
|
-
'Authorization': 'Bearer ' + token,
|
|
57
|
-
})
|
|
58
|
-
phase = "delivery"
|
|
59
|
-
with http.open(request, timeout=30) as response:
|
|
60
|
-
status = response.status
|
|
61
|
-
self.send_response(status)
|
|
62
|
-
self.end_headers()
|
|
63
|
-
except urllib.error.HTTPError as error:
|
|
64
|
-
# An unsuccessful token request or destination response must retry.
|
|
65
|
-
print(f"pubsub push {phase} returned HTTP {error.code}", flush=True)
|
|
66
|
-
self.send_response(error.code if error.code >= 300 else 502)
|
|
67
|
-
self.end_headers()
|
|
68
|
-
error.close()
|
|
69
|
-
except Exception as error:
|
|
70
|
-
# Do not log tokens, request bodies, or the encoded config URL.
|
|
71
|
-
print('pubsub push adapter failed: ' + type(error).__name__, flush=True)
|
|
72
|
-
self.send_response(502)
|
|
73
|
-
self.end_headers()
|
|
74
|
-
|
|
75
|
-
def log_message(self, format, *args):
|
|
76
|
-
pass
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if __name__ == '__main__':
|
|
80
|
-
server = ThreadingHTTPServer(('127.0.0.1', int(sys.argv[1])), Handler)
|
|
81
|
-
server.daemon_threads = True
|
|
82
|
-
print('pubsub authenticated push adapter ready', flush=True)
|
|
83
|
-
server.serve_forever()
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import type { ServiceSetupContext, ServicesMap } from "../index.js";
|
|
2
|
-
export interface PubSubOidcToken {
|
|
3
|
-
serviceAccountEmail: string;
|
|
4
|
-
/** Defaults to the original push endpoint URL. */
|
|
5
|
-
audience?: string;
|
|
6
|
-
}
|
|
7
|
-
export interface PubSubPushConfig {
|
|
8
|
-
pushEndpoint: string;
|
|
9
|
-
oidcToken?: PubSubOidcToken;
|
|
10
|
-
attributes?: Record<string, string>;
|
|
11
|
-
}
|
|
12
|
-
export interface PubSubDeadLetterPolicy {
|
|
13
|
-
/** Fully qualified projects/{project}/topics/{topic} name. Create this topic separately. */
|
|
14
|
-
deadLetterTopic: string;
|
|
15
|
-
maxDeliveryAttempts?: number;
|
|
16
|
-
}
|
|
17
|
-
export interface PubSubRetryPolicy {
|
|
18
|
-
/** Google duration strings, for example "1s" and "60s". */
|
|
19
|
-
minimumBackoff?: string;
|
|
20
|
-
maximumBackoff?: string;
|
|
21
|
-
}
|
|
22
|
-
export interface PubSubSubscriptionOptions {
|
|
23
|
-
/** Topic ID in this component's project. */
|
|
24
|
-
topic: string;
|
|
25
|
-
ackDeadlineSeconds?: number;
|
|
26
|
-
/** Shorthand for pushConfig.pushEndpoint. HTTP and HTTPS are supported. */
|
|
27
|
-
pushEndpoint?: string;
|
|
28
|
-
pushConfig?: PubSubPushConfig;
|
|
29
|
-
deadLetterPolicy?: PubSubDeadLetterPolicy;
|
|
30
|
-
retryPolicy?: PubSubRetryPolicy;
|
|
31
|
-
}
|
|
32
|
-
export interface PubSubOptions {
|
|
33
|
-
/** Local project ID; no Google Cloud account is needed. Default "spectest". */
|
|
34
|
-
projectId?: string;
|
|
35
|
-
/** Official Cloud CLI image with the Pub/Sub emulator installed. */
|
|
36
|
-
image?: string;
|
|
37
|
-
/** Emulator HTTP and gRPC port. Default 8085. */
|
|
38
|
-
port?: number;
|
|
39
|
-
/** TLS on pubsub.googleapis.com by default. Set false for direct emulator
|
|
40
|
-
* access, or choose a different hostname for a second isolated instance. */
|
|
41
|
-
tls?: false | {
|
|
42
|
-
hostname: string;
|
|
43
|
-
};
|
|
44
|
-
topics?: string[];
|
|
45
|
-
/** Subscription ID → options. Referenced topics are created automatically. */
|
|
46
|
-
subscriptions?: Record<string, PubSubSubscriptionOptions>;
|
|
47
|
-
/** Runs after the declared topics and subscriptions have been created. */
|
|
48
|
-
setup?: (ctx: ServiceSetupContext<PubSubHelpers>) => void | Promise<void>;
|
|
49
|
-
}
|
|
50
|
-
export interface PubSubMessage {
|
|
51
|
-
/** Base64 encoded data, as returned by the Pub/Sub REST API. */
|
|
52
|
-
data?: string;
|
|
53
|
-
attributes?: Record<string, string>;
|
|
54
|
-
messageId: string;
|
|
55
|
-
publishTime: string;
|
|
56
|
-
orderingKey?: string;
|
|
57
|
-
}
|
|
58
|
-
export interface PubSubReceivedMessage {
|
|
59
|
-
ackId: string;
|
|
60
|
-
message: PubSubMessage;
|
|
61
|
-
deliveryAttempt?: number;
|
|
62
|
-
}
|
|
63
|
-
/** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
|
|
64
|
-
* Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
|
|
65
|
-
*/
|
|
66
|
-
export declare function pubsubClient(endpoint: string, projectId?: string, fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): {
|
|
67
|
-
projectId: string;
|
|
68
|
-
endpoint: string;
|
|
69
|
-
topicName: (id: string) => string;
|
|
70
|
-
subscriptionName: (id: string) => string;
|
|
71
|
-
createTopic(id: string): Promise<{
|
|
72
|
-
name: string;
|
|
73
|
-
}>;
|
|
74
|
-
createSubscription(id: string, options: PubSubSubscriptionOptions): Promise<{
|
|
75
|
-
name: string;
|
|
76
|
-
topic: string;
|
|
77
|
-
ackDeadlineSeconds: number;
|
|
78
|
-
}>;
|
|
79
|
-
publish(topic: string, data: string | Uint8Array, attributes?: Record<string, string>): Promise<string>;
|
|
80
|
-
/** A pull leases messages; call acknowledge() to remove them. Empty is valid.
|
|
81
|
-
* Uses the emulator's immediate pull mode for bounded test polling.
|
|
82
|
-
*/
|
|
83
|
-
pull(subscription: string, options?: {
|
|
84
|
-
maxMessages?: number;
|
|
85
|
-
timeoutMs?: number;
|
|
86
|
-
}): Promise<PubSubReceivedMessage[]>;
|
|
87
|
-
acknowledge(subscription: string, ackIds: string[]): Promise<void>;
|
|
88
|
-
};
|
|
89
|
-
export type PubSubClient = ReturnType<typeof pubsubClient>;
|
|
90
|
-
export interface PubSubHelpers extends PubSubClient {
|
|
91
|
-
/** Direct plaintext address for clients explicitly using emulator mode. */
|
|
92
|
-
emulatorHost: string;
|
|
93
|
-
}
|
|
94
|
-
/** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
|
|
95
|
-
* Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
|
|
96
|
-
* Applications use their default pubsub.googleapis.com:443 endpoint with
|
|
97
|
-
* verified TLS and local test credentials. No emulator endpoint override.
|
|
98
|
-
* State survives VM snapshots/forks, but not an emulator process restart.
|
|
99
|
-
*/
|
|
100
|
-
export declare function pubsub(opts?: PubSubOptions): import("../index.js").ServiceGroup<ServicesMap, PubSubHelpers, "emulator">;
|