From c5fd85e85f1129dcad9173a7c8c68a6913905514 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:44:49 -0700 Subject: [PATCH 1/7] Forward executor.sh paths for Executor 2 (#2220) * Route sign-up and v2 paths on executor.sh to v2 * Fail edge requests on broken v2 settings instead of serving them from v1 * Answer edge requests with a 500 on broken v2 settings * Forward only v2's contract paths and reserve the apps and experiments slugs * Test the executor.sh v2 edge and reserved slugs * Test that broken edge settings fail only edge requests * Test the contract paths and the apps and experiments slugs * Forward connected-account callbacks with v2's state prefix to v2 * Test v2's connected-account callbacks and the shipped v2 edge settings --- apps/cloud/src/edge/marketing.test.ts | 472 +++++++++++++++++- apps/cloud/src/edge/marketing.ts | 178 ++++++- apps/cloud/src/edge/production-config.test.ts | 53 ++ apps/cloud/src/env-augment.d.ts | 10 + apps/cloud/src/server.ts | 7 +- apps/cloud/wrangler.jsonc | 13 + .../core/api/src/account/org-slug.test.ts | 39 ++ packages/core/api/src/account/org-slug.ts | 9 + 8 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 apps/cloud/src/edge/production-config.test.ts 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", From 2672e6e085d58e7591b325d8578af978b9e5953f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:30:26 -0700 Subject: [PATCH 2/7] Route executor.sh sign-up and marketing to Executor 2 (#2217) * Send sign-up to v2's app host * Test the shipped sign-up URL on app.executor.sh * Serve executor.sh marketing from v2 and keep v1's terms * Describe the visitor cookie the edge passes to v2 * Keep v1's privacy and Google OAuth pages, match v2's contract and pin its telemetry paths * Forward only below v2's analytics proxy root * Redirect slashed v2 pages on executor.sh to the page * Test v2 marketing, telemetry proxies and v1's terms on executor.sh * Test v1's edge against v2's pinned edge contract * Test the analytics proxy root stays with v1 and update the pinned contract * Test that slashed v2 pages redirect and update the pinned contract --- .oxfmtrc.json | 3 +- apps/cloud/src/edge/marketing.test.ts | 522 +++++++++++++++--- apps/cloud/src/edge/marketing.ts | 257 +++++++-- apps/cloud/src/edge/production-config.test.ts | 121 +++- apps/cloud/src/edge/v2-edge-contract.json | 426 ++++++++++++++ apps/cloud/src/env-augment.d.ts | 6 + apps/cloud/src/server.ts | 8 +- apps/cloud/wrangler.jsonc | 27 +- apps/marketing/astro.config.mjs | 4 + apps/marketing/src/layouts/Layout.astro | 7 +- apps/marketing/src/middleware.ts | 7 +- 11 files changed, 1240 insertions(+), 148 deletions(-) create mode 100644 apps/cloud/src/edge/v2-edge-contract.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 73f7255814..5662270268 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -14,6 +14,7 @@ "executor-*.tgz", "**/routeTree.gen.ts", "**/smoke-harness-bundle.gen.ts", - "apps/cloud/src/services/executor-schema.ts" + "apps/cloud/src/services/executor-schema.ts", + "apps/cloud/src/edge/v2-edge-contract.json" ] } diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index 66645162dd..2fdaff85c3 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -3,96 +3,58 @@ import { describe, expect, it } from "@effect/vitest"; import { isMarketingPath, isSignUpPath, + isV2MarketingPath, isV2Path, marketingProxyRequest, parseV2Edge, v2EdgeResponse, + v2PageForSlashedPath, 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 -// cloud app (the sign-in page). `/blog` and `/llms.txt` are public content, so -// they must be on the allow-list: without it an unauthenticated visit redirects -// to `/login?returnTo=...` and the reader bounces. +// On executor.sh only v1's legal pages (terms, privacy policy and Google OAuth +// disclosure) and their asset root still go to v1's `executor-marketing` +// worker; v2 serves the rest of marketing. describe("isMarketingPath", () => { - const marketing = [ - "/home", - "/privacy", + const v1Marketing = [ "/terms", - "/pricing", - "/about-executor", + "/terms/", + "/privacy", "/google-oauth", - "/google-workspace", - "/blog", - "/blog/", - "/blog/some-post", - "/llms.txt", - "/index.md", - "/setup-prompt.md", - "/pricing.md", - "/og-image.png", - "/_astro/app.css", - // The blog author card loads its avatar from marketing's public/authors; - // without this the pfp 404s on every post. - "/authors/rhys-sullivan.png", + "/_v1-marketing/Layout.css", + "/_v1-marketing/_ph/e", ]; - for (const pathname of marketing) { - it(`proxies ${pathname} to marketing`, () => { + for (const pathname of v1Marketing) { + it(`sends ${pathname} to v1's marketing worker`, () => { expect(isMarketingPath(pathname)).toBe(true); }); } - // App-owned routes must reach the Effect handler, not marketing. `/blogger` - // guards against a bare `startsWith("/blog")` swallowing unrelated words. - const notMarketing = ["/", "/login", "/cloud", "/mcp", "/dashboard", "/blogger"]; - for (const pathname of notMarketing) { - it(`leaves ${pathname} alone`, () => { + const notV1Marketing = [ + "/", + "/home", + "/pricing", + "/blog", + "/google-workspace", + "/_astro/app.css", + "/_astro/_ph/e", + "/termsandconditions", + "/privacy-team/mcp", + "/google-oauthx", + "/_v1-marketingx", + "/login", + ]; + for (const pathname of notV1Marketing) { + it(`does not send ${pathname} to v1's marketing worker`, () => { expect(isMarketingPath(pathname)).toBe(false); }); } }); describe("marketingProxyRequest", () => { - it("routes a signed-out homepage request", () => { - const request = new Request("https://executor.sh/?source=test"); - - const proxied = marketingProxyRequest(request); - - expect(proxied?.url).toBe("https://executor.sh/?source=test"); - }); - - it("leaves the signed-in homepage with the cloud application", () => { - const request = new Request("https://executor.sh/", { - headers: { cookie: "other=value; wos-session=sealed" }, - }); - - expect(marketingProxyRequest(request)).toBeNull(); - }); - - it("routes public content even when a session cookie is present", () => { - const request = new Request("https://executor.sh/blog/post", { - headers: { cookie: "wos-session=sealed" }, - }); - - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); - }); - - it("routes /pricing to the marketing worker", () => { - const request = new Request("https://executor.sh/pricing"); - - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/pricing"); - }); - - it("rewrites the public home alias to the marketing root", () => { - const request = new Request("https://executor.sh/home?source=test"); - - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); - }); - - it("preserves the request method, headers, and body", async () => { - const request = new Request("https://executor.sh/_astro/_ph/capture", { + it("routes v1's terms, method, headers and body unchanged", async () => { + const request = new Request("https://executor.sh/_v1-marketing/_ph/capture?ip=1", { method: "POST", headers: { "content-type": "application/json", "x-request-id": "request-1" }, body: JSON.stringify({ event: "test" }), @@ -100,14 +62,25 @@ describe("marketingProxyRequest", () => { const proxied = marketingProxyRequest(request); + expect(proxied?.url).toBe("https://executor.sh/_v1-marketing/_ph/capture?ip=1"); expect(proxied?.method).toBe("POST"); expect(proxied?.headers.get("x-request-id")).toBe("request-1"); await expect(proxied?.json()).resolves.toEqual({ event: "test" }); + for (const path of ["/terms", "/privacy", "/google-oauth"]) { + expect(marketingProxyRequest(new Request(`https://executor.sh${path}`))?.url).toBe( + `https://executor.sh${path}`, + ); + } }); - it("does not proxy non-production hosts or app-owned paths", () => { - expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); - expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); + it("leaves the homepage and v2's marketing to the v2 edge", () => { + expect(marketingProxyRequest(new Request("https://executor.sh/"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/home"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/pricing"))).toBeNull(); + }); + + it("does not proxy non-production hosts", () => { + expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/terms"))).toBeNull(); }); }); @@ -152,9 +125,9 @@ describe("isV2Path", () => { "/github", "/.well-known/agent-skills", "/.well-known/agent-skillset/index.json", - // The connected-account callback goes by its state, not its path. + // The connected-account callback goes by its state, not its path, and + // marketing has its own list. "/api/oauth/callback", - // Not yet: marketing. "/pricing", // v1 dashboard, org pages and MCP, including org slugs that look similar. "/", @@ -173,6 +146,18 @@ describe("isV2Path", () => { } }); +describe("v2PageForSlashedPath", () => { + it("canonicalizes only v2's exact pages", () => { + expect(v2PageForSlashedPath("/pricing/")).toBe("/pricing"); + expect(v2PageForSlashedPath("/google-workspace/")).toBe("/google-workspace"); + expect(v2PageForSlashedPath("/pricing")).toBeNull(); + expect(v2PageForSlashedPath("/pricing.md/")).toBeNull(); + expect(v2PageForSlashedPath("/blog/")).toBeNull(); + expect(v2PageForSlashedPath("/terms/")).toBeNull(); + expect(v2PageForSlashedPath("/")).toBeNull(); + }); +}); + describe("isSignUpPath", () => { it("claims /sign-up and /signup only", () => { expect(isSignUpPath("/sign-up")).toBe(true); @@ -190,6 +175,8 @@ describe("parseV2Edge", () => { V2: service, V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", }; it("is off when no setting is present", () => { @@ -200,6 +187,8 @@ describe("parseV2Edge", () => { 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({ ...settings, V2_ANALYTICS_PROXY_PATH: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_ERROR_TUNNEL_PATH: undefined })).toBe("string"); expect(typeof parseV2Edge({ V2_OAUTH_STATE_PREFIX: "x2." })).toBe("string"); }); @@ -220,11 +209,47 @@ describe("parseV2Edge", () => { } }); + it("refuses an analytics proxy path other than /api/<16 lowercase hex>", () => { + for (const path of [ + "", + "/api/0123456789abcde", + "/api/0123456789abcdef0", + "/api/0123456789ABCDEF", + "/api/0123456789abcdef/", + "/api/0123456789abcdef/submit", + "/api/connections", + "/0123456789abcdef", + " /api/0123456789abcdef", + "/api/0123456789abcdef,/api/fedcba9876543210", + ]) { + expect(typeof parseV2Edge({ ...settings, V2_ANALYTICS_PROXY_PATH: path })).toBe("string"); + } + }); + + it("refuses an error tunnel path other than /api/<16 lowercase hex>/submit", () => { + for (const path of [ + "", + "/api/fedcba9876543210", + "/api/fedcba9876543210/", + "/api/fedcba9876543210/submit/", + "/api/fedcba9876543210/submitx", + "/api/FEDCBA9876543210/submit", + "/api/fedcba987654321/submit", + "/api/fedcba9876543210/e", + "/fedcba9876543210/submit", + " /api/fedcba9876543210/submit", + ]) { + expect(typeof parseV2Edge({ ...settings, V2_ERROR_TUNNEL_PATH: path })).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(edge.analyticsProxyPath).toBe("/api/0123456789abcdef"); + expect(edge.errorTunnelPath).toBe("/api/fedcba9876543210/submit"); expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: "v2~" })).toBe("object"); }); }); @@ -238,6 +263,8 @@ describe("v2EdgeResponse", () => { V2: service, V2_SIGN_UP_URL: signUpUrl, V2_OAUTH_STATE_PREFIX: STATE_PREFIX, + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", }); /** A v2 service that records what it receives and answers with `respond`. */ @@ -440,6 +467,8 @@ describe("v2EdgeResponse connected-account callback", () => { V2: service, V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", }); const recordingService = () => { @@ -571,3 +600,350 @@ describe("v2EdgeResponse connected-account callback", () => { expect(received).toHaveLength(0); }); }); + +// v2's marketing site answers executor.sh: the landing page without a v1 +// session, its pages, files, assets and docs, and its telemetry proxies. A +// pattern is exact, or `/x/*` for everything that starts with `/x/`. +describe("isV2MarketingPath", () => { + const v2Marketing = [ + "/home", + "/about-executor", + "/blog", + "/blog/", + "/blog/some-post", + "/pricing", + "/google-workspace", + "/index.md", + "/llms.txt", + "/pricing.md", + "/setup-prompt.md", + "/_astro/app.Cld-QA3g.css", + "/authors/author.png", + "/og-image.png", + "/pattern-graph-paper.svg", + "/docs", + "/docs/", + "/docs/quickstart", + "/docs/llms.txt", + // `apps` and `experiments` are reserved v1 organization slugs. + "/apps", + "/apps/", + "/apps/detail", + "/experiments/", + "/experiments/hero/b", + ]; + for (const pathname of v2Marketing) { + it(`sends ${pathname} to v2`, () => { + expect(isV2MarketingPath(pathname)).toBe(true); + }); + } + + const v1Owned = [ + // The homepage depends on the session; see v2EdgeResponse. + "/", + // `/demo` is an unreserved v1 organization slug; v2 publishes nothing at + // a bare `/experiments`. + "/demo", + "/demo/workflows", + "/experiments", + // Exact pages match only themselves, and directories only what is below + // them. + "/pricing/extra", + "/home/extra", + "/home/", + "/llms.txt/x", + "/_astro", + "/authors", + "/google-workspace/x", + // v1's legal pages, and its dashboard's favicons. + "/terms", + "/privacy", + "/google-oauth", + "/favicon.ico", + "/favicon-32.png", + "/apple-touch-icon.png", + // Lookalikes and v1 routes. + "/blogger", + "/appsmith", + "/experimentsx/a", + "/docsearch", + "/_astrox/app.css", + "/home-team/policies", + "/pricing-team/mcp", + "/setup", + "/login", + "/api/docs", + "/acme/docs", + ]; + for (const pathname of v1Owned) { + it(`leaves ${pathname} with v1`, () => { + expect(isV2MarketingPath(pathname)).toBe(false); + }); + } +}); + +describe("v2EdgeResponse marketing", () => { + const settings = (service: V2Service): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }); + + const recordingService = (respond: () => Response = () => new Response("v2")) => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return respond(); + }, + }; + return { received, service }; + }; + + // A signed-out visitor at `/pricing/` would otherwise reach v1's sign-in + // gate, which redirects to login. + it("redirects v2's slashed exact pages to the page, query kept, without forwarding", async () => { + const { received, service } = recordingService(); + + for (const [target, location] of [ + ["/pricing/", "/pricing"], + ["/pricing/?ref=hn", "/pricing?ref=hn"], + ["/home/", "/home"], + ["/about-executor/", "/about-executor"], + ["/google-workspace/", "/google-workspace"], + ] as const) { + for (const method of ["GET", "HEAD"]) { + const response = await v2EdgeResponse( + new Request(`https://executor.sh${target}`, { + method, + headers: { cookie: "wos-session=sealed" }, + }), + settings(service), + ); + expect(response?.status, `${method} ${target}`).toBe(308); + expect(response?.headers.get("location"), `${method} ${target}`).toBe( + `https://executor.sh${location}`, + ); + } + } + expect(received).toHaveLength(0); + }); + + it("forwards the slashed form of a page v2 owns below, such as /blog/", async () => { + const { received, service } = recordingService(); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/docs/"), + settings(service), + ); + + expect(await response?.text()).toBe("v2"); + expect(received[0]?.url).toBe("https://executor.sh/docs/"); + }); + + it("keeps deeper paths, other methods and v1's own slashed pages with v1", () => { + const { received, service } = recordingService(); + + for (const [method, target] of [ + ["GET", "/pricing/extra"], + ["GET", "/home/extra/"], + ["GET", "/pricing//"], + ["POST", "/pricing/"], + ["GET", "/terms/"], + ["GET", "/demo/"], + ] as const) { + expect( + v2EdgeResponse(new Request(`https://executor.sh${target}`, { method }), settings(service)), + `${method} ${target}`, + ).toBeNull(); + } + expect(received).toHaveLength(0); + }); + + it("sends the signed-out homepage to v2 with the URL unchanged", async () => { + const { received, service } = recordingService(); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/?hero=b&utm_source=x"), + settings(service), + ); + + expect(await response?.text()).toBe("v2"); + expect(received[0]?.url).toBe("https://executor.sh/?hero=b&utm_source=x"); + expect(received[0]?.redirect).toBe("manual"); + }); + + it("keeps the signed-in homepage with v1's dashboard", () => { + const { received, service } = recordingService(); + + expect( + v2EdgeResponse( + new Request("https://executor.sh/", { + headers: { cookie: "executor_visitor=v; wos-session=sealed" }, + }), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("passes only v2's visitor cookie, never v1's session", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/blog/post", { + headers: { + cookie: + "ph_id=1; executor_visitor=0f1e2d3c-4b5a-4987-a6b5-c4d3e2f1a0b9; wos-session=sealed", + "x-forwarded-host": "attacker.example", + }, + }), + settings(service), + ); + await v2EdgeResponse( + new Request("https://executor.sh/", { headers: { cookie: "ph_id=1; executor_hero=x" } }), + settings(service), + ); + + expect(received[0]?.headers.get("cookie")).toBe( + "executor_visitor=0f1e2d3c-4b5a-4987-a6b5-c4d3e2f1a0b9", + ); + expect(received[0]?.headers.has("x-forwarded-host")).toBe(false); + expect(received[1]?.headers.has("cookie")).toBe(false); + }); + + it("serves /home, docs and assets from v2 without rewriting the path", async () => { + const { received, service } = recordingService(); + + for (const url of [ + "https://executor.sh/home?ref=x", + "https://executor.sh/docs/quickstart", + "https://executor.sh/_astro/app.css", + "https://executor.sh/llms.txt", + ]) { + await v2EdgeResponse(new Request(url), settings(service)); + } + + expect(received.map((request) => request.url)).toEqual([ + "https://executor.sh/home?ref=x", + "https://executor.sh/docs/quickstart", + "https://executor.sh/_astro/app.css", + "https://executor.sh/llms.txt", + ]); + }); + + it("returns v2's response unchanged, its cookies included", async () => { + const upstream = new Response("", { + status: 200, + headers: { + "content-type": "text/html", + "set-cookie": "executor_visitor=v; Path=/; SameSite=Lax; Secure", + vary: "Cookie", + }, + }); + const { service } = recordingService(() => upstream); + + expect(await v2EdgeResponse(new Request("https://executor.sh/"), settings(service))).toBe( + upstream, + ); + }); + + it("answers marketing with a 500 on broken settings", async () => { + const { received, service } = recordingService(); + + const brokenAnalytics = await v2EdgeResponse(new Request("https://executor.sh/pricing"), { + ...settings(service), + V2_ANALYTICS_PROXY_PATH: "/api/nothex", + }); + const brokenTunnel = await v2EdgeResponse(new Request("https://executor.sh/pricing"), { + ...settings(service), + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210", + }); + + expect(brokenAnalytics?.status).toBe(500); + expect(brokenTunnel?.status).toBe(500); + expect(received).toHaveLength(0); + }); + + it("is off without settings and off executor.sh", () => { + const { received, service } = recordingService(); + + expect(v2EdgeResponse(new Request("https://executor.sh/"), {})).toBeNull(); + expect(v2EdgeResponse(new Request("https://executor.sh/pricing"), {})).toBeNull(); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/"), settings(service)), + ).toBeNull(); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/docs"), settings(service)), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("forwards v2's telemetry proxies without cookies, body and method intact", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/0123456789abcdef/e/?ip=1", { + method: "POST", + headers: { cookie: "wos-session=sealed", "content-type": "text/plain" }, + body: "event", + }), + settings(service), + ); + await v2EdgeResponse( + new Request("https://executor.sh/api/fedcba9876543210/submit", { method: "POST" }), + settings(service), + ); + expect(received.map((request) => request.url)).toEqual([ + "https://executor.sh/api/0123456789abcdef/e/?ip=1", + "https://executor.sh/api/fedcba9876543210/submit", + ]); + expect(received[0]?.method).toBe("POST"); + expect(received[0]?.headers.has("cookie")).toBe(false); + expect(await received[0]?.text()).toBe("event"); + }); + + const v1Api = [ + // Another 16-hex root, and v1's own PostHog proxy (8 hex). + "https://executor.sh/api/aaaaaaaaaaaaaaaa/e/", + "https://executor.sh/api/aaaaaaaaaaaaaaaa/submit", + // The analytics proxy is forwarded below its root, not the root itself. + "https://executor.sh/api/0123456789abcdef", + // The error tunnel is forwarded exactly: not its root or other subpaths. + "https://executor.sh/api/fedcba9876543210", + "https://executor.sh/api/fedcba9876543210/", + "https://executor.sh/api/fedcba9876543210/e/", + "https://executor.sh/api/fedcba9876543210/submit/", + "https://executor.sh/api/fedcba9876543210/submit/x", + "https://executor.sh/api/0a1b2c3d/e/", + // Lookalikes of a configured root. + "https://executor.sh/api/0123456789abcdef0/e/", + "https://executor.sh/api/0123456789abcdefx", + "https://executor.sh/0123456789abcdef/e/", + "https://executor.sh/acme/api/0123456789abcdef/e/", + // v1's API. + "https://executor.sh/api/connections", + "https://executor.sh/api/docs", + ]; + for (const url of v1Api) { + it(`leaves ${new URL(url).pathname} with v1`, () => { + const { received, service } = recordingService(); + expect(v2EdgeResponse(new Request(url), settings(service))).toBeNull(); + expect(received).toHaveLength(0); + }); + } + + it("leaves telemetry roots with v1 when the settings are broken", () => { + const { received, service } = recordingService(); + + expect( + v2EdgeResponse(new Request("https://executor.sh/api/0123456789abcdef/e/"), { + ...settings(service), + V2_SIGN_UP_URL: "/relative", + }), + ).toBeNull(); + expect(received).toHaveLength(0); + }); +}); diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 31942b1cae..9f19e58128 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -2,14 +2,16 @@ // The `executor.sh` edge — decides which Worker answers a public request. // // On the production domain (`executor.sh`): -// - marketing paths and the unauthenticated landing page go to the separate -// `executor-marketing` worker; +// - v2's marketing site answers the landing page without a v1 session, its +// pages, assets and docs, and its browser telemetry proxies; // - 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. +// prefix; +// - v1's terms of service, privacy policy and Google OAuth disclosure (and +// their assets) stay on the `executor-marketing` worker. // 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. @@ -17,49 +19,40 @@ import { parseCookie } from "../auth/cookies"; -const MARKETING_PATHS = [ - "/home", - "/setup", - "/privacy", - "/terms", - "/pricing", - "/about-executor", - "/google-oauth", - "/google-workspace", - "/blog", - "/llms.txt", - "/index.md", - "/setup-prompt.md", - "/pricing.md", - "/api/detect", - "/_astro", - "/authors", - "/og-image.png", - "/pattern-graph-paper.svg", -]; +const PRODUCTION_HOST = "executor.sh"; +/** v1's session cookie. `/` with it is v1's dashboard; without it, marketing. */ const SESSION_COOKIE = "wos-session"; -const PRODUCTION_HOST = "executor.sh"; +// --------------------------------------------------------------------------- +// v1's legal pages on the `executor-marketing` worker +// --------------------------------------------------------------------------- -/** Whether an exact pathname belongs to the public marketing worker. */ +/** v1's legal pages stay on v1's marketing worker. v2's terms describe only + * v2's billing, and v1's cover the plans v1 customers are on; v1's privacy + * policy and Google OAuth disclosure describe v1's own data handling and + * Google OAuth app. The worker builds its assets under `/_v1-marketing`, + * apart from v2's `/_astro`. Each path matches itself and everything below + * it. */ +const V1_MARKETING_PATHS: ReadonlyArray = [ + "/terms", + "/privacy", + "/google-oauth", + "/_v1-marketing", +]; + +/** Whether a pathname belongs to v1's marketing worker on `executor.sh`. */ export const isMarketingPath = (pathname: string): boolean => - MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); + V1_MARKETING_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); /** - * Project a production request onto the marketing service-binding request. - * Returns `null` when the cloud application owns the request instead. + * Project a production request for v1's legal pages (or their assets) onto the + * `executor-marketing` service-binding request. Returns `null` for every + * other request. */ export const marketingProxyRequest = (request: Request): Request | null => { const url = new URL(request.url); - if (url.hostname !== PRODUCTION_HOST) return null; - - const shouldProxy = - isMarketingPath(url.pathname) || - (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); - if (!shouldProxy) return null; - - if (url.pathname === "/home") url.pathname = "/"; + if (url.hostname !== PRODUCTION_HOST || !isMarketingPath(url.pathname)) return null; return new Request(url, request); }; @@ -103,10 +96,92 @@ 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. */ +/** v2's marketing site on `executor.sh`, as path patterns (see + * {@link matchesPathPattern}): the pages, files and assets v2's marketing + * build publishes, and its docs. `/pricing` and `/home` match only + * themselves. `apps` and `experiments` are reserved org slugs, so `/apps`, + * `/apps/*` and `/experiments/*` are v2's; v2 publishes nothing at a bare + * `/experiments`, which stays with v1. `/demo` is a valid, unreserved v1 + * organization slug, so it and everything below it stay with v1. The + * favicons stay with v1 too: v1's dashboard serves identical files. */ +const V2_MARKETING_PATHS: ReadonlyArray = [ + "/home", + "/pricing", + "/pricing.md", + "/index.md", + "/setup-prompt.md", + "/llms.txt", + "/about-executor", + "/google-workspace", + "/blog", + "/blog/*", + "/docs", + "/docs/*", + "/apps", + "/apps/*", + "/experiments/*", + "/_astro/*", + "/authors/*", + "/og-image.png", + "/pattern-graph-paper.svg", +]; + +/** Whether a pathname belongs to v2's marketing site on `executor.sh`. `/` + * also does, without a v1 session; see {@link isV2Homepage}. */ +export const isV2MarketingPath = (pathname: string): boolean => + V2_MARKETING_PATHS.some((pattern) => matchesPathPattern(pathname, pattern)); + +/** v2's pages that match only themselves (`/pricing`, `/home`, + * `/about-executor`, `/google-workspace`): exact patterns that are not + * files and have no `/x/*` beside them. Their slashed form (`/pricing/`) + * matches no pattern, so v1's sign-in gate would answer it. */ +const V2_EXACT_PAGES: ReadonlySet = new Set( + V2_MARKETING_PATHS.filter( + (pattern) => + !pattern.endsWith("/*") && + !pattern.includes(".") && + !V2_MARKETING_PATHS.includes(`${pattern}/*`), + ), +); + +/** The page a slashed v2 page (`/pricing/`) canonicalizes to (`/pricing`), + * or `null`. Deeper paths (`/pricing/extra`) stay with v1. */ +export const v2PageForSlashedPath = (pathname: string): string | null => { + if (!pathname.endsWith("/")) return null; + const page = pathname.slice(0, -1); + return V2_EXACT_PAGES.has(page) ? page : null; +}; + +/** The landing page without a v1 session is v2's marketing homepage. */ +const isV2Homepage = (url: URL, request: Request): boolean => + url.pathname === "/" && parseCookie(request.headers.get("cookie"), SESSION_COOKIE) === null; + +/** The one cookie v2's marketing reads: the anonymous visitor ID that keeps + * a visitor's homepage experiment assignment stable. v2 sets it on + * `executor.sh` itself. Every other cookie, v1's session included, stays + * with v1. */ +const V2_MARKETING_COOKIES: ReadonlyArray = ["executor_visitor"]; + +/** v2's browser telemetry lives under `/api/<16 lowercase hex>`, which v1's + * API never uses. Two settings say which paths are v2's. Both are outputs + * of v2's production infrastructure (stage `v2`; random, retained values), + * which v2 pins in its edge contract; v2 refuses to deploy stage `v2` if + * its outputs differ: + * - `V2_ANALYTICS_PROXY_PATH`, the PostHog browser proxy root (output + * `proxyPath` of v2's `executor-next-posthog` stack), forwarded below + * it (`/*`), not the root itself, which v2 does not serve; + * - `V2_ERROR_TUNNEL_PATH`, the Sentry browser error tunnel (output + * `browserTunnel` of v2's `executor-next-sentry` stack), forwarded + * exactly. */ +const ANALYTICS_PROXY_PATH_PATTERN = /^\/api\/[a-f0-9]{16}$/; +const ERROR_TUNNEL_PATH_PATTERN = /^\/api\/[a-f0-9]{16}\/submit$/; +const TELEMETRY_PATH_SHAPE = /^\/api\/[a-f0-9]{16}(?:\/|$)/; + +/** Request headers v1 never passes to v2 as sent. The cookie header carries + * v1's `wos-session`; v2's own cookies are host-only on its own hosts, + * except marketing's visitor cookie, which is passed on by name. 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; /** @@ -114,12 +189,23 @@ const V2_STRIPPED_HEADERS = ["cookie", "x-forwarded-host", "x-forwarded-proto"] * * 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. + * including `Authorization`. The cookie header is rebuilt from `keptCookies` + * only, and dropped when none of them is present. Redirects are returned to + * the client, not followed: v2 answers callbacks with a redirect to its own + * host. */ -export const v2ForwardRequest = (request: Request): Request => { +export const v2ForwardRequest = ( + request: Request, + keptCookies: ReadonlyArray = [], +): Request => { + const cookieHeader = request.headers.get("cookie"); + const cookies = keptCookies.flatMap((name) => { + const value = parseCookie(cookieHeader, name); + return value === null ? [] : [`${name}=${value}`]; + }); const headers = new Headers(request.headers); for (const name of V2_STRIPPED_HEADERS) headers.delete(name); + if (cookies.length > 0) headers.set("cookie", cookies.join("; ")); return new Request(request, { headers, redirect: "manual" }); }; @@ -145,6 +231,10 @@ export interface V2EdgeEnv { readonly V2_SIGN_UP_URL?: string; /** The prefix v2 puts on every connected-account OAuth `state`. */ readonly V2_OAUTH_STATE_PREFIX?: string; + /** Root of v2's PostHog browser proxy, `/api/<16 hex>`. */ + readonly V2_ANALYTICS_PROXY_PATH?: string; + /** v2's Sentry browser error tunnel, `/api/<16 hex>/submit`. */ + readonly V2_ERROR_TUNNEL_PATH?: string; } /** What the edge needs to hand requests to v2. */ @@ -155,24 +245,42 @@ export interface V2Edge { readonly signUpUrl: URL; /** v2's connected-account OAuth state prefix (the `V2_OAUTH_STATE_PREFIX` var). */ readonly oauthStatePrefix: string; + /** Root of v2's PostHog browser proxy (the `V2_ANALYTICS_PROXY_PATH` var). */ + readonly analyticsProxyPath: string; + /** v2's Sentry browser error tunnel (the `V2_ERROR_TUNNEL_PATH` var). */ + readonly errorTunnelPath: 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. + * set, a sign-up URL that is not absolute, a state prefix that could match a + * v1 state, or a telemetry path of the wrong shape. */ 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) { + const analyticsProxyPath = env.V2_ANALYTICS_PROXY_PATH; + const errorTunnelPath = env.V2_ERROR_TUNNEL_PATH; + if ( + service === undefined && + signUpUrl === undefined && + oauthStatePrefix === undefined && + analyticsProxyPath === undefined && + errorTunnelPath === 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"; + if ( + service === undefined || + signUpUrl === undefined || + oauthStatePrefix === undefined || + analyticsProxyPath === undefined || + errorTunnelPath === undefined + ) { + return "The V2 binding, V2_SIGN_UP_URL, V2_OAUTH_STATE_PREFIX, V2_ANALYTICS_PROXY_PATH and V2_ERROR_TUNNEL_PATH must be set together"; } const parsed = URL.parse(signUpUrl); if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { @@ -181,7 +289,13 @@ export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { if (!OAUTH_STATE_PREFIX_PATTERN.test(oauthStatePrefix)) { return "V2_OAUTH_STATE_PREFIX must be URL-safe and contain '.' or '~'"; } - return { service, signUpUrl: parsed, oauthStatePrefix }; + if (!ANALYTICS_PROXY_PATH_PATTERN.test(analyticsProxyPath)) { + return "V2_ANALYTICS_PROXY_PATH must be /api/<16 lowercase hex>"; + } + if (!ERROR_TUNNEL_PATH_PATTERN.test(errorTunnelPath)) { + return "V2_ERROR_TUNNEL_PATH must be /api/<16 lowercase hex>/submit"; + } + return { service, signUpUrl: parsed, oauthStatePrefix, analyticsProxyPath, errorTunnelPath }; }; /** Whether a connected-account callback carries v2's state prefix. Reads the @@ -189,32 +303,57 @@ export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { const isV2OAuthCallback = (url: URL, prefix: string): boolean => url.searchParams.get("state")?.startsWith(prefix) === true; +/** Whether a pathname is below v2's analytics proxy root, or exactly v2's + * error tunnel. */ +const isV2TelemetryPath = (pathname: string, edge: V2Edge): boolean => + matchesPathPattern(pathname, `${edge.analyticsProxyPath}/*`) || pathname === edge.errorTunnelPath; + /** - * 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. + * Answer a production request that belongs to v2 and return `null` when v1 + * owns it: + * - sign-up (`GET`, any query) redirects to v2's sign-up page; + * - a `GET` or `HEAD` for a slashed v2 page (`/pricing/`) redirects (308) + * to the page, query kept, as v2's site does on its own hosts; + * - a {@link isV2Path} request, a connected-account callback whose `state` + * starts with v2's prefix, or a request for v2's analytics proxy or error + * tunnel is forwarded to v2's Worker without cookies; + * - a {@link isV2MarketingPath} request, or `/` without a v1 session, is + * forwarded with only v2's marketing visitor cookie. + * v2's response comes back unchanged (status, headers and body stream). * - * 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. + * Broken settings answer the requests the edge always owns with a 500 and + * leave the rest of v1 serving. Which callbacks and `/api/<16 hex>` requests + * are v2's depends on the settings, so v1 keeps those 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 slashedPage = + request.method === "GET" || request.method === "HEAD" + ? v2PageForSlashedPath(url.pathname) + : null; + const marketing = isV2MarketingPath(url.pathname) || isV2Homepage(url, request); + const owned = signUp || slashedPage !== null || marketing || isV2Path(url.pathname); const callback = url.pathname === OAUTH_CALLBACK_PATH; - if (!signUp && !callback && !isV2Path(url.pathname)) return null; + const telemetry = TELEMETRY_PATH_SHAPE.test(url.pathname); + if (!owned && !callback && !telemetry) return null; const edge = parseV2Edge(env); if (edge === null) return null; if (typeof edge === "string") { + if (!owned) return null; 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 (slashedPage !== null) { + const page = new URL(url); + page.pathname = slashedPage; + return Promise.resolve(Response.redirect(page.href, 308)); + } + if (marketing) return edge.service.fetch(v2ForwardRequest(request, V2_MARKETING_COOKIES)); if (callback && !isV2OAuthCallback(url, edge.oauthStatePrefix)) return null; + if (telemetry && !isV2TelemetryPath(url.pathname, edge)) 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 index 40010143f7..700e3634ff 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "@effect/vitest"; @@ -29,6 +30,8 @@ const shipped: V2EdgeEnv = { V2: standIn, V2_SIGN_UP_URL: stringVar("V2_SIGN_UP_URL"), V2_OAUTH_STATE_PREFIX: stringVar("V2_OAUTH_STATE_PREFIX"), + V2_ANALYTICS_PROXY_PATH: stringVar("V2_ANALYTICS_PROXY_PATH"), + V2_ERROR_TUNNEL_PATH: stringVar("V2_ERROR_TUNNEL_PATH"), }; describe("production v2 edge settings", () => { @@ -38,11 +41,11 @@ describe("production v2 edge settings", () => { expect(bindings[0]?.service).toMatch(/^[a-z0-9-]+$/); }); - it("redirects sign-up to v2's sign-up page on v2.executor.sh", async () => { + it("redirects sign-up to v2's sign-up page on app.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"); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/login?mode=signup"); }); it("ships v2's connected-account state prefix", () => { @@ -50,4 +53,118 @@ describe("production v2 edge settings", () => { if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); expect(edge.oauthStatePrefix).toBe("x2."); }); + + it("ships v2's analytics proxy root and error tunnel", () => { + const edge = parseV2Edge(shipped); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.analyticsProxyPath).toBe("/api/00e2e1f082a6ef17"); + expect(edge.errorTunnelPath).toBe("/api/fd6fab1fbb4883e1/submit"); + }); + + it("keeps the MARKETING binding for v1's terms", () => { + expect(config.services.filter((service) => service.binding === "MARKETING")).toHaveLength(1); + }); +}); + +// v2 publishes the exact set of executor.sh requests v1's edge forwards to it. +// `v2-edge-contract.json` is a verbatim copy of v2's edge contract; update it +// only together with v2's contract, never by hand here. Every case runs +// through v1's real edge decision with the shipped settings. +const EdgeContract = Schema.Struct({ + origin: Schema.String, + oauthStatePrefix: Schema.String, + telemetry: Schema.Struct({ analyticsProxy: Schema.String, errorTunnel: Schema.String }), + cases: Schema.Array( + Schema.Struct({ method: Schema.String, target: Schema.String, forwards: Schema.Boolean }), + ), + slashRedirects: Schema.Struct({ + pages: Schema.Array(Schema.String), + cases: Schema.Array(Schema.Struct({ target: Schema.String, location: Schema.String })), + }), +}); +const contract = Schema.decodeUnknownSync(Schema.fromJsonString(EdgeContract))( + readFileSync(fileURLToPath(new URL("./v2-edge-contract.json", import.meta.url)), "utf8"), +); + +const SIGN_UP_TARGETS: ReadonlySet = new Set(["/sign-up", "/signup"]); + +/** Run one contract case through v1's edge with the shipped settings and a + * stand-in for v2's Worker that records every call. */ +const runCase = async (method: string, target: string) => { + const calls: Request[] = []; + const v2: V2Service = { + fetch: (request) => { + calls.push(request); + return Promise.resolve(new Response("v2")); + }, + }; + const request = new Request(`${contract.origin}${target}`, { method }); + const response = await v2EdgeResponse(request, { ...shipped, V2: v2 }); + return { request, calls, response }; +}; + +const forwarded = contract.cases.filter((c) => c.forwards); +const signUps = contract.cases.filter((c) => !c.forwards && SIGN_UP_TARGETS.has(c.target)); +const kept = contract.cases.filter((c) => !c.forwards && !SIGN_UP_TARGETS.has(c.target)); + +describe("v2's edge contract", () => { + it("pins the telemetry paths and state prefix v1 ships", () => { + expect(contract.origin).toBe("https://executor.sh"); + expect(contract.oauthStatePrefix).toBe(stringVar("V2_OAUTH_STATE_PREFIX")); + expect(contract.telemetry.analyticsProxy).toBe(stringVar("V2_ANALYTICS_PROXY_PATH")); + expect(contract.telemetry.errorTunnel).toBe(stringVar("V2_ERROR_TUNNEL_PATH")); + }); + + for (const { method, target } of forwarded) { + it(`forwards ${method} ${target} to v2`, async () => { + const { request, calls, response } = await runCase(method, target); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(request.url); + expect(calls[0]?.method).toBe(method); + expect(await response?.text()).toBe("v2"); + }); + } + + for (const { method, target } of kept) { + it(`keeps ${method} ${target} with v1`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response).toBeNull(); + }); + } + + // Sign-up is not forwarded: the edge redirects it to v2's sign-up page. + it("lists both sign-up paths as not forwarded", () => { + expect(signUps.map((c) => c.target).toSorted()).toEqual(["/sign-up", "/signup"]); + }); + + // A slashed exact page is not forwarded: the edge redirects it to the page, + // which is. Every page v2 lists has an example. + it("covers every slashed page v2 lists", () => { + expect( + contract.slashRedirects.cases + .map((c) => new URL(c.target, contract.origin).pathname) + .toSorted(), + ).toEqual(expect.arrayContaining(contract.slashRedirects.pages.map((page) => `${page}/`))); + }); + + for (const { target, location } of contract.slashRedirects.cases) { + for (const method of ["GET", "HEAD"]) { + it(`redirects ${method} ${target} to ${location} without forwarding`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response?.status).toBe(308); + expect(response?.headers.get("location")).toBe(`${contract.origin}${location}`); + }); + } + } + + for (const { method, target } of signUps) { + it(`redirects ${method} ${target} to v2's sign-up page without forwarding`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe(stringVar("V2_SIGN_UP_URL")); + }); + } }); diff --git a/apps/cloud/src/edge/v2-edge-contract.json b/apps/cloud/src/edge/v2-edge-contract.json new file mode 100644 index 0000000000..8ad182e0e2 --- /dev/null +++ b/apps/cloud/src/edge/v2-edge-contract.json @@ -0,0 +1,426 @@ +{ + "source": "Executor v2: apps/hosted/cloud/src/contracts/edge-contract.json, generated by apps/hosted/cloud/scripts/edge-contract.ts from productionEdgeForwards", + "origin": "https://executor.sh", + "oauthStatePrefix": "x2.", + "telemetry": { + "analyticsProxy": "/api/00e2e1f082a6ef17", + "errorTunnel": "/api/fd6fab1fbb4883e1/submit" + }, + "rules": [ + "/.well-known/oauth-authorization-server/api/auth", + "/api/auth/callback/", + "/api/oauth/callback, when state starts with x2.", + "/git/*", + "/ (without a v1 session)", + "/home", + "/pricing", + "/pricing.md", + "/index.md", + "/setup-prompt.md", + "/llms.txt", + "/about-executor", + "/google-workspace", + "/blog", + "/blog/*", + "/docs", + "/docs/*", + "/apps", + "/apps/*", + "/experiments/*", + "/_astro/*", + "/authors/*", + "/og-image.png", + "/pattern-graph-paper.svg", + "/.well-known/agent-skills/*", + "/api/00e2e1f082a6ef17/*", + "/api/fd6fab1fbb4883e1/submit" + ], + "cases": [ + { + "method": "GET", + "target": "/.well-known/oauth-authorization-server/api/auth", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/callback/google?code=c&state=s", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/callback/github", + "forwards": true + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c&state=x2.abc", + "forwards": true + }, + { + "method": "GET", + "target": "/git/acme/site.git/info/refs?service=git-upload-pack", + "forwards": true + }, + { + "method": "POST", + "target": "/git/acme/site.git/git-receive-pack", + "forwards": true + }, + { + "method": "GET", + "target": "/", + "forwards": true + }, + { + "method": "GET", + "target": "/home", + "forwards": true + }, + { + "method": "GET", + "target": "/pricing", + "forwards": true + }, + { + "method": "GET", + "target": "/pricing.md", + "forwards": true + }, + { + "method": "GET", + "target": "/index.md", + "forwards": true + }, + { + "method": "GET", + "target": "/setup-prompt.md", + "forwards": true + }, + { + "method": "GET", + "target": "/llms.txt", + "forwards": true + }, + { + "method": "GET", + "target": "/about-executor", + "forwards": true + }, + { + "method": "GET", + "target": "/google-workspace", + "forwards": true + }, + { + "method": "GET", + "target": "/blog", + "forwards": true + }, + { + "method": "GET", + "target": "/blog/", + "forwards": true + }, + { + "method": "GET", + "target": "/blog/a-post", + "forwards": true + }, + { + "method": "GET", + "target": "/docs", + "forwards": true + }, + { + "method": "GET", + "target": "/docs/", + "forwards": true + }, + { + "method": "GET", + "target": "/docs/quickstart", + "forwards": true + }, + { + "method": "GET", + "target": "/apps", + "forwards": true + }, + { + "method": "GET", + "target": "/apps/detail", + "forwards": true + }, + { + "method": "GET", + "target": "/experiments/hero/a", + "forwards": true + }, + { + "method": "GET", + "target": "/experiments/demo/posthog", + "forwards": true + }, + { + "method": "GET", + "target": "/_astro/page.abc123.js", + "forwards": true + }, + { + "method": "GET", + "target": "/authors/author.png", + "forwards": true + }, + { + "method": "GET", + "target": "/og-image.png", + "forwards": true + }, + { + "method": "GET", + "target": "/pattern-graph-paper.svg", + "forwards": true + }, + { + "method": "GET", + "target": "/.well-known/agent-skills/index.json", + "forwards": true + }, + { + "method": "POST", + "target": "/api/00e2e1f082a6ef17/e/?ip=0", + "forwards": true + }, + { + "method": "GET", + "target": "/api/00e2e1f082a6ef17/static/array.js", + "forwards": true + }, + { + "method": "POST", + "target": "/api/fd6fab1fbb4883e1/submit", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/.well-known/openid-configuration", + "forwards": false + }, + { + "method": "GET", + "target": "/.well-known/oauth-authorization-server", + "forwards": false + }, + { + "method": "GET", + "target": "/.well-known/oauth-protected-resource", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/callback?code=c&state=s", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/callback/google/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/login", + "forwards": false + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c&state=abc", + "forwards": false + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c", + "forwards": false + }, + { + "method": "GET", + "target": "/oauth/client-metadata.json", + "forwards": false + }, + { + "method": "GET", + "target": "/oauth/client-id-metadata.json", + "forwards": false + }, + { + "method": "GET", + "target": "/git", + "forwards": false + }, + { + "method": "GET", + "target": "/gitlab/repo", + "forwards": false + }, + { + "method": "GET", + "target": "/demo", + "forwards": false + }, + { + "method": "GET", + "target": "/demo/posthog", + "forwards": false + }, + { + "method": "GET", + "target": "/experiments", + "forwards": false + }, + { + "method": "GET", + "target": "/privacy", + "forwards": false + }, + { + "method": "GET", + "target": "/terms", + "forwards": false + }, + { + "method": "GET", + "target": "/google-oauth", + "forwards": false + }, + { + "method": "GET", + "target": "/_v1-marketing/style.css", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon-32.png", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon-192.png", + "forwards": false + }, + { + "method": "GET", + "target": "/apple-touch-icon.png", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon.ico", + "forwards": false + }, + { + "method": "GET", + "target": "/pricing/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/home/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/about-executor/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/google-workspace/extra", + "forwards": false + }, + { + "method": "POST", + "target": "/pricing/", + "forwards": false + }, + { + "method": "GET", + "target": "/login", + "forwards": false + }, + { + "method": "GET", + "target": "/sign-up", + "forwards": false + }, + { + "method": "GET", + "target": "/signup", + "forwards": false + }, + { + "method": "POST", + "target": "/mcp", + "forwards": false + }, + { + "method": "POST", + "target": "/acme/mcp", + "forwards": false + }, + { + "method": "GET", + "target": "/acme", + "forwards": false + }, + { + "method": "GET", + "target": "/api/00e2e1f082a6ef17", + "forwards": false + }, + { + "method": "GET", + "target": "/api/fd6fab1fbb4883e1", + "forwards": false + }, + { + "method": "POST", + "target": "/api/fd6fab1fbb4883e1/submit/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/api/0123456789abcdef/e/", + "forwards": false + }, + { + "method": "POST", + "target": "/api/webhooks/workos", + "forwards": false + } + ], + "slashRedirects": { + "rule": "GET or HEAD / answers 308 to , query kept, for each of pages", + "pages": ["/home", "/pricing", "/about-executor", "/google-workspace"], + "cases": [ + { + "target": "/pricing/", + "location": "/pricing" + }, + { + "target": "/pricing/?ref=hn", + "location": "/pricing?ref=hn" + }, + { + "target": "/home/", + "location": "/home" + }, + { + "target": "/about-executor/", + "location": "/about-executor" + }, + { + "target": "/google-workspace/", + "location": "/google-workspace" + } + ] + } +} diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index e733d119fe..3e1fb0d8c1 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -146,6 +146,12 @@ declare global { /** 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; + /** Root of v2's PostHog browser proxy (`/api/<16 hex>`), which v2's + * marketing pages call on executor.sh; forwarded with its subpaths. */ + V2_ANALYTICS_PROXY_PATH?: string; + /** v2's Sentry browser error tunnel (`/api/<16 hex>/submit`), forwarded + * exactly. */ + V2_ERROR_TUNNEL_PATH?: string; // Shared with frontend VITE_PUBLIC_SITE_URL?: string; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 1a1a713c2d..6e3cad473a 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -318,8 +318,10 @@ const cloudflareHandler = { prewarmAppPlane(ctx); } - // Sign-up, the fixed list of v2 paths and v2's connected-account - // callbacks on `executor.sh` go to v2. + // On `executor.sh`, v2 answers marketing (including `/docs` and its + // telemetry proxies), sign-up, the fixed list of v2 paths and v2's + // connected-account callbacks; v1's legal pages stay on v1's marketing + // worker. const v2 = v2EdgeResponse(request, env); if (v2) return v2; @@ -333,6 +335,8 @@ const cloudflareHandler = { // first — measured at p50 3.1s on a cold isolate, against p50 33ms for the // request's own work, on a Worker where 1,666 dispatches spread across // 1,608 isolates (so nearly every request is cold). Forward before Start. + // On `executor.sh` v2 answers `/docs` above, so this serves it on other + // hosts; v1's own PostHog proxy is served here on every host. const passthroughPath = new URL(request.url).pathname; const passthrough = passthroughResponse(request, passthroughPath); if (passthrough) return passthrough; diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 9f7b22f258..3fc0f540a1 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -91,15 +91,21 @@ }, ], "services": [ + // v1's marketing worker. On executor.sh it now serves only v1's terms of + // service, privacy policy and Google OAuth disclosure, and their assets + // (src/edge/marketing.ts); v2 serves the rest of marketing. { "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`. + // (src/edge/marketing.ts) forwards marketing and a fixed list of + // executor.sh paths to it with the URL unchanged, so v2 sees host + // `executor.sh`. { "binding": "V2", + // v2's Worker name. It must equal the name v2's deployment pins for + // this binding; this is the only place v1 names it. "service": "executor-next-hosted-api-v2-qs32brgjwvt7ytx4", }, ], @@ -140,12 +146,25 @@ }, "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", + // Where /sign-up and /signup redirect: v2's sign-up page on its app host. + "V2_SIGN_UP_URL": "https://app.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.", + // v2's browser telemetry on executor.sh. Both values are outputs of v2's + // production infrastructure (stage `v2`; random and retained, so they do + // not change between deploys). v2 pins them in its edge contract and + // refuses to deploy stage `v2` if its outputs differ; change them here + // only together with that contract. + // PostHog browser proxy root: output `proxyPath` of v2's + // `executor-next-posthog` stack (apps/hosted/cloud/alchemy.posthog.ts). + // Forwarded below it (`/*`), not the root itself. + "V2_ANALYTICS_PROXY_PATH": "/api/00e2e1f082a6ef17", + // Sentry browser error tunnel: output `browserTunnel` of v2's + // `executor-next-sentry` stack (apps/hosted/cloud/alchemy.sentry.ts). + // Forwarded exactly, not its root or other subpaths. + "V2_ERROR_TUNNEL_PATH": "/api/fd6fab1fbb4883e1/submit", // 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/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 877af5b812..9d3f13f420 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -30,6 +30,10 @@ export default defineConfig({ site: "https://executor.sh", output: "server", integrations: [react()], + // On executor.sh this worker now serves only v1's legal pages; v2's + // marketing site owns `/_astro`. Build assets under their own root, which + // the cloud edge (apps/cloud/src/edge/marketing.ts) forwards here. + build: { assets: "_v1-marketing" }, vite: { plugins: [tailwindcss()], define: wranglerPublicDefine(), diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index d791ecda8c..35b4f689a1 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -62,11 +62,12 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString( // `history_change` is for SPAs like apps/cloud. const phKey = import.meta.env.PUBLIC_POSTHOG_KEY; if (phKey) { - // api_host rides the `/_astro` prefix that the cloud edge forwards to - // this worker on executor.sh; src/middleware.ts proxies it to PostHog. + // api_host rides the `/_v1-marketing` asset root that the cloud edge + // forwards to this worker on executor.sh; src/middleware.ts proxies it + // to PostHog. const posthogReady = import("posthog-js").then(({ default: posthog }) => { posthog.init(phKey, { - api_host: `${window.location.origin}/_astro/_ph`, + api_host: `${window.location.origin}/_v1-marketing/_ph`, ui_host: import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.posthog.com", autocapture: false, capture_pageview: true, diff --git a/apps/marketing/src/middleware.ts b/apps/marketing/src/middleware.ts index dbbd339e7f..a673f0b165 100644 --- a/apps/marketing/src/middleware.ts +++ b/apps/marketing/src/middleware.ts @@ -7,12 +7,11 @@ import type { MiddlewareHandler } from "astro"; // // The path MUST sit under a prefix that the cloud worker's edge forwards to // this worker. On the production apex (executor.sh) the cloud worker owns the -// custom domain and only proxies an allow-list to executor-marketing; `/api/*` -// stays in cloud (it has its own posthog proxy on a randomized path), so a -// top-level `/api/...` path here 404s. `/_astro` IS forwarded, so we ride it. +// custom domain and only proxies v1's legal pages and the `/_v1-marketing` +// asset root to executor-marketing, so we ride the asset root. const POSTHOG_INGEST_HOST = "us.i.posthog.com"; const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; -const POSTHOG_PROXY_PATH = "/_astro/_ph"; +const POSTHOG_PROXY_PATH = "/_v1-marketing/_ph"; export const onRequest: MiddlewareHandler = async (context, next) => { const { pathname } = new URL(context.request.url); From 61b61a159b0003adac61104187fc0bcc8f28f15f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:06:53 -0700 Subject: [PATCH 3/7] Keep forwarded marketing off the v1 application path (#2222) * Keep forwarded marketing requests off the application path * Verify the production marketing binding --- apps/cloud/src/edge/production-config.test.ts | 4 ++-- apps/cloud/src/server.ts | 17 +++++++---------- apps/cloud/wrangler.jsonc | 4 ++-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts index 700e3634ff..03075e641d 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -35,10 +35,10 @@ const shipped: V2EdgeEnv = { }; describe("production v2 edge settings", () => { - it("binds V2 to exactly one Worker", () => { + it("binds V2 to the unplaced marketing gateway", () => { const bindings = config.services.filter((service) => service.binding === "V2"); expect(bindings).toHaveLength(1); - expect(bindings[0]?.service).toMatch(/^[a-z0-9-]+$/); + expect(bindings[0]?.service).toBe("executor-next-marketing-v2"); }); it("redirects sign-up to v2's sign-up page on app.executor.sh", async () => { diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 6e3cad473a..ca00c5132b 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -308,16 +308,6 @@ const cloudflareHandler = { fetch: async (request, env, ctx) => { isolateRequestSeq += 1; - // Public pages must not enter TanStack Start: its first-request dynamic - // import loads the entire React + Effect server graph and can take seconds - // on a cold isolate. Classify and service-bind marketing at the Worker - // entry, before telemetry or fetchHandler touches that graph. - // Everything that returns before the app-plane dispatch below leaves the - // graph unevaluated for the next request; warm it in the background. - if (!servedByAppPlane(new URL(request.url).pathname, request.method)) { - prewarmAppPlane(ctx); - } - // On `executor.sh`, v2 answers marketing (including `/docs` and its // telemetry proxies), sign-up, the fixed list of v2 paths and v2's // connected-account callbacks; v1's legal pages stay on v1's marketing @@ -329,6 +319,13 @@ const cloudflareHandler = { const marketing: Fetcher | undefined = env.MARKETING; if (marketingRequest && marketing) return marketing.fetch(marketingRequest); + // Only v1-owned requests may warm v1's application. A dynamic import still + // evaluates JavaScript on this isolate's thread under waitUntil; starting it + // for a forwarded page or stylesheet delays the otherwise independent response. + if (!servedByAppPlane(new URL(request.url).pathname, request.method)) { + prewarmAppPlane(ctx); + } + // Same reasoning, same seam: `/docs` and the PostHog proxy forward to an // external origin and never touch the router, React, or the Effect app. // Left in Start's middleware they still paid its lazy `loadEntries` import diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 3fc0f540a1..21cba7aac1 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -98,7 +98,7 @@ "binding": "MARKETING", "service": "executor-marketing", }, - // v2's API Worker (the executor-next `v2` stage, same account). The edge + // v2's unplaced marketing Worker (the executor-next `v2` stage, same account). The edge // (src/edge/marketing.ts) forwards marketing and a fixed list of // executor.sh paths to it with the URL unchanged, so v2 sees host // `executor.sh`. @@ -106,7 +106,7 @@ "binding": "V2", // v2's Worker name. It must equal the name v2's deployment pins for // this binding; this is the only place v1 names it. - "service": "executor-next-hosted-api-v2-qs32brgjwvt7ytx4", + "service": "executor-next-marketing-v2", }, ], "hyperdrive": [ From 1ea720beaf2ef7567e9971605f16e15d91f1cfb3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:37:26 -0700 Subject: [PATCH 4/7] Serve executor.sh's forwarded paths from an unplaced front Worker (#2225) * Serve executor.sh's forwarded paths from an unplaced front Worker * Test the executor.sh front Worker --- .github/workflows/deploy.yml | 10 ++ apps/cloud/src/edge/front.test.ts | 112 ++++++++++++++++++ apps/cloud/src/edge/front.ts | 47 ++++++++ apps/cloud/src/edge/production-config.test.ts | 98 +++++++++++++++ apps/cloud/wrangler.edge.jsonc | 46 +++++++ 5 files changed, 313 insertions(+) create mode 100644 apps/cloud/src/edge/front.test.ts create mode 100644 apps/cloud/src/edge/front.ts create mode 100644 apps/cloud/wrangler.edge.jsonc diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 12b83997c8..b9e59936d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -106,6 +106,16 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # The unplaced front Worker on the `executor.sh/*` route (src/edge/front.ts). + # After executor-cloud, so each deploy changes the edge's settings in + # executor-cloud before the Worker in front of it. + - name: Deploy executor.sh front Worker + run: bun run wrangler deploy -c wrangler.edge.jsonc + working-directory: apps/cloud + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Deploy marker: one event per deploy into the same Axiom dataset the # worker traces land in, so a latency step-change lines up with its # deploy in one query. Skipped (not failed) when the secret is absent, diff --git a/apps/cloud/src/edge/front.test.ts b/apps/cloud/src/edge/front.test.ts new file mode 100644 index 0000000000..d2b3684fc8 --- /dev/null +++ b/apps/cloud/src/edge/front.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { frontResponse, type FrontEnv } from "./front"; + +/** A Worker stand-in that records what it receives and answers with `respond`. */ +const recordingWorker = (respond: () => Response) => { + const received: Request[] = []; + return { + received, + worker: { + fetch: (request: Request) => { + received.push(request); + return Promise.resolve(respond()); + }, + }, + }; +}; + +const settings = (v2: FrontEnv["V2"], marketing?: FrontEnv["MARKETING"]): FrontEnv => ({ + V2: v2, + MARKETING: marketing, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", +}); + +const IMMUTABLE = "public, max-age=31536000, immutable"; + +describe("frontResponse", () => { + it("returns v2's asset with its caching, status and body unchanged, adding only the referrer policy", async () => { + const v2 = recordingWorker( + () => + new Response("console.log(1)", { + status: 200, + headers: { + "cache-control": IMMUTABLE, + "content-type": "text/javascript", + etag: '"abc"', + }, + }), + ); + + const response = await frontResponse( + new Request("https://executor.sh/_astro/page.abc123.js"), + settings(v2.worker), + ); + + expect(v2.received.map((request) => request.url)).toEqual([ + "https://executor.sh/_astro/page.abc123.js", + ]); + expect(response?.status).toBe(200); + expect(response?.headers.get("cache-control")).toBe(IMMUTABLE); + expect(response?.headers.get("content-type")).toBe("text/javascript"); + expect(response?.headers.get("etag")).toBe('"abc"'); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + expect(await response?.text()).toBe("console.log(1)"); + }); + + it("answers v2's redirects itself, as executor-cloud does", async () => { + const v2 = recordingWorker(() => new Response("v2")); + + const response = await frontResponse( + new Request("https://executor.sh/sign-up"), + settings(v2.worker), + ); + + expect(v2.received).toHaveLength(0); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/login?mode=signup"); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("sends v1's legal pages to v1's marketing worker", async () => { + const v2 = recordingWorker(() => new Response("v2")); + const marketing = recordingWorker(() => new Response("terms")); + + const response = await frontResponse( + new Request("https://executor.sh/terms"), + settings(v2.worker, marketing.worker), + ); + + expect(v2.received).toHaveLength(0); + expect(marketing.received.map((request) => request.url)).toEqual(["https://executor.sh/terms"]); + expect(await response?.text()).toBe("terms"); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("passes on every request executor-cloud serves itself", () => { + const v2 = recordingWorker(() => new Response("v2")); + const marketing = recordingWorker(() => new Response("terms")); + const env = settings(v2.worker, marketing.worker); + + for (const request of [ + new Request("https://executor.sh/", { headers: { cookie: "wos-session=sealed" } }), + new Request("https://executor.sh/login"), + new Request("https://executor.sh/mcp", { method: "POST" }), + new Request("https://executor.sh/api/auth/callback?code=c&state=s"), + new Request("https://executor.sh/api/oauth/callback?code=c&state=abc"), + new Request("https://executor.sh/acme"), + new Request("https://executor.sh/favicon.ico"), + ]) { + expect(frontResponse(request, env), request.url).toBeNull(); + } + expect(v2.received).toHaveLength(0); + expect(marketing.received).toHaveLength(0); + }); + + it("passes everything on when v2's settings are absent", () => { + expect(frontResponse(new Request("https://executor.sh/_astro/page.abc123.js"), {})).toBeNull(); + }); +}); diff --git a/apps/cloud/src/edge/front.ts b/apps/cloud/src/edge/front.ts new file mode 100644 index 0000000000..910a42eef9 --- /dev/null +++ b/apps/cloud/src/edge/front.ts @@ -0,0 +1,47 @@ +// --------------------------------------------------------------------------- +// The `executor.sh` front Worker (`wrangler.edge.jsonc`, Worker +// `executor-cloud-edge`). +// +// `executor-cloud` is placed near v1's database (`placement.region` in +// wrangler.jsonc), so every request it runs crosses to Virginia first. The +// requests its edge hands to v2 or to v1's marketing worker never use that +// database, yet each paid the trip: a 2 KB `/_astro/*` file took up to 2.6 s. +// This Worker has no placement and runs the same edge decision +// (./marketing.ts) where the request lands, on a zone route +// `executor.sh/*`. A zone route runs before the Worker on the hostname's +// Custom Domain, which Cloudflare treats as the origin, so a request v1 owns +// goes on with `fetch(request)` to `executor-cloud` unchanged: same URL, +// method, headers, cookies, body stream and redirect handling as before. +// +// `executor-cloud` keeps its own copy of the edge, which answers nothing this +// Worker passes on. Deleting this Worker (or its route) returns `executor.sh` +// to exactly that, which is the rollback. +// --------------------------------------------------------------------------- + +import { marketingProxyRequest, v2EdgeResponse, type V2EdgeEnv } from "./marketing"; +import { withPrivateReferrerPolicy } from "./referrer-policy"; + +/** The front Worker's bindings and settings (`wrangler.edge.jsonc`). */ +export interface FrontEnv extends V2EdgeEnv { + /** v1's marketing worker, which serves v1's legal pages. */ + readonly MARKETING?: { readonly fetch: (request: Request) => Promise }; +} + +/** + * Answer an `executor.sh` request that v2 or v1's marketing worker owns, + * exactly as `executor-cloud`'s entry does, including its `no-referrer` + * policy. Returns `null` for every request `executor-cloud` serves itself. + */ +export const frontResponse = (request: Request, env: FrontEnv): Promise | null => { + const v2 = v2EdgeResponse(request, env); + if (v2) return v2.then(withPrivateReferrerPolicy); + const marketingRequest = marketingProxyRequest(request); + if (marketingRequest && env.MARKETING) { + return env.MARKETING.fetch(marketingRequest).then(withPrivateReferrerPolicy); + } + return null; +}; + +export default { + fetch: (request, env) => frontResponse(request, env) ?? fetch(request), +} satisfies ExportedHandler; diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts index 03075e641d..95c82fcac6 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { unstable_readConfig } from "wrangler"; +import { frontResponse } from "./front"; import { parseV2Edge, v2EdgeResponse, type V2EdgeEnv, type V2Service } from "./marketing"; // The deployed edge reads its v2 settings from wrangler.jsonc. These tests read @@ -18,6 +19,23 @@ const config = Schema.decodeUnknownSync(WranglerConfig)( unstable_readConfig({ config: fileURLToPath(new URL("../../wrangler.jsonc", import.meta.url)) }), ); +// The front Worker (wrangler.edge.jsonc) runs the same edge on executor.sh +// before `executor-cloud`, with its own copy of the settings. +const EdgeWranglerConfig = Schema.Struct({ + main: Schema.String, + vars: Schema.Record(Schema.String, Schema.Unknown), + services: Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String })), + routes: Schema.Array( + Schema.Struct({ pattern: Schema.String, zone_name: Schema.optional(Schema.String) }), + ), + placement: Schema.optional(Schema.Unknown), +}); +const edgeConfig = Schema.decodeUnknownSync(EdgeWranglerConfig)( + unstable_readConfig({ + config: fileURLToPath(new URL("../../wrangler.edge.jsonc", import.meta.url)), + }), +); + const stringVar = (name: string): string | undefined => { const value = config.vars[name]; return typeof value === "string" ? value : undefined; @@ -34,6 +52,20 @@ const shipped: V2EdgeEnv = { V2_ERROR_TUNNEL_PATH: stringVar("V2_ERROR_TUNNEL_PATH"), }; +const edgeStringVar = (name: string): string | undefined => { + const value = edgeConfig.vars[name]; + return typeof value === "string" ? value : undefined; +}; + +/** The front Worker's shipped settings, with the same stand-in. */ +const frontShipped: V2EdgeEnv = { + V2: standIn, + V2_SIGN_UP_URL: edgeStringVar("V2_SIGN_UP_URL"), + V2_OAUTH_STATE_PREFIX: edgeStringVar("V2_OAUTH_STATE_PREFIX"), + V2_ANALYTICS_PROXY_PATH: edgeStringVar("V2_ANALYTICS_PROXY_PATH"), + V2_ERROR_TUNNEL_PATH: edgeStringVar("V2_ERROR_TUNNEL_PATH"), +}; + describe("production v2 edge settings", () => { it("binds V2 to the unplaced marketing gateway", () => { const bindings = config.services.filter((service) => service.binding === "V2"); @@ -66,6 +98,40 @@ describe("production v2 edge settings", () => { }); }); +describe("the executor.sh front Worker's settings", () => { + it("runs the front entry on a zone route for every executor.sh path", () => { + expect(edgeConfig.main).toMatch(/src\/edge\/front\.ts$/); + expect(edgeConfig.routes).toEqual([{ pattern: "executor.sh/*", zone_name: "executor.sh" }]); + }); + + // Placement would send every request it answers to the placed region, + // which is the trip this Worker exists to avoid. + it("is not placed", () => { + expect(edgeConfig.placement).toBeUndefined(); + }); + + it("ships the same v2 settings as executor-cloud", () => { + const v2Vars = (vars: Readonly>) => + Object.fromEntries(Object.entries(vars).filter(([name]) => name.startsWith("V2_"))); + expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual([ + "V2_ANALYTICS_PROXY_PATH", + "V2_ERROR_TUNNEL_PATH", + "V2_OAUTH_STATE_PREFIX", + "V2_SIGN_UP_URL", + ]); + expect(v2Vars(edgeConfig.vars)).toEqual(v2Vars(config.vars)); + }); + + it("binds the same v2 and marketing Workers as executor-cloud", () => { + const bound = (services: typeof config.services) => + services + .filter((service) => service.binding === "V2" || service.binding === "MARKETING") + .toSorted((a, b) => a.binding.localeCompare(b.binding)); + expect(bound(edgeConfig.services)).toEqual(bound(config.services)); + expect(bound(edgeConfig.services)).toHaveLength(2); + }); +}); + // v2 publishes the exact set of executor.sh requests v1's edge forwards to it. // `v2-edge-contract.json` is a verbatim copy of v2's edge contract; update it // only together with v2's contract, never by hand here. Every case runs @@ -133,6 +199,38 @@ describe("v2's edge contract", () => { }); } + // The front Worker answers the same requests the same way: everything the + // contract forwards reaches v2 once, and nothing else reaches it. + const runFrontCase = async (method: string, target: string) => { + const calls: Request[] = []; + const v2: V2Service = { + fetch: (request) => { + calls.push(request); + return Promise.resolve(new Response("v2")); + }, + }; + const request = new Request(`${contract.origin}${target}`, { method }); + const response = await frontResponse(request, { ...frontShipped, V2: v2 }); + return { request, calls, response }; + }; + + for (const { method, target } of forwarded) { + it(`front Worker forwards ${method} ${target} to v2`, async () => { + const { request, calls, response } = await runFrontCase(method, target); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(request.url); + expect(calls[0]?.method).toBe(method); + expect(await response?.text()).toBe("v2"); + }); + } + + for (const { method, target } of contract.cases.filter((c) => !c.forwards)) { + it(`front Worker does not forward ${method} ${target} to v2`, async () => { + const { calls } = await runFrontCase(method, target); + expect(calls).toHaveLength(0); + }); + } + // Sign-up is not forwarded: the edge redirects it to v2's sign-up page. it("lists both sign-up paths as not forwarded", () => { expect(signUps.map((c) => c.target).toSorted()).toEqual(["/sign-up", "/signup"]); diff --git a/apps/cloud/wrangler.edge.jsonc b/apps/cloud/wrangler.edge.jsonc new file mode 100644 index 0000000000..7958bf386c --- /dev/null +++ b/apps/cloud/wrangler.edge.jsonc @@ -0,0 +1,46 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // The `executor.sh` front Worker (src/edge/front.ts). It answers the + // requests v1's edge hands to v2 or to v1's marketing worker where they + // land, and passes every other request on to `executor-cloud`, the Worker + // on the `executor.sh` Custom Domain. Deployed after `executor-cloud` by + // .github/workflows/deploy.yml. + "name": "executor-cloud-edge", + "compatibility_date": "2025-04-01", + "main": "src/edge/front.ts", + "workers_dev": false, + "preview_urls": false, + // A zone route runs before the Custom Domain's Worker; see front.ts. + "routes": [ + { + "pattern": "executor.sh/*", + "zone_name": "executor.sh", + }, + ], + // No `placement`, on purpose: this Worker exists to run where the request + // lands. `executor-cloud` stays placed for v1's own requests. + "observability": { + "enabled": true, + "redact_query_string": true, + }, + "logpush": true, + // The same Workers and v2 settings as `executor-cloud`'s wrangler.jsonc, + // which keeps its own copy of the edge. src/edge/production-config.test.ts + // fails when the two differ. + "services": [ + { + "binding": "MARKETING", + "service": "executor-marketing", + }, + { + "binding": "V2", + "service": "executor-next-marketing-v2", + }, + ], + "vars": { + "V2_SIGN_UP_URL": "https://app.executor.sh/login?mode=signup", + "V2_OAUTH_STATE_PREFIX": "x2.", + "V2_ANALYTICS_PROXY_PATH": "/api/00e2e1f082a6ef17", + "V2_ERROR_TUNNEL_PATH": "/api/fd6fab1fbb4883e1/submit", + }, +} From af8d07235667f8501b9218b6b12ac95725e5a299 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:57:36 -0700 Subject: [PATCH 5/7] Redirect executor.sh/org links to the v2 app host (#2230) --- apps/cloud/src/edge/marketing.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 9f19e58128..23a7dea4c8 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -152,6 +152,11 @@ export const v2PageForSlashedPath = (pathname: string): string | null => { return V2_EXACT_PAGES.has(page) ? page : null; }; +/** v2's dashboard pages are `/org//...` on v2's app host. `org` is a + * reserved v1 slug and v1's own `/org` page has nothing below it, so + * `executor.sh/org/*` is always a v2 dashboard link with the wrong host. */ +const V2_DASHBOARD_PREFIX = "/org/"; + /** The landing page without a v1 session is v2's marketing homepage. */ const isV2Homepage = (url: URL, request: Request): boolean => url.pathname === "/" && parseCookie(request.headers.get("cookie"), SESSION_COOKIE) === null; @@ -314,6 +319,9 @@ const isV2TelemetryPath = (pathname: string, edge: V2Edge): boolean => * - sign-up (`GET`, any query) redirects to v2's sign-up page; * - a `GET` or `HEAD` for a slashed v2 page (`/pricing/`) redirects (308) * to the page, query kept, as v2's site does on its own hosts; + * - a `GET` or `HEAD` for a v2 dashboard page (`/org//...`) redirects + * (308) to the same path and query on v2's app host, the sign-up URL's + * origin; * - a {@link isV2Path} request, a connected-account callback whose `state` * starts with v2's prefix, or a request for v2's analytics proxy or error * tunnel is forwarded to v2's Worker without cookies; @@ -334,8 +342,11 @@ export const v2EdgeResponse = (request: Request, env: V2EdgeEnv): Promise Date: Thu, 8 Oct 2026 17:57:37 -0700 Subject: [PATCH 6/7] Test the executor.sh/org redirect (#2231) --- apps/cloud/src/edge/marketing.test.ts | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index 2fdaff85c3..146ea0c2ef 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -762,6 +762,44 @@ describe("v2EdgeResponse marketing", () => { expect(received).toHaveLength(0); }); + it("redirects v2 dashboard links to v2's app host, path and query kept", async () => { + const { received, service } = recordingService(); + + for (const target of ["/org/acme/apps/app_1/open", "/org/acme?tab=kpis", "/org/acme/"]) { + for (const method of ["GET", "HEAD"]) { + const response = await v2EdgeResponse( + new Request(`https://executor.sh${target}`, { + method, + headers: { cookie: "wos-session=sealed" }, + }), + settings(service), + ); + expect(response?.status, `${method} ${target}`).toBe(308); + expect(response?.headers.get("location"), `${method} ${target}`).toBe( + `https://app.executor.sh${target}`, + ); + } + } + expect(received).toHaveLength(0); + }); + + it("keeps v1's own /org page and non-navigation methods with v1", () => { + const { received, service } = recordingService(); + + for (const [method, target] of [ + ["GET", "/org"], + ["GET", "/acme/org"], + ["GET", "/organization/x"], + ["POST", "/org/acme"], + ] as const) { + expect( + v2EdgeResponse(new Request(`https://executor.sh${target}`, { method }), settings(service)), + `${method} ${target}`, + ).toBeNull(); + } + expect(received).toHaveLength(0); + }); + it("sends the signed-out homepage to v2 with the URL unchanged", async () => { const { received, service } = recordingService(); From 71620ce321cec4bac70e6f8c7476b945f2aaeca6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:27:26 -0700 Subject: [PATCH 7/7] Skip CI on fork PRs (#2240) --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9daf5378b..5faa155563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,12 @@ env: EXECUTOR_DISABLE_ANALYTICS: "1" EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" +# Outside contributions are ported into the private repo, which runs the +# checks there, so fork PRs skip CI here. Jobs that need `changes` skip with it. jobs: changes: name: Changed paths + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -78,6 +81,7 @@ jobs: format: name: Format + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -101,6 +105,7 @@ jobs: lint: name: Lint + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -124,6 +129,7 @@ jobs: typecheck: name: Typecheck + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 15 env: @@ -165,6 +171,7 @@ jobs: test: name: Test + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 15 # Run eight independent packages at once and cap each Vitest process at two