import { HttpApiSwagger } from "effect/unstable/httpapi"; import { HttpEffect, HttpRouter } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; import { runSqliteDataMigrations } from "@executor-js/sdk"; import { resolveAuthProviders } from "./auth"; import { selfHostDataMigrations } from "./db/data-migrations"; import { makeSelfHostAdminApiLayer } from "./admin/handlers"; import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; import { SelfHostCodeExecutorProvider, SelfHostHostConfig, SelfHostPluginsProvider, } from "./execution"; import { makeSelfHostMcpSeams } from "./mcp"; import { selfHostPlugins } from "./plugins"; import { ErrorCaptureLive } from "./observability"; import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; // =========================================================================== // The self-hosted Executor app, as ONE `ExecutorApp.make` call. // // The whole scenario in 60 seconds: Better Auth (cookie/bearer/api-key identity // + /api/auth handler + account API + MCP OAuth) over a libSQL file, QuickJS // in-process code execution, in-process MCP, console error capture, Swagger at // /docs — and NO billing (the cloud `extensions.services` + /autumn route are // simply absent). `diff` against the cloud app is the entire product difference. // // `ExecutorApp.make` owns the assembly (execution-stack middleware wrapping the // protected API, the MCP envelope, the account API on the /api-prefixed router, // the extension routes, provideMerge(boot)). This file's job is the eager async // boot + slotting self-host's seam Layers into the named slots. // // Built eagerly (async) so the DB connection, schema migration, and Better Auth // org/admin seeding happen at boot — fail fast on misconfig. The DB is opened // ONCE and shared (Layer.succeed) by the per-request executor, Better Auth, and // the MCP session store. // =========================================================================== export interface MakeSelfHostAppOptions { /** Override the SQLite path (tests point at a throwaway file). */ readonly dbPath?: string; } export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { const config = loadConfig(); // ---- eager async boot: the shared libSQL handle ----------------------- const dbHandle = await createSelfHostDb({ path: options.dbPath ?? config.dbPath, namespace: SELF_HOST_NAMESPACE, version: SELF_HOST_SCHEMA_VERSION, }); // Boot-time data migrations: each registry entry runs once and is stamped // in the `data_migration` ledger; stamped entries are skipped without // touching the data. await Effect.runPromise(runSqliteDataMigrations(dbHandle.client, selfHostDataMigrations)); // ---- auth providers --------------------------------------------------- // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account // API + MCP OAuth seam, all over the shared libSQL handle. const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); const { appLayer, toWebHandler } = ExecutorApp.make({ plugins: selfHostPlugins, providers: { identity: identityLayer, account: selfHostAccountMiddleware(betterAuth), db: SelfHostDbProvider, engine: { codeExecutor: SelfHostCodeExecutorProvider }, // decorator defaults to no-op (no metering) mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, errorCapture: ErrorCaptureLive, }, extensions: { routes: [ // Better Auth owns /api/auth/* — the full path reaches it unmodified. HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), // Browser approval of paused MCP executions: the console resume page // reads paused detail (GET) and records the decision (POST .../resume), // session-cookie-gated, delegating to the in-process MCP store. HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), ], }, config: { mountPrefix: "/api", failure: textFailureStrategy }, // The boot-scoped context provideMerge'd under everything: the long-lived DB // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the // resolved identity (captured once by the execution middleware + MCP auth). boot: Layer.merge(Layer.succeed(SelfHostDb)(dbHandle), identityLayer), }); return { // Every route requirement is provided (the seams + boot resolve to nothing // residual), so the assembled app is a `Layer` — the precise shape // `serve.ts` binds to the Bun socket. `make` types its `appLayer` loosely // (it can't prove each host's resolution); self-host narrows it here. AppLayer: appLayer as Layer.Layer, toWebHandler, betterAuth, closeDb: async () => { await mcp.close(); await dbHandle.close(); }, }; }; export interface SelfHostApiHandler { /** Unified web handler: serves /api/*, /api/auth/*, /mcp, and /docs. */ readonly handler: (request: Request) => Promise; readonly dispose: () => Promise; } // Web-handler binding of `AppLayer` — used by tests (and the same shape cloud // uses for Workers). The self-host server (serve.ts) binds `AppLayer` to a // listening socket instead. We wrap `dispose` to also close the DB / MCP store. export const makeSelfHostApiHandler = async ( options: MakeSelfHostAppOptions = {}, ): Promise => { const { toWebHandler, betterAuth, closeDb } = await makeSelfHostApp(options); const web = toWebHandler(); return { handler: async (request) => { const location = await oauthCallbackSignInRedirectLocation(request, betterAuth.auth); if (location) return new Response(null, { status: 302, headers: { location } }); return web.handler(request); }, dispose: async () => { await web.dispose(); await closeDb(); }, }; };