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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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. + + + +
+ +