Skip to content

Commit e4db068

Browse files
authored
Proxy /docs to Mintlify at the worker edge (#1035)
Serve executor.sh/docs from the Mintlify deployment (executor.mintlify.dev) on the first-party origin instead of the *.mintlify.dev subdomain, via a new docs reverse-proxy edge middleware. Mirrors the existing PostHog/marketing edge proxies: path-gated on `/docs`, runs before the app dispatch and the SSR auth gate (docs are public, so they skip the sign-in redirect), forwards the path/query unchanged to the upstream, sets X-Forwarded-Host/-Proto so Mintlify can build canonical links, and strips the session cookie so it never reaches the docs origin. `/docs` is disjoint from the app-owned `/api/docs` (Swagger), so it never shadows an Effect-served route.
1 parent 49e1675 commit e4db068

4 files changed

Lines changed: 123 additions & 11 deletions

File tree

apps/cloud/src/edge/docs.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { buildDocsUpstream, isDocsPath } from "./docs";
4+
5+
// The docs proxy claims `/docs` and forwards it to Mintlify. Two things must
6+
// hold: the path matcher catches the docs tree without swallowing the app-owned
7+
// `/api/docs` (Swagger) or unrelated `/docs`-prefixed words, and the rewritten
8+
// upstream request points at Mintlify with the path preserved and the session
9+
// cookie stripped.
10+
describe("isDocsPath", () => {
11+
const docs = ["/docs", "/docs/", "/docs/quickstart", "/docs/guides/auth"];
12+
for (const pathname of docs) {
13+
it(`proxies ${pathname} to Mintlify`, () => {
14+
expect(isDocsPath(pathname)).toBe(true);
15+
});
16+
}
17+
18+
// `/api/docs` is the app-owned Swagger UI — it must reach the Effect handler,
19+
// not Mintlify. `/docsmith` guards against a bare `startsWith("/docs")` that
20+
// would capture unrelated words.
21+
const notDocs = ["/api/docs", "/docsmith", "/", "/home", "/login", "/mcp"];
22+
for (const pathname of notDocs) {
23+
it(`leaves ${pathname} alone`, () => {
24+
expect(isDocsPath(pathname)).toBe(false);
25+
});
26+
}
27+
});
28+
29+
describe("buildDocsUpstream", () => {
30+
const upstreamFor = (url: string, headers?: HeadersInit) =>
31+
buildDocsUpstream(new Request(url, { headers }));
32+
33+
it("swaps the origin to Mintlify over https while preserving path + query", () => {
34+
const upstream = new URL(upstreamFor("https://executor.sh/docs/guides/auth?theme=dark").url);
35+
expect(upstream.hostname).toBe("executor.mintlify.dev");
36+
expect(upstream.protocol).toBe("https:");
37+
expect(upstream.pathname).toBe("/docs/guides/auth");
38+
expect(upstream.search).toBe("?theme=dark");
39+
});
40+
41+
it("forwards the public host so Mintlify can build canonical links", () => {
42+
const upstream = upstreamFor("https://executor.sh/docs");
43+
expect(upstream.headers.get("x-forwarded-host")).toBe("executor.sh");
44+
expect(upstream.headers.get("x-forwarded-proto")).toBe("https");
45+
});
46+
47+
it("strips the cookie so the session never leaks to the docs origin", () => {
48+
const upstream = upstreamFor("https://executor.sh/docs", {
49+
cookie: "wos-session=secret",
50+
});
51+
expect(upstream.headers.has("cookie")).toBe(false);
52+
});
53+
});

apps/cloud/src/edge/docs.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// ---------------------------------------------------------------------------
2+
// Docs reverse proxy — `/docs` (and everything under it) is served by Mintlify,
3+
// not this worker. We forward those requests to the Mintlify deployment so the
4+
// docs live on the first-party origin (`executor.sh/docs`) instead of a
5+
// `*.mintlify.dev` subdomain. Mintlify hosts the site under the same `/docs`
6+
// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap
7+
// to the upstream origin (unlike the PostHog proxy, which strips its prefix).
8+
//
9+
// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs
10+
// the prod-only `env.MARKETING` service binding), this is a plain external
11+
// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in
12+
// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger),
13+
// so this never shadows an Effect-served route.
14+
// ---------------------------------------------------------------------------
15+
16+
import { createMiddleware } from "@tanstack/react-start";
17+
18+
const DOCS_UPSTREAM_HOST = "executor.mintlify.dev";
19+
20+
export const isDocsPath = (pathname: string) =>
21+
pathname === "/docs" || pathname.startsWith("/docs/");
22+
23+
// Build the upstream request for an already-classified `/docs` path. Caller
24+
// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the
25+
// forwarding headers, preserving method, body, path, and query.
26+
export const buildDocsUpstream = (request: Request): Request => {
27+
const url = new URL(request.url);
28+
const forwardedHost = url.host;
29+
30+
url.hostname = DOCS_UPSTREAM_HOST;
31+
url.protocol = "https:";
32+
url.port = "";
33+
34+
const upstream = new Request(url, request);
35+
// Mintlify keys canonical links off the public host; tell it the real one.
36+
upstream.headers.set("X-Forwarded-Host", forwardedHost);
37+
upstream.headers.set("X-Forwarded-Proto", "https");
38+
// Never leak the executor.sh session cookie to the docs origin.
39+
upstream.headers.delete("cookie");
40+
return upstream;
41+
};
42+
43+
export const docsProxyMiddleware = createMiddleware({ type: "request" }).server(
44+
({ pathname, request, next }) => {
45+
if (!isDocsPath(pathname)) return next();
46+
return fetch(buildDocsUpstream(request));
47+
},
48+
);

apps/cloud/src/edge/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// ---------------------------------------------------------------------------
2-
// Edge concerns — the analytics/marketing request middlewares that run at the
3-
// worker edge BEFORE the app's own mcp + api dispatch. None of these touch the
4-
// Effect app layer; they proxy or tunnel to external services (the marketing
5-
// worker, Sentry, PostHog).
2+
// Edge concerns — the analytics/marketing/docs request middlewares that run at
3+
// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch
4+
// the Effect app layer; they proxy or tunnel to external services (the
5+
// marketing worker, Sentry, PostHog, Mintlify docs).
66
// ---------------------------------------------------------------------------
77

88
export { marketingMiddleware } from "./marketing";
99
export { sentryTunnelMiddleware } from "./sentry-tunnel";
1010
export { posthogProxyMiddleware } from "./posthog";
11+
export { docsProxyMiddleware } from "./docs";

apps/cloud/src/start.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { cloudApiHandler } from "./app";
44
import { isAppOwnedPath } from "./app-paths";
55
import { authGateMiddleware } from "./auth/ssr-gate";
66
import { prepareMcpOrgScope } from "./mcp/mount";
7-
import { marketingMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware } from "./edge";
7+
import {
8+
docsProxyMiddleware,
9+
marketingMiddleware,
10+
posthogProxyMiddleware,
11+
sentryTunnelMiddleware,
12+
} from "./edge";
813

914
// ---------------------------------------------------------------------------
1015
// The unified app web handler — `ExecutorApp.make`'s `toWebHandler` (app.ts).
@@ -42,15 +47,20 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server(
4247
},
4348
);
4449

45-
// The edge concerns (marketing proxy, sentry tunnel, posthog proxy) live in
46-
// `./edge`; they run before the app's own dispatch. Ordering is load-bearing:
47-
// marketing first (production landing/page proxy), then the analytics tunnels,
48-
// then the unified app plane (api + mcp), and last the SSR auth gate — it only
49-
// sees document requests nothing above claimed, so signed-out visitors are
50-
// redirected to /login before the SPA (and its app-shell skeleton) is served.
50+
// The edge concerns (marketing proxy, docs proxy, sentry tunnel, posthog proxy)
51+
// live in `./edge`; they run before the app's own dispatch. Ordering is
52+
// load-bearing: marketing first (production landing/page proxy), then the docs
53+
// proxy and analytics tunnels, then the unified app plane (api + mcp), and last
54+
// the SSR auth gate — it only sees document requests nothing above claimed, so
55+
// signed-out visitors are redirected to /login before the SPA (and its
56+
// app-shell skeleton) is served. The docs proxy sits among the edges (not after
57+
// the auth gate) because `/docs` is public and must skip the sign-in redirect;
58+
// its path is disjoint from every other matcher, so its slot is not otherwise
59+
// load-bearing.
5160
export const startInstance = createStart(() => ({
5261
requestMiddleware: [
5362
marketingMiddleware,
63+
docsProxyMiddleware,
5464
sentryTunnelMiddleware,
5565
posthogProxyMiddleware,
5666
appRequestMiddleware,

0 commit comments

Comments
 (0)