@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 CHANGED
@@ -15,39 +15,10 @@ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/lat
15
15
  and run `spectest docs /installation` for project setup and authentication.
16
16
  `spectest docs` contains the complete offline authoring guide.
17
17
 
18
- ## Google Cloud Pub/Sub
19
-
20
- Google Cloud components are available from `@specific.dev/spectest/components/gcp`.
21
- The existing Pub/Sub exports from `/components` remain supported.
22
-
23
- `pubsub()` runs Google's local emulator and creates topics/subscriptions before
24
- dependent services start:
25
-
26
- ```ts
27
- import { pubsub } from "@specific.dev/spectest/components/gcp";
28
-
29
- const services = {
30
- messages: pubsub({
31
- projectId: "local-orders",
32
- subscriptions: { "orders-worker": { topic: "orders" } },
33
- }),
34
- };
35
- ```
36
-
37
- The default endpoint is `https://pubsub.googleapis.com`, with trusted TLS and
38
- gRPC support. Keep your application's normal endpoint configuration, supply
39
- local test credentials and project `local-orders`, and use
40
- `dependsOn: ["messages"]`. Tests can call
41
- `ctx.svc.messages.publish()`, `pull()` and `acknowledge()`. VM snapshots preserve
42
- the emulator's live state. See `examples/google-pubsub` and
43
- `spectest docs /components/pubsub` for test credential setup and options.
44
- Set `tls: false` to use explicit plaintext emulator mode instead.
45
-
46
- Push subscriptions support HTTPS, `pushConfig.oidcToken`, `deadLetterPolicy`,
47
- and `retryPolicy`. Add the `google()` component for locally signed service-account
48
- ID tokens verified through Google’s normal certificate endpoints. Declare
49
- authenticated subscriptions in `subscriptions` or through the component’s
50
- `createSubscription` helper; raw emulator API calls do not add authentication.
18
+ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
19
+ `pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
20
+ Use a local service definition or `serviceGroup()` wrapper; the repository’s
21
+ `examples/google-pubsub` demonstrates this using public SDK APIs.
51
22
 
52
23
  ## HTTP assertions
53
24
 
@@ -156,4 +127,36 @@ Clients use trusted TLS on port 443. The ingress selects HTTP/2 with ALPN,
156
127
  routes each stream by `:authority`, and preserves streaming bodies, metadata,
157
128
  trailers, and cancellation. HTTP/1.1 requests still use the existing HTTP proxy.
158
129
  Runtime services support the same option. HTTP/2 streams currently bypass
159
- `ctx.intercept` and HTTP request recording. `pubsub()` enables this by default.
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,3 @@
1
- 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";
2
1
  export { temporal, temporalClient, type TemporalOptions, type TemporalClient, type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller, type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
3
2
  export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
4
3
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
@@ -1,5 +1,4 @@
1
1
  // Reusable, pre-built service definitions for common dependencies.
2
- export { pubsub, pubsubClient } from "./pubsub.js";
3
2
  export { temporal, temporalClient } from "./temporal.js";
4
3
  //
5
4
  // Each component returns a fully-formed `ServiceDefinition` ready to
@@ -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, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, upstreamRequestHeaders, } from "./harness/http-proxy.js";
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
- // `ctx.intercept` middleware runs first, in registration order, and reaches
2053
- // the upstream through `next()`. The CORS headers go on *after* the chain,
2054
- // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2055
- // the trap `page.route`-style interception falls into.
2056
- const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2057
- const res = chain.length === 0
2058
- ? await upstream(req)
2059
- : await runChain(chain, req, upstream, recordInterceptedRequest);
2060
- return augmentCorsResponse(req, res);
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 permissively — the environment is hermetic, so
41
- * there is nothing here to protect from itself, and a test failing on
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 to a proxied response, without overriding the
57
- * upstream's own.
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 permissively — the environment is hermetic, so
68
- * there is nothing here to protect from itself, and a test failing on
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 to a proxied response, without overriding the
95
- * upstream's own.
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 { DnsTarget } from "./ingress.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.97.0",
3
+ "version": "0.99.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,11 +16,6 @@
16
16
  "bun": "./src/components/index.ts",
17
17
  "default": "./dist/components/index.js"
18
18
  },
19
- "./components/gcp": {
20
- "types": "./dist/components/gcp.d.ts",
21
- "bun": "./src/components/gcp.ts",
22
- "default": "./dist/components/gcp.js"
23
- },
24
19
  "./coverage": {
25
20
  "types": "./dist/coverage.d.ts",
26
21
  "bun": "./src/coverage.ts",
@@ -1,6 +1,4 @@
1
1
  // Reusable, pre-built service definitions for common dependencies.
2
- export { pubsub, pubsubClient, type PubSubOptions, type PubSubOidcToken, type PubSubPushConfig, type PubSubDeadLetterPolicy, type PubSubRetryPolicy, type PubSubClient, type PubSubHelpers,
3
- type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage } from "./pubsub.js";
4
2
  export { temporal, temporalClient, type TemporalOptions, type TemporalClient,
5
3
  type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller,
6
4
  type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
@@ -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
- augmentCorsResponse,
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
- // `ctx.intercept` middleware runs first, in registration order, and reaches
2526
- // the upstream through `next()`. The CORS headers go on *after* the chain,
2527
- // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2528
- // the trap `page.route`-style interception falls into.
2529
- const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2530
- const res =
2531
- chain.length === 0
2532
- ? await upstream(req)
2533
- : await runChain(chain, req, upstream, recordInterceptedRequest);
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({