Skip to content
Draft
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
22 changes: 21 additions & 1 deletion apps/cloud/scripts/start-closure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
// app - the Effect app plane. `/api/*` dispatches at the Worker entry and
// skips Start entirely, so an API request evaluates this instead of
// `start`; reported separately because the two planes now diverge.
// auth - the session/auth plane (`./app-auth`). The session routes are
// dispatched ahead of `app` and mount none of the plugin/OpenAPI/
// MCP/GraphQL/execution graph, so a sign-out on a cold isolate
// evaluates this closure instead of `app`.
//
// Anything reachable only through a dynamic import is not counted: making a
// heavy dependency lazy is exactly the outcome this rewards.
Expand Down Expand Up @@ -84,8 +88,16 @@ const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
/(start|router|tanstack)/.test(name(f)),
);
const start = closure(startRoots);
const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f));
// Rollup names an entry chunk after its module, so `./app` emits `app-<hash>.js`
// and `./app-auth` emits `app-auth-<hash>.js`. Match the auth root first and
// subtract it, or the app pattern would claim both.
const dynamicRoots = [...graph.get(ENTRY).dynamic];
const authRoots = dynamicRoots.filter((f) => /\/app-auth-[A-Za-z0-9_-]+\.js$/.test(f));
const appRoots = dynamicRoots.filter(
(f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f) && !authRoots.includes(f),
);
const app = closure([ENTRY, ...appRoots]);
const auth = closure([ENTRY, ...authRoots]);
// The budget tracks the worst plane: whichever costs a cold isolate more.
const evaluated = new Set([...startup, ...start]);

Expand All @@ -99,10 +111,18 @@ const report = (label, files) => {

report("startup", startup);
report("start", start);
if (authRoots.length > 0) report("auth", auth);
console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`);
console.log(
`API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`,
);
console.log(
`auth request (startup + auth): ${mb(bytes(auth))}${
authRoots.length
? ` [+${mb(bytes(auth) - bytes(startup))} over startup]`
: " [no auth chunk - session routes still route through the app plane]"
}`,
);
const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
console.log(
`deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
Expand Down
19 changes: 5 additions & 14 deletions apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {

import { SessionAuthLive } from "../auth/middleware-live";
import { UserStoreService } from "../auth/context";
import { cloudMemberDirectoryLayer } from "../auth/member-directory";
import { WorkOsMirror } from "../auth/workos-mirror";
import {
CloudAuthPublicHandlers,
Expand All @@ -29,20 +28,12 @@ import { AutumnService } from "../extensions/billing/service";
import { cloudPlugins } from "../plugins";
import { CoreSharedServices } from "../auth/workos";

const DbLive = DbService.Live;
const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive));
// The shared `MemberDirectory` read seam over the membership mirror — the
// same per-request socket the mirror writes through.
const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive));
// The per-request layer now lives in `./request-scoped` so modules that need
// only the postgres socket (the auth plane) do not pull in the protected API
// assembled below. Re-exported here for the existing callers.
import { RequestScopedServicesLive } from "./request-scoped";

// Per-request layer. Anything that opens an I/O object (postgres.js socket,
// fetch stream readers, anything backed by a `Writable`) MUST live here —
// `provideRequestScoped` rebuilds it per request so Cloudflare Workers'
// I/O isolation is satisfied. See `api.request-scope.test.ts`.
export const RequestScopedServicesLive: Layer.Layer<
DbService | UserStoreService | WorkOsMirror | MemberDirectory
> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive);
export { RequestScopedServicesLive };

// Boot-scoped layer. Built once at worker boot, reused across requests.
// Safe for config, in-memory caches, the global tracer provider, and
Expand Down
35 changes: 35 additions & 0 deletions apps/cloud/src/api/request-scoped.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// ---------------------------------------------------------------------------
// The per-request service layer, in its own module.
// ---------------------------------------------------------------------------
//
// Split out of `./layers` so a module can depend on the per-request postgres
// socket WITHOUT pulling in the protected (plugin) API that `./layers` also
// assembles — `makeProtectedApiLayer(cloudPlugins, …)` drags the whole plugin /
// OpenAPI / GraphQL / MCP / execution-substrate graph in with it. The auth
// plane (`../app-auth`) needs exactly this layer and none of that, so the
// definition lives here and `./layers` re-exports it for existing callers.
// ---------------------------------------------------------------------------

import { Layer } from "effect";

import type { MemberDirectory } from "@executor-js/api/server";

import { UserStoreService } from "../auth/context";
import { cloudMemberDirectoryLayer } from "../auth/member-directory";
import { WorkOsMirror } from "../auth/workos-mirror";
import { DbService } from "../db/db";

const DbLive = DbService.Live;
const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive));
// The shared `MemberDirectory` read seam over the membership mirror — the
// same per-request socket the mirror writes through.
const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive));

// Per-request layer. Anything that opens an I/O object (postgres.js socket,
// fetch stream readers, anything backed by a `Writable`) MUST live here —
// `provideRequestScoped` rebuilds it per request so Cloudflare Workers'
// I/O isolation is satisfied. See `api.request-scope.test.ts`.
export const RequestScopedServicesLive: Layer.Layer<
DbService | UserStoreService | WorkOsMirror | MemberDirectory
> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive);
73 changes: 73 additions & 0 deletions apps/cloud/src/app-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Layer } from "effect";

import { toApiHandler } from "@executor-js/api/server";

import { RequestScopedServicesLive } from "./api/request-scoped";
import { CoreSharedServices } from "./auth/workos";
import { makeOrgRoutes, makeSessionRoutes } from "./extensions/session-routes";
import { ApiErrorLoggingLive } from "./observability/error-logging";
import { WorkerTelemetryLive } from "./observability/telemetry";

// ===========================================================================
// The Executor cloud AUTH plane — the session/auth surface on its own handler.
//
// Why a second handler instead of a lazier `./app`: Effect's `HttpApiBuilder`
// registers every group into one router at layer-BUILD time, so the first
// `/api/*` request that reaches `ExecutorApp.make`'s handler evaluates the
// whole composition — plugin + OpenAPI + MCP + GraphQL catalogs, the execution
// substrate (@babel/parser, sucrase), Swagger. A lazy split inside a single
// `HttpApi` is not expressible. The seam that IS expressible is the worker
// entry, where `servedByAppPlane` (./app-paths) already decides which paths
// skip TanStack Start — so `servedByAuthPlane` names the session routes and
// `server.ts` dispatches them here first.
//
// Measured on production 2026-09-18 for `/api/*` on the app plane: warm p50
// ~120ms, cold p50 ~2.2s, ~31% of requests cold. `POST /api/auth/logout` is
// the user-visible victim — the console posts it as a top-level form
// navigation, so the cold wait is a blank page.
//
// What makes the two planes agree on the wire: they mount the SAME Layer
// values. `makeSessionRoutes` / `makeOrgRoutes` (./extensions/session-routes)
// are the exact constructors `makeCloudExtensionRoutes` feeds to
// `ExecutorApp.make`, on the same `/api`-prefixed router view, over the same
// `RequestScopedServicesLive`. Nothing is re-derived here.
//
// What this plane deliberately does NOT carry, because these routes never read
// it: the protected (plugin) API and its execution-stack middleware, the
// neutral `IdentityProvider` + `cloudIdentityFailureStrategy` (session routes
// authenticate through `SessionAuth`, whose `Unauthorized` is rendered by the
// HttpApi machinery, not by the identity failure strategy), the MCP envelope,
// Swagger/OpenAPI, the billing proxy, the admin plane, and the WorkOS webhook.
// `ErrorCapture` is absent for the same reason — no route here resolves that
// tag (`captureCauseEffect` is a plain Effect and needs no service).
//
// `HttpMiddleware.tracer`'s `http.server` span still opens per request:
// `toApiHandler` -> `HttpRouter.toWebHandler` installs it exactly as it does
// for `./app`, so an auth-plane request traces like an app-plane one.
// ===========================================================================

// Boot-scoped context, the auth-plane subset of `./app`'s `boot`: the raw
// WorkOS SDK client the session handlers and `SessionAuthLive` read, plus the
// worker tracer. No api-key service (no Bearer plane here), no `AutumnService`
// on the core — `makeSessionRoutes` provide-merges its own, exactly as it does
// inside the full app. `HttpServer.layerServices` is supplied by
// `toApiHandler`. A boot-time WorkOS misconfig is unrecoverable -> `orDie`.
const authBoot = Layer.merge(CoreSharedServices, WorkerTelemetryLive).pipe(
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a boot-time WorkOS misconfiguration is unrecoverable
Layer.orDie,
);

const AuthPlaneLayer = Layer.mergeAll(
makeSessionRoutes(RequestScopedServicesLive),
makeOrgRoutes(RequestScopedServicesLive),
// The same global request-failure logging the app plane mounts, so a failing
// session route logs identically on either plane.
ApiErrorLoggingLive,
).pipe(Layer.provideMerge(authBoot));

/**
* The auth-plane web handler: serves exactly the paths `servedByAuthPlane`
* (./app-paths) names — `/api/auth/*`, `/api/org/domains*`, and the MCP
* approval endpoints. Everything else under `/api` stays on `./app`.
*/
export const cloudAuthHandler = () => toApiHandler(AuthPlaneLayer);
76 changes: 75 additions & 1 deletion apps/cloud/src/app-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";

import { isAppOwnedPath, servedByAppPlane } from "./app-paths";
import { isAppOwnedPath, servedByAppPlane, servedByAuthPlane } from "./app-paths";

// Guards the start.ts dispatch decision: every surface the unified app handler
// serves must be classified app-owned (forwarded to `app.handler`), and Start's
Expand Down Expand Up @@ -98,3 +98,77 @@ describe("app-plane dispatch", () => {
expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false);
});
});

// The auth plane (`app-auth.ts`) is dispatched BEFORE the app plane, so this
// list is load-bearing twice over: a path it claims but the auth handler does
// not mount answers 404 from the wrong router, and a session path it misses
// keeps paying the full app-graph cold start it exists to avoid.
describe("auth-plane dispatch", () => {
const authPlane = [
["GET", "/api/auth/login"],
["POST", "/api/auth/logout"],
["GET", "/api/auth/callback"],
["GET", "/api/auth/cli-login"],
["GET", "/api/auth/me"],
["GET", "/api/auth/organizations"],
["POST", "/api/auth/create-organization"],
["POST", "/api/auth/delete-organization"],
["GET", "/api/auth/pending-invitations"],
["POST", "/api/auth/accept-invitation"],
["GET", "/api/mcp-sessions/sess_1/executions/exec_1"],
["POST", "/api/mcp-sessions/sess_1/executions/exec_1/resume"],
["GET", "/api/org/domains"],
["POST", "/api/org/domains/verify-link"],
["DELETE", "/api/org/domains/dom_1"],
] as const;
for (const [method, pathname] of authPlane) {
it(`serves ${method} ${pathname} on the auth plane`, () => {
expect(servedByAuthPlane(pathname, method)).toBe(true);
// Still app-owned: the auth plane is a subset of `/api`, not a new namespace.
expect(servedByAppPlane(pathname, method)).toBe(true);
});
}

// Everything else under `/api` stays on the full app plane. `/api/account/*`
// is the closest neighbour — it is the shared account API behind the WorkOS
// AccountProvider, NOT a session route, and it is not mounted here.
const appPlaneOnly = [
["GET", "/api/account/members"],
["GET", "/api/connections"],
["GET", "/api/docs"],
["GET", "/api/openapi.json"],
["POST", "/api/billing/attach"],
["POST", "/api/webhooks/workos"],
["GET", "/api/admin/users"],
] as const;
for (const [method, pathname] of appPlaneOnly) {
it(`leaves ${method} ${pathname} to the app plane`, () => {
expect(servedByAuthPlane(pathname, method)).toBe(false);
});
}

it("matches the method as well as the path", () => {
// `logout` is POST-only; a GET to it is not a route either plane mounts,
// and must not be claimed by the auth plane's router.
expect(servedByAuthPlane("/api/auth/logout", "GET")).toBe(false);
expect(servedByAuthPlane("/api/auth/me", "POST")).toBe(false);
expect(servedByAuthPlane("/api/org/domains", "DELETE")).toBe(false);
});

it("claims no unlisted path under /api/auth", () => {
expect(servedByAuthPlane("/api/auth/switch-organization", "POST")).toBe(false);
expect(servedByAuthPlane("/api/auth", "GET")).toBe(false);
});

it("matches one segment per route parameter", () => {
expect(servedByAuthPlane("/api/mcp-sessions/a/executions/b/c", "GET")).toBe(false);
expect(servedByAuthPlane("/api/org/domains/a/b", "DELETE")).toBe(false);
});

it("never overrides a Start-owned path", () => {
expect(servedByAuthPlane("/api/oauth/callback", "GET")).toBe(false);
expect(servedByAuthPlane("/api/sentry-tunnel", "POST")).toBe(false);
expect(servedByAuthPlane("/", "GET")).toBe(false);
expect(servedByAuthPlane("/mcp", "POST")).toBe(false);
});
});
67 changes: 67 additions & 0 deletions apps/cloud/src/app-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,70 @@ export const isStartOwnedApiPath = (pathname: string, method: string): boolean =

export const servedByAppPlane = (pathname: string, method: string): boolean =>
isApiPath(pathname) && !isStartOwnedApiPath(pathname, method);

// ---------------------------------------------------------------------------
// Which paths the AUTH plane serves (`app-auth.ts`), ahead of the app plane.
//
// The app plane is `ExecutorApp.make`'s single handler: one `HttpApiBuilder`
// router built in one pass, so the first `/api/*` request in an isolate
// evaluates the plugin + OpenAPI + MCP + GraphQL catalogs, the execution
// substrate and Swagger — cold p50 ~2.2s against ~120ms warm, on ~31% of
// requests. The session routes need none of that, so they get their own small
// handler and `server.ts` tries this classifier first.
//
// It is an EXACT allowlist rather than an `/api/auth/` prefix test, because the
// two planes 404 differently: a path this claims but `app-auth.ts` does not
// mount would answer from the wrong router. Every entry below is a route
// `makeSessionRoutes` / `makeOrgRoutes` register — keep them in step.
// ---------------------------------------------------------------------------

// A Map, not an object literal: the method comes off the wire, and an object
// would resolve `constructor` (a legal HTTP token) to `Object` and then throw.
const AUTH_PLANE_EXACT_PATHS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
// CloudAuthPublicApi (no session required) + the read side of CloudAuthApi.
[
"GET",
new Set([
"/api/auth/login",
"/api/auth/callback",
"/api/auth/cli-login",
"/api/auth/me",
"/api/auth/organizations",
"/api/auth/pending-invitations",
"/api/org/domains",
]),
],
[
"POST",
new Set([
"/api/auth/logout",
"/api/auth/create-organization",
"/api/auth/delete-organization",
"/api/auth/accept-invitation",
"/api/org/domains/verify-link",
]),
],
]);

// The parameterised routes. `:mcpSessionId` / `:executionId` / `:domainId` are
// single path segments, so an anchored one-segment match is the same grammar
// the Effect router applies.
const MCP_APPROVAL_GET = /^\/api\/mcp-sessions\/[^/]+\/executions\/[^/]+$/;
const MCP_APPROVAL_RESUME = /^\/api\/mcp-sessions\/[^/]+\/executions\/[^/]+\/resume$/;
const ORG_DOMAIN_DELETE = /^\/api\/org\/domains\/[^/]+$/;

/**
* Does the small auth-plane handler serve this request?
*
* Gated on `servedByAppPlane` first so the Start-owned `/api` paths keep their
* route no matter what this list says — the auth plane must never be a second
* way to lose `sentryTunnelMiddleware` or the signed-out OAuth redirect.
*/
export const servedByAuthPlane = (pathname: string, method: string): boolean => {
if (!servedByAppPlane(pathname, method)) return false;
if (AUTH_PLANE_EXACT_PATHS.get(method)?.has(pathname) === true) return true;
if (method === "GET") return MCP_APPROVAL_GET.test(pathname);
if (method === "POST") return MCP_APPROVAL_RESUME.test(pathname);
if (method === "DELETE") return ORG_DOMAIN_DELETE.test(pathname);
return false;
};
Loading
Loading