391 lines · 13.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Cloud API — non-protected (auth) endpoints + org-protected executor endpoints// --------------------------------------------------------------------------- import { env } from "cloudflare:workers";import * as Sentry from "@sentry/cloudflare";import {  HttpApiBuilder,  HttpApiSwagger,  HttpMiddleware,  HttpRouter,  HttpServer,} from "@effect/platform";import { Effect, Layer } from "effect";import { Autumn } from "autumn-js";import { autumnHandler } from "autumn-js/backend"; import { CoreExecutorApi } from "@executor/api";import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";import { createExecutionEngine } from "@executor/execution";import { makeDynamicWorkerExecutor, type CodeExecutor } from "@executor/runtime-dynamic-worker";import {  OpenApiGroup,  OpenApiExtensionService,  OpenApiHandlers,} from "@executor/plugin-openapi/api";import { McpGroup, McpExtensionService, McpHandlers } from "@executor/plugin-mcp/api";import {  GoogleDiscoveryGroup,  GoogleDiscoveryExtensionService,  GoogleDiscoveryHandlers,} from "@executor/plugin-google-discovery/api";import {  GraphqlGroup,  GraphqlExtensionService,  GraphqlHandlers,} from "@executor/plugin-graphql/api"; import { OrgAuth } from "./auth/middleware";import { OrgAuthLive, SessionAuthLive } from "./auth/middleware-live";import { UserStoreService } from "./auth/context";import {  CloudAuthPublicHandlers,  CloudSessionAuthHandlers,  NonProtectedApi,} from "./auth/handlers";import { WorkOSAuth } from "./auth/workos";import { DbService } from "./services/db";import { createOrgExecutor } from "./services/executor";import { TeamOrgApi } from "./team/compose";import { TeamHandlers } from "./team/handlers";import { TelemetryLive } from "./services/telemetry";import { server } from "./env"; // ---------------------------------------------------------------------------// API definitions// --------------------------------------------------------------------------- /** Protected (org-required) API — all the executor groups + OrgAuth middleware */const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)  .add(McpGroup)  .add(GoogleDiscoveryGroup)  .add(GraphqlGroup)  .middleware(OrgAuth); // ---------------------------------------------------------------------------// Layers// --------------------------------------------------------------------------- const DbLive = DbService.Live;const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); const SharedServices = Layer.mergeAll(  DbLive,  UserStoreLive,  WorkOSAuth.Default,  HttpServer.layerContext,).pipe(Layer.provideMerge(TelemetryLive));const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(  Layer.provide(    Layer.mergeAll(      CoreHandlers,      OpenApiHandlers,      McpHandlers,      GoogleDiscoveryHandlers,      GraphqlHandlers,      OrgAuthLive,    ),  ),); const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(  Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),  Layer.provideMerge(SessionAuthLive),); const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(  Layer.provide(TeamHandlers),  Layer.provideMerge(OrgAuthLive),); // ---------------------------------------------------------------------------// Public auth web handler// ---------------------------------------------------------------------------//// Build per-request, not once at module load. `toWebHandler` creates a// single long-lived `Layer.MemoMap` that memoizes `DbService.Live`'s// `Layer.scoped` acquire — the resulting `sql` connection is created in// the module scope, not the request scope. Workerd tears down TCP sockets// at request boundaries, so the second request on a cached handler fails// with "Cannot perform I/O on behalf of a different request". Building a// fresh handler per request gives each request its own layer scope and a// fresh socket. Auth endpoints (login/callback/me/logout) are infrequent// so the overhead is negligible.// --------------------------------------------------------------------------- const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 }); const createNonProtectedHandler = () =>  HttpApiBuilder.toWebHandler(    NonProtectedApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),    { middleware: HttpMiddleware.logger },  ); const createTeamHandler = () =>  HttpApiBuilder.toWebHandler(    TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),    { middleware: HttpMiddleware.logger },  ); // ---------------------------------------------------------------------------// Protected handler — must be built per-request because the executor varies// --------------------------------------------------------------------------- const buildProtectedHandler = (  organizationId: string,  organizationName: string,  codeExecutor: CodeExecutor,) =>  Effect.gen(function* () {    const executor = yield* createOrgExecutor(      organizationId,      organizationName,      server.ENCRYPTION_KEY,    );     const engine = createExecutionEngine({ executor, codeExecutor });     const requestServices = Layer.mergeAll(      Layer.succeed(ExecutorService, executor),      Layer.succeed(ExecutionEngineService, engine),      Layer.succeed(OpenApiExtensionService, executor.openapi),      Layer.succeed(McpExtensionService, executor.mcp),      Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),      Layer.succeed(GraphqlExtensionService, executor.graphql),    );     return HttpApiBuilder.toWebHandler(      HttpApiSwagger.layer({ path: "/docs" }).pipe(        Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),        Layer.provideMerge(ProtectedCloudApiLive),        Layer.provideMerge(requestServices),        Layer.provideMerge(SharedServices),        Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),      ),      { middleware: HttpMiddleware.logger },    );  }); // ---------------------------------------------------------------------------// Routing// --------------------------------------------------------------------------- const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/");const isExecutionPath = (pathname: string): boolean =>  pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname); // ---------------------------------------------------------------------------// Autumn billing — lazy-initialized SDK for fire-and-forget tracking// --------------------------------------------------------------------------- let _autumn: Autumn | null = null;const getAutumn = () => {  if (!_autumn && server.AUTUMN_SECRET_KEY) {    _autumn = new Autumn({ secretKey: server.AUTUMN_SECRET_KEY });  }  return _autumn;}; /** * Resolve the user's organization for executor creation. Reads from the * session cookie via WorkOS — returns null if there's no session or no * organization yet, so the caller can return a 403. */const lookupOrgForRequest = (request: Request) =>  Effect.gen(function* () {    const workos = yield* WorkOSAuth;    const result = yield* workos.authenticateRequest(request);    if (!result || !result.organizationId) return null;    const users = yield* UserStoreService;    return yield* users.use((s) => s.getOrganization(result.organizationId!));  }); // ---------------------------------------------------------------------------// Autumn billing proxy — authenticates the session, then forwards to Autumn// --------------------------------------------------------------------------- const handleAutumnRequest = async (request: Request): Promise<Response> => {  const program = Effect.gen(function* () {    const workos = yield* WorkOSAuth;    const result = yield* workos.authenticateRequest(request);     if (!result || !result.organizationId) {      return Response.json({ error: "Unauthorized" }, { status: 401 });    }     const url = new URL(request.url);    const body =      request.method !== "GET" && request.method !== "HEAD"        ? yield* Effect.promise(() => request.json())        : undefined;     const { statusCode, response } = yield* Effect.promise(() =>      autumnHandler({        request: {          url: url.pathname,          method: request.method,          body,        },        customerId: result.organizationId,        customerData: {          name: result.email,          email: result.email,        },        clientOptions: {          secretKey: server.AUTUMN_SECRET_KEY,        },        pathPrefix: "/autumn",      }),    );     if (statusCode >= 400) {      console.error("[autumn] upstream error:", statusCode, response);      return Response.json({ error: "Billing request failed" }, { status: statusCode });    }     return Response.json(response, { status: statusCode });  });   return Effect.runPromise(program.pipe(Effect.provide(SharedServices), Effect.scoped)).catch(    (err) => {      Sentry.captureException(err);      console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);      return Response.json({ error: "Internal server error" }, { status: 500 });    },  );}; // ---------------------------------------------------------------------------// Widget token endpoint — returns a WorkOS widget token for the session user// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------// Sentry tunnel — proxies client events to Sentry to bypass ad blockers// --------------------------------------------------------------------------- const SENTRY_HOST = "o4511196985556992.ingest.us.sentry.io";const SENTRY_PROJECT_IDS = new Set(["4511197001416704", "4511197010067456"]); const handleSentryTunnel = async (request: Request): Promise<Response> => {  try {    const body = await request.text();    const header = body.split("\n")[0];    const { dsn } = JSON.parse(header) as { dsn: string };    const url = new URL(dsn);    const projectId = url.pathname.replace("/", "");     if (url.host !== SENTRY_HOST || !SENTRY_PROJECT_IDS.has(projectId)) {      return new Response("Invalid Sentry DSN", { status: 400 });    }     return fetch(`https://${SENTRY_HOST}/api/${projectId}/envelope/`, {      method: "POST",      body,    });  } catch {    return new Response("Invalid request", { status: 400 });  }}; export const handleApiRequest = async (request: Request): Promise<Response> => {  const pathname = new URL(request.url).pathname;   if (pathname === "/sentry-tunnel" && request.method === "POST") {    return handleSentryTunnel(request);  }   if (isTeamPath(pathname)) {    const handler = createTeamHandler();    try {      return await handler.handler(request);    } finally {      await handler.dispose();    }  }   if (isAutumnPath(pathname)) {    return handleAutumnRequest(request);  }   if (isAuthPath(pathname)) {    const handler = createNonProtectedHandler();    try {      return await handler.handler(request);    } finally {      await handler.dispose();    }  }   // Protected path — build the executor lazily for the request's org.  try {    const program = Effect.gen(function* () {      const org = yield* lookupOrgForRequest(request);      if (!org) return null;       const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });      const handler = yield* buildProtectedHandler(org.id, org.name, codeExecutor);      const response = yield* Effect.promise(() => handler.handler(request));       // Sanitize error responses — only let declared API errors through,      // replace anything else with a generic message to prevent leaking internals      if (!response.ok) {        const sanitized = yield* Effect.promise(async () => {          try {            const body = await response.clone().json();            const hasTag = body && typeof body === "object" && "_tag" in body;            if (!hasTag) {              Sentry.captureMessage(`API ${response.status}: ${pathname}`, { level: "error" });              return Response.json(                { error: "Internal server error" },                { status: response.status >= 500 ? 500 : response.status },              );            }          } catch {            Sentry.captureMessage(`API ${response.status}: ${pathname}`, { level: "error" });            return Response.json({ error: "Internal server error" }, { status: 500 });          }          return null;        });        if (sanitized) return { response: sanitized, orgId: org.id };      }       return { response, orgId: org.id };    });     const result = await Effect.runPromise(      program.pipe(Effect.provide(SharedServices), Effect.scoped),    );     if (result === null) {      return Response.json(        { error: "No organization in session", code: "no_organization" },        { status: 403 },      );    }     // Fire-and-forget: track execution usage    if (isExecutionPath(pathname) && result.response.ok) {      const autumn = getAutumn();      if (autumn) {        autumn          .track({            customerId: result.orgId,            featureId: "executions",            value: 1,          })          .catch((err) => {            console.error("[billing] track failed:", err);          });      }    }     return result.response;  } catch (err) {    Sentry.captureException(err);    console.error("[api] request failed:", err instanceof Error ? err.stack : err);    return Response.json({ error: "Internal server error" }, { status: 500 });  }};