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-moved-notice.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 9 additions & 9 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/",
Expand Down Expand Up @@ -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 }) =>
Expand Down
51 changes: 45 additions & 6 deletions apps/cloud/src/auth/v2-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = <RImport, RMoved>(input: {
readonly env: V2EdgeEnv;
readonly importSource: Effect.Effect<V2ImportSource, never, RImport>;
readonly movedSource: Effect.Effect<V2MovedSource, never, RMoved>;
readonly userId: string;
readonly organizationId: string;
}): Effect.Effect<V2ImportAnswer, never, RImport | RMoved> =>
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 };
});
86 changes: 86 additions & 0 deletions apps/cloud/src/auth/v2-moved.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>` 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: `<v2 origin>/org/<slug>`. */
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<Record<string, unknown>>,
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<Readonly<Record<string, unknown>>, 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<V2Moved | null> =>
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;
});
23 changes: 19 additions & 4 deletions apps/cloud/src/web/components/v2-import-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -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 (
<a
href={result.value.url}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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"
>
<MoveIcon className="size-3.5 text-muted-foreground" />
Move to Executor v2
{link.label}
</a>
);
}
Loading
Loading