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/v1-no-org-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/cloud": patch
---

Executor Cloud no longer creates organizations: new organizations are created in Executor v2. A signed-in account with no organization is sent to Executor v2's sign-up page, or to a page listing its pending invitations when it has been invited to an existing organization. `/create-org` and the organization switcher's new-organization link open Executor v2, and `POST /api/auth/create-organization` answers `OrganizationCreationMoved` (410) without creating anything.
6 changes: 3 additions & 3 deletions apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ export const BootSharedServices = Layer.mergeAll(
// boot scope and Cloudflare Workers' I/O isolation kills the second
// request.
//
// `AutumnService.Default` is provided HERE because the `createOrganization`
// handler reads it for the free-organizations-per-user limit gate — one of the
// few app-only billing touchpoints. (It is NOT on the neutral boot core.)
// `AutumnService.Default` is provided HERE because the `deleteOrganization`
// handler cancels the org's billing customer — one of the few app-only billing
// touchpoints. (It is NOT on the neutral boot core.)
export const makeNonProtectedApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
) =>
Expand Down
4 changes: 2 additions & 2 deletions apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware<
//
// `AutumnService` is provided HERE — the billing service is scoped to the
// executor plane that meters, not to the neutral boot core. (`/autumn`, the
// account seat-gate, and the createOrganization free-limit gate each provide it
// where they run.)
// account seat-gate, and org deletion's customer cancel each provide it where
// they run.)
export const makeProtectedApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>,
) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry";
// session store (the DO class is exported straight from server.ts), console+Sentry error
// capture — and Autumn BILLING entering ONLY as extensions: the engine
// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,
// and the createOrganization free-limit gate. `diff` against
// and the org-deletion customer cancel. `diff` against
// `apps/host-selfhost/src/app.ts` is the entire product difference.
//
// `ExecutorApp.make` owns the assembly (the execution-stack middleware wrapping
Expand Down
27 changes: 14 additions & 13 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,6 @@ const V2ImportResponse = Schema.Struct({
moved: Schema.NullOr(V2MovedResponse),
});

const CreateOrganizationBody = Schema.Struct({
name: Schema.String,
});

const CreateOrganizationResponse = Schema.Struct({
id: Schema.String,
name: Schema.String,
slug: Schema.String,
});

// Deleting an org requires re-typing its name (the label the UI shows) as a
// deliberate, non-automatable confirmation. Verified server-side too.
const DeleteOrganizationBody = Schema.Struct({
Expand Down Expand Up @@ -192,6 +182,18 @@ export class OrganizationFrozen extends Schema.TaggedErrorClass<OrganizationFroz
{ httpApiStatus: 423 },
) {}

/** Shown wherever v1 is asked to create an organization. */
export const ORGANIZATION_CREATION_MOVED_MESSAGE =
"Executor has moved. New organizations are created in Executor v2.";

// v1 creates no organizations: every new organization is created in Executor
// v2. `url` is v2's sign-up page, or null where this deployment has no v2.
export class OrganizationCreationMoved extends Schema.TaggedErrorClass<OrganizationCreationMoved>()(
"OrganizationCreationMoved",
{ message: Schema.String, url: Schema.NullOr(Schema.String) },
{ httpApiStatus: 410 },
) {}

// Whether the organization is moving to v2 could not be read, so a change to
// it is refused rather than risked. Retryable.
export class OrganizationStateUnavailable extends Schema.TaggedErrorClass<OrganizationStateUnavailable>()(
Expand Down Expand Up @@ -272,10 +274,9 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
}),
)
.add(
// Refuses every request: new organizations are created in Executor v2.
HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
payload: CreateOrganizationBody,
success: CreateOrganizationResponse,
error: AuthErrors,
error: OrganizationCreationMoved,
}),
)
.add(
Expand Down
56 changes: 43 additions & 13 deletions apps/cloud/src/auth/doc-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
// KNOWS who it's serving:
//
// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
// - org-less → 302 /create-org (onboarding owns those sessions)
// - org-less → 302 to Executor v2's sign-up page (v1 creates no
// organizations), or to /join when the session holds a pending invitation
// into an existing organization
// - /create-org → 302 to v2's sign-up page, for every signed-in session
// - signed in → the prerendered shell is served, and the auth-hint cookie is
// minted when the browser doesn't hold a current one — the client seeds its
// auth state from that cookie right after mount (there is no per-request
Expand Down Expand Up @@ -40,7 +43,7 @@ import { env } from "cloudflare:workers";

import { isAppOwnedPath } from "../app-paths";
import { makeDbLayer } from "../db/db";
import { v2AppOrigin } from "../edge/marketing";
import { v2AppOrigin, v2SignUpUrl } from "../edge/marketing";
import { movedDashboardLocation } from "../org/move-to-v2";
import { makeUserStoreLayer, UserStoreService } from "./context";
import { parseCookie } from "./cookies";
Expand All @@ -50,8 +53,8 @@ import { makeWorkOsMirrorLayer } from "./workos-mirror";
import { sealedSessionDisplayName } from "./middleware";
import { authorizeOrganization, authorizeOrganizationSelector } from "./organization";
import { loginPath, safeReturnTo } from "./return-to";
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
import { WorkOSClient } from "./workos";
import { CREATE_ORG_PATH, JOIN_PATH, PUBLIC_PATHS } from "./route-paths";
import { WorkOSClient, isPendingOrganizationInvitation } from "./workos";

const SESSION_COOKIE = "wos-session";
/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
Expand Down Expand Up @@ -195,6 +198,21 @@ const authorizeLastOrgSlug = async (
return Exit.isSuccess(exit) ? exit.value : null;
};

// ── No organization ──────────────────────────────────────────────────────────

/**
* Whether this email holds a pending invitation into an organization — the
* same WorkOS read the join page lists them from (`pendingInvitations`). A
* failed read answers `true`: the join page reads again, and an invitee must
* not be sent away from their invitation by a transient failure.
*/
const hasPendingInvitation = async (email: string): Promise<boolean> => {
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.listInvitationsByEmail(email)),
);
return !Exit.isSuccess(exit) || exit.value.data.some(isPendingOrganizationInvitation);
};

// ── Moved to v2 ──────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -305,13 +323,25 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
return redirect("/", { refreshedSession: session.refreshedSession });
}

// A session with no organization belongs in onboarding — same decision
// the client AuthGate makes mid-session, made here before the document
// exists so the app shell is never painted for an org-less session.
if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) {
return redirect("/create-org", {
refreshedSession: session.refreshedSession,
});
// v1 creates no organizations: its create-an-organization address opens
// v2's sign-up page, whatever organizations the session already has.
const signUpUrl = v2SignUpUrl(env);
if (pathname === CREATE_ORG_PATH && signUpUrl !== null) {
return redirect(signUpUrl.href, { refreshedSession: session.refreshedSession });
}

// A session with no organization is sent on before the document exists,
// so the app shell is never painted for it — the client AuthGate does the
// same mid-session. New organizations are created in v2, so it goes to
// v2's sign-up page, unless it holds a pending invitation into an existing
// organization: that is accepted on the join page. Where this deployment
// has no v2, the join page is the only place it can go.
if (!session.organizationId && pathname !== JOIN_PATH) {
const location =
signUpUrl !== null && !(await hasPendingInvitation(session.email))
? signUpUrl.href
: JOIN_PATH;
return redirect(location, { refreshedSession: session.refreshedSession });
}

// A BARE console path (no org slug in the URL) canonicalizes onto the org
Expand All @@ -331,7 +361,7 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
lastOrgSlug &&
isValidOrgSlug(lastOrgSlug) &&
!isValidOrgSlug(firstSegment) &&
!ONBOARDING_PATHS.has(pathname)
pathname !== JOIN_PATH
) {
const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug;
if (lastOrgSlug !== sessionOrgSlug) {
Expand All @@ -348,7 +378,7 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
}

// An organization that has moved to v2 is opened there instead.
if (!ONBOARDING_PATHS.has(pathname)) {
if (pathname !== JOIN_PATH) {
const movedTo = await movedToV2Location(session, firstSegment);
if (movedTo) return redirect(movedTo, { refreshedSession: session.refreshedSession });
}
Expand Down
139 changes: 19 additions & 120 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {
McpExecutionNotFoundError,
McpSessionForbiddenError,
OrganizationDeletionForbidden,
OrganizationCreationMoved,
OrganizationDeletionIncomplete,
OrganizationFrozen,
OrganizationStateUnavailable,
ORGANIZATION_CREATION_MOVED_MESSAGE,
} from "./api";
import { MemberDirectory, NoOrganization } from "@executor-js/api/server";
// Pure constants/codec module (no React) — safe in the backend graph.
Expand All @@ -25,15 +27,9 @@ import { UserStoreService } from "./context";
import { mirrorMembership, mirrorSignIn } from "./mirror-feeders";
import { env } from "cloudflare:workers";
import { WorkOSError } from "./errors";
import { WorkOSClient } from "./workos";
import { WorkOSClient, isPendingOrganizationInvitation } from "./workos";
import { AutumnService, autumnStatusOf } from "../extensions/billing/service";
import { forkReportMemberSeats } from "../extensions/billing/member-seats";
import { captureCauseEffect } from "../observability";
import {
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
} from "../extensions/billing/plans";
import { LAST_ORG_COOKIE } from "./last-org-cookie";
import {
ORG_SELECTOR_HEADER,
Expand All @@ -47,6 +43,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
import { liveV2ImportSource, v2ImportAnswer } from "./v2-import";
import { liveV2MovedSource } from "./v2-moved";
import { assertOrgWritable, orgFreezeRefusal } from "../org/move-to-v2";
import { v2SignUpUrl } from "../edge/marketing";

const COOKIE_OPTIONS = {
path: "/",
Expand Down Expand Up @@ -456,110 +453,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
});
}),
)
.handle("createOrganization", ({ payload }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
const users = yield* UserStoreService;
const session = yield* SessionContext;
const autumn = yield* AutumnService;

const name = payload.name.trim();
// The free-organizations-per-user limit counts the caller's ACTIVE
// memberships, read from the local mirror.
const directory = yield* MemberDirectory;
const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]);

if (isOverFreeOrganizationLimit(activeMemberships)) {
const paidOrganizationIds = yield* Effect.all(
activeMemberships.map((membership) =>
autumn
.use((client) =>
client.customers.getOrCreate({
customerId: membership.organizationId,
}),
)
.pipe(
Effect.map((customer) =>
hasPaidOrganizationSubscription(customer.subscriptions)
? membership.organizationId
: null,
),
),
),
{ concurrency: 3 },
).pipe(
// Any Autumn failure here (outage or missing customer) leaves the
// paid/free split unknown, and the limit must fail closed.
Effect.mapError(() => new WorkOSError()),
Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
);

if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) {
return yield* new WorkOSError();
}
}

const org = yield* workos.createOrganization(name);
const membership = yield* workos.createMembership(org.id, session.accountId, "admin");
// `upsertOrganization` mints the slug at insert — no separate heal step.
const mirrored = yield* users.use("upsertOrganization", (s) =>
s.upsertOrganization({
id: org.id,
name: org.name,
updatedAt: new Date(org.updatedAt),
}),
);
// Write-through: the creator's admin membership, from the create
// response, lands in the mirror before anything reads it.
yield* mirrorMembership(membership);

// Provision the org's billing customer while we're the ones creating
// the org. Without this the first billing call an org ever makes is a
// non-creating one (balance check / usage track), which 404s and keeps
// 404ing — unlimited unbilled executions. Non-fatal: a billing blip
// must not block signup, and the billing seam heals a customer that
// is still missing later.
yield* autumn.ensureCustomer(org.id).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logWarning(
"createOrganization: could not provision the Autumn customer",
{ organizationId: org.id },
);
yield* captureCauseEffect(error);
}),
),
);
// Seed the new org's billed seat count (the creator's seat).
yield* forkReportMemberSeats(org.id);

// Try to attach the new org to the current session. This can fail
// (or silently return a session still scoped to the old org) when
// the caller's current session is stale — most commonly after the
// user was removed from the org their cookie is pinned to. In that
// case we can't repair the session in-place, so we clear the
// cookie and fail loudly; the frontend will bounce to login and
// the callback's rehydrate path will pick up the new membership.
const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;

if (!refreshed || !verified || verified.organizationId !== org.id) {
yield* Effect.logWarning(
"createOrganization: unable to attach new org to current session",
{
userId: session.accountId,
newOrgId: org.id,
refreshReturnedSession: refreshed != null,
verifiedOrgId: verified?.organizationId ?? null,
},
);
(yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS);
return yield* new WorkOSError();
}

(yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
return { id: org.id, name: org.name, slug: mirrored.slug };
}),
// v1 creates no organizations; Executor v2 does. Nothing is written to
// WorkOS, the mirror or billing: the request is answered with where to go.
.handle("createOrganization", () =>
Effect.fail(
new OrganizationCreationMoved({
message: ORGANIZATION_CREATION_MOVED_MESSAGE,
url: v2SignUpUrl(env)?.href ?? null,
}),
),
)
.handle("deleteOrganization", ({ payload }) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -721,7 +623,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(

// The caller's session is pinned to the now-deleted org — clear it so
// the browser bounces to login and rehydrates to another membership
// (or the create-org screen when they have none left).
// (or, with none left, is sent on to Executor v2).
(yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS);

return { success: true };
Expand All @@ -733,9 +635,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
const session = yield* SessionContext;

const invitations = yield* workos.listInvitationsByEmail(session.email);
const pending = invitations.data.filter(
(i) => i.state === "pending" && i.organizationId !== null,
);
const pending = invitations.data.filter(isPendingOrganizationInvitation);

// Resolve org names + inviter identities in parallel. Treat
// individual failures as "skip the field" rather than failing the
Expand All @@ -747,7 +647,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
pending.map((inv) =>
Effect.gen(function* () {
const org = yield* workos
.getOrganization(inv.organizationId!)
.getOrganization(inv.organizationId)
.pipe(Effect.orElseSucceed(() => null));
if (!org) return null;
const inviter = inv.inviterUserId
Expand Down Expand Up @@ -827,10 +727,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
// seat count now.
yield* forkReportMemberSeats(org.id);

// Attach the just-accepted org to the current session. Same shape
// as createOrganization: refresh + verify; if we can't pin the
// session in-place, clear the cookie and let the user bounce
// through login again. The acceptance has already succeeded
// Attach the just-accepted org to the current session: refresh +
// verify; if we can't pin the session in-place, clear the cookie
// and let the user bounce through login again. The acceptance has already succeeded
// server-side, so the next login will pick up the membership.
const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
Expand Down
Loading
Loading