diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index b6af946dda..66645162dd 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { isMarketingPath, marketingProxyRequest } from "./marketing"; +import { + isMarketingPath, + isSignUpPath, + isV2Path, + marketingProxyRequest, + parseV2Edge, + v2EdgeResponse, + type V2EdgeEnv, + type V2Service, +} from "./marketing"; // On executor.sh the marketing middleware proxies an allow-list of paths to the // `executor-marketing` worker; everything else falls through to the auth-gated @@ -101,3 +110,464 @@ describe("marketingProxyRequest", () => { expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); }); }); + +// v2 on executor.sh: sign-up redirects to v2, and a fixed list of paths is +// forwarded to v2's Worker. Everything else stays with v1. +describe("isV2Path", () => { + const forwarded = [ + "/.well-known/oauth-authorization-server/api/auth", + "/api/auth/callback/google", + "/api/auth/callback/github", + "/git/acme/tools/info/refs", + "/git/acme/tools/git-upload-pack", + "/git/acme/tools/git-receive-pack", + "/.well-known/agent-skills/", + "/.well-known/agent-skills/index.json", + ]; + for (const pathname of forwarded) { + it(`forwards ${pathname} to v2`, () => { + expect(isV2Path(pathname)).toBe(true); + }); + } + + const v1Owned = [ + // v1's WorkOS callback has no provider segment. + "/api/auth/callback", + "/api/auth/callback/", + "/api/auth/callback/google/extra", + "/api/auth/login", + "/api/auth/me", + // v1's own issuer metadata and client metadata document. + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server/api/auth/extra", + "/.well-known/oauth-protected-resource/mcp", + "/oauth/client-id-metadata.json", + // v2 does not serve OpenID configuration on executor.sh, and its client + // metadata document's move to executor.sh is pending. + "/api/auth/.well-known/openid-configuration", + "/oauth/client-metadata.json", + // Git remotes need a path under /git/. + "/git", + "/gitlab/acme/tools/info/refs", + "/github", + "/.well-known/agent-skills", + "/.well-known/agent-skillset/index.json", + // The connected-account callback goes by its state, not its path. + "/api/oauth/callback", + // Not yet: marketing. + "/pricing", + // v1 dashboard, org pages and MCP, including org slugs that look similar. + "/", + "/login", + "/mcp", + "/acme/mcp", + "/gitops/mcp", + "/git-team/policies", + "/oauth-team/mcp", + "/api/connections", + ]; + for (const pathname of v1Owned) { + it(`leaves ${pathname} with v1`, () => { + expect(isV2Path(pathname)).toBe(false); + }); + } +}); + +describe("isSignUpPath", () => { + it("claims /sign-up and /signup only", () => { + expect(isSignUpPath("/sign-up")).toBe(true); + expect(isSignUpPath("/signup")).toBe(true); + expect(isSignUpPath("/sign-up/")).toBe(false); + expect(isSignUpPath("/signup/team")).toBe(false); + expect(isSignUpPath("/sign-in")).toBe(false); + expect(isSignUpPath("/signups")).toBe(false); + }); +}); + +describe("parseV2Edge", () => { + const service: V2Service = { fetch: () => Promise.resolve(new Response(null)) }; + const settings = { + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + }; + + it("is off when no setting is present", () => { + expect(parseV2Edge({})).toBeNull(); + }); + + it("refuses any setting set without the others", () => { + expect(typeof parseV2Edge({ ...settings, V2: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: undefined })).toBe("string"); + expect(typeof parseV2Edge({ V2_OAUTH_STATE_PREFIX: "x2." })).toBe("string"); + }); + + it("refuses a sign-up URL that is not absolute http(s)", () => { + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: "/login?mode=signup" })).toBe( + "string", + ); + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: "javascript:alert(1)" })).toBe( + "string", + ); + }); + + // v1's states are base64url (raw, or the org-wrapped JSON encoding), so a + // prefix made only of base64url characters could claim a v1 callback. + it("refuses a state prefix that a v1 state could start with", () => { + for (const prefix of ["", "x2", "x2-", "x2_", "eyJ", "x2 .", "x2.%", "x2./"]) { + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: prefix })).toBe("string"); + } + }); + + it("parses all settings", () => { + const edge = parseV2Edge(settings); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.signUpUrl.href).toBe("https://app.executor.sh/login?mode=signup"); + expect(edge.oauthStatePrefix).toBe("x2."); + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: "v2~" })).toBe("object"); + }); +}); + +describe("v2EdgeResponse", () => { + const SIGN_UP_URL = "https://v2.executor.sh/login?mode=signup"; + const STATE_PREFIX = "x2."; + + /** The edge's settings with `service` as v2's Worker. */ + const settings = (service: V2Service, signUpUrl = SIGN_UP_URL): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: signUpUrl, + V2_OAUTH_STATE_PREFIX: STATE_PREFIX, + }); + + /** A v2 service that records what it receives and answers with `respond`. */ + const recordingService = (respond: (request: Request) => Promise | Response) => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return respond(request); + }, + }; + return { received, service }; + }; + + it("redirects sign-up to v2's configured sign-up page, ignoring the query", async () => { + const { received, service } = recordingService(() => new Response(null)); + + for (const url of ["https://executor.sh/sign-up", "https://executor.sh/signup?ref=docs"]) { + const response = await v2EdgeResponse(new Request(url), settings(service)); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe(SIGN_UP_URL); + } + expect(received).toHaveLength(0); + }); + + it("follows the configured sign-up URL", async () => { + const response = await v2EdgeResponse( + new Request("https://executor.sh/signup"), + settings( + { fetch: () => Promise.resolve(new Response(null)) }, + "https://app.executor.sh/sign-up", + ), + ); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/sign-up"); + }); + + it("leaves non-GET sign-up requests with v1", () => { + const { service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse( + new Request("https://executor.sh/sign-up", { method: "POST" }), + settings(service), + ), + ).toBeNull(); + }); + + it("answers edge requests with a 500 on a broken setting and leaves other paths with v1", async () => { + const { received, service } = recordingService(() => new Response(null)); + const response = await v2EdgeResponse( + new Request("https://executor.sh/sign-up"), + settings(service, "/relative"), + ); + expect(response?.status).toBe(500); + expect( + v2EdgeResponse(new Request("https://executor.sh/acme/mcp"), settings(service, "/relative")), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("is off without settings", () => { + expect(v2EdgeResponse(new Request("https://executor.sh/sign-up"), {})).toBeNull(); + }); + + it("only acts on executor.sh", () => { + const { service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/sign-up"), settings(service)), + ).toBeNull(); + expect( + v2EdgeResponse( + new Request("https://v2.executor.sh/api/auth/callback/google"), + settings(service), + ), + ).toBeNull(); + }); + + it("leaves v1 paths with v1", () => { + const { received, service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse( + new Request("https://executor.sh/api/auth/callback?code=c"), + settings(service), + ), + ).toBeNull(); + expect( + v2EdgeResponse(new Request("https://executor.sh/gitlab/x/info/refs"), settings(service)), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("forwards with the public host, path and query, and returns v2's redirect unfollowed", async () => { + const { received, service } = recordingService( + () => + new Response(null, { + status: 302, + headers: { location: "https://app.executor.sh/api/auth/callback/google?code=c&state=s" }, + }), + ); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/api/auth/callback/google?code=c&state=s"), + settings(service), + ); + + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe( + "https://app.executor.sh/api/auth/callback/google?code=c&state=s", + ); + expect(received).toHaveLength(1); + expect(received[0]?.url).toBe("https://executor.sh/api/auth/callback/google?code=c&state=s"); + expect(received[0]?.redirect).toBe("manual"); + }); + + it("strips v1's cookies and client forwarding headers but keeps Authorization", async () => { + const { received, service } = recordingService(() => new Response("ok")); + + await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/info/refs?service=git-upload-pack", { + headers: { + authorization: "Basic dXNlcjp0b2tlbg==", + cookie: "wos-session=sealed; ph_id=1", + "git-protocol": "version=2", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "http", + }, + }), + settings(service), + ); + + const forwarded = received[0]; + expect(forwarded?.headers.get("authorization")).toBe("Basic dXNlcjp0b2tlbg=="); + expect(forwarded?.headers.get("git-protocol")).toBe("version=2"); + expect(forwarded?.headers.has("cookie")).toBe(false); + expect(forwarded?.headers.has("x-forwarded-host")).toBe(false); + expect(forwarded?.headers.has("x-forwarded-proto")).toBe(false); + expect(new URL(forwarded?.url ?? "").search).toBe("?service=git-upload-pack"); + }); + + it("returns v2's response unchanged, status and body stream included", async () => { + const upstream = new Response("not found", { + status: 404, + headers: { "content-type": "text/plain", "www-authenticate": 'Basic realm="git"' }, + }); + const { service } = recordingService(() => upstream); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/info/refs"), + settings(service), + ); + + expect(response).toBe(upstream); + }); + + it("streams a git push body to v2 without buffering it, preserving the method", async () => { + const encoder = new TextEncoder(); + let source: ReadableStreamDefaultController | undefined; + const body = new ReadableStream({ + start(controller) { + source = controller; + controller.enqueue(encoder.encode("first-pack-chunk")); + }, + }); + // v2 reads the first chunk while the client is still sending: a buffering + // edge would wait for the end of the body and never reach v2. + const { received, service } = recordingService(async (request) => { + const reader = request.body?.getReader(); + if (reader === undefined) return new Response("no body", { status: 500 }); + const first = await reader.read(); + source?.enqueue(encoder.encode("second-pack-chunk")); + source?.close(); + const second = await reader.read(); + const done = await reader.read(); + const decoder = new TextDecoder(); + return new Response( + `${decoder.decode(first.value)}|${decoder.decode(second.value)}|${done.done}`, + ); + }); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/git-receive-pack", { + method: "POST", + headers: { "content-type": "application/x-git-receive-pack-request" }, + body, + // @ts-expect-error -- Node's fetch needs `duplex` for a stream body; workerd does not. + duplex: "half", + }), + settings(service), + ); + + expect(received[0]?.method).toBe("POST"); + expect(received[0]?.headers.get("content-type")).toBe("application/x-git-receive-pack-request"); + expect(await response?.text()).toBe("first-pack-chunk|second-pack-chunk|true"); + }); +}); + +// v1 and v2 share `/api/oauth/callback` on executor.sh. v2 starts its state +// with a fixed prefix; the edge reads only the query's `state` to decide. +describe("v2EdgeResponse connected-account callback", () => { + const settings = (service: V2Service): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + }); + + const recordingService = () => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return new Response(null, { + status: 302, + headers: { location: `https://app.executor.sh${new URL(request.url).search}` }, + }); + }, + }; + return { received, service }; + }; + + it("forwards a callback whose state carries v2's prefix, query unchanged", async () => { + const { received, service } = recordingService(); + const callback = "https://executor.sh/api/oauth/callback?code=c&state=x2.abc123"; + + const response = await v2EdgeResponse( + new Request(callback, { headers: { cookie: "wos-session=sealed" } }), + settings(service), + ); + + expect(response?.status).toBe(302); + expect(received).toHaveLength(1); + expect(received[0]?.url).toBe(callback); + expect(received[0]?.redirect).toBe("manual"); + expect(received[0]?.headers.has("cookie")).toBe(false); + }); + + it("reads a percent-encoded prefix as the prefix", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback?state=x2%2Eabc&code=c"), + settings(service), + ); + + expect(received).toHaveLength(1); + }); + + it("forwards v2's provider errors, which carry the state but no code", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback?error=access_denied&state=x2.abc"), + settings(service), + ); + + expect(received).toHaveLength(1); + }); + + const v1Callbacks = [ + // v1's raw state and its org-wrapped base64url JSON state. + "https://executor.sh/api/oauth/callback?code=c&state=Q2xpZW50U3RhdGUxMjM0NTY3ODkw", + "https://executor.sh/api/oauth/callback?code=c&state=eyJzdGF0ZSI6InMiLCJvcmdTbHVnIjoiYWNtZSJ9", + // No state, or an empty one. + "https://executor.sh/api/oauth/callback?code=c", + "https://executor.sh/api/oauth/callback?code=c&state=", + "https://executor.sh/api/oauth/callback", + // Lookalikes: the prefix without its dot, another case, inside the + // value, or in another parameter. + "https://executor.sh/api/oauth/callback?code=c&state=x2abc", + "https://executor.sh/api/oauth/callback?code=c&state=x2-abc", + "https://executor.sh/api/oauth/callback?code=c&state=X2.abc", + "https://executor.sh/api/oauth/callback?code=c&state=ax2.abc", + "https://executor.sh/api/oauth/callback?code=c&state=%20x2.abc", + "https://executor.sh/api/oauth/callback?code=x2.abc&state=v1state", + "https://executor.sh/api/oauth/callback?code=c&xstate=x2.abc", + // Only the first state counts. + "https://executor.sh/api/oauth/callback?state=v1state&state=x2.abc", + // Other paths with a v2 state. + "https://executor.sh/api/oauth/callback/?state=x2.abc", + "https://executor.sh/api/oauth/callbacks?state=x2.abc", + "https://executor.sh/api/oauth/callback/extra?state=x2.abc", + "https://executor.sh/acme/api/oauth/callback?state=x2.abc", + ]; + for (const url of v1Callbacks) { + it(`leaves ${new URL(url).pathname}${new URL(url).search} with v1`, () => { + const { received, service } = recordingService(); + expect(v2EdgeResponse(new Request(url), settings(service))).toBeNull(); + expect(received).toHaveLength(0); + }); + } + + it("decides from the query without reading a posted body", () => { + const { received, service } = recordingService(); + const body = new ReadableStream({ + pull: () => expect.unreachable("the edge must not read the body"), + }); + + expect( + v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + // @ts-expect-error -- Node's fetch needs `duplex` for a stream body; workerd does not. + duplex: "half", + }), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("only acts on executor.sh", () => { + const { received, service } = recordingService(); + expect( + v2EdgeResponse( + new Request("https://v2.executor.sh/api/oauth/callback?state=x2.abc"), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("leaves every callback with v1 when the settings are absent or broken", () => { + const { received, service } = recordingService(); + const request = () => new Request("https://executor.sh/api/oauth/callback?state=x2.abc"); + + expect(v2EdgeResponse(request(), {})).toBeNull(); + expect(v2EdgeResponse(request(), { ...settings(service), V2_SIGN_UP_URL: "/x" })).toBeNull(); + expect( + v2EdgeResponse(request(), { ...settings(service), V2_OAUTH_STATE_PREFIX: "x2" }), + ).toBeNull(); + expect(received).toHaveLength(0); + }); +}); diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 3e0fa5493d..31942b1cae 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -1,10 +1,18 @@ // --------------------------------------------------------------------------- -// Marketing routes — proxied to the marketing worker via service binding. +// The `executor.sh` edge — decides which Worker answers a public request. // -// On the production domain (`executor.sh`), marketing paths and the -// unauthenticated landing page are served by the separate `executor-marketing` -// worker. This module deliberately has no TanStack Start or cloud application -// imports: the Worker entry calls it before loading the Start server graph. +// On the production domain (`executor.sh`): +// - marketing paths and the unauthenticated landing page go to the separate +// `executor-marketing` worker; +// - sign-up goes to v2 (a redirect to v2's sign-up page); +// - a fixed list of v2 paths (sign-in issuer metadata, social sign-in +// callbacks, Git smart HTTP and the agent skills index) is forwarded to +// v2's Worker over a service binding; +// - so is a connected-account OAuth callback whose `state` carries v2's +// prefix. +// v1 owns everything not listed. This module deliberately has no TanStack +// Start or cloud application imports: the Worker entry calls it before +// loading the Start server graph. // --------------------------------------------------------------------------- import { parseCookie } from "../auth/cookies"; @@ -32,6 +40,8 @@ const MARKETING_PATHS = [ const SESSION_COOKIE = "wos-session"; +const PRODUCTION_HOST = "executor.sh"; + /** Whether an exact pathname belongs to the public marketing worker. */ export const isMarketingPath = (pathname: string): boolean => MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); @@ -42,7 +52,7 @@ export const isMarketingPath = (pathname: string): boolean => */ export const marketingProxyRequest = (request: Request): Request | null => { const url = new URL(request.url); - if (url.hostname !== "executor.sh") return null; + if (url.hostname !== PRODUCTION_HOST) return null; const shouldProxy = isMarketingPath(url.pathname) || @@ -52,3 +62,159 @@ export const marketingProxyRequest = (request: Request): Request | null => { if (url.pathname === "/home") url.pathname = "/"; return new Request(url, request); }; + +// --------------------------------------------------------------------------- +// v2 on `executor.sh` +// --------------------------------------------------------------------------- + +const SIGN_UP_PATHS: ReadonlySet = new Set(["/sign-up", "/signup"]); + +/** A path pattern is an exact path, or `/x/*`, which matches every path + * that starts with `/x/` (and not `/x` itself). */ +const matchesPathPattern = (pathname: string, pattern: string): boolean => + pattern.endsWith("/*") ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern; + +/** Path patterns v2 answers on `executor.sh`. `/git/*` never matches + * `/gitlab/...`. v2's outbound OAuth client metadata document stays off this + * list: its move to `executor.sh` is pending, and v1's own document + * (`/oauth/client-id-metadata.json`) stays with v1. */ +const V2_PATHS: ReadonlyArray = [ + // Sign-in issuer metadata for the issuer `https://executor.sh/api/auth`. + "/.well-known/oauth-authorization-server/api/auth", + "/git/*", + "/.well-known/agent-skills/*", +]; + +/** v2's social sign-in callback is `/api/auth/callback/`. v1's + * WorkOS callback is the bare `/api/auth/callback`, which stays with v1. */ +const SOCIAL_CALLBACK_PREFIX = "/api/auth/callback/"; + +const isSocialCallbackPath = (pathname: string): boolean => { + if (!pathname.startsWith(SOCIAL_CALLBACK_PREFIX)) return false; + const provider = pathname.slice(SOCIAL_CALLBACK_PREFIX.length); + return provider.length > 0 && !provider.includes("/"); +}; + +/** Whether a pathname is a sign-up entry point that redirects to v2. */ +export const isSignUpPath = (pathname: string): boolean => SIGN_UP_PATHS.has(pathname); + +/** Whether `executor.sh` forwards a pathname to v2's Worker. */ +export const isV2Path = (pathname: string): boolean => + V2_PATHS.some((pattern) => matchesPathPattern(pathname, pattern)) || + isSocialCallbackPath(pathname); + +/** Request headers v1 never passes to v2. The cookie header carries v1's + * `wos-session` (v2's cookies are host-only on its own hosts, so nothing of + * v2's travels on `executor.sh`). Client-sent forwarding headers are dropped + * so v2 sees no host claim other than the request URL's. */ +const V2_STRIPPED_HEADERS = ["cookie", "x-forwarded-host", "x-forwarded-proto"] as const; + +/** + * Project an `executor.sh` request onto the request sent to v2's Worker. + * + * Keeps the URL (so v2 sees host `executor.sh`, path and query unchanged), + * method, body stream and every header except {@link V2_STRIPPED_HEADERS}, + * including `Authorization`. Redirects are returned to the client, not + * followed: v2 answers callbacks with a redirect to its own host. + */ +export const v2ForwardRequest = (request: Request): Request => { + const headers = new Headers(request.headers); + for (const name of V2_STRIPPED_HEADERS) headers.delete(name); + return new Request(request, { headers, redirect: "manual" }); +}; + +/** The Worker that serves v2, reached over a service binding. */ +export interface V2Service { + readonly fetch: (request: Request) => Promise; +} + +/** v1's connected-account OAuth callback. v2 shares it on `executor.sh`: + * a callback whose `state` starts with v2's prefix belongs to v2. */ +const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; + +/** A state prefix is URL-safe as is (so the query value carries it + * unencoded) and includes a character outside base64url. v1's states are + * base64url, so no v1 state can start with such a prefix. */ +const OAUTH_STATE_PREFIX_PATTERN = /^[A-Za-z0-9._~-]*[.~][A-Za-z0-9._~-]*$/; + +/** The edge's raw v2 settings, as the Worker environment holds them. */ +export interface V2EdgeEnv { + /** Service binding to v2's Worker. */ + readonly V2?: V2Service; + /** Absolute URL of v2's sign-up page. */ + readonly V2_SIGN_UP_URL?: string; + /** The prefix v2 puts on every connected-account OAuth `state`. */ + readonly V2_OAUTH_STATE_PREFIX?: string; +} + +/** What the edge needs to hand requests to v2. */ +export interface V2Edge { + /** v2's Worker. */ + readonly service: V2Service; + /** Absolute URL of v2's sign-up page (the `V2_SIGN_UP_URL` var). */ + readonly signUpUrl: URL; + /** v2's connected-account OAuth state prefix (the `V2_OAUTH_STATE_PREFIX` var). */ + readonly oauthStatePrefix: string; +} + +/** + * Parse the edge's v2 settings from the Worker environment. Returns `null` + * when none is set (local dev, test workers), so v1 serves everything, and + * the reason as a string when the deployment is broken: only some of them + * set, a sign-up URL that is not absolute, or a state prefix that could + * match a v1 state. + */ +export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { + const service = env.V2; + const signUpUrl = env.V2_SIGN_UP_URL; + const oauthStatePrefix = env.V2_OAUTH_STATE_PREFIX; + if (service === undefined && signUpUrl === undefined && oauthStatePrefix === undefined) { + return null; + } + if (service === undefined || signUpUrl === undefined || oauthStatePrefix === undefined) { + return "The V2 binding, V2_SIGN_UP_URL and V2_OAUTH_STATE_PREFIX must be set together"; + } + const parsed = URL.parse(signUpUrl); + if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + return "V2_SIGN_UP_URL must be an absolute http(s) URL"; + } + if (!OAUTH_STATE_PREFIX_PATTERN.test(oauthStatePrefix)) { + return "V2_OAUTH_STATE_PREFIX must be URL-safe and contain '.' or '~'"; + } + return { service, signUpUrl: parsed, oauthStatePrefix }; +}; + +/** Whether a connected-account callback carries v2's state prefix. Reads the + * query only: a callback without `state` in its query stays with v1. */ +const isV2OAuthCallback = (url: URL, prefix: string): boolean => + url.searchParams.get("state")?.startsWith(prefix) === true; + +/** + * Answer a production request that belongs to v2: redirect sign-up (`GET`, + * any query) to v2's sign-up page, or forward a {@link isV2Path} request, or + * a connected-account callback whose `state` starts with v2's prefix, to + * v2's Worker and return its response unchanged (status, headers and body + * stream). Returns `null` when v1 owns the request. + * + * Broken settings answer the requests the edge owns with a 500 and leave the + * rest of v1 serving. The callback is the exception: which callbacks are v2's + * depends on the settings, so v1 keeps every callback until they parse. + */ +export const v2EdgeResponse = (request: Request, env: V2EdgeEnv): Promise | null => { + const url = new URL(request.url); + if (url.hostname !== PRODUCTION_HOST) return null; + + const signUp = isSignUpPath(url.pathname) && request.method === "GET"; + const callback = url.pathname === OAUTH_CALLBACK_PATH; + if (!signUp && !callback && !isV2Path(url.pathname)) return null; + const edge = parseV2Edge(env); + if (edge === null) return null; + if (typeof edge === "string") { + console.error(`executor.sh v2 edge misconfigured: ${edge}`); + if (callback) return null; + return Promise.resolve(new Response("Service misconfigured", { status: 500 })); + } + if (signUp) return Promise.resolve(Response.redirect(edge.signUpUrl.href, 302)); + if (callback && !isV2OAuthCallback(url, edge.oauthStatePrefix)) return null; + return edge.service.fetch(v2ForwardRequest(request)); +}; diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts new file mode 100644 index 0000000000..40010143f7 --- /dev/null +++ b/apps/cloud/src/edge/production-config.test.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; +import { unstable_readConfig } from "wrangler"; + +import { parseV2Edge, v2EdgeResponse, type V2EdgeEnv, type V2Service } from "./marketing"; + +// The deployed edge reads its v2 settings from wrangler.jsonc. These tests read +// that file the way wrangler does and run the edge with the values it ships. +// wrangler's config arrives untyped here, so it is decoded first. +const WranglerConfig = Schema.Struct({ + vars: Schema.Record(Schema.String, Schema.Unknown), + services: Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String })), +}); +const config = Schema.decodeUnknownSync(WranglerConfig)( + unstable_readConfig({ config: fileURLToPath(new URL("../../wrangler.jsonc", import.meta.url)) }), +); + +const stringVar = (name: string): string | undefined => { + const value = config.vars[name]; + return typeof value === "string" ? value : undefined; +}; + +const standIn: V2Service = { fetch: () => Promise.resolve(new Response("v2")) }; + +/** The shipped settings, with a stand-in for v2's Worker. */ +const shipped: V2EdgeEnv = { + V2: standIn, + V2_SIGN_UP_URL: stringVar("V2_SIGN_UP_URL"), + V2_OAUTH_STATE_PREFIX: stringVar("V2_OAUTH_STATE_PREFIX"), +}; + +describe("production v2 edge settings", () => { + it("binds V2 to exactly one Worker", () => { + const bindings = config.services.filter((service) => service.binding === "V2"); + expect(bindings).toHaveLength(1); + expect(bindings[0]?.service).toMatch(/^[a-z0-9-]+$/); + }); + + it("redirects sign-up to v2's sign-up page on v2.executor.sh", async () => { + const response = await v2EdgeResponse(new Request("https://executor.sh/sign-up"), shipped); + + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe("https://v2.executor.sh/login?mode=signup"); + }); + + it("ships v2's connected-account state prefix", () => { + const edge = parseV2Edge(shipped); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.oauthStatePrefix).toBe("x2."); + }); +}); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 017570cf1a..e733d119fe 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -137,6 +137,16 @@ declare global { MCP_RESIDENT_RUNTIME_SOFT_CAP?: string; NODE_ENV?: string; + // v2 on executor.sh (wrangler.jsonc `services` + `vars`). Optional so + // local dev and test workers without them serve everything from v1. + /** Service binding to v2's API Worker; `edge/marketing.ts` forwards to it. */ + V2?: Fetcher; + /** Absolute URL `/sign-up` and `/signup` redirect to. */ + V2_SIGN_UP_URL?: string; + /** Prefix of v2's connected-account OAuth `state`; `/api/oauth/callback` + * requests whose state starts with it go to v2. */ + V2_OAUTH_STATE_PREFIX?: string; + // Shared with frontend VITE_PUBLIC_SITE_URL?: string; VITE_PUBLIC_OTLP_TRACES_URL?: string; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 2dde0e8403..1a1a713c2d 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -11,7 +11,7 @@ import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; -import { marketingProxyRequest } from "./edge/marketing"; +import { marketingProxyRequest, v2EdgeResponse } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; import { withPrivateReferrerPolicy } from "./edge/referrer-policy"; import { runWorkOsEventsSync } from "./auth/workos-events-runner"; @@ -318,6 +318,11 @@ const cloudflareHandler = { prewarmAppPlane(ctx); } + // Sign-up, the fixed list of v2 paths and v2's connected-account + // callbacks on `executor.sh` go to v2. + const v2 = v2EdgeResponse(request, env); + if (v2) return v2; + const marketingRequest = marketingProxyRequest(request); const marketing: Fetcher | undefined = env.MARKETING; if (marketingRequest && marketing) return marketing.fetch(marketingRequest); diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 04da6b0e34..9f7b22f258 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -95,6 +95,13 @@ "binding": "MARKETING", "service": "executor-marketing", }, + // v2's API Worker (the executor-next `v2` stage, same account). The edge + // (src/edge/marketing.ts) forwards a fixed list of executor.sh paths to + // it with the URL unchanged, so v2 sees host `executor.sh`. + { + "binding": "V2", + "service": "executor-next-hosted-api-v2-qs32brgjwvt7ytx4", + }, ], "hyperdrive": [ { @@ -133,6 +140,12 @@ }, "vars": { "VITE_PUBLIC_SITE_URL": "https://executor.sh", + // Where /sign-up and /signup redirect: v2's sign-up page. + "V2_SIGN_UP_URL": "https://v2.executor.sh/login?mode=signup", + // v2 starts every connected-account OAuth `state` with this prefix, and + // /api/oauth/callback requests carrying it go to v2. It must equal the + // prefix v2 mints. + "V2_OAUTH_STATE_PREFIX": "x2.", // Keeps the /__sentry-otel-verify probe live in production: Sentry // delivery failed silently for weeks (zero events after ~Jul 30 with an // active DSN), and without this there is no way to test the pipeline diff --git a/packages/core/api/src/account/org-slug.test.ts b/packages/core/api/src/account/org-slug.test.ts index 28e5a1d6f6..4dad5d5ecc 100644 --- a/packages/core/api/src/account/org-slug.test.ts +++ b/packages/core/api/src/account/org-slug.test.ts @@ -88,6 +88,45 @@ describe("isValidOrgSlug", () => { expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true); } }); + + it("reserves the root segments the executor.sh edge gives to v2", () => { + // `/git//` and the other forwarded or redirected roots would + // otherwise read as an org's console URL (`//...`). + for (const claimed of [ + "git", + "apps", + "experiments", + "oauth", + "api", + "app", + "mcp", + "docs", + "sign-up", + "signup", + "pricing", + "blog", + ]) { + expect(isValidOrgSlug(claimed), claimed).toBe(false); + } + // Look-alikes stay claimable. + for (const lookalike of [ + "gitlab", + "git-team", + "github", + "app-team", + "experiment", + "oauth-co", + "blogs", + ]) { + expect(isValidOrgSlug(lookalike), lookalike).toBe(true); + } + }); + + it("never mints a reserved edge slug for an org name", async () => { + const slug = await generateOrgSlug("Git", async () => false); + expect(slug).not.toBe("git"); + expect(slug).toMatch(/^git-[a-z2-9]{4}$/); + }); }); describe("generateOrgSlug", () => { diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index addd842831..5b83758805 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -27,6 +27,11 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; * - Marketing worker: home, setup, privacy, terms, blog, pricing, careers, * changelog, _astro (executor.sh edge routes; the * non-route names are cheap insurance) + * - v2 on executor.sh: git (Git smart HTTP remotes the executor.sh edge + * forwards to v2), apps and experiments (v2 marketing + * pages; no v1 organization uses them), plus oauth, + * api, app, mcp, docs, sign-up, signup, pricing and + * blog listed elsewhere here * - Infra: assets (vite build output), cdn-cgi (Cloudflare), * static, public, favicon.ico, robots.txt, sitemap.xml * - Auth flows: auth, oauth, callback, logout, signin, signout, @@ -69,6 +74,10 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "careers", "changelog", "_astro", + // v2 on executor.sh + "git", + "apps", + "experiments", // infra "assets", "cdn-cgi",