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/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 95aa2eca4f..3fc0f540a1 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -91,13 +91,17 @@ }, ], "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 @@ -148,6 +152,19 @@ // /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);