@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.
@@ -176,11 +176,12 @@ describe("preflight response", () => {
176
176
  });
177
177
 
178
178
  /** Without Vary, a cache can serve one origin's preflight to another. */
179
- test("varies on origin and requested headers", () => {
179
+ test("varies on origin, requested method and headers", () => {
180
180
  const res = preflight({ origin: "http://a.test", "access-control-request-method": "GET" });
181
181
  const vary = res.headers.get("vary") ?? "";
182
182
  expect(vary.toLowerCase()).toContain("origin");
183
183
  expect(vary.toLowerCase()).toContain("access-control-request-headers");
184
+ expect(vary.toLowerCase()).toContain("access-control-request-method");
184
185
  });
185
186
  });
186
187
 
@@ -1,3 +1,5 @@
1
+ import type { CorsMode } from "../ingress.js";
2
+
1
3
  /**
2
4
  * HTTP semantics the ingress proxy has to get right: which headers must
3
5
  * not be forwarded, and how CORS is answered.
@@ -75,9 +77,8 @@ export function isCorsPreflight(req: {
75
77
  }
76
78
 
77
79
  /**
78
- * Answer a preflight permissively — the environment is hermetic, so
79
- * there is nothing here to protect from itself, and a test failing on
80
- * CORS teaches nothing about the app.
80
+ * Answer a preflight for explicitly permissive ingress only. Passthrough
81
+ * must let the application or interceptor decide, including rejecting it.
81
82
  *
82
83
  * The one rule that is not a matter of taste: the **specific** origin is
83
84
  * echoed rather than `*`. `Allow-Origin: *` together with
@@ -104,16 +105,14 @@ export function corsPreflightResponse(req: {
104
105
  // one origin's preflight to another.
105
106
  headers.append("vary", "Origin");
106
107
  headers.append("vary", "Access-Control-Request-Headers");
108
+ headers.append("vary", "Access-Control-Request-Method");
107
109
  return new Response(null, { status: 204, headers });
108
110
  }
109
111
 
110
112
  /**
111
- * Add CORS headers to a proxied response, without overriding the
112
- * upstream's own.
113
- *
114
- * An app that already sets `Access-Control-Allow-Origin` has an opinion —
115
- * possibly a deliberately restrictive one that a test is checking — and
116
- * replacing it would make the environment lie about the app's behaviour.
113
+ * Add CORS headers only when permissive ingress was explicitly requested.
114
+ * An absent allow-origin can be a deliberate rejection; callers must never
115
+ * use this augmentation in passthrough mode. Existing allow-origin is kept.
117
116
  */
118
117
  export function augmentCorsResponse(
119
118
  req: { headers: { get(n: string): string | null } },
@@ -133,3 +132,15 @@ export function augmentCorsResponse(
133
132
  }
134
133
  return res;
135
134
  }
135
+
136
+ /** Apply the same policy around the entire fake/proxy + interceptor chain.
137
+ * Passthrough forwards even preflights and preserves the response unchanged. */
138
+ export async function withIngressCors(
139
+ req: Request,
140
+ cors: CorsMode | undefined,
141
+ handle: (req: Request) => Response | Promise<Response>,
142
+ ): Promise<Response> {
143
+ if (cors === "permissive" && isCorsPreflight(req)) return corsPreflightResponse(req);
144
+ const res = await handle(req);
145
+ return cors === "permissive" ? augmentCorsResponse(req, res) : res;
146
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment, defineFake, lowerIngress, provides, proxy, type CorsMode } from "../index.js";
3
+ import { supabase } from "../components/supabase.js";
4
+ import { withIngressCors, upstreamRequestHeaders } from "./http-proxy.js";
5
+ import { InterceptRegistry, runChain } from "./intercept.js";
6
+
7
+ const allowed = "https://app.tangent.test";
8
+ const denied = "https://untrusted.test";
9
+ const preflight = (origin: string, method = "PUT"): RequestInit => ({
10
+ method: "OPTIONS",
11
+ headers: { origin, "access-control-request-method": method, "access-control-request-headers": "authorization" },
12
+ });
13
+
14
+ for (const kind of ["fake", "service"] as const) {
15
+ for (const cors of [undefined, "passthrough"] as const) {
16
+ describe(`${kind} ingress, cors=${cors ?? "default"}`, () => {
17
+ test("preserves application CORS, rejections, range reads, OPTIONS and interceptor responses", async () => {
18
+ const seen: string[] = [];
19
+ const handler = async (req: Request): Promise<Response> => {
20
+ const path = new URL(req.url).pathname;
21
+ seen.push(`${req.method} ${path}`);
22
+ if (path === "/no-policy") return new Response("private", { headers: { vary: "Accept-Encoding" } });
23
+ if (req.method === "OPTIONS" && !req.headers.has("access-control-request-method")) {
24
+ return new Response("options", { status: 200, headers: { allow: "GET, PUT" } });
25
+ }
26
+ if (req.headers.get("origin") !== allowed) return new Response("origin denied", { status: 403 });
27
+ if (req.method === "OPTIONS" && req.headers.get("access-control-request-method") !== "PUT") {
28
+ return new Response("method denied", { status: 405, headers: { allow: "PUT" } });
29
+ }
30
+ const headers = {
31
+ "access-control-allow-origin": allowed,
32
+ "access-control-allow-credentials": "true",
33
+ "access-control-allow-methods": "PUT",
34
+ "access-control-allow-headers": "authorization",
35
+ "access-control-expose-headers": "Content-Range",
36
+ vary: "Origin, Accept-Encoding",
37
+ };
38
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers });
39
+ if (req.headers.get("range")) return new Response("par", {
40
+ status: 206, headers: { ...headers, "content-range": "bytes 0-2/7" },
41
+ });
42
+ return new Response(req.headers.get("cookie"), { headers });
43
+ };
44
+ const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler });
45
+ const interceptors = new InterceptRegistry();
46
+ const pass = interceptors.register("bucket.test", undefined, (_req, next) => next());
47
+ const blocked = interceptors.register("bucket.test", "/intercepted", () =>
48
+ new Response("intercepted", { status: 418, headers: { vary: "Accept" } }));
49
+ const fake = defineFake({ name: "bucket", hostnames: ["bucket.test"], cors, handler });
50
+ const serviceRoute = lowerIngress(defineEnvironment({ name: "cors", services: {
51
+ web: { image: "nginx", tls: [{ hostname: "bucket.test", port: upstream.port!, cors }] },
52
+ } }).project([])).proxies[0];
53
+ const mode = kind === "fake" ? fake.cors : serviceRoute.cors;
54
+ const forward = kind === "fake" ? handler : async (req: Request) => fetch(
55
+ new URL(new URL(req.url).pathname, upstream.url), {
56
+ method: req.method, headers: upstreamRequestHeaders(req.headers, { proto: "https" }),
57
+ body: req.body, redirect: "manual",
58
+ });
59
+ const ingress = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
+ withIngressCors(req, mode, current => runChain(
61
+ interceptors.chainFor("bucket.test", new URL(current.url).pathname), current, forward)),
62
+ });
63
+ try {
64
+ const send = (path: string, init: RequestInit) => fetch(new URL(path, ingress.url), init);
65
+ const ok = await send("/probe", preflight(allowed));
66
+ expect(ok.status).toBe(204);
67
+ expect(ok.headers.get("access-control-allow-origin")).toBe(allowed);
68
+ expect(ok.headers.get("access-control-allow-credentials")).toBe("true");
69
+ expect(ok.headers.get("access-control-allow-methods")).toBe("PUT");
70
+ expect(ok.headers.get("access-control-allow-headers")).toBe("authorization");
71
+ expect(ok.headers.get("vary")).toBe("Origin, Accept-Encoding");
72
+ for (const [init, status, body] of [
73
+ [preflight(denied), 403, "origin denied"],
74
+ [preflight(allowed, "DELETE"), 405, "method denied"],
75
+ [{ headers: { origin: denied } }, 403, "origin denied"],
76
+ ] as const) {
77
+ const res = await send("/probe", init);
78
+ expect(res.status).toBe(status);
79
+ expect(await res.text()).toBe(body);
80
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
81
+ expect(res.headers.get("access-control-allow-credentials")).toBeNull();
82
+ }
83
+ const credentialed = await send("/probe", { method: "PUT", headers: { origin: allowed, cookie: "session=test" } });
84
+ expect(await credentialed.text()).toBe("session=test");
85
+ expect(credentialed.headers.get("access-control-allow-origin")).toBe(allowed);
86
+ expect(credentialed.headers.get("access-control-allow-credentials")).toBe("true");
87
+ const range = await send("/probe", { headers: { origin: allowed, range: "bytes=0-2" } });
88
+ expect(range.status).toBe(206);
89
+ expect(range.headers.get("content-range")).toBe("bytes 0-2/7");
90
+ expect(range.headers.get("access-control-expose-headers")).toBe("Content-Range");
91
+ expect(await range.text()).toBe("par");
92
+ const noPolicy = await send("/no-policy", { headers: { origin: allowed } });
93
+ expect(await noPolicy.text()).toBe("private");
94
+ expect(noPolicy.headers.get("access-control-allow-origin")).toBeNull();
95
+ expect(noPolicy.headers.get("access-control-allow-credentials")).toBeNull();
96
+ expect(noPolicy.headers.get("vary")).toBe("Accept-Encoding");
97
+ const options = await send("/probe", { method: "OPTIONS", headers: { origin: allowed } });
98
+ expect(options.status).toBe(200);
99
+ expect(options.headers.get("allow")).toBe("GET, PUT");
100
+ expect(options.headers.get("access-control-allow-origin")).toBeNull();
101
+ expect(await options.text()).toBe("options");
102
+ for (const init of [preflight(denied), { headers: { origin: denied } }]) {
103
+ const res = await send("/intercepted", init);
104
+ expect(res.status).toBe(418);
105
+ expect(await res.text()).toBe("intercepted");
106
+ expect(res.headers.get("access-control-allow-origin")).toBeNull();
107
+ expect(res.headers.get("vary")).toBe("Accept");
108
+ }
109
+ expect(pass.requests).toHaveLength(10);
110
+ expect(blocked.requests.map(r => r.method)).toEqual(["OPTIONS", "GET"]);
111
+ expect(seen.filter(s => s === "OPTIONS /probe")).toHaveLength(4);
112
+ expect(seen.some(s => s.endsWith("/intercepted"))).toBe(false);
113
+ } finally {
114
+ ingress.stop(true);
115
+ upstream.stop(true);
116
+ }
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ test("passthrough preserves the exact response, including interceptor CORS and repeated cookies", async () => {
123
+ const res = new Response("error", { status: 500, headers: {
124
+ "access-control-allow-origin": allowed, "access-control-allow-credentials": "false", vary: "Accept",
125
+ } });
126
+ res.headers.append("set-cookie", "a=1");
127
+ res.headers.append("set-cookie", "b=2");
128
+ const headers = [...res.headers];
129
+ expect(await withIngressCors(new Request("http://bucket.test", preflight(denied)), undefined, () => res)).toBe(res);
130
+ expect([...res.headers]).toEqual(headers);
131
+ });
132
+
133
+ test("permissive is opt-in for preflight interception and response augmentation", async () => {
134
+ let calls = 0;
135
+ const handle = () => { calls++; return new Response("denied", { status: 403 }); };
136
+ const pre = await withIngressCors(new Request("http://bucket.test", preflight(denied)), "permissive", handle);
137
+ expect(pre.status).toBe(204);
138
+ expect(pre.headers.get("access-control-allow-origin")).toBe(denied);
139
+ expect(pre.headers.get("access-control-allow-credentials")).toBe("true");
140
+ expect(calls).toBe(0);
141
+ const ordinary = await withIngressCors(new Request("http://bucket.test", { method: "OPTIONS" }), "permissive", handle);
142
+ expect(ordinary.status).toBe(403);
143
+ expect(calls).toBe(1);
144
+ const res = await withIngressCors(new Request("http://bucket.test", { headers: { origin: denied } }), "permissive", handle);
145
+ expect(res.status).toBe(403);
146
+ expect(res.headers.get("access-control-allow-origin")).toBe(denied);
147
+ expect(res.headers.get("access-control-allow-credentials")).toBe("true");
148
+ expect(calls).toBe(2);
149
+ });
150
+
151
+ test.each(["passthrough", "permissive"] as CorsMode[])("public config retains %s for fakes, TLS and component proxies", cors => {
152
+ const fake = defineFake({ name: "fake", hostnames: ["fake.test"], cors, handler: () => new Response() });
153
+ const env = defineEnvironment({ name: "cors", fakes: { fake }, services: {
154
+ web: provides({ image: "nginx", tls: [{ hostname: "web.test", port: 81, cors }] }, [
155
+ proxy("*.proxy.test", { service: "web", port: 81, cors }),
156
+ ]),
157
+ } });
158
+ const project = env.project([]);
159
+ expect(project.fakes?.fake.cors).toBe(cors);
160
+ expect(lowerIngress(project).proxies.map(p => p.cors)).toEqual([cors, cors]);
161
+ });
162
+
163
+ test("invalid CORS modes fail at configuration time", () => {
164
+ const cors = "typo" as CorsMode;
165
+ expect(() => defineFake({ name: "fake", hostnames: ["fake.test"], cors, handler: () => new Response() })).toThrow("cors must be");
166
+ expect(() => proxy("proxy.test", { service: "web", port: 81, cors })).toThrow("cors must be");
167
+ expect(() => defineEnvironment({ name: "cors", services: {
168
+ web: { image: "nginx", tls: [{ hostname: "web.test", port: 81, cors }] },
169
+ } })).toThrow("cors must be");
170
+ });
171
+
172
+ for (const cors of [undefined, "passthrough", "permissive"] as const) {
173
+ test(`Supabase gateway carries ${cors ?? "default"} CORS through group expansion`, () => {
174
+ const sb = supabase({ hostname: "supabase.test", cors });
175
+ const project = defineEnvironment({ name: "cors", services: { supabase: sb.group } }).project([]);
176
+ const gateway = lowerIngress(project).proxies.find(p => p.hostname === "supabase.test");
177
+ expect(gateway).toBeDefined();
178
+ expect(gateway?.cors).toBe(cors);
179
+ });
180
+ }
181
+
182
+ test("Supabase rejects an invalid CORS mode", () => {
183
+ expect(() => supabase({ hostname: "supabase.test", cors: "typo" as CorsMode })).toThrow("cors must be");
184
+ });
@@ -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 { DnsTarget } from "./ingress.js";
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 ?? []) {
@@ -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";
@@ -1,2 +0,0 @@
1
- /** Google Cloud components for local Spectest environments. */
2
- export { pubsub, pubsubClient, } from "./pubsub.js";
@@ -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">;