From a3d70bea643c4b9657e3db6f737aa2ee25814c2d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:55:23 -0700 Subject: [PATCH 1/2] Link eligible orgs to Executor v2's import from the sidebar --- .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 | 104 ++++++++++++++++++ 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 + 8 files changed, 196 insertions(+), 2 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..9ff33504f3 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.ts @@ -0,0 +1,104 @@ +// --------------------------------------------------------------------------- +// 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 } 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 = { readonly planId: string; readonly status: string }; + +/** 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. An organization Autumn has never seen is provisioned on + * the free plan, 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 + .use((client) => client.customers.getOrCreate({ customerId: organizationId })) + .pipe(Effect.map((customer) => customer.subscriptions)), + } satisfies V2ImportSource; +}); 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 5c8d46757769f4af042a85ca8c4888eed5a684b4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:42:34 -0700 Subject: [PATCH 2/2] Read the v2 import's billing without creating an Autumn customer --- apps/cloud/src/auth/v2-import.ts | 13 +-- apps/cloud/src/extensions/billing/service.ts | 92 +++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts index 9ff33504f3..964d643207 100644 --- a/apps/cloud/src/auth/v2-import.ts +++ b/apps/cloud/src/auth/v2-import.ts @@ -14,7 +14,7 @@ import { Effect } from "effect"; import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; -import { AutumnService } from "../extensions/billing/service"; +import { AutumnService, type AutumnSubscription } from "../extensions/billing/service"; import { WorkOSClient } from "./workos"; /** v2's import page, on v2's app host. */ @@ -36,7 +36,7 @@ export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { * `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 = { readonly planId: string; readonly status: string }; +export type V2ImportSubscription = AutumnSubscription; /** Whether v2 treats the organization as on a paid v1 plan. */ export const isPaidForV2Import = (subscriptions: ReadonlyArray): boolean => @@ -78,8 +78,9 @@ export const v2ImportEligible = ( ); /** The reads over v1's WorkOS (live, not the membership mirror, as v2 reads - * them) and Autumn. An organization Autumn has never seen is provisioned on - * the free plan, the same answer v2 gives for a missing customer. */ + * 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; @@ -98,7 +99,7 @@ export const liveV2ImportSource = Effect.gen(function* () { ), subscriptions: (organizationId) => autumn - .use((client) => client.customers.getOrCreate({ customerId: organizationId })) - .pipe(Effect.map((customer) => customer.subscriptions)), + .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,