From c03c9adc1fcd4a3f2b0a839878f7310f5fc835b4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:27:53 -0700 Subject: [PATCH 01/22] Restrict OAuth token destination selection (#2190) --- .changeset/trusted-oauth-token-regions.md | 5 ++ packages/core/sdk/src/executor.ts | 9 ++- packages/core/sdk/src/oauth-helpers.ts | 72 +++++++++++++++-------- 3 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 .changeset/trusted-oauth-token-regions.md diff --git a/.changeset/trusted-oauth-token-regions.md b/.changeset/trusted-oauth-token-regions.md new file mode 100644 index 0000000000..9f3abec23a --- /dev/null +++ b/.changeset/trusted-oauth-token-regions.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Restrict callback-selected OAuth token endpoints to supported Datadog regions and validate persisted regional endpoints before refreshing credentials. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 0f338405cd..2a6b823a5f 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -212,6 +212,7 @@ import { isUnusableSuccessTokenResponse, optionalScopesFromAuthorizationUrl, shouldRefreshToken, + tokenEndpointForRefresh, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; @@ -2694,7 +2695,13 @@ export const createExecutor = = new Set([ + "app.datadoghq.com", + "us3.datadoghq.com", + "us5.datadoghq.com", + "ap1.datadoghq.com", + "ap2.datadoghq.com", +]); + const hostnameFromCallbackDomain = (callbackDomain: string): string | undefined => { const trimmed = callbackDomain.trim(); if (trimmed.length === 0) return undefined; @@ -327,23 +330,17 @@ const hostnameFromCallbackDomain = (callbackDomain: string): string | undefined if (!URL.canParse(candidate)) return undefined; const url = new URL(candidate); // A legitimate regional host carries no port, credentials, or path. - if (url.port !== "" || url.username !== "" || url.password !== "") return undefined; + if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "") { + return undefined; + } + if (url.search !== "" || url.hash !== "") return undefined; if (url.pathname !== "/" && url.pathname !== "") return undefined; return url.hostname.toLowerCase(); }; -/** Parent domain after stripping the leftmost DNS label, or `undefined` when - * the host has no sibling space (a single label, or a parent that is a bare - * TLD). `app.datadoghq.com` -> `datadoghq.com`; `foo.com` -> undefined. */ -const siblingParentDomainOf = (hostname: string): string | undefined => { - const labels = hostname.split("."); - if (labels.length < 3) return undefined; - const parent = labels.slice(1).join("."); - // Require the parent to itself be multi-label so a 2-label configured host - // can never rebind across an entire TLD (e.g. foo.com -> bar.com). - return parent.includes(".") ? parent : undefined; -}; - +/** Select a supported Datadog token host from callback data, preserving every + * other URL component. Untrusted or malformed callbacks leave the configured + * endpoint unchanged; DNS suffix similarity never grants trust. */ export const rebindTokenEndpointHostToCallbackDomain = ( configuredTokenUrl: string, callbackDomain: string | null | undefined, @@ -351,14 +348,21 @@ export const rebindTokenEndpointHostToCallbackDomain = ( if (!callbackDomain) return configuredTokenUrl; if (!URL.canParse(configuredTokenUrl)) return configuredTokenUrl; const configured = new URL(configuredTokenUrl); - if (configured.protocol !== "https:") return configuredTokenUrl; + if ( + configured.protocol !== "https:" || + configured.port !== "" || + configured.username !== "" || + configured.password !== "" + ) + return configuredTokenUrl; const targetHost = hostnameFromCallbackDomain(callbackDomain); if (!targetHost) return configuredTokenUrl; const configuredHost = configured.hostname.toLowerCase(); if (targetHost === configuredHost) return configuredTokenUrl; - const configuredParent = siblingParentDomainOf(configuredHost); - const targetParent = siblingParentDomainOf(targetHost); - if (!configuredParent || !targetParent || configuredParent !== targetParent) { + if ( + !DATADOG_REGIONAL_TOKEN_HOSTS.has(configuredHost) || + !DATADOG_REGIONAL_TOKEN_HOSTS.has(targetHost) + ) { return configuredTokenUrl; } const rebound = new URL(configuredTokenUrl); @@ -366,6 +370,22 @@ export const rebindTokenEndpointHostToCallbackDomain = ( return rebound.toString(); }; +/** Parse a persisted refresh endpoint against the current client configuration. + * Only the configured URL or an exact supported regional substitution is + * accepted. Invalid legacy overrides return undefined, requiring reconnect. */ +export const tokenEndpointForRefresh = ( + configuredTokenUrl: string, + persistedTokenUrl: string | null, +): string | undefined => { + if (persistedTokenUrl === null || persistedTokenUrl === configuredTokenUrl) { + return configuredTokenUrl; + } + if (!URL.canParse(persistedTokenUrl) || !URL.canParse(configuredTokenUrl)) return undefined; + const persisted = new URL(persistedTokenUrl); + const trusted = rebindTokenEndpointHostToCallbackDomain(configuredTokenUrl, persisted.origin); + return new URL(trusted).href === persisted.href ? trusted : undefined; +}; + // --------------------------------------------------------------------------- // Error mapping — `oauth4webapi`'s `process*Response` failure shapes are // either a WWW-Authenticate challenge or an RFC 6749 §5.2 error body, From 27dccb896fbaf9d1790496d1a8f131b790c89c68 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:27:54 -0700 Subject: [PATCH 02/22] Cover OAuth token destination trust boundaries (#2191) --- packages/core/sdk/src/oauth-flow.test.ts | 233 ++++++++++++++--------- packages/core/sdk/src/test-config.ts | 2 + 2 files changed, 149 insertions(+), 86 deletions(-) diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index b98d500716..281d6062ce 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -139,19 +139,13 @@ interface TokenEndpointCall { readonly grantType: string | null; } -// Route token-endpoint requests aimed at a *non-loopback* host (the -// regional/attacker hosts a multi-site rebind test exercises) back to the -// loopback test AS, recording the host + grant each one was sent to. The token -// exchange/refresh runs through `oauth4webapi`, which calls the global `fetch` -// at request time, so swapping `globalThis.fetch` lets the real -// `oauth.complete` / refresh path drive the rebind decision while still hitting -// a live authorization server. Loopback traffic (the authorize/login hops) -// passes straight through untouched. Returns a restore function. +// Route configured and regional token hosts to the live loopback authorization +// server through ExecutorConfig.fetch, recording each outgoing token request. const routeTokenEndpointToLoopback = ( server: { readonly issuerUrl: string }, record: TokenEndpointCall[], -): (() => void) => { - // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: oauth4webapi reads the global `fetch` at call time, so doubling it is the only seam to observe the token exchange/refresh host. +): typeof fetch => { + // oxlint-disable-next-line executor/no-raw-fetch -- test transport boundary. const originalFetch = globalThis.fetch; const loopback = new URL(server.issuerUrl); const patched: typeof fetch = async (input, init) => { @@ -182,12 +176,7 @@ const routeTokenEndpointToLoopback = ( ? originalFetch(new Request(rerouted.href, input)) : originalFetch(rerouted.href, init); }; - // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: install the doubled fetch (see above). - globalThis.fetch = patched; - return () => { - // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: restore the original fetch. - globalThis.fetch = originalFetch; - }; + return patched; }; describe("oauth.start / oauth.complete", () => { @@ -2214,29 +2203,27 @@ describe("oauth token refresh in resolveConnectionValue", () => { // the org lives on, signalled by the callback's non-standard `domain`/`site` // param. The token endpoint host must rebind to that region for both the // initial exchange and later refreshes — but only when the callback host is a -// trusted sibling subdomain, never an attacker-influenced arbitrary origin. +// supported Datadog region, never an attacker-influenced arbitrary origin. describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", () => { // Configured (statically advertised) host: the leftmost label differs from - // the org's region, but they share the `datadoghq.test` parent. - const ADVERTISED_TOKEN_URL = "https://app.datadoghq.test/token"; + // the org's region, but they share the `datadoghq.com` parent. + const ADVERTISED_TOKEN_URL = "https://app.datadoghq.com/token"; it.effect( - "redeems + refreshes at the callback's sibling-subdomain region, never the advertised host", + "redeems + refreshes at the callback's supported region, never the advertised host", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); - yield* executor.acme.seed(); - // Reroute the https regional/advertised hosts back to the loopback test // AS so the real exchange/refresh path drives the rebind decision while - // hitting a live server. Restored when the test scope closes. + // hitting a live server through this executor's fetch transport. const tokenCalls: TokenEndpointCall[] = []; - yield* Effect.acquireRelease( - Effect.sync(() => routeTokenEndpointToLoopback(server, tokenCalls)), - (restore) => Effect.sync(restore), - ); + const { executor, config } = yield* makeTestWorkspaceHarness({ + plugins, + fetch: routeTokenEndpointToLoopback(server, tokenCalls), + }); + yield* executor.acme.seed(); // Authorize on the loopback AS (passthrough), but advertise the US1-style // token host the way Datadog's AS metadata does. @@ -2269,19 +2256,19 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( yield* executor.oauth.complete({ state: started.state, code: callback.code, - callbackDomain: "us5.datadoghq.test", + callbackDomain: "us5.datadoghq.com", }); // The code was redeemed at the regional host, not the advertised one. const exchangeCall = tokenCalls.find((c) => c.grantType === "authorization_code"); - expect(exchangeCall?.host).toBe("us5.datadoghq.test"); + expect(exchangeCall?.host).toBe("us5.datadoghq.com"); // The regional token endpoint is persisted on the connection so later // refreshes target the same region (the AS metadata still says US1). const row = yield* Effect.promise(() => config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), ); - expect(row?.oauth_token_url).toBe("https://us5.datadoghq.test/token"); + expect(row?.oauth_token_url).toBe("https://us5.datadoghq.com/token"); // Mint, then expire so the next resolve must refresh. const firstToken = (yield* executor.execute( @@ -2304,72 +2291,146 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( // The refresh hit the persisted region too … const refreshCall = tokenCalls.find((c) => c.grantType === "refresh_token"); - expect(refreshCall?.host).toBe("us5.datadoghq.test"); + expect(refreshCall?.host).toBe("us5.datadoghq.com"); // … and the statically advertised host was never contacted. - expect(tokenCalls.some((c) => c.host === "app.datadoghq.test")).toBe(false); + expect(tokenCalls.some((c) => c.host === "app.datadoghq.com")).toBe(false); }), ), ); - it.effect("ignores a non-sibling callback domain — exchange stays on the advertised host", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* serveOAuthTestServer({ scopes: ["read"] }); - const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); - yield* executor.acme.seed(); + for (const [tokenUrl, callbackDomain] of [ + [ADVERTISED_TOKEN_URL, "evil.example.test"], + ["https://provider.co.uk/token", "attacker.co.uk"], + ["https://auth.example.test/token", "untrusted.example.test"], + [ADVERTISED_TOKEN_URL, "unlisted.datadoghq.com"], + ["https://oauth2.googleapis.com/token", "untrusted.googleapis.com"], + [ADVERTISED_TOKEN_URL, "http://us5.datadoghq.com"], + [ADVERTISED_TOKEN_URL, "us5.datadoghq.com:8443"], + [ADVERTISED_TOKEN_URL, "https://user:password@us5.datadoghq.com"], + [ADVERTISED_TOKEN_URL, "us5.datadoghq.com/other-path"], + [ADVERTISED_TOKEN_URL, "us5.datadoghq.com?redirect=elsewhere"], + ]) { + it.effect(`keeps shared client credentials at ${tokenUrl} for callback ${callbackDomain}`, () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const tokenCalls: TokenEndpointCall[] = []; + const { executor, config } = yield* makeTestWorkspaceHarness({ + plugins, + fetch: routeTokenEndpointToLoopback(server, tokenCalls), + }); + yield* executor.acme.seed(); - const tokenCalls: TokenEndpointCall[] = []; - yield* Effect.acquireRelease( - Effect.sync(() => routeTokenEndpointToLoopback(server, tokenCalls)), - (restore) => Effect.sync(restore), - ); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); - yield* executor.oauth.createClient({ - owner: "org", - slug: CLIENT, - authorizationUrl: server.authorizationEndpoint, - tokenUrl: ADVERTISED_TOKEN_URL, - grant: "authorization_code", - clientId: "test-client", - clientSecret: "test-secret", - }); + const member = yield* Effect.acquireRelease( + createExecutor({ ...config, orgWrites: "denied" }), + (instance) => instance.close().pipe(Effect.orDie), + ); + const started = yield* member.oauth.start({ + owner: "user", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); - const started = yield* executor.oauth.start({ - owner: "org", - client: CLIENT, - clientOwner: "org", - name: ConnectionName.make("main"), - integration: INTEG, - template: TEMPLATE, - }); - expect(started.status).toBe("redirect"); - if (started.status !== "redirect") return; - const callback = yield* server.completeAuthorizationCodeFlow({ - authorizationUrl: started.authorizationUrl, - }); + yield* member.oauth.complete({ + state: started.state, + code: callback.code, + callbackDomain, + }); - // An attacker-influenced callback host that is NOT a sibling subdomain of - // the configured token host (`evil.example.test` vs `app.datadoghq.test`). - // The token request carries the client secret + code + PKCE verifier, so - // the rebind must refuse and fall back to the advertised host. - yield* executor.oauth.complete({ - state: started.state, - code: callback.code, - callbackDomain: "evil.example.test", - }); + const exchangeCall = tokenCalls.find((c) => c.grantType === "authorization_code"); + expect(exchangeCall?.host).toBe(new URL(tokenUrl).host); + expect(tokenCalls.some((c) => c.host === callbackDomain)).toBe(false); - const exchangeCall = tokenCalls.find((c) => c.grantType === "authorization_code"); - expect(exchangeCall?.host).toBe("app.datadoghq.test"); - expect(tokenCalls.some((c) => c.host === "evil.example.test")).toBe(false); + // Nothing regional was persisted: refresh keeps using the configured host. + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(row?.oauth_token_url ?? null).toBeNull(); + }), + ), + ); + } +}); - // Nothing regional was persisted: refresh keeps using the configured host. - const row = yield* Effect.promise(() => - config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), - ); - expect(row?.oauth_token_url ?? null).toBeNull(); - }), - ), - ); +describe("OAuth refresh token destination trust", () => { + for (const override of [ + "https://attacker.co.uk/token", + "https://unlisted.datadoghq.com/token", + "https://us5.datadoghq.com/other-path", + "https://us5.datadoghq.com/token?redirect=elsewhere", + "https://us5.datadoghq.com:8443/token", + "https://user:password@us5.datadoghq.com/token", + "http://us5.datadoghq.com/token", + ]) { + it.effect(`requires reconnect without sending credentials to ${override}`, () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const tokenCalls: TokenEndpointCall[] = []; + const { executor, config } = yield* makeTestWorkspaceHarness({ + plugins, + fetch: routeTokenEndpointToLoopback(server, tokenCalls), + }); + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: "https://app.datadoghq.com/token", + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000, oauth_token_url: override }, + }), + ); + tokenCalls.length = 0; + yield* server.clearRequests; + const error = yield* Effect.flip( + executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {}), + ); + expect(JSON.stringify(error)).toContain("no longer trusted"); + expect(tokenCalls).toEqual([]); + expect(yield* server.requests).toEqual([]); + }), + ), + ); + } }); describe("missingGrantedOAuthScopes canonicalization", () => { diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index df32bcda0f..8c0ebabc25 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -138,6 +138,7 @@ export type TestConfigOptions["orgWrites"]; + readonly fetch?: ExecutorConfig["fetch"]; readonly waitUntil?: ExecutorConfig["waitUntil"]; }; @@ -182,6 +183,7 @@ export const makeTestConfig = Date: Thu, 8 Oct 2026 12:44:49 -0700 Subject: [PATCH 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 From 6e768da9dad13c7c469d2c2071bf542157f1033c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:43:35 -0700 Subject: [PATCH 10/22] Send a default User-Agent on remote MCP requests (#2254) --- .changeset/mcp-default-user-agent.md | 5 +++++ packages/plugins/mcp/src/sdk/connection.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-default-user-agent.md diff --git a/.changeset/mcp-default-user-agent.md b/.changeset/mcp-default-user-agent.md new file mode 100644 index 0000000000..b557d205b6 --- /dev/null +++ b/.changeset/mcp-default-user-agent.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Send a default User-Agent on remote MCP requests so servers that reject requests without one accept the connection. A configured User-Agent header still wins. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 344de1f640..94fed481d4 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -72,6 +72,17 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record) return url; }; +// Some upstreams (Cloudflare bot rules among them) reject requests without a +// User-Agent, and Workers' fetch adds none. Both remote SDK transports apply +// `requestInit.headers` to every request they send, including OAuth metadata +// discovery, so the default goes there. A configured User-Agent wins. +const DEFAULT_USER_AGENT = "executor"; + +const withDefaultUserAgent = (headers: Record): Record => + Object.keys(headers).some((name) => name.toLowerCase() === "user-agent") + ? headers + : { "User-Agent": DEFAULT_USER_AGENT, ...headers }; + type HttpMethod = Parameters[0]; const HTTP_METHODS = new Set([ "DELETE", @@ -626,9 +637,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { } // Remote transport - const headers = input.headers ?? {}; + const headers = withDefaultUserAgent(input.headers ?? {}); const remoteTransport = input.remoteTransport ?? "auto"; - const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; + const requestInit = { headers }; const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer, input.staticOAuthBearer === true) : undefined; From 9fa93c28ee1c575d437a66a5e196c731dfc1a8d1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:43:36 -0700 Subject: [PATCH 11/22] Test the default MCP User-Agent (#2255) --- .../mcp/src/sdk/connection-user-agent.test.ts | 175 ++++++++++++++++++ packages/plugins/mcp/src/testing/server.ts | 3 + 2 files changed, 178 insertions(+) create mode 100644 packages/plugins/mcp/src/sdk/connection-user-agent.test.ts diff --git a/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts b/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts new file mode 100644 index 0000000000..d16a13a5b5 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import type { OAuthClientProvider } from "@modelcontextprotocol/client"; + +import { createMcpConnector } from "./connection"; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; + +const endpoint = "https://mcp.example/mcp"; + +/** Records each request's User-Agent and answers 405, so `auto` exercises + * both the Streamable HTTP attempt and the SSE fallback. */ +const recordingClientLayer = (userAgents: Array) => + Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + userAgents.push(request.headers["user-agent"]); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("unsupported", { status: 405 })), + ); + }), + ); + +const unauthorizedProvider: OAuthClientProvider = { + get redirectUrl() { + return "http://localhost/oauth/callback"; + }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost/oauth/callback"], + grant_types: ["authorization_code"] as string[], + response_types: ["code"] as string[], + token_endpoint_auth_method: "none" as const, + client_name: "Executor", + }; + }, + clientInformation: () => ({ client_id: "test-client" }), + saveClientInformation: () => undefined, + tokens: () => ({ access_token: "rejected-token", token_type: "Bearer" }), + saveTokens: () => undefined, + redirectToAuthorization: () => undefined, + saveCodeVerifier: () => undefined, + codeVerifier: () => "unused", +}; + +describe("MCP remote User-Agent", () => { + it.effect("sends a default User-Agent on both transports through the HTTP client layer", () => + Effect.gen(function* () { + const userAgents: Array = []; + + yield* createMcpConnector({ + transport: "remote", + endpoint, + remoteTransport: "auto", + httpClientLayer: recordingClientLayer(userAgents), + }).pipe(Effect.flip); + + expect(userAgents.length).toBeGreaterThanOrEqual(2); + expect(new Set(userAgents)).toEqual(new Set(["executor"])); + }), + ); + + it.effect("keeps a configured User-Agent through the HTTP client layer", () => + Effect.gen(function* () { + const userAgents: Array = []; + + yield* createMcpConnector({ + transport: "remote", + endpoint, + remoteTransport: "auto", + headers: { "user-agent": "custom-agent/1.0" }, + httpClientLayer: recordingClientLayer(userAgents), + }).pipe(Effect.flip); + + expect(userAgents.length).toBeGreaterThanOrEqual(2); + expect(new Set(userAgents)).toEqual(new Set(["custom-agent/1.0"])); + }), + ); + + it.effect("sends a default User-Agent over Streamable HTTP with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + const connection = yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + }); + yield* Effect.promise(() => connection.client.listTools()); + yield* Effect.promise(() => connection.close()); + + const requests = yield* server.requests; + expect(requests.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); + + it.effect("keeps a configured User-Agent over Streamable HTTP with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + const connection = yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + headers: { "User-Agent": "custom-agent/1.0" }, + }); + yield* Effect.promise(() => connection.close()); + + const requests = yield* server.requests; + expect(requests.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["custom-agent/1.0"]), + ); + }), + ), + ); + + it.effect("sends a default User-Agent on the SSE stream with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + // The test server speaks Streamable HTTP only, so the SSE dial fails; + // the request it made is what this asserts on. + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "sse", + }).pipe(Effect.flip); + + const requests = yield* server.requests; + expect(requests.map((request) => request.method)).toContain("GET"); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); + + it.effect("sends a default User-Agent on OAuth resource metadata discovery", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer(), { + auth: { + validateAuthorization: () => Effect.succeed(false), + // Unreachable on purpose: discovery stops after the resource + // metadata request this test inspects. + authorizationServerUrls: ["http://127.0.0.1:1"], + }, + }); + + yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + authProvider: unauthorizedProvider, + }).pipe(Effect.flip); + + const requests = yield* server.requests; + const discovery = requests.filter((request) => + request.url.startsWith("/.well-known/oauth-protected-resource"), + ); + expect(discovery.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 2c0ca2f20c..af116968d0 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -31,6 +31,7 @@ export type McpTestRequest = { readonly method: string; readonly url: string; readonly authorization: string | undefined; + readonly userAgent: string | undefined; readonly sessionId: string | undefined; }; @@ -137,6 +138,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO const authorization = Array.isArray(request.headers.authorization) ? request.headers.authorization[0] : request.headers.authorization; + const userAgent = request.headers["user-agent"]; const origin = request.headers.host ? `http://${request.headers.host}` : "http://127.0.0.1"; @@ -147,6 +149,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO method: request.method ?? "GET", url: requestUrl, authorization, + userAgent, sessionId, }, ]); From 8e4a8a40048e8252f4d7f182456da62ca1d5c296 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:00:16 -0700 Subject: [PATCH 12/22] Send the default User-Agent on the MCP endpoint probe (#2258) --- .changeset/mcp-probe-user-agent.md | 5 +++++ packages/plugins/mcp/src/sdk/connection.ts | 16 ++++------------ packages/plugins/mcp/src/sdk/probe-shape.ts | 9 ++++++++- packages/plugins/mcp/src/sdk/user-agent.ts | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-probe-user-agent.md create mode 100644 packages/plugins/mcp/src/sdk/user-agent.ts diff --git a/.changeset/mcp-probe-user-agent.md b/.changeset/mcp-probe-user-agent.md new file mode 100644 index 0000000000..bc264ea5da --- /dev/null +++ b/.changeset/mcp-probe-user-agent.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Send the default User-Agent on the MCP endpoint probe and its OAuth metadata request too, so adding a server that rejects requests without one no longer fails. A configured User-Agent still wins. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 94fed481d4..f8ddefca7e 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -21,6 +21,7 @@ import { McpOAuthReauthorizationRequired, } from "./errors"; import { connectionHttpStatusFromCause, isStreamableHttpProtocolError } from "./http-status"; +import { withDefaultUserAgent } from "./user-agent"; import { detectInsufficientScope } from "@executor-js/sdk/core"; // --------------------------------------------------------------------------- @@ -72,17 +73,6 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record) return url; }; -// Some upstreams (Cloudflare bot rules among them) reject requests without a -// User-Agent, and Workers' fetch adds none. Both remote SDK transports apply -// `requestInit.headers` to every request they send, including OAuth metadata -// discovery, so the default goes there. A configured User-Agent wins. -const DEFAULT_USER_AGENT = "executor"; - -const withDefaultUserAgent = (headers: Record): Record => - Object.keys(headers).some((name) => name.toLowerCase() === "user-agent") - ? headers - : { "User-Agent": DEFAULT_USER_AGENT, ...headers }; - type HttpMethod = Parameters[0]; const HTTP_METHODS = new Set([ "DELETE", @@ -636,7 +626,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { }); } - // Remote transport + // Remote transport. Both remote SDK transports apply `requestInit.headers` + // to every request they send, including OAuth metadata discovery, so the + // default User-Agent goes there. const headers = withDefaultUserAgent(input.headers ?? {}); const remoteTransport = input.remoteTransport ?? "auto"; const requestInit = { headers }; diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index b64f791139..5fd6f192de 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -44,6 +44,7 @@ import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { userAgentFor } from "./user-agent"; /** MCP initialize request body used as the shape probe. Any real MCP * server either answers it (unauth-OK server) or returns the spec- @@ -277,7 +278,13 @@ export const probeMcpEndpointShape = ( Effect.gen(function* () { const timeoutMs = options.timeoutMs ?? 8_000; const outcome = yield* Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; + // Every probe request — including the RFC 9728 metadata fallback, which + // carries none of the configured headers — sends the User-Agent. + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest( + HttpClientRequest.setHeader("user-agent", userAgentFor(options.headers ?? {})), + ), + ); const readBody = (response: { readonly text: Effect.Effect; diff --git a/packages/plugins/mcp/src/sdk/user-agent.ts b/packages/plugins/mcp/src/sdk/user-agent.ts new file mode 100644 index 0000000000..11fd5fe58e --- /dev/null +++ b/packages/plugins/mcp/src/sdk/user-agent.ts @@ -0,0 +1,16 @@ +// Some upstreams (Cloudflare bot rules among them) reject requests without a +// User-Agent, and Workers' fetch adds none. Every request the MCP plugin sends +// to an MCP server or its OAuth metadata carries this default. A configured +// User-Agent wins. +export const DEFAULT_USER_AGENT = "executor"; + +const configuredUserAgent = (headers: Readonly>): string | undefined => + Object.entries(headers).find(([name]) => name.toLowerCase() === "user-agent")?.[1]; + +export const withDefaultUserAgent = (headers: Record): Record => + configuredUserAgent(headers) === undefined + ? { "User-Agent": DEFAULT_USER_AGENT, ...headers } + : headers; + +export const userAgentFor = (headers: Readonly>): string => + configuredUserAgent(headers) ?? DEFAULT_USER_AGENT; From a54b513e81759370295291c8157ab473f2a53871 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:00:16 -0700 Subject: [PATCH 13/22] Test the MCP probe User-Agent (#2259) --- .../src/sdk/probe-shape-user-agent.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts diff --git a/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts new file mode 100644 index 0000000000..67087dc5b2 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { probeMcpEndpointShape } from "./probe-shape"; + +const endpoint = "https://mcp.example/mcp"; +const metadataUrl = "https://mcp.example/.well-known/oauth-protected-resource/mcp"; + +interface RecordedRequest { + readonly method: string; + readonly url: string; + readonly mcpProtocolVersion: string | undefined; + readonly userAgent: string | undefined; +} + +/** Records every request and answers with `respond`'s status. */ +const recordingClientLayer = ( + recorded: Array, + respond: (request: HttpClientRequest.HttpClientRequest) => number, +) => + Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + recorded.push({ + method: request.method, + url: request.url, + mcpProtocolVersion: request.headers["mcp-protocol-version"], + userAgent: request.headers["user-agent"], + }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("", { status: respond(request) })), + ); + }), + ); + +/** POST → 405, so the probe falls back to GET; GET → 401 with no Bearer + * challenge, so the probe reads RFC 9728 metadata, which is missing. */ +const challengeWithoutBearer = (request: HttpClientRequest.HttpClientRequest): number => + request.method === "POST" ? 405 : request.url === endpoint ? 401 : 404; + +const summarize = (recorded: ReadonlyArray) => + recorded.map((request) => ({ + request: `${request.method} ${request.url}${ + request.mcpProtocolVersion ? ` (${request.mcpProtocolVersion})` : "" + }`, + userAgent: request.userAgent, + })); + +describe("MCP shape probe User-Agent", () => { + it.effect("sends a default User-Agent on the probe and its OAuth metadata request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + httpClientLayer: recordingClientLayer(recorded, challengeWithoutBearer), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "executor" }, + { request: `GET ${endpoint}`, userAgent: "executor" }, + { request: `GET ${metadataUrl}`, userAgent: "executor" }, + ]); + }), + ); + + it.effect("sends a default User-Agent on the discover request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + httpClientLayer: recordingClientLayer(recorded, () => 404), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "executor" }, + { request: `GET ${endpoint}`, userAgent: "executor" }, + { request: `POST ${endpoint} (2026-07-28)`, userAgent: "executor" }, + ]); + }), + ); + + it.effect("keeps a configured User-Agent on every probe request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + headers: { "User-Agent": "custom-agent/1.0" }, + httpClientLayer: recordingClientLayer(recorded, challengeWithoutBearer), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "custom-agent/1.0" }, + { request: `GET ${endpoint}`, userAgent: "custom-agent/1.0" }, + { request: `GET ${metadataUrl}`, userAgent: "custom-agent/1.0" }, + ]); + }), + ); +}); From 90e415c98c5414e019695e8501439755a8bc838a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:50 -0700 Subject: [PATCH 14/22] Link eligible orgs to Executor v2's import from the sidebar (#2266) * Link eligible orgs to Executor v2's import from the sidebar * Read the v2 import's billing without creating an Autumn customer --- .changeset/v2-import-sidebar-link.md | 5 + apps/cloud/src/auth/api.ts | 13 +++ apps/cloud/src/auth/handlers.ts | 14 +++ apps/cloud/src/auth/v2-import.ts | 105 ++++++++++++++++++ apps/cloud/src/extensions/billing/service.ts | 92 ++++++++++++++- apps/cloud/src/web/auth.tsx | 7 ++ .../src/web/components/v2-import-link.tsx | 42 +++++++ apps/cloud/src/web/shell.tsx | 12 +- packages/react/src/api/analytics.tsx | 1 + 9 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 .changeset/v2-import-sidebar-link.md create mode 100644 apps/cloud/src/auth/v2-import.ts create mode 100644 apps/cloud/src/web/components/v2-import-link.tsx diff --git a/.changeset/v2-import-sidebar-link.md b/.changeset/v2-import-sidebar-link.md new file mode 100644 index 0000000000..a355117d94 --- /dev/null +++ b/.changeset/v2-import-sidebar-link.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Link eligible single-member free organizations to Executor v2's workspace import from the sidebar. diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 4ff1fc25e1..952210ffb1 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -34,6 +34,13 @@ const AuthOrganizationsResponse = Schema.Struct({ activeOrganizationId: Schema.NullOr(Schema.String), }); +// Executor v2's workspace import for the selected org: its page URL when v2 +// would let the signed-in user import the org, otherwise null. Nothing else +// about the membership or billing reads behind it is returned. +const V2ImportResponse = Schema.Struct({ + url: Schema.NullOr(Schema.String), +}); + const CreateOrganizationBody = Schema.Struct({ name: Schema.String, }); @@ -231,6 +238,12 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: [WorkOSError, UserStoreError, MemberDirectoryError], }), ) + .add( + HttpApiEndpoint.get("v2Import", "/auth/v2-import", { + success: V2ImportResponse, + error: NoOrganization, + }), + ) .add( HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { payload: CreateOrganizationBody, diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index f5e42b16ea..92ab84cb3c 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -42,6 +42,7 @@ import { type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { liveV2ImportSource, v2ImportEligible, v2ImportUrl } from "./v2-import"; const COOKIE_OPTIONS = { path: "/", @@ -439,6 +440,19 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }; }), ) + .handle("v2Import", () => + Effect.gen(function* () { + const session = yield* requireSelectedOrganization; + const url = v2ImportUrl(env); + if (url === null) return { url: null }; + const eligible = yield* v2ImportEligible( + yield* liveV2ImportSource, + session.accountId, + session.organizationId, + ); + return { url: eligible ? url.href : null }; + }), + ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSClient; diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts new file mode 100644 index 0000000000..964d643207 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.ts @@ -0,0 +1,105 @@ +// --------------------------------------------------------------------------- +// The dashboard's link to Executor v2's workspace import. +// +// v2 imports a v1 organization only when all of these hold (v2's +// `V1Import.choices`), and v1 shows the link under exactly the same rules: +// 1. the signed-in user's email is verified, +// 2. the organization has exactly one active member, and it is that user, +// 3. the organization is not on a paid v1 plan: no Autumn subscription to a +// plan outside v2's free list that has not expired. +// Every failure to read one of them hides the link. Only the yes/no answer +// leaves the server; memberships and billing never do. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; +import { AutumnService, type AutumnSubscription } from "../extensions/billing/service"; +import { WorkOSClient } from "./workos"; + +/** v2's import page, on v2's app host. */ +const V2_IMPORT_PATH = "/v1-import"; + +/** + * The absolute URL of v2's import page, or `null` where v1 does not hand + * requests to v2 (local dev without the v2 settings, test workers, or broken + * settings). v2's app host is the origin of its sign-up page, the same host + * the edge sends v2 dashboard links to. + */ +export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { + const edge = parseV2Edge(env); + if (edge === null || typeof edge === "string") return null; + return new URL(V2_IMPORT_PATH, edge.signUpUrl.origin); +}; + +/** v1's plans that cost nothing, as v2's import counts them (v2's + * `freePlans`). Every other plan, including one added later, is paid. */ +export const V2_IMPORT_FREE_PLAN_IDS: ReadonlyArray = ["free", "free-pay-as-you-go"]; + +export type V2ImportSubscription = AutumnSubscription; + +/** Whether v2 treats the organization as on a paid v1 plan. */ +export const isPaidForV2Import = (subscriptions: ReadonlyArray): boolean => + subscriptions.some( + (subscription) => + !V2_IMPORT_FREE_PLAN_IDS.includes(subscription.planId) && subscription.status !== "expired", + ); + +/** The reads the eligibility check makes, each against the system of record. */ +export interface V2ImportSource { + readonly emailVerified: (userId: string) => Effect.Effect; + /** The user ids of the organization's active members. */ + readonly activeMemberIds: ( + organizationId: string, + ) => Effect.Effect, unknown>; + readonly subscriptions: ( + organizationId: string, + ) => Effect.Effect, unknown>; +} + +/** + * Whether v2 would let this user import this organization. Reads in order and + * stops at the first refusal, so most refusals cost one or two calls. Never + * fails: an unavailable read answers `false`. + */ +export const v2ImportEligible = ( + source: V2ImportSource, + userId: string, + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + if (!(yield* source.emailVerified(userId))) return false; + const members = yield* source.activeMemberIds(organizationId); + if (members.length !== 1 || members[0] !== userId) return false; + return !isPaidForV2Import(yield* source.subscriptions(organizationId)); + }).pipe( + Effect.orElseSucceed(() => false), + Effect.withSpan("v2Import.eligible"), + ); + +/** The reads over v1's WorkOS (live, not the membership mirror, as v2 reads + * them) and Autumn. Billing is read without provisioning: an organization + * Autumn has never seen has no subscriptions, so it is free, the same answer + * v2 gives for a missing customer. */ +export const liveV2ImportSource = Effect.gen(function* () { + const workos = yield* WorkOSClient; + const autumn = yield* AutumnService; + return { + emailVerified: (userId) => + workos.getUser(userId).pipe(Effect.map((user) => user.emailVerified)), + activeMemberIds: (organizationId) => + workos + .listOrgMembers(organizationId, ["active"]) + .pipe( + Effect.map((list) => + list.data + .filter((membership) => membership.status === "active") + .map((membership) => membership.userId), + ), + ), + subscriptions: (organizationId) => + autumn + .findCustomer(organizationId) + .pipe(Effect.map((customer) => customer?.subscriptions ?? [])), + } satisfies V2ImportSource; +}); diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index 75de6e1fb6..666ac359f7 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -4,7 +4,7 @@ import { env } from "cloudflare:workers"; import { Autumn } from "autumn-js"; -import { Context, Data, Effect, Layer } from "effect"; +import { Context, Data, Effect, Layer, Option, Schema } from "effect"; import { captureCauseEffect } from "../../observability"; @@ -69,6 +69,27 @@ export const autumnStatusOf = (failure: AutumnFailure): number | undefined => { return typeof statusCode === "number" ? statusCode : undefined; }; +// --------------------------------------------------------------------------- +// Read-only customer lookup +// --------------------------------------------------------------------------- + +/** Autumn's API host, the autumn-js SDK's default server. */ +const AUTUMN_DEFAULT_URL = "https://api.useautumn.com"; +/** The API version autumn-js 1.2.8 sends, so this read sees the same shapes + * as the SDK's calls. */ +const AUTUMN_API_VERSION = "2.2.0"; + +/** One of an Autumn customer's subscriptions, as plan id and status. */ +export type AutumnSubscription = { readonly planId: string; readonly status: string }; + +/** The fields of a `customers.get` answer the app reads; the rest is ignored. */ +const CustomerBody = Schema.Struct({ + subscriptions: Schema.Array(Schema.Struct({ plan_id: Schema.String, status: Schema.String })), +}); +const decodeCustomerBody = Schema.decodeUnknownOption(CustomerBody); +/** Autumn's error body; only its code is read. */ +const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.String })); + // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- @@ -80,6 +101,18 @@ export type IAutumnService = Readonly<{ * never seen it. Idempotent — safe to call on every org creation. */ ensureCustomer: (organizationId: string) => Effect.Effect; + /** + * Read the organization's Autumn customer without creating it: its + * subscriptions, or `null` when Autumn has no customer with this id. Any + * other answer, including a 404 that is not `customer_not_found`, fails. + */ + findCustomer: ( + organizationId: string, + ) => Effect.Effect< + { readonly subscriptions: ReadonlyArray } | null, + AutumnError, + never + >; checkExecutionBalance: ( organizationId: string, ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; @@ -116,6 +149,7 @@ const make = Effect.sync(() => { return { use: () => notConfigured, ensureCustomer: () => notConfigured, + findCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -145,6 +179,61 @@ const make = Effect.sync(() => { const ensureCustomer = (organizationId: string) => Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); + // autumn-js 1.2.8 has no read-only customer call, so `customers.get` goes + // over HTTP with the SDK's secret, server and API version. + const customersGetUrl = `${(env.AUTUMN_API_URL || AUTUMN_DEFAULT_URL).replace(/\/+$/, "")}/v1/customers.get`; + + const findCustomer = (organizationId: string) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); + const failed = (message: string, cause?: unknown) => new AutumnError({ message, cause }); + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(customersGetUrl, { + method: "POST", + headers: { + authorization: `Bearer ${secretKey}`, + accept: "application/json", + "content-type": "application/json", + "x-api-version": AUTUMN_API_VERSION, + }, + body: JSON.stringify({ customer_id: organizationId }), + signal, + }), + catch: (cause) => failed("Autumn customer lookup failed", cause), + }); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }); + if (response.status !== 200 && response.status !== 404) { + return yield* failed(`Autumn customer lookup answered ${response.status}`, { + statusCode: response.status, + }); + } + const body = yield* Effect.tryPromise({ + try: (): Promise => response.json(), + catch: (cause) => failed("Autumn customer lookup returned invalid JSON", cause), + }); + if (response.status === 404) { + // Only Autumn's own code means "no customer"; any other 404, such as + // an unknown route, is a failure. + const missing = Option.exists( + decodeErrorBody(body), + (error) => error.code === CUSTOMER_NOT_FOUND_CODE, + ); + if (missing) return null; + return yield* failed("Autumn customer lookup answered 404", { statusCode: 404 }); + } + const customer = decodeCustomerBody(body); + if (Option.isNone(customer)) { + return yield* failed("Autumn customer lookup returned an unexpected body"); + } + return { + subscriptions: customer.value.subscriptions.map((subscription) => ({ + planId: subscription.plan_id, + status: subscription.status, + })), + }; + }).pipe(Effect.withSpan("autumn.findCustomer")); + /** * Run `operation`; if Autumn answers "no such customer", provision the * organization's customer and run it ONCE more. @@ -239,6 +328,7 @@ const make = Effect.sync(() => { return { use, ensureCustomer, + findCustomer, checkExecutionBalance, trackExecution, setMemberSeats, diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 8e46a6cad8..10070a026c 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -34,6 +34,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( }), ); +// Whether the selected org can move to Executor v2, and where. Eligibility +// reads WorkOS and Autumn, so it is fetched once per dashboard load and kept. +export const v2ImportAtom = CloudApiClient.query("cloudAuth", "v2Import", { + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.auth], +}); + export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); export const deleteOrganization = CloudApiClient.mutation("cloudAuth", "deleteOrganization"); diff --git a/apps/cloud/src/web/components/v2-import-link.tsx b/apps/cloud/src/web/components/v2-import-link.tsx new file mode 100644 index 0000000000..e8d9e0683b --- /dev/null +++ b/apps/cloud/src/web/components/v2-import-link.tsx @@ -0,0 +1,42 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { trackEvent } from "@executor-js/react/api/analytics"; +import { v2ImportAtom } from "../auth"; + +// --------------------------------------------------------------------------- +// Cloud-only sidebar link to Executor v2's workspace import. The server +// decides who sees it (`auth/v2-import.ts`) and answers with the page URL only +// for users v2 would let import the selected org; everyone else, and every +// failure, renders nothing. Opens in a new tab, like the sidebar's Docs link. +// --------------------------------------------------------------------------- + +function MoveIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function V2ImportLink() { + const result = useAtomValue(v2ImportAtom); + if (!AsyncResult.isSuccess(result) || result.value.url === null) return null; + return ( + trackEvent("v2_import_opened")} + className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" + > + + Move to Executor v2 + + ); +} diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 99a60178df..c27759193c 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -6,6 +6,7 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import { AUTH_PATHS } from "../auth/api"; import { OrgMenuSlot } from "./components/org-menu-slot"; import { SupportSlot } from "./components/support-slot"; +import { V2ImportLink } from "./components/v2-import-link"; // --------------------------------------------------------------------------- // Cloud shell — the SHARED multiplayer shell, identical to self-host, with @@ -13,7 +14,9 @@ import { SupportSlot } from "./components/support-slot"; // - sign-out navigate through cloud's WorkOS logout, land home // - nav items defaults + Organization + Billing (cloud-only sections) // - org menu slot multi-org switcher + create-org dialog (cloud-only) -// - support slot the "Get support" dialog button (cloud-only) +// - support slot the "Move to Executor v2" link, shown only to users +// v2 would let import this org, and the "Get support" +// dialog button (cloud-only) // API keys live in the main sidebar nav (via `defaultShellNavItems`); the // shared shell renders the account dropdown frame and sign-out, with // `orgMenuSlot` injected at the top of the dropdown. @@ -52,7 +55,12 @@ export function Shell(props: { readonly content?: React.ReactNode }) { onSignOut={signOut} navItems={items} orgMenuSlot={} - supportSlot={} + supportSlot={ + <> + + + + } content={props.content} /> ); diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 1161bbb5fc..f03b6052e6 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -189,6 +189,7 @@ export interface AnalyticsEvents { org_invitation_accepted: { success: boolean }; setup_mcp_completed: {}; setup_mcp_skipped: {}; + v2_import_opened: {}; // ── Cloud: billing & support ───────────────────────────────────────────── billing_plan_selected: { From 2da6542a3019efd5a7a5070e2cf8b1d2e4bce6e6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:50 -0700 Subject: [PATCH 15/22] Test the v2 import sidebar link (#2267) * Test the v2 import sidebar link * Test the v2 import's read-only billing lookup --- .../account/org-api-key-revoke.node.test.ts | 1 + .../src/auth/mirror-feeders.node.test.ts | 6 + apps/cloud/src/auth/v2-import.test.ts | 284 ++++++++++++++++++ apps/cloud/src/org/handlers.test.ts | 1 + bun.lock | 1 + e2e/cloud/v2-import-link.test.ts | 162 ++++++++++ package.json | 3 +- .../@executor-js%2Femulate@0.14.3-mfa.0.patch | 30 ++ 8 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 apps/cloud/src/auth/v2-import.test.ts create mode 100644 e2e/cloud/v2-import-link.test.ts create mode 100644 patches/@executor-js%2Femulate@0.14.3-mfa.0.patch diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index a43456e1ed..2f93064573 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -158,6 +158,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({ const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), ensureCustomer: () => Effect.die("revoke does not touch billing"), + findCustomer: () => Effect.die("revoke does not touch billing"), checkExecutionBalance: () => Effect.die("revoke does not touch billing"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index d166e49b83..e5c3173e5a 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -175,6 +175,7 @@ const seedOrganization = (id: string) => const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("feeders do not read billing"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("feeders do not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -673,6 +674,7 @@ describe("session handlers read membership from the mirror", () => { const deletingAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({} as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -866,6 +868,7 @@ describe("session handlers read membership from the mirror", () => { return Effect.fail(failure); }), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1234,6 +1237,7 @@ describe("account service writes through to the mirror", () => { subscriptions: [{ planId: "team", status: "active" }], } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1314,6 +1318,7 @@ describe("account service writes through to the mirror", () => { const freeAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({ subscriptions: [] } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1541,6 +1546,7 @@ describe("seat reporter", () => { const recording = Layer.succeed(AutumnService)({ use: () => Effect.die("the seat reporter sets seats, it does not read"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), trackExecution: () => Effect.void, setMemberSeats: (organizationId, seats) => diff --git a/apps/cloud/src/auth/v2-import.test.ts b/apps/cloud/src/auth/v2-import.test.ts new file mode 100644 index 0000000000..584f7cb568 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.test.ts @@ -0,0 +1,284 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AutumnService } from "../extensions/billing/service"; +import { + isPaidForV2Import, + liveV2ImportSource, + v2ImportEligible, + v2ImportUrl, + type V2ImportSource, + type V2ImportSubscription, +} from "./v2-import"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +const USER = "user_signed_in"; +const OTHER = "user_other"; +const ORG = "org_example"; + +interface Facts { + readonly emailVerified?: boolean; + readonly members?: ReadonlyArray; + readonly subscriptions?: ReadonlyArray; + readonly failing?: "user" | "members" | "billing"; +} + +/** A source answering from fixed facts, recording which reads were made. */ +const sourceOf = (facts: Facts) => { + const reads: string[] = []; + const answer = (read: string, value: A) => + Effect.suspend(() => { + reads.push(read); + return facts.failing === read ? Effect.fail("unavailable") : Effect.succeed(value); + }); + const source: V2ImportSource = { + emailVerified: (userId) => { + expect(userId).toBe(USER); + return answer("user", facts.emailVerified ?? true); + }, + activeMemberIds: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("members", facts.members ?? [USER]); + }, + subscriptions: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("billing", facts.subscriptions ?? [{ planId: "free", status: "active" }]); + }, + }; + return { source, reads }; +}; + +describe("v2 import eligibility", () => { + it.effect("admits a verified user who is the free org's only active member", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({}); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + expect(reads).toEqual(["user", "members", "billing"]); + }), + ); + + it.effect("admits an org on the free pay-as-you-go plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [[{ planId: "free-pay-as-you-go", status: "active" }], []]) { + const { source } = sourceOf({ subscriptions }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + } + }), + ); + + it.effect("refuses an unverified email without reading the org", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ emailVerified: false }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user"]); + }), + ); + + it.effect("refuses an org with more than one active member, without reading billing", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ members: [USER, OTHER] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user", "members"]); + }), + ); + + it.effect("refuses an org whose only active member is someone else", () => + Effect.gen(function* () { + for (const members of [[OTHER], []]) { + const { source } = sourceOf({ members }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + } + }), + ); + + it.effect("refuses an org on a paid plan", () => + Effect.gen(function* () { + const { source } = sourceOf({ subscriptions: [{ planId: "team", status: "active" }] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + }), + ); + + it.effect("refuses when any read is unavailable", () => + Effect.gen(function* () { + for (const failing of ["user", "members", "billing"] as const) { + const { source } = sourceOf({ failing }); + expect(yield* v2ImportEligible(source, USER, ORG), failing).toBe(false); + } + }), + ); +}); + +describe("v2 import billing read", () => { + const AUTUMN_URL = "https://autumn.example.test"; + interface AutumnRequest { + readonly url: string; + readonly method: string; + readonly body: string; + } + + let requests: AutumnRequest[] = []; + const originalFetch = globalThis.fetch; + const { AUTUMN_SECRET_KEY, AUTUMN_API_URL } = env; + + /** Autumn answering every request with `answer`, recording each request. */ + const autumnAnswers = (answer: () => Response | Promise) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body), + }); + return answer(); + }) as typeof fetch; + }; + const customer = (subscriptions: ReadonlyArray<{ plan_id: string; status: string }>) => + Response.json({ id: ORG, name: null, balances: {}, subscriptions, purchases: [] }); + + beforeEach(() => { + requests = []; + env.AUTUMN_SECRET_KEY = "am_sk_test"; + env.AUTUMN_API_URL = AUTUMN_URL; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + env.AUTUMN_SECRET_KEY = AUTUMN_SECRET_KEY; + env.AUTUMN_API_URL = AUTUMN_API_URL; + }); + + // WorkOS is not read here: the user and member answers come from `sourceOf`. + const unreadWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + /** The eligibility answer for a verified sole member, billing read from Autumn. */ + const eligibleWithLiveBilling = Effect.gen(function* () { + const live = yield* liveV2ImportSource; + const source = { ...sourceOf({}).source, subscriptions: live.subscriptions }; + return yield* v2ImportEligible(source, USER, ORG); + }).pipe(Effect.provide(Layer.merge(AutumnService.Default, unreadWorkOS))); + + it.effect("looks the customer up read-only, so an org Autumn has never seen stays absent", () => + Effect.gen(function* () { + autumnAnswers(() => + Response.json( + { message: `Customer ${ORG} not found`, code: "customer_not_found" }, + { status: 404 }, + ), + ); + expect(yield* eligibleWithLiveBilling, "no customer is free").toBe(true); + expect(requests).toEqual([ + { url: `${AUTUMN_URL}/v1/customers.get`, method: "POST", body: `{"customer_id":"${ORG}"}` }, + ]); + }), + ); + + it.effect("admits a customer on a free plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [ + [{ plan_id: "free", status: "active" }], + [{ plan_id: "free-pay-as-you-go", status: "active" }], + [], + ]) { + autumnAnswers(() => customer(subscriptions)); + expect(yield* eligibleWithLiveBilling, JSON.stringify(subscriptions)).toBe(true); + } + }), + ); + + it.effect("refuses a customer on a paid plan", () => + Effect.gen(function* () { + autumnAnswers(() => customer([{ plan_id: "team", status: "active" }])); + expect(yield* eligibleWithLiveBilling).toBe(false); + }), + ); + + it.effect("admits a customer whose paid plan has expired", () => + Effect.gen(function* () { + autumnAnswers(() => + customer([ + { plan_id: "free", status: "active" }, + { plan_id: "team", status: "expired" }, + ]), + ); + expect(yield* eligibleWithLiveBilling).toBe(true); + }), + ); + + it.effect("refuses when the lookup fails or answers something unexpected", () => + Effect.gen(function* () { + const failures: ReadonlyArray Response | Promise]> = [ + [ + "network error", + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch rejects with a TypeError on a network failure; that rejection is the fixture + () => Promise.reject(new TypeError("fetch failed")), + ], + ["server error", () => Response.json({ message: "boom" }, { status: 500 })], + ["unauthorized", () => Response.json({ code: "invalid_secret_key" }, { status: 401 })], + ["404 without customer_not_found", () => new Response("Not Found", { status: 404 })], + ["body without subscriptions", () => Response.json({ id: ORG })], + ["body that is not JSON", () => new Response("", { status: 200 })], + ]; + for (const [name, answer] of failures) { + autumnAnswers(answer); + expect(yield* eligibleWithLiveBilling, name).toBe(false); + } + }), + ); + + it.effect("refuses without an Autumn secret, without calling Autumn", () => + Effect.gen(function* () { + delete env.AUTUMN_SECRET_KEY; + autumnAnswers(() => customer([])); + expect(yield* eligibleWithLiveBilling).toBe(false); + expect(requests).toEqual([]); + }), + ); +}); + +describe("v2 import paid plans", () => { + it("counts every plan outside v2's free list that has not expired", () => { + expect(isPaidForV2Import([{ planId: "team", status: "active" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "enterprise", status: "trialing" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "past_due" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "scheduled" }])).toBe(true); + // A grandfathered or later plan v1 never listed is paid too. + expect(isPaidForV2Import([{ planId: "professional", status: "active" }])).toBe(true); + expect( + isPaidForV2Import([ + { planId: "free", status: "active" }, + { planId: "team", status: "active" }, + ]), + ).toBe(true); + }); + + it("does not count free plans or expired subscriptions", () => { + expect(isPaidForV2Import([])).toBe(false); + expect(isPaidForV2Import([{ planId: "free", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "free-pay-as-you-go", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "team", status: "expired" }])).toBe(false); + }); +}); + +describe("v2 import url", () => { + const configured = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + it("is the import page on v2's app host, the sign-up page's origin", () => { + expect(v2ImportUrl(configured)?.href).toBe("https://app.example.test/v1-import"); + }); + + it("is absent without the v2 settings or with broken ones", () => { + expect(v2ImportUrl({})).toBeNull(); + expect(v2ImportUrl({ ...configured, V2_SIGN_UP_URL: "not a url" })).toBeNull(); + expect(v2ImportUrl({ ...configured, V2: undefined })).toBeNull(); + }); +}); diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index b5c99af906..07c5e38f01 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -227,6 +227,7 @@ const stubDb = Layer.succeed(DbService)({ db: {} as never }); const stubAutumn = Layer.succeed(AutumnService)({ use: unread("the delete does not consult billing"), ensureCustomer: unread("the delete does not provision billing"), + findCustomer: unread("the delete does not read billing"), checkExecutionBalance: unread("the delete does not check balances"), trackExecution: unread("the delete does not track usage"), setMemberSeats: unread("the delete does not count seats"), diff --git a/bun.lock b/bun.lock index c9436aa422..b2431ad8d7 100644 --- a/bun.lock +++ b/bun.lock @@ -1272,6 +1272,7 @@ "patchedDependencies": { "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", + "@executor-js/emulate@0.14.3-mfa.0": "patches/@executor-js%2Femulate@0.14.3-mfa.0.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", diff --git a/e2e/cloud/v2-import-link.test.ts b/e2e/cloud/v2-import-link.test.ts new file mode 100644 index 0000000000..d31b28bd5c --- /dev/null +++ b/e2e/cloud/v2-import-link.test.ts @@ -0,0 +1,162 @@ +// Cloud-only: the sidebar's "Move to Executor v2" link to v2's workspace +// import. v2 imports an organization only for a verified user who is its one +// active member and only when it is not on a paid plan, so v1 shows the link to +// exactly those users. Each scenario reads the real `/api/auth/v2-import` +// answer the dashboard renders from, and the browser checks the sidebar itself. +// +// An unverified email is covered by `apps/cloud/src/auth/v2-import.test.ts`: +// the WorkOS emulator signs every user in with a verified email. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Autumn, Billing, Browser, Target } from "../src/services"; +import { settle, visit } from "../src/surfaces/browser"; +import type { Identity, Target as TargetShape } from "../src/target"; +import { activeOrg, forBrowser, joinOrg } from "./support/session"; + +const LINK_NAME = "Move to Executor v2"; +const V2_IMPORT_API = "/api/auth/v2-import"; + +/** The link the dashboard would render for this identity's org, or null. */ +const v2ImportUrlOf = (target: TargetShape, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const response = await fetch(new URL(V2_IMPORT_API, target.baseUrl), { + headers: identity.headers, + }); + if (!response.ok) throw new Error(`${V2_IMPORT_API} failed (${response.status})`); + return ((await response.json()) as { url: string | null }).url; + }); + +scenario( + "v2 import · the only member of a free org sees the link to v2's import", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + const url = yield* v2ImportUrlOf(target, identity); + expect(url, "a sole member of a free org is offered the import").not.toBeNull(); + const parsed = new URL(url!); + expect(parsed.pathname, "the link opens v2's import page").toBe("/v1-import"); + expect(parsed.origin, "the import page is on v2's host, not v1's").not.toBe( + new URL(target.baseUrl).origin, + ); + + const { slug } = yield* activeOrg(target, identity); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the dashboard", async () => { + await visit(page, `/${slug}`); + }); + await step(`See "${LINK_NAME}" in the sidebar`, async () => { + const link = page.getByRole("link", { name: LINK_NAME }); + await link.waitFor(); + expect(await link.getAttribute("href"), "the sidebar links to v2's import").toBe(url); + expect(await link.getAttribute("target"), "it opens in a new tab").toBe("_blank"); + }); + }); + }), +); + +scenario( + "v2 import · billing is read without creating a customer, and no customer is free", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const identity = yield* target.newIdentity(); + const { id } = yield* activeOrg(target, identity); + const lookups = autumn + .ledgerFor("customers.get") + .pipe(Effect.map((entries) => entries.filter((entry) => entry.customerId === id))); + + // The org's customer exists, so the read-only lookup answers it: free. + expect( + yield* v2ImportUrlOf(target, identity), + "a free customer is offered the import", + ).not.toBeNull(); + const answered = yield* lookups; + expect(answered.length, "the billing read is Autumn's read-only customers.get").toBeGreaterThan( + 0, + ); + expect(answered.every((entry) => !entry.faulted)).toBe(true); + + // Autumn answering that it has no such customer is an answer too: free. + yield* Effect.gen(function* () { + yield* autumn.armFault({ + match: { operationId: "customers.get" }, + response: { + status: 404, + body: { message: "Customer not found", code: "customer_not_found" }, + }, + times: 1, + }); + expect( + yield* v2ImportUrlOf(target, identity), + "an org Autumn has no customer for is offered the import", + ).not.toBeNull(); + expect( + (yield* lookups).some((entry) => entry.faulted), + "the lookup reached Autumn and was answered customer_not_found", + ).toBe(true); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "v2 import · members of a shared org do not see the link", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + + expect( + yield* v2ImportUrlOf(target, admin), + "the admin of a two-member org is not offered the import", + ).toBeNull(); + expect( + yield* v2ImportUrlOf(target, member), + "a member of a two-member org is not offered the import", + ).toBeNull(); + + const { slug } = yield* activeOrg(target, admin); + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + await step("Open the shared org's dashboard", async () => { + const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); + await visit(page, `/${slug}`); + await answered; + await settle(page); + }); + await step(`See no "${LINK_NAME}" in the sidebar`, async () => { + await page.getByRole("button", { name: "Get support" }).waitFor(); + expect(await page.getByRole("link", { name: LINK_NAME }).count()).toBe(0); + }); + }); + }), +); + +scenario( + "v2 import · the only member of a paid org does not see the link", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const identity = yield* target.newIdentity(); + const { id } = yield* activeOrg(target, identity); + + // Enterprise activates inline in the emulator (no checkout), like the + // rate-limit scenario's paid org. + yield* autumn.attachPlan(id, "enterprise"); + + expect( + yield* v2ImportUrlOf(target, identity), + "a paid org is not offered the import", + ).toBeNull(); + }), +); diff --git a/package.json b/package.json index 1b1e9cfded..4ef32460ed 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "agents@0.17.3": "patches/agents@0.17.3.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", - "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch" + "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", + "@executor-js/emulate@0.14.3-mfa.0": "patches/@executor-js%2Femulate@0.14.3-mfa.0.patch" } } diff --git a/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch new file mode 100644 index 0000000000..dd32e79318 --- /dev/null +++ b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch @@ -0,0 +1,30 @@ +diff --git a/dist/dist-AJUH5YLR.js b/dist/dist-AJUH5YLR.js +index 0590e15752d2536b7d8e63f378c92ac7910a91be..de28354560fa4ef00122ef4407b667d9f84a16c6 100644 +--- a/dist/dist-AJUH5YLR.js ++++ b/dist/dist-AJUH5YLR.js +@@ -234,6 +234,17 @@ function autumnApiRoutes(ctx) { + const customer = ensureCustomer(as(), id, data); + return c.json(serializeCustomer(as(), customer, { expand: parseExpand(body.expand) })); + }); ++ app.post("/v1/customers.get", async (c) => { ++ const body = await c.req.json().catch(() => ({})); ++ const id = String(body.customer_id ?? body.customerId ?? ""); ++ if (!id) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); ++ const store2 = as(); ++ const customer = store2.customers.findOneBy("customer_id", id); ++ if (!customer) { ++ return c.json({ message: `Customer ${id} not found`, code: "customer_not_found" }, 404); ++ } ++ return c.json(serializeCustomer(store2, customer, { expand: parseExpand(body.expand) })); ++ }); + app.post("/v1/customers.list", async (c) => { + const store2 = as(); + const customers = store2.customers.all().map((customer) => serializeCustomer(store2, customer)); +@@ -1318,6 +1329,7 @@ var manifest = { + path: "/v1/customers.get_or_create", + status: "hand-authored" + }, ++ { operationId: "customers.get", method: "POST", path: "/v1/customers.get", status: "hand-authored" }, + { operationId: "customers.list", method: "POST", path: "/v1/customers.list", status: "hand-authored" }, + { operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" }, + { operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" }, From 49bd2959b4dd203b2a413304f7b3d4d5238d50a4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:50 -0700 Subject: [PATCH 16/22] Tell users when their organization has moved to Executor v2 (#2269) Reads the move v2 records on the WorkOS organization (executor_v2_org_slug, executor_v2_moved_at) into the v2-import answer, shows a dismissible notice on the dashboard with a feedback step for staying on v1, and points the sidebar link at the org in v2. --- .changeset/v2-moved-notice.md | 5 + apps/cloud/src/auth/api.ts | 11 + apps/cloud/src/auth/handlers.ts | 18 +- apps/cloud/src/auth/v2-import.ts | 51 ++++- apps/cloud/src/auth/v2-moved.ts | 86 ++++++++ .../src/web/components/v2-import-link.tsx | 23 +- .../src/web/components/v2-moved-notice.tsx | 205 ++++++++++++++++++ apps/cloud/src/web/shell.tsx | 7 +- apps/cloud/src/web/v2-moved-snooze.ts | 64 ++++++ packages/react/src/api/analytics.tsx | 14 +- 10 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 .changeset/v2-moved-notice.md create mode 100644 apps/cloud/src/auth/v2-moved.ts create mode 100644 apps/cloud/src/web/components/v2-moved-notice.tsx create mode 100644 apps/cloud/src/web/v2-moved-snooze.ts diff --git a/.changeset/v2-moved-notice.md b/.changeset/v2-moved-notice.md new file mode 100644 index 0000000000..5222cd7246 --- /dev/null +++ b/.changeset/v2-moved-notice.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Tell users when their organization has moved to Executor v2, with a link to it in v2 and a way to say why they are staying on v1. diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 952210ffb1..47ad43cfa7 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -37,8 +37,19 @@ const AuthOrganizationsResponse = Schema.Struct({ // Executor v2's workspace import for the selected org: its page URL when v2 // would let the signed-in user import the org, otherwise null. Nothing else // about the membership or billing reads behind it is returned. +// +// `moved` is set instead when the org has already been imported (v2 records +// the move on the WorkOS organization): where it lives in v2 and when it +// moved. It drives a notice only; the org keeps working in v1. +const V2MovedResponse = Schema.Struct({ + v2Url: Schema.String, + v2Slug: Schema.String, + movedAt: Schema.String, +}); + const V2ImportResponse = Schema.Struct({ url: Schema.NullOr(Schema.String), + moved: Schema.NullOr(V2MovedResponse), }); const CreateOrganizationBody = Schema.Struct({ diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 92ab84cb3c..e6857d0aba 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -42,7 +42,8 @@ import { type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; -import { liveV2ImportSource, v2ImportEligible, v2ImportUrl } from "./v2-import"; +import { liveV2ImportSource, v2ImportAnswer } from "./v2-import"; +import { liveV2MovedSource } from "./v2-moved"; const COOKIE_OPTIONS = { path: "/", @@ -443,14 +444,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("v2Import", () => Effect.gen(function* () { const session = yield* requireSelectedOrganization; - const url = v2ImportUrl(env); - if (url === null) return { url: null }; - const eligible = yield* v2ImportEligible( - yield* liveV2ImportSource, - session.accountId, - session.organizationId, - ); - return { url: eligible ? url.href : null }; + return yield* v2ImportAnswer({ + env, + importSource: liveV2ImportSource, + movedSource: liveV2MovedSource, + userId: session.accountId, + organizationId: session.organizationId, + }); }), ) .handle("createOrganization", ({ payload }) => diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts index 964d643207..10a811dd3b 100644 --- a/apps/cloud/src/auth/v2-import.ts +++ b/apps/cloud/src/auth/v2-import.ts @@ -15,21 +15,27 @@ import { Effect } from "effect"; import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; import { AutumnService, type AutumnSubscription } from "../extensions/billing/service"; +import { v2Moved, type V2Moved, type V2MovedSource } from "./v2-moved"; import { WorkOSClient } from "./workos"; /** v2's import page, on v2's app host. */ const V2_IMPORT_PATH = "/v1-import"; /** - * The absolute URL of v2's import page, or `null` where v1 does not hand - * requests to v2 (local dev without the v2 settings, test workers, or broken - * settings). v2's app host is the origin of its sign-up page, the same host - * the edge sends v2 dashboard links to. + * v2's app host, or `null` where v1 does not hand requests to v2 (local dev + * without the v2 settings, test workers, or broken settings). It is the origin + * of v2's sign-up page, the same host the edge sends v2 dashboard links to. */ -export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { +export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { const edge = parseV2Edge(env); if (edge === null || typeof edge === "string") return null; - return new URL(V2_IMPORT_PATH, edge.signUpUrl.origin); + return new URL(edge.signUpUrl.origin); +}; + +/** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */ +export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { + const origin = v2AppOrigin(env); + return origin === null ? null : new URL(V2_IMPORT_PATH, origin); }; /** v1's plans that cost nothing, as v2's import counts them (v2's @@ -103,3 +109,36 @@ export const liveV2ImportSource = Effect.gen(function* () { .pipe(Effect.map((customer) => customer?.subscriptions ?? [])), } satisfies V2ImportSource; }); + +/** What the dashboard shows for the selected org: the import link, or where + * the org now lives in v2. At most one is set. */ +export interface V2ImportAnswer { + readonly url: string | null; + readonly moved: V2Moved | null; +} + +/** + * The `GET /api/auth/v2-import` answer. Without a v2 host there is nothing to + * link to. An org that has already moved is not offered the import again, so + * its eligibility is never read. Never fails. + */ +export const v2ImportAnswer = (input: { + readonly env: V2EdgeEnv; + readonly importSource: Effect.Effect; + readonly movedSource: Effect.Effect; + readonly userId: string; + readonly organizationId: string; +}): Effect.Effect => + Effect.gen(function* () { + const v2Origin = v2AppOrigin(input.env); + const importUrl = v2ImportUrl(input.env); + if (v2Origin === null || importUrl === null) return { url: null, moved: null }; + const moved = yield* v2Moved(yield* input.movedSource, input.organizationId, v2Origin); + if (moved !== null) return { url: null, moved }; + const eligible = yield* v2ImportEligible( + yield* input.importSource, + input.userId, + input.organizationId, + ); + return { url: eligible ? importUrl.href : null, moved: null }; + }); diff --git a/apps/cloud/src/auth/v2-moved.ts b/apps/cloud/src/auth/v2-moved.ts new file mode 100644 index 0000000000..4d8da5c811 --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.ts @@ -0,0 +1,86 @@ +// --------------------------------------------------------------------------- +// Whether the selected organization has moved to Executor v2. +// +// When a user finishes importing a v1 organization, v2 writes two keys onto +// the v1 WorkOS organization's metadata: +// executor_v2_org_slug the v2 organization's slug (its `/org/` URL) +// executor_v2_moved_at when the import finished, as an ISO 8601 timestamp +// Both must be present and well formed; anything else, and every failure to +// read the organization, answers "not moved". The answer only drives a notice: +// a moved organization keeps working in v1. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { WorkOSClient } from "./workos"; + +export const V2_MOVED_SLUG_KEY = "executor_v2_org_slug"; +export const V2_MOVED_AT_KEY = "executor_v2_moved_at"; + +/** v2's organization slug rule (`OrganizationSlug` in v2's hosted contracts). */ +const V2_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const V2_SLUG_MAX_LENGTH = 45; + +/** An ISO 8601 date and time with an explicit offset, as `Date#toISOString` + * writes it. A bare date or a local time is ambiguous, so it is refused. */ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; + +export interface V2Moved { + /** The v2 organization's page: `/org/`. */ + readonly v2Url: string; + readonly v2Slug: string; + /** When the import finished, normalized to `Date#toISOString`. */ + readonly movedAt: string; +} + +/** The move recorded in an organization's metadata, or `null` when there is + * none or it is malformed. */ +export const parseV2Moved = ( + metadata: Readonly>, + v2Origin: URL, +): V2Moved | null => { + const slug = metadata[V2_MOVED_SLUG_KEY]; + const movedAt = metadata[V2_MOVED_AT_KEY]; + if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) { + return null; + } + if (typeof movedAt !== "string" || !ISO_TIMESTAMP.test(movedAt)) return null; + const time = Date.parse(movedAt); + if (!Number.isFinite(time)) return null; + return { + v2Url: new URL(`/org/${slug}`, v2Origin).href, + v2Slug: slug, + movedAt: new Date(time).toISOString(), + }; +}; + +/** The read the check makes. */ +export interface V2MovedSource { + readonly organizationMetadata: ( + organizationId: string, + ) => Effect.Effect>, unknown>; +} + +/** Whether the organization has moved to v2. Never fails: an unavailable + * read answers `null`, so a WorkOS outage shows no notice. */ +export const v2Moved = ( + source: V2MovedSource, + organizationId: string, + v2Origin: URL, +): Effect.Effect => + source.organizationMetadata(organizationId).pipe( + Effect.map((metadata) => parseV2Moved(metadata, v2Origin)), + Effect.orElseSucceed(() => null), + Effect.withSpan("v2Moved.read"), + ); + +/** The organization's metadata, read live from WorkOS (where v2 writes it). */ +export const liveV2MovedSource = Effect.gen(function* () { + const workos = yield* WorkOSClient; + return { + organizationMetadata: (organizationId) => + workos + .getOrganization(organizationId) + .pipe(Effect.map((organization) => organization.metadata)), + } satisfies V2MovedSource; +}); diff --git a/apps/cloud/src/web/components/v2-import-link.tsx b/apps/cloud/src/web/components/v2-import-link.tsx index e8d9e0683b..b53668339c 100644 --- a/apps/cloud/src/web/components/v2-import-link.tsx +++ b/apps/cloud/src/web/components/v2-import-link.tsx @@ -8,6 +8,9 @@ import { v2ImportAtom } from "../auth"; // decides who sees it (`auth/v2-import.ts`) and answers with the page URL only // for users v2 would let import the selected org; everyone else, and every // failure, renders nothing. Opens in a new tab, like the sidebar's Docs link. +// +// Once the org has moved, the link opens the org in v2 instead: the moved +// notice can be snoozed, and this stays the way back to v2. // --------------------------------------------------------------------------- function MoveIcon({ className }: { className?: string }) { @@ -26,17 +29,29 @@ function MoveIcon({ className }: { className?: string }) { export function V2ImportLink() { const result = useAtomValue(v2ImportAtom); - if (!AsyncResult.isSuccess(result) || result.value.url === null) return null; + if (!AsyncResult.isSuccess(result)) return null; + const { url, moved } = result.value; + const link = + moved !== null + ? { + href: moved.v2Url, + label: "Open in Executor v2", + track: () => trackEvent("v2_moved_open_clicked", { via: "sidebar" }), + } + : url !== null + ? { href: url, label: "Move to Executor v2", track: () => trackEvent("v2_import_opened") } + : null; + if (link === null) return null; return ( trackEvent("v2_import_opened")} + onClick={link.track} className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" > - Move to Executor v2 + {link.label} ); } diff --git a/apps/cloud/src/web/components/v2-moved-notice.tsx b/apps/cloud/src/web/components/v2-moved-notice.tsx new file mode 100644 index 0000000000..2da79ed59f --- /dev/null +++ b/apps/cloud/src/web/components/v2-moved-notice.tsx @@ -0,0 +1,205 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { trackEvent } from "@executor-js/react/api/analytics"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Label } from "@executor-js/react/components/label"; +import { Textarea } from "@executor-js/react/components/textarea"; +import { useAuth, v2ImportAtom } from "../auth"; +import { + browserSnoozeStorage, + isV2MovedSnoozed, + snoozeV2Moved, + V2_MOVED_DISMISS_SNOOZE_MS, + V2_MOVED_FEEDBACK_SNOOZE_MS, +} from "../v2-moved-snooze"; + +// --------------------------------------------------------------------------- +// Cloud-only notice for an organization v2 has already imported (the server +// reads the move from the WorkOS organization, `auth/v2-moved.ts`). It opens +// once per dashboard load unless snoozed for this org in this browser: +// - ✕ / Escape / opening v2 hidden for 7 days, nothing asked +// - "Keep using v1" step 2 asks why; sending hides it for 30 days +// v1 keeps working for a moved org; this only tells the user where it went. +// --------------------------------------------------------------------------- + +type Step = "notice" | "feedback"; + +const FEEDBACK_MAX_LENGTH = 2000; + +const movedDate = (movedAt: string): string => + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format( + new Date(movedAt), + ); + +function ArrowIcon() { + return ( + + + + ); +} + +export function V2MovedNotice() { + const auth = useAuth(); + const result = useAtomValue(v2ImportAtom); + const moved = AsyncResult.isSuccess(result) ? result.value.moved : null; + const organization = auth.status === "authenticated" ? auth.organization : null; + const organizationId = organization?.id ?? null; + + const [open, setOpen] = useState(false); + const [step, setStep] = useState("notice"); + const [feedback, setFeedback] = useState(""); + const shownFor = useRef(null); + const feedbackId = useId(); + + // Decide once per org per page load, after hydration: the snooze lives in + // localStorage, which the server render cannot see. + useEffect(() => { + if (moved === null || organizationId === null) return; + if (shownFor.current === organizationId) return; + shownFor.current = organizationId; + if (isV2MovedSnoozed(browserSnoozeStorage(), organizationId, Date.now())) return; + setStep("notice"); + setFeedback(""); + setOpen(true); + trackEvent("v2_moved_modal_shown"); + }, [moved, organizationId]); + + if (moved === null || organization === null) return null; + + const close = (snoozeMs: number) => { + snoozeV2Moved(browserSnoozeStorage(), organization.id, Date.now(), snoozeMs); + setOpen(false); + }; + + const trimmed = feedback.trim(); + + return ( + { + if (next) return; + trackEvent("v2_moved_dismissed", { step }); + close(V2_MOVED_DISMISS_SNOOZE_MS); + }} + > + + {step === "notice" ? ( + <> + + + Moved {movedDate(moved.movedAt)} + + + {organization.name} now lives in Executor v2 + + + +
+ + v1 · {organization.name} + + + → + + + v2 · {moved.v2Slug} + +
+ + + Changes made here won't sync to v2. + + + + + + + + ) : ( +
{ + event.preventDefault(); + if (trimmed === "") return; + trackEvent("v2_moved_feedback_sent", { feedback: trimmed }); + close(V2_MOVED_FEEDBACK_SNOOZE_MS); + }} + > + + What went wrong? + + Tell us why you're staying on v1, so we can fix it. + + + +
+ +