diff --git a/apps/cloud/scripts/start-closure.mjs b/apps/cloud/scripts/start-closure.mjs index a841cb5977..1569a3d0e9 100644 --- a/apps/cloud/scripts/start-closure.mjs +++ b/apps/cloud/scripts/start-closure.mjs @@ -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. @@ -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-.js` +// and `./app-auth` emits `app-auth-.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]); @@ -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)`, diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index c64452e6dd..1ee3a2bda4 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -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, @@ -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 diff --git a/apps/cloud/src/api/request-scoped.ts b/apps/cloud/src/api/request-scoped.ts new file mode 100644 index 0000000000..3b2ebe8b87 --- /dev/null +++ b/apps/cloud/src/api/request-scoped.ts @@ -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); diff --git a/apps/cloud/src/app-auth.ts b/apps/cloud/src/app-auth.ts new file mode 100644 index 0000000000..3ebf37d173 --- /dev/null +++ b/apps/cloud/src/app-auth.ts @@ -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); diff --git a/apps/cloud/src/app-paths.test.ts b/apps/cloud/src/app-paths.test.ts index dd9ff1af3c..2d899a4ca9 100644 --- a/apps/cloud/src/app-paths.test.ts +++ b/apps/cloud/src/app-paths.test.ts @@ -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 @@ -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); + }); +}); diff --git a/apps/cloud/src/app-paths.ts b/apps/cloud/src/app-paths.ts index 48e70a8c3a..212b00a986 100644 --- a/apps/cloud/src/app-paths.ts +++ b/apps/cloud/src/app-paths.ts @@ -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> = 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; +}; diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe7..9148c6b506 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -24,7 +24,6 @@ import { env, waitUntil } from "cloudflare:workers"; import { Effect, Layer } from "effect"; import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; import { AccountApi, AdminUsersApi } from "@executor-js/api"; @@ -32,33 +31,17 @@ import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; -import { - CloudAuthPublicHandlers, - CloudSessionAuthHandlers, - NonProtectedApi, -} from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; -import { SessionAuthLive } from "../auth/middleware-live"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api"; -import { OrgApi, OrgHttpApi } from "../org/api"; -import { orgAuthMiddleware } from "../org/auth-middleware"; -import { OrgHandlers } from "../org/handlers"; -import { AutumnService } from "../extensions/billing/service"; +import { OrgApi } from "../org/api"; import { DbService } from "../db/db"; import { ProtectedCloudApi } from "../api/layers"; import { AutumnRoutesLive } from "./billing/route"; +import { apiPrefixedRouter, makeOrgRoutes, makeSessionRoutes } from "./session-routes"; import { ApiErrorLoggingLive } from "../observability/error-logging"; -// The `/api`-prefixed `HttpRouter` view every cloud HttpApi group registers on, -// so `/auth/me` serves at `/api/auth/me` (matching the protected + account -// plane). Derived from the ambient router, exactly as `ExecutorApp.make` builds -// its own internal prefixed view for the protected API. -const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( - Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api")), -); - // The full cloud OpenAPI spec, prefixed so the served paths match `/api/*`. const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) .add(CloudAuthApi) @@ -81,27 +64,10 @@ const spec = OpenApi.fromApi(CloudOpenApi); export const makeCloudExtensionRoutes = ( rsLive: Layer.Layer, ) => { - // Session routes (login / callback / me / switch-org / …). Handlers yield - // `UserStoreService` directly; the per-request DB combine keeps the postgres - // socket request-scoped. - const SessionRoutes = HttpApiBuilder.layer(NonProtectedApi).pipe( - Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), - Layer.provide(requestScopedMiddleware(rsLive).layer), - Layer.provideMerge(SessionAuthLive), - Layer.provideMerge(AutumnService.Default), - Layer.provide(apiPrefixedRouter), - ); - - // Cloud-only WorkOS domain-verification routes; the auth middleware resolves - // the URL org selector header before falling back to the session org, so slug - // lookup needs the same request-scoped UserStoreService as other org-scoped - // APIs. - const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe( - Layer.provide(OrgHandlers), - Layer.provide(orgAuthMiddleware(rsLive)), - Layer.provideMerge(AutumnService.Default), - Layer.provide(apiPrefixedRouter), - ); + // Session + org routes, from the shared constructors the auth plane + // (`../app-auth`) mounts too — one definition, two planes. + const SessionRoutes = makeSessionRoutes(rsLive); + const OrgRoutes = makeOrgRoutes(rsLive); // Swagger UI at /api/docs + the OpenAPI JSON at /api/openapi.json, over the // `/api`-prefixed spec (so the served paths match). diff --git a/apps/cloud/src/extensions/session-routes.ts b/apps/cloud/src/extensions/session-routes.ts new file mode 100644 index 0000000000..9cb68ddeca --- /dev/null +++ b/apps/cloud/src/extensions/session-routes.ts @@ -0,0 +1,82 @@ +// --------------------------------------------------------------------------- +// The WorkOS session + org route Layers, and the `/api`-prefixed router view +// they register on. +// --------------------------------------------------------------------------- +// +// Split out of `./routes` so BOTH planes can mount the SAME Layer values: +// +// - the full app plane (`../app` -> `ExecutorApp.make`'s `extensions.routes`) +// - the auth plane (`../app-auth`), the small handler `server.ts` dispatches +// session/auth traffic to without evaluating the plugin/OpenAPI/MCP graph. +// +// Sharing the constructors rather than re-deriving them is what makes the two +// planes byte-identical on these routes: same handlers, same middleware order, +// same prefixed router, same error rendering. +// --------------------------------------------------------------------------- + +import { Effect, Layer } from "effect"; +import { HttpRouter } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server"; + +import { UserStoreService } from "../auth/context"; +import { WorkOsMirror } from "../auth/workos-mirror"; +import { + CloudAuthPublicHandlers, + CloudSessionAuthHandlers, + NonProtectedApi, +} from "../auth/handlers"; +import { SessionAuthLive } from "../auth/middleware-live"; +import { OrgHttpApi } from "../org/api"; +import { orgAuthMiddleware } from "../org/auth-middleware"; +import { OrgHandlers } from "../org/handlers"; +import { AutumnService } from "./billing/service"; +import { DbService } from "../db/db"; + +/** + * The `/api`-prefixed `HttpRouter` view every cloud HttpApi group registers on, + * so `/auth/me` serves at `/api/auth/me` (matching the protected + account + * plane). Derived from the ambient router, exactly as `ExecutorApp.make` builds + * its own internal prefixed view for the protected API. + */ +export const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api")), +); + +/** The per-request layer the session + org handlers read (the postgres socket). */ +export type SessionRequestScoped = Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory +>; + +/** + * Session routes (login / callback / logout / me / organizations / …). + * Handlers yield `UserStoreService` directly; the per-request DB combine keeps + * the postgres socket request-scoped. + * + * `AutumnService.Default` is provided because the `createOrganization` free-limit + * gate, `deleteOrganization`, and the seat report every sign-in fires read it — + * the few app-only billing touchpoints. It is NOT on the neutral boot core. + */ +export const makeSessionRoutes = (rsLive: SessionRequestScoped) => + HttpApiBuilder.layer(NonProtectedApi).pipe( + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), + Layer.provide(requestScopedMiddleware(rsLive).layer), + Layer.provideMerge(SessionAuthLive), + Layer.provideMerge(AutumnService.Default), + Layer.provide(apiPrefixedRouter), + ); + +/** + * Cloud-only WorkOS domain-verification routes; the auth middleware resolves + * the URL org selector header before falling back to the session org, so slug + * lookup needs the same request-scoped UserStoreService as other org-scoped + * APIs. The verification-link handler gates on billing, hence `AutumnService`. + */ +export const makeOrgRoutes = (rsLive: SessionRequestScoped) => + HttpApiBuilder.layer(OrgHttpApi).pipe( + Layer.provide(OrgHandlers), + Layer.provide(orgAuthMiddleware(rsLive)), + Layer.provideMerge(AutumnService.Default), + Layer.provide(apiPrefixedRouter), + ); diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index fc9c146f3a..3770b00688 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -10,7 +10,7 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; -import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; +import { isAppOwnedPath, servedByAppPlane, servedByAuthPlane } from "./app-paths"; import { marketingProxyRequest } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; import { runWorkOsEventsSync } from "./auth/workos-events-runner"; @@ -258,6 +258,26 @@ const getAppPlane = async (): Promise> => { return appPlane; }; +// The AUTH plane — the same seam one level finer. `ExecutorApp.make` builds +// every HttpApi group into one router at layer-build time, so the app plane +// above cannot be made lazy from the inside: the first `/api/*` request +// evaluates the plugin/OpenAPI/MCP/GraphQL catalogs, the execution substrate +// and Swagger whatever it asked for. `./app-auth` mounts ONLY the session +// routes (the same Layer values, see extensions/session-routes.ts), so +// `POST /api/auth/logout` on a cold isolate pays that closure instead of the +// whole app graph. `servedByAuthPlane` (./app-paths) is the exact allowlist. +let authPlane: ReturnType | undefined; +let authGraphEntered = false; + +const getAuthPlane = async (): Promise> => { + if (authPlane === undefined) { + const { cloudAuthHandler } = await import("./app-auth"); + authPlane = cloudAuthHandler(); + authGraphEntered = true; + } + return authPlane; +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { isolateRequestSeq += 1; @@ -348,15 +368,25 @@ const cloudflareHandler: ExportedHandler = { // `start_graph.entered` says nothing about it. `app_graph.entered` // is the app-plane analogue - false means this request paid for the // Effect graph's first evaluation in this isolate. - const appPlaneRequest = servedByAppPlane(url.pathname, request.method); - span.setAttribute("executor.dispatch.plane", appPlaneRequest ? "app" : "start"); + const authPlaneRequest = servedByAuthPlane(url.pathname, request.method); + const appPlaneRequest = + !authPlaneRequest && servedByAppPlane(url.pathname, request.method); + span.setAttribute( + "executor.dispatch.plane", + authPlaneRequest ? "auth" : appPlaneRequest ? "app" : "start", + ); + // `auth_graph.entered` mirrors `app_graph.entered`: false means this + // request paid for the auth plane's first evaluation in this isolate. + if (authPlaneRequest) span.setAttribute("executor.auth_graph.entered", authGraphEntered); if (appPlaneRequest) span.setAttribute("executor.app_graph.entered", appGraphEntered); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response try { const traced = withTraceparent(request, span.spanContext()); - const response = appPlaneRequest - ? await (await getAppPlane()).handler(prepareMcpOrgScope(traced)) - : await fetchHandler(traced, env, ctx); + const response = authPlaneRequest + ? await (await getAuthPlane()).handler(traced) + : appPlaneRequest + ? await (await getAppPlane()).handler(prepareMcpOrgScope(traced)) + : await fetchHandler(traced, env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); return response; } catch (err) {