diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index f049201169..93846f5e49 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -608,6 +608,144 @@ it.live("tells upstreams the client address", () => const headerOf = (value: string | ReadonlyArray | undefined) => value === undefined || typeof value === "string" ? value : value.join(", "); +it.live("tells upstreams the scheme, host, port, path and stripped prefix the client reached", () => + Effect.scoped( + Effect.gen(function* () { + const forwarded = ( + headers: Readonly | undefined>>, + ) => ({ + proto: headerOf(headers["x-forwarded-proto"]), + host: headerOf(headers["x-forwarded-host"]), + port: headerOf(headers["x-forwarded-port"]), + path: headerOf(headers["x-forwarded-path"]), + prefix: headerOf(headers["x-forwarded-prefix"]), + }); + const backend = createServer((incoming, response) => { + response.end(JSON.stringify(forwarded(incoming.headers))); + }); + const sockets = new WebSocketServer({ server: backend }); + const upgraded = yield* Deferred.make, HttpProxyTestError>(); + sockets.on("connection", (socket, incoming) => { + Deferred.doneUnsafe(upgraded, Effect.succeed(forwarded(incoming.headers))); + socket.close(); + }); + const backendAddress = yield* listen(backend, { beforeClose: () => sockets.close() }); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + const target = Effect.succeed(backendAddress); + yield* proxy.setRoutes([ + { id: "functions", prefix: "/functions/v1", upstreamPrefix: "/", gateway: true, target }, + { + id: "graphql", + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + forwardedPrefix: "/graphql/v1", + gateway: true, + target, + }, + { id: "studio", prefix: "/", target }, + ]); + const spoofed = { + "x-forwarded-proto": "https", + "x-forwarded-host": "spoofed.example", + "x-forwarded-port": "443", + "x-forwarded-path": "/spoofed", + "x-forwarded-prefix": "/spoofed", + }; + const send = (path: string, headers: Readonly>) => + request(proxy.port, path, new Uint8Array(), headers, "GET").pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))), + ); + const port = String(proxy.port); + + expect(yield* send("/functions/v1/hello?name=x", spoofed)).toEqual({ + proto: "http", + host: "127.0.0.1", + port, + path: "/functions/v1/hello", + prefix: "/functions/v1/", + }); + expect(yield* send("/functions/v1/hello", { host: "LOCALHOST.:54321" })).toEqual( + expect.objectContaining({ host: "localhost" }), + ); + expect(yield* send("/graphql/v1", {})).toEqual( + expect.objectContaining({ path: "/graphql/v1", prefix: "/graphql/v1" }), + ); + /* A dedicated listener's route is not Kong's, so it adds none of them. */ + expect(yield* send("/project/default", { host: "Studio.Example.test:9999" })).toEqual({ + proto: undefined, + host: undefined, + port: undefined, + path: undefined, + prefix: undefined, + }); + + const client = new WebSocket(`ws://127.0.0.1:${proxy.port}/functions/v1/socket`, { + headers: spoofed, + }); + client.on("error", (cause) => + Deferred.doneUnsafe( + upgraded, + Effect.fail(new HttpProxyTestError({ message: cause.message, cause })), + ), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => client.terminate())); + expect(yield* Deferred.await(upgraded).pipe(Effect.timeout("10 seconds"))).toEqual({ + proto: "http", + host: "127.0.0.1", + port, + path: "/functions/v1/socket", + prefix: "/functions/v1/", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("adds a route's default headers only when the client sent none", () => + Effect.scoped( + Effect.gen(function* () { + const backend = createServer((incoming, response) => { + response.end( + JSON.stringify({ url: incoming.url, profile: incoming.headers["content-profile"] }), + ); + }); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([ + { + id: "graphql", + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + addHeaders: { "content-profile": "graphql_public" }, + target: Effect.succeed(backendAddress), + }, + { + id: "mixed", + prefix: "/mixed", + upstreamPrefix: "/rpc/graphql", + addHeaders: { "Content-Profile": "graphql_public" }, + target: Effect.succeed(backendAddress), + }, + ]); + const send = (headers: Readonly>, path = "/graphql/v1") => + request(proxy.port, path, new Uint8Array(), headers).pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))), + ); + + expect(yield* send({})).toEqual({ url: "/rpc/graphql", profile: "graphql_public" }); + expect(yield* send({ "Content-Profile": "private" })).toEqual({ + url: "/rpc/graphql", + profile: "private", + }); + expect(yield* send({ "content-profile": "private" }, "/mixed")).toEqual({ + url: "/rpc/graphql", + profile: "private", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + it.live("returns a gateway error naming the route and cause when a target cannot wake", () => { const logs: Array = []; return Effect.scoped( diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index a1394e4358..60232c13c1 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -28,6 +28,12 @@ export interface HttpRoute { readonly target: Effect.Effect; readonly upstreamPrefix?: string; readonly upstreamHost?: string; + /** Headers sent only when the client sent none, like Kong's request-transformer `add`. */ + readonly addHeaders?: Readonly>; + /** X-Forwarded-Prefix of a stripped route; defaults to `prefix/`, legacy Kong's path form. */ + readonly forwardedPrefix?: string; + /** A shared API listener route, which sends legacy Kong's X-Forwarded-Proto/Host/Port/Path/Prefix. */ + readonly gateway?: boolean; readonly keyRewrite?: HttpRouteKeyRewrite; /** Sends each write over a fresh upstream connection; reads still share the pool and its retry. */ readonly freshWrites?: boolean; @@ -144,6 +150,29 @@ const upstreamHeadersFor = (request: IncomingMessage, route: HttpRoute) => { result["x-forwarded-for"] = forwardedFor === undefined ? client : `${forwardedFor}, ${client}`; result["x-real-ip"] = client; } + /* Like Kong without trusted IPs: client-sent X-Forwarded-* values are replaced, the host is + nginx's `$host` (lowercase, no port or trailing dot: ngx_http_validate_host, release-1.19.10 + src/http/ngx_http_request.c#L2026-L2028), and only a stripped route sends its prefix. */ + if (route.gateway === true) { + const host = headerValue(headers.host) + ?.toLowerCase() + .replace(/\.?(?::\d*)?$/u, ""); + const port = request.socket.localPort; + const prefix = + route.upstreamPrefix === undefined || route.prefix === "/" + ? undefined + : (route.forwardedPrefix ?? `${route.prefix}/`); + result["x-forwarded-proto"] = "http"; + if (host === undefined) delete result["x-forwarded-host"]; + else result["x-forwarded-host"] = host; + if (port === undefined) delete result["x-forwarded-port"]; + else result["x-forwarded-port"] = String(port); + result["x-forwarded-path"] = pathnameFor(request.url ?? "/"); + if (prefix === undefined) delete result["x-forwarded-prefix"]; + else result["x-forwarded-prefix"] = prefix; + } + for (const [name, value] of Object.entries(route.addHeaders ?? {})) + result[name.toLowerCase()] ??= value; const keyRewrite = route.keyRewrite; if (keyRewrite?.policy === "bearer") { const value = bearerValueFor(headers, keyRewrite.keys); diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 71ea4f4d3a..e4a5a93ee4 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -3,12 +3,12 @@ import { expect, it } from "@effect/vitest"; import { Context, Data, Deferred, Effect, Fiber, FileSystem, Hash, Layer, Path, Ref } from "effect"; import { randomUUID } from "node:crypto"; import * as Net from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- fixture retains an idle HTTP connection. -import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. +import { createServer, type RequestListener } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. import { HttpBody, HttpClient } from "effect/http"; import * as Network from "./Network.ts"; import * as PortReservations from "./namespace/PortReservations.ts"; import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; -import { freshWrites } from "./host/Endpoints.ts"; +import { freshWrites, joinRoutes, sharedRoutes } from "./host/Endpoints.ts"; import * as StackNamespace from "./StackNamespace.ts"; const makeTestState = (root: string) => @@ -45,34 +45,37 @@ const reservedPort = (root: string, stackId: string, key: string) => class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const backend = Effect.acquireRelease( - Effect.callback< - { host: string; port: number; server: ReturnType }, - FixtureError - >((resume) => { - const server = createServer((request, response) => { - response.end(`backend:${request.url ?? "/"}`); - }); - const onError = (cause: Error) => - resume(Effect.fail(new FixtureError({ message: cause.message }))); - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address === null || typeof address === "string") - resume(Effect.fail(new FixtureError({ message: "no address" }))); - else resume(Effect.succeed({ host: "127.0.0.1", port: address.port, server })); - }); - return Effect.sync(() => { - server.off("error", onError); - server.close(); - }); - }), - ({ server }) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; +const backendWith = (handler: RequestListener) => + Effect.acquireRelease( + Effect.callback< + { host: string; port: number; server: ReturnType }, + FixtureError + >((resume) => { + const server = createServer(handler); + const onError = (cause: Error) => + resume(Effect.fail(new FixtureError({ message: cause.message }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") + resume(Effect.fail(new FixtureError({ message: "no address" }))); + else resume(Effect.succeed({ host: "127.0.0.1", port: address.port, server })); + }); + return Effect.sync(() => { + server.off("error", onError); + server.close(); + }); }), -); + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +const backend = backendWith((request, response) => { + response.end(`backend:${request.url ?? "/"}`); +}); const request = (host: string, port: number, path: string) => Effect.gen(function* () { @@ -574,6 +577,147 @@ it.live("wakes a sleeping backend when a request reaches its joined route", () = ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); +/** Answers with its own name and what a gateway route forwarded to it. */ +const echo = + (name: string): RequestListener => + (incoming, response) => { + response.end( + JSON.stringify({ + backend: name, + url: incoming.url, + authorization: incoming.headers.authorization, + profile: incoming.headers["content-profile"], + prefix: incoming.headers["x-forwarded-prefix"], + }), + ); + }; + +it.live("serves legacy Kong's gateway routes while each service runs", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-gateway-routes-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack")); + const api = yield* backendWith(echo("api")); + const admin = yield* backendWith(echo("admin")); + const keys = { + publishableKey: "sb_publishable_example", + secretKey: "sb_secret_example", + anonKey: "anon.jwt.value", + serviceRoleKey: "service.role.jwt", + }; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const register = (service: "rest" | "auth" | "pgmeta" | "analytics" | "pooler") => + network + .register({ + id: service, + endpoints: { + http: { + ...endpoint(api, Effect.succeed(true)), + shared: sharedRoutes({ service }, "http", keys, (name) => + Effect.succeed(name === "admin" ? admin : api), + ), + join: joinRoutes({ service }, "http"), + }, + }, + }) + .pipe(Effect.tap((namespace) => namespace.bind)); + const rest = yield* register("rest"); + for (const service of ["auth", "pgmeta", "analytics"] as const) yield* register(service); + const address = yield* rest.address("http", "host"); + const send = (path: string, headers: Readonly> = {}) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get(`http://${address.host}:${address.port}${path}`, { + headers, + }); + return response.status === 404 ? 404 : yield* response.json; + }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)); + const publishable = { apikey: keys.publishableKey }; + + expect(yield* send("/graphql/v1", publishable)).toEqual({ + backend: "api", + url: "/rpc/graphql", + authorization: "Bearer anon.jwt.value", + profile: "graphql_public", + prefix: "/graphql/v1", + }); + expect(yield* send("/graphql/v1", { ...publishable, "content-profile": "app" })).toEqual( + expect.objectContaining({ profile: "app" }), + ); + expect(yield* send("/rest-admin/v1/ready")).toEqual({ + backend: "admin", + url: "/ready", + prefix: "/rest-admin/v1/", + }); + for (const path of [ + "/auth/v1/verify?token=t&type=magiclink", + "/auth/v1/callback?code=c", + "/auth/v1/authorize?provider=github", + ]) + expect(yield* send(path, publishable)).toEqual({ + backend: "api", + url: path.slice("/auth/v1".length), + prefix: path.split("?", 1)[0], + }); + expect(yield* send("/auth/v1/user", publishable)).toEqual( + expect.objectContaining({ url: "/user", authorization: "Bearer anon.jwt.value" }), + ); + expect(yield* send("/.well-known/oauth-authorization-server")).toEqual({ + backend: "api", + url: "/.well-known/oauth-authorization-server", + prefix: "/.well-known/oauth-authorization-server", + }); + expect(yield* send("/pg/tables")).toEqual({ backend: "api", url: "/tables", prefix: "/pg/" }); + expect(yield* send("/analytics/v1/health")).toEqual( + expect.objectContaining({ url: "/health", prefix: "/analytics/v1/" }), + ); + expect(yield* send("/pooler/v2/tenants")).toBe(404); + yield* register("pooler"); + expect(yield* send("/pooler/v2/tenants")).toEqual( + expect.objectContaining({ url: "/v2/tenants", prefix: "/pooler/v2/" }), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("keeps a dedicated listener's client X-Forwarded headers without Kong's gateway set", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-dedicated-headers-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack")); + const target = yield* backendWith((incoming, response) => { + response.end(JSON.stringify(incoming.headers)); + }); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: endpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + const address = yield* studio.address("http", "host"); + const client = yield* HttpClient.HttpClient; + const response = yield* client.get(`http://${address.host}:${address.port}/`, { + headers: { "x-forwarded-proto": "https", "x-forwarded-host": "spoofed.example" }, + }); + const headers = yield* response.json; + expect(headers).toEqual( + expect.objectContaining({ + "x-forwarded-proto": "https", + "x-forwarded-host": "spoofed.example", + "x-forwarded-for": "127.0.0.1", + "x-real-ip": "127.0.0.1", + }), + ); + for (const name of ["x-forwarded-port", "x-forwarded-path", "x-forwarded-prefix"]) + expect(headers).not.toHaveProperty(name); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live("addresses docker runtime endpoints through the stack host alias", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index 33397a07a3..36fc8cf230 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -8,10 +8,12 @@ import { makeHttpProxy, type HttpAccessSink, type HttpProxy, type HttpRoute } fr export type NetworkRuntime = "native" | "docker" | "podman"; +/* `target` serves a route from another backend than its endpoint's, like PostgREST's admin port. */ type RouteContribution = Pick< HttpRoute, - "prefix" | "upstreamPrefix" | "upstreamHost" | "keyRewrite" ->; + "prefix" | "upstreamPrefix" | "upstreamHost" | "addHeaders" | "forwardedPrefix" | "keyRewrite" +> & + Partial>; export interface NetworkEndpoint { readonly protocol: "tcp" | "http"; @@ -122,20 +124,19 @@ const makeNetwork = (options: { const routeKey = (route: Pick) => `${route.id}:${route.prefix}`; - /** Maps one endpoint's route contributions to `HttpRoute`s on a dedicated, shared or joined listener. */ + /** Maps one endpoint's route contributions to `HttpRoute`s; shared and joined ones are `gateway` routes. */ const toHttpRoutes = ( id: string, contributions: ReadonlyArray, endpoint: Pick, + gateway: boolean, ): ReadonlyArray => contributions.map((route) => ({ + ...route, id, - prefix: route.prefix, - upstreamPrefix: route.upstreamPrefix, - upstreamHost: route.upstreamHost, - ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), + ...(gateway ? { gateway: true } : {}), ...(endpoint.freshWrites === true ? { freshWrites: true } : {}), - target: endpoint.backend, + target: route.target ?? endpoint.backend, })); /** Installs a namespace's joined routes onto the shared listener, or queues them if it does not exist yet. */ @@ -144,7 +145,7 @@ const makeNetwork = (options: { join: NonNullable, endpoint: NetworkEndpoint, ) { - const routes = toHttpRoutes(routeId, join, endpoint); + const routes = toHttpRoutes(routeId, join, endpoint, true); const ownKeys = new Set(routes.map(routeKey)); const current = yield* Ref.get(shared); if (current === undefined) { @@ -209,7 +210,7 @@ const makeNetwork = (options: { yield* probe(key, host, port); const proxy = yield* makeHttpProxy({ host, port }); yield* proxy.setRoutes( - toHttpRoutes(id, [{ prefix: "/" }], endpoint), + toHttpRoutes(id, [{ prefix: "/" }], endpoint, false), ); return { proxy }; } @@ -248,7 +249,7 @@ const makeNetwork = (options: { else if (seeded.length > 0) yield* Ref.set(pendingJoins, []); const routes = [ ...current.routes, - ...toHttpRoutes(id, endpoint.shared, endpoint), + ...toHttpRoutes(id, endpoint.shared, endpoint, true), ]; yield* current.proxy.setRoutes(routes); yield* Ref.set(shared, { ...current, routes }); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index 4467d43b89..397a70ce06 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -579,24 +579,22 @@ const makeOwner = Effect.fn("Owner.make")(function* ( Effect.flatMap((next) => mergeInputs(next, inputs)), Effect.flatMap(requireInputs), ); + const backendFor = (name: string): NetworkEndpoint["backend"] => + orchestrator.acquire(id, name !== "inspector", `traffic on endpoint ${name}`).pipe( + Effect.andThen(recipe.endpoint(name)), + Effect.map(backendAddress), + Effect.mapError((cause) => + cause instanceof ProxyError ? cause : new ProxyError({ message: cause.message, cause }), + ), + ); const endpoints = Object.fromEntries( endpointNames(initial).map((name) => { - const shared = sharedRoutes(initial, name, routeKeys); + const shared = sharedRoutes(initial, name, routeKeys, backendFor); const join = joinRoutes(initial, name); const endpoint: NetworkEndpoint = { protocol: name === "http" ? "http" : "tcp", port: endpointPort(initial, name), - backend: orchestrator - .acquire(id, name !== "inspector", `traffic on endpoint ${name}`) - .pipe( - Effect.andThen(recipe.endpoint(name)), - Effect.map(backendAddress), - Effect.mapError((cause) => - cause instanceof ProxyError - ? cause - : new ProxyError({ message: cause.message, cause }), - ), - ), + backend: backendFor(name), enabled, ...(freshWrites(initial, name) ? { freshWrites: true } : {}), ...(shared === undefined ? {} : { shared }), diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 2a45395b0d..535ca9ba5d 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -57,11 +57,15 @@ export const sharesApiEndpoint = (creation: EndpointIntents): boolean => type RouteKeys = NonNullable[number]["keyRewrite"]>["keys"]; -/** Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. */ +/** + * Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. + * `backend` reaches another of the service's endpoints, like PostgREST's admin port. + */ export const sharedRoutes = ( - creation: ServiceCreation, + creation: Pick, name: string, keys: RouteKeys, + backend: (endpoint: string) => NetworkEndpoint["backend"], ): NetworkEndpoint["shared"] => { const route = name === "http" ? apiRoute(creation.service) : undefined; if (route === undefined) return undefined; @@ -72,6 +76,7 @@ export const sharedRoutes = ( prefix: "/realtime/v1/api", upstreamPrefix: "/api", upstreamHost: "realtime-dev", + forwardedPrefix: "/realtime/v1/api", keyRewrite: { policy: "bearer", keys }, }, { @@ -87,8 +92,32 @@ export const sharedRoutes = ( { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, ]; case "rest": + return [ + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + { + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + forwardedPrefix: "/graphql/v1", + addHeaders: { "content-profile": "graphql_public" }, + keyRewrite: { policy: "bearer", keys }, + }, + { prefix: "/rest-admin/v1", upstreamPrefix: "/", target: backend("admin") }, + ]; case "auth": - return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }]; + return [ + /* Like legacy Kong, email links, OAuth redirects and discovery keep the client's headers. */ + ...["verify", "callback", "authorize"].map((path) => ({ + prefix: `${route}/${path}`, + upstreamPrefix: `/${path}`, + forwardedPrefix: `${route}/${path}`, + })), + { + prefix: "/.well-known/oauth-authorization-server", + upstreamPrefix: "/.well-known/oauth-authorization-server", + forwardedPrefix: "/.well-known/oauth-authorization-server", + }, + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + ]; case "functions": return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "sb-api-key", keys } }]; default: @@ -96,11 +125,28 @@ export const sharedRoutes = ( } }; -/** Shared-listener routes a dedicated HTTP endpoint also serves: Studio's MCP server at `/mcp`. */ -export const joinRoutes = (creation: ServiceCreation, name: string): NetworkEndpoint["join"] => - creation.service === "studio" && name === "http" - ? [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }] - : undefined; +/** + * Shared-listener routes a dedicated HTTP endpoint also serves while its service runs, like + * legacy Kong's: Studio's MCP server, postgres-meta, Logflare and Supavisor's API. + */ +export const joinRoutes = ( + creation: Pick, + name: string, +): NetworkEndpoint["join"] => { + if (name !== "http") return undefined; + switch (creation.service) { + case "studio": + return [{ prefix: "/mcp", upstreamPrefix: "/api/mcp", forwardedPrefix: "/mcp" }]; + case "pgmeta": + return [{ prefix: "/pg", upstreamPrefix: "/" }]; + case "analytics": + return [{ prefix: "/analytics/v1", upstreamPrefix: "/" }]; + case "pooler": + return [{ prefix: "/pooler/v2", upstreamPrefix: "/v2" }]; + default: + return undefined; + } +}; /** * Whether an endpoint's writes each take a fresh upstream connection instead of the pool: only