Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v2-import-sidebar-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/cloud": patch
---

Link eligible single-member free organizations to Executor v2's workspace import from the sidebar.
13 changes: 13 additions & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/",
Expand Down Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions apps/cloud/src/auth/v2-import.ts
Original file line number Diff line number Diff line change
@@ -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<string> = ["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<V2ImportSubscription>): 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<boolean, unknown>;
/** The user ids of the organization's active members. */
readonly activeMemberIds: (
organizationId: string,
) => Effect.Effect<ReadonlyArray<string>, unknown>;
readonly subscriptions: (
organizationId: string,
) => Effect.Effect<ReadonlyArray<V2ImportSubscription>, 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<boolean> =>
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;
});
92 changes: 91 additions & 1 deletion apps/cloud/src/extensions/billing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -80,6 +101,18 @@ export type IAutumnService = Readonly<{
* never seen it. Idempotent — safe to call on every org creation.
*/
ensureCustomer: (organizationId: string) => Effect.Effect<void, AutumnFailure, never>;
/**
* 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<AutumnSubscription> } | null,
AutumnError,
never
>;
checkExecutionBalance: (
organizationId: string,
) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>;
Expand Down Expand Up @@ -116,6 +149,7 @@ const make = Effect.sync(() => {
return {
use: () => notConfigured,
ensureCustomer: () => notConfigured,
findCustomer: () => notConfigured,
checkExecutionBalance: () => notConfigured,
trackExecution: () => Effect.void,
setMemberSeats: () => Effect.void,
Expand Down Expand Up @@ -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<unknown> => 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.
Expand Down Expand Up @@ -239,6 +328,7 @@ const make = Effect.sync(() => {
return {
use,
ensureCustomer,
findCustomer,
checkExecutionBalance,
trackExecution,
setMemberSeats,
Expand Down
7 changes: 7 additions & 0 deletions apps/cloud/src/web/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
42 changes: 42 additions & 0 deletions apps/cloud/src/web/components/v2-import-link.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg viewBox="0 0 16 16" fill="none" className={className} aria-hidden>
<path
d="M3 8h9M8.5 4.5L12 8l-3.5 3.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

export function V2ImportLink() {
const result = useAtomValue(v2ImportAtom);
if (!AsyncResult.isSuccess(result) || result.value.url === null) return null;
return (
<a
href={result.value.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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"
>
<MoveIcon className="size-3.5 text-muted-foreground" />
Move to Executor v2
</a>
);
}
12 changes: 10 additions & 2 deletions apps/cloud/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ 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
// cloud-only bits injected through its slots:
// - 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.
Expand Down Expand Up @@ -52,7 +55,12 @@ export function Shell(props: { readonly content?: React.ReactNode }) {
onSignOut={signOut}
navItems={items}
orgMenuSlot={<OrgMenuSlot />}
supportSlot={<SupportSlot />}
supportSlot={
<>
<V2ImportLink />
<SupportSlot />
</>
}
content={props.content}
/>
);
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/api/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading